diff --git a/docs/howto/plugins/apis.md b/docs/howto/plugins/apis.md new file mode 100644 index 0000000000000000000000000000000000000000..50e28d79e51465c97a0770f282add396a9a54147 --- /dev/null +++ b/docs/howto/plugins/apis.md @@ -0,0 +1,96 @@ +# How to write an API + +APIs allow you to add more APIs to the NOMAD app. More specifically you can create +a [FastAPI](https://fastapi.tiangolo.com) apps that can be mounted into the main NOMAD app alongside other apis +such as `/api/v1`, `/optimade`, etc. + +This documentation shows you how to write a plugin entry point for an API. +You should read the [documentation on getting started with plugins](./plugins.md) +to have a basic understanding of how plugins and plugin entry points work in the NOMAD ecosystem. + +## Getting started + +You can use our [template repository](https://github.com/FAIRmat-NFDI/nomad-plugin-template) to +create an initial structure for a plugin containing an API. +The relevant part of the repository layout will look something like this: + +```txt +nomad-example + ├── src + │ ├── nomad_example + │ │ ├── apis + │ │ │ ├── __init__.py + │ │ │ ├── myapi.py + ├── LICENSE.txt + ├── README.md + └── pyproject.toml +``` + +See the documentation on [plugin development guidelines](./plugins.md#plugin-development-guidelines) +for more details on the best development practices for plugins, including linting, testing and documenting. + +## API entry point + +The entry point defines basic information about your API and is used to automatically +load it into a NOMAD distribution. It is an instance of a `APIEntryPoint` or its subclass and it contains a `load` method which returns a `fastapi.FastAPI` app instance. +Furthermore, it allows you to define a path prefix for your API. +The entry point should be defined in `*/apis/__init__.py` like this: + +```python +from pydantic import Field +from nomad.config.models.plugins import APIEntryPoint + + +class MyAPIEntryPoint(APIEntryPoint): + + def load(self): + from nomad_example.apis.myapi import app + + return app + + +myapi = MyAPIEntryPoint( + prefix = 'myapi', + name = 'MyAPI', + description = 'My custom API.', +) +``` + +Here you can see that a new subclass of `APIEntryPoint` was defined. In this new class you have to override the `load` method to determine the FastAPI app that makes your API. +In the reference you can see all of the available [configuration options for a `APIEntryPoint`](../../reference/plugins.md#apientrypoint). + +The entry point instance should then be added to the `[project.entry-points.'nomad.plugin']` table in `pyproject.toml` in order for it to be automatically detected: + +```toml +[project.entry-points.'nomad.plugin'] +myapi = "nomad_example.apis:myapi" +``` + +## The FastAPI app + +The `load`-method of an API entry point has to return an instance of a `fastapi.FastAPI`. +This app should be implemented in a separate file (e.g. `*/apis/myapi.py`) and could look like this: + +```python +from fastapi import FastAPI +from nomad.config import config + +myapi_entry_point = config.get_plugin_entry_point('nomad_example.apis:myapi') + +app = FastAPI( + root_path=f'{config.services.api_base}/{myapi_entry_points.prefix}' +) + +app.get('/') +async def root(): + return {"message": "Hello World"} +``` + +Read the official [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/) to learn how to build apps and APIs with +FastAPI. + +If you run NOMAD with this plugin following our [Oasis installation documentation](../oasis/install.md) and our [plugin installation documentation](../oasis/plugins_install.md), you can curl this API and should receive the message: + +```sh +curl localhost/nomad-oasis/myapi +``` diff --git a/docs/reference/plugins.md b/docs/reference/plugins.md index 1178e2eec3fb6b925f73ba50791d5a9a6fd5413c..5b7324de2491114493f1c15610e4277111e053f2 100644 --- a/docs/reference/plugins.md +++ b/docs/reference/plugins.md @@ -15,6 +15,7 @@ This is a list of the available plugin entry point configuration models. {{ pydantic_model('nomad.config.models.plugins.NormalizerEntryPoint') }} {{ pydantic_model('nomad.config.models.plugins.ParserEntryPoint') }} {{ pydantic_model('nomad.config.models.plugins.SchemaPackageEntryPoint') }} +{{ pydantic_model('nomad.config.models.plugins.APIEntryPoint') }} ## Default plugin entry points diff --git a/mkdocs.yml b/mkdocs.yml index 13bc1d39cc34960163f093714320c9c8814bfce9..dd8e8e8603e07591967a3bbf53c10dc8ffe3924f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Write a normalizer: howto/plugins/normalizers.md - Write a parser: howto/plugins/parsers.md - Write a schema package: howto/plugins/schema_packages.md + - Write an API: howto/plugins/apis.md - Customization: - Write a YAML schema package: howto/customization/basics.md - Define ELNs: howto/customization/elns.md diff --git a/nomad/app/main.py b/nomad/app/main.py index d916e20773b1698f1c20d8dfc2eb2f66608add73..2168e5fd965f7e7bd939d5779e7f707dd2cf7c6f 100644 --- a/nomad/app/main.py +++ b/nomad/app/main.py @@ -29,6 +29,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from nomad import infrastructure from nomad.config import config +from nomad.config.models.plugins import APIEntryPoint from .v1.main import app as v1_app from .static import app as static_files_app, GuiFiles @@ -96,6 +97,12 @@ if config.resources.enabled: app.mount(f'{app_base}/resources', resources_app) +# Add API plugins +for entry_point in config.plugins.entry_points.filtered_values(): + if isinstance(entry_point, APIEntryPoint): + api_app = entry_point.load() + app.mount(f'{app_base}/{entry_point.prefix}', api_app) + # Make sure to mount this last, as it is a catch-all routes that are not yet mounted. app.mount(app_base, static_files_app) diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py index c921c0edbc4dbea73f04fe2245936da63b662c55..53ebf6d4f2f685c41cfe8b6b19cb59863163978d 100644 --- a/nomad/app/v1/routers/entries.py +++ b/nomad/app/v1/routers/entries.py @@ -1506,7 +1506,7 @@ async def post_entry_edit( key = to_key(path_segment) repeated_sub_section = isinstance(next_key, int) - next_value = [] if repeated_sub_section else {} + next_value: Union[list, dict] = [] if repeated_sub_section else {} if isinstance(section_data, list): if section_data[key] is None: diff --git a/nomad/config/models/plugins.py b/nomad/config/models/plugins.py index 9416446d42edcbcf0c8c8c62737ce1f1f6bd0669..506114afc6e97b2faba9ed6486086202250cd231 100644 --- a/nomad/config/models/plugins.py +++ b/nomad/config/models/plugins.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from nomad.metainfo import SchemaPackage from nomad.normalizing import Normalizer as NormalizerBaseClass from nomad.parsing import Parser as ParserBaseClass + from fastapi import FastAPI class EntryPoint(BaseModel): @@ -299,6 +300,44 @@ class ExampleUploadEntryPoint(EntryPoint): ) +class APIEntryPoint(EntryPoint, metaclass=ABCMeta): + """Base model for API plugin entry points.""" + + entry_point_type: Literal['api'] = Field( + 'api', description='Specifies the entry point type.' + ) + + prefix: str = Field( + None, + description=( + 'The prefix for the API. The URL for the API will be the base URL of the NOMAD ' + 'installation followed by this prefix. The prefix must not collide with any other ' + 'API prefixes. There is no default, this field must be set.' + ), + ) + + @root_validator(pre=True) + def prefix_must_be_defined_and_valid(cls, v): + import urllib.parse + + if 'prefix' not in v: + raise ValueError('prefix must be defined') + if not v['prefix']: + raise ValueError('prefix must be defined') + if urllib.parse.quote(v['prefix']) != v['prefix']: + raise ValueError('prefix must be a valid URL path') + + v['prefix'] = v['prefix'].strip('/') + return v + + @abstractmethod + def load(self) -> 'FastAPI': + """Used to lazy-load the API instance. You should override this + method in your subclass. Note that any Python module imports required + for the API should be done within this function as well.""" + pass + + class PluginBase(BaseModel): """ Base model for a NOMAD plugin. @@ -536,6 +575,7 @@ EntryPointType = Union[ NormalizerEntryPoint, AppEntryPoint, ExampleUploadEntryPoint, + APIEntryPoint, ] diff --git a/tests/app/v1/test_models.py b/tests/app/v1/test_models.py index 26627f4384247f4b699f26207f6bc7ba233df027..8b86751a837823ce14ca3ac9bdb454c525fafc16 100644 --- a/tests/app/v1/test_models.py +++ b/tests/app/v1/test_models.py @@ -122,7 +122,6 @@ def test_module(): '*': entry: process_status: '*' - '*': '*' """, [ 'users.m_children.me.uploads.m_request.query.is_published:False', @@ -134,7 +133,6 @@ def test_module(): 'users.m_children.me.datasets.m_children.*.doi:*', 'search.m_children.*.entry.process_status:*', 'search.m_children.*.entry:GraphEntryRequest', - 'search.m_children.*.*:*', ], None, id='ok', @@ -184,7 +182,6 @@ def test_validation(request_yaml: str, paths: List[str], error_path: str): export_kwargs = dict( exclude_unset=True, exclude_defaults=False, exclude_none=False ) - print(request.json(indent=2, **export_kwargs)) for path in paths: assert_path(request, path) diff --git a/tests/config/models/test_plugins.py b/tests/config/models/test_plugins.py index 9c9018245e38649f292d3bdc578bccc000c8a8f8..5bfda6aec624a9d5049fa0ca5e759e178da9e68f 100644 --- a/tests/config/models/test_plugins.py +++ b/tests/config/models/test_plugins.py @@ -21,6 +21,7 @@ import pytest from nomad.config.models.plugins import ( ExampleUploadEntryPoint, + APIEntryPoint, example_upload_path_prefix, ) @@ -117,3 +118,40 @@ def test_example_upload_entry_point_invalid(config, error, monkeypatch): entry_point.load() assert exc_info.match(error) + + +@pytest.mark.parametrize( + 'config, error, value', + [ + pytest.param({}, 'prefix must be defined', None, id='prefix-must-be-defined'), + pytest.param( + {'prefix': None}, + 'prefix must be defined', + None, + id='prefix-must-not-be-none', + ), + pytest.param( + {'prefix': ''}, 'prefix must be defined', None, id='prefix-must-not-empty' + ), + pytest.param({'prefix': '/foo/bar/'}, None, 'foo/bar', id='prefix-slashes'), + pytest.param( + {'prefix': 'not_$url& save'}, + 'prefix must be a valid URL path', + None, + id='prefix-is-valid-url', + ), + ], +) +def test_api_entry_point_invalid(config, error, value): + class MyAPIEntryPoint(APIEntryPoint): + def load(self): + pass + + if error: + with pytest.raises(Exception) as exc_info: + MyAPIEntryPoint(**config) + assert exc_info.match(error) + + if not error: + entry_point = MyAPIEntryPoint(**config) + assert entry_point.prefix == value