From 7d0c22ed60e63f3b311e9a5473f1240d89a1197b Mon Sep 17 00:00:00 2001 From: bkis Date: Wed, 13 Mar 2024 12:04:52 +0100 Subject: [PATCH] Refactor API config and OAI metadata handling Closes #154: Complete endpoint tags documentation or drop it alltogether --- Tekst-API/tekst/__init__.py | 26 +++--- Tekst-API/tekst/app.py | 5 +- Tekst-API/tekst/config.py | 13 +-- Tekst-API/tekst/openapi.py | 102 ----------------------- Tekst-API/tekst/openapi/__init__.py | 77 +++++++++++++++++ Tekst-API/tekst/openapi/tags_metadata.py | 86 +++++++++++++++++++ Tekst-API/tests/test_api_platform.py | 4 +- 7 files changed, 186 insertions(+), 127 deletions(-) delete mode 100644 Tekst-API/tekst/openapi.py create mode 100644 Tekst-API/tekst/openapi/__init__.py create mode 100644 Tekst-API/tekst/openapi/tags_metadata.py diff --git a/Tekst-API/tekst/__init__.py b/Tekst-API/tekst/__init__.py index 083ae933..30571e99 100644 --- a/Tekst-API/tekst/__init__.py +++ b/Tekst-API/tekst/__init__.py @@ -1,22 +1,24 @@ from importlib import metadata -data = metadata.metadata(__package__) +_package_metadata = metadata.metadata(__package__) # whyyyyy -license_url = [ - e for e in data.get_all("Project-URL", failobj="") if e.startswith("License") -][0].split(", ")[1] +_project_urls = _package_metadata.get_all("Project-URL", failobj="") +license_url = [e for e in _project_urls if e.startswith("License, ")][0].split(", ")[1] +documentation = [e for e in _project_urls if e.startswith("Documentation, ")][0].split( + ", " +)[1] -pkg_meta = dict( - version=data["Version"], - description=data["Summary"], - long_description=data["Description"], - license=data["License"], +package_metadata = dict( + version=_package_metadata["Version"], + description=_package_metadata["Summary"], + license=_package_metadata["License"], license_url=license_url, - website=data["Home-page"], + website=_package_metadata["Home-page"], + documentation=documentation, ) -__version__ = pkg_meta["version"] +__version__ = package_metadata["version"] -del metadata, data +del metadata, _package_metadata diff --git a/Tekst-API/tekst/app.py b/Tekst-API/tekst/app.py index c59db39d..e5e07cef 100644 --- a/Tekst-API/tekst/app.py +++ b/Tekst-API/tekst/app.py @@ -29,14 +29,15 @@ async def startup_routine(app: FastAPI) -> None: if not _cfg.dev_mode or _cfg.dev_use_db: await db.init_odm() if not _cfg.dev_mode or _cfg.dev_use_es: - await search.init_es_client(overwrite_existing_index=_cfg.dev_mode) + pass + # await search.init_es_client(overwrite_existing_index=_cfg.dev_mode) # # TEMP DEV # await search.init_es_client( # overwrite_existing_index=False # ) settings = await get_settings() if _cfg.dev_use_db else PlatformSettings() - customize_openapi(app=app, cfg=_cfg, settings=settings) + customize_openapi(app=app, settings=settings) if not _cfg.email_smtp_server: log.warning("No SMTP server configured") # pragma: no cover diff --git a/Tekst-API/tekst/config.py b/Tekst-API/tekst/config.py index 443b8c22..699d0074 100644 --- a/Tekst-API/tekst/config.py +++ b/Tekst-API/tekst/config.py @@ -15,7 +15,7 @@ ) from pydantic_settings import BaseSettings, SettingsConfigDict -from tekst import pkg_meta +from tekst import package_metadata from tekst.models.common import CustomHttpUrl from tekst.utils import validators as val from tekst.utils.strings import safe_name @@ -194,14 +194,9 @@ def generate_db_name(cls, v: str) -> str: @computed_field @property def tekst_info(self) -> dict[str, str]: - return { - "name": "Tekst", - "version": pkg_meta["version"], - "description": pkg_meta["description"], - "website": pkg_meta["website"], - "license": pkg_meta["license"], - "license_url": pkg_meta["license_url"], - } + info_data = {"name": "Tekst"} + info_data.update(package_metadata) + return info_data @computed_field @property diff --git a/Tekst-API/tekst/openapi.py b/Tekst-API/tekst/openapi.py deleted file mode 100644 index 8fb48e01..00000000 --- a/Tekst-API/tekst/openapi.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import Any -from urllib.parse import urljoin - -from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi - -from tekst.config import TekstConfig -from tekst.models.settings import PlatformSettings -from tekst.utils import pick_translation - - -tags_metadata = [ - { - "name": "texts", - "description": "Text-related operations", - "externalDocs": { - "description": "View full documentation", - "url": "https://vedawebproject.github.io/Tekst", - }, - }, -] - - -def customize_openapi(app: FastAPI, cfg: TekstConfig, settings: PlatformSettings): - def _custom_openapi(): - if not app.openapi_schema: - app.openapi_schema = generate_schema(app, cfg, settings) - return app.openapi_schema - - app.openapi = _custom_openapi - - -def generate_schema(app: FastAPI, cfg: TekstConfig, settings: PlatformSettings): - schema = get_openapi( - title=settings.info_platform_name, - version=cfg.tekst_info["version"], - description=pick_translation(settings.info_subtitle), - routes=app.routes, - servers=[{"url": urljoin(str(cfg.server_url), str(cfg.api_path))}], - terms_of_service=str(settings.info_terms), - tags=tags_metadata, - contact={ - "name": settings.info_contact_name, - "url": settings.info_contact_url, - "email": settings.info_contact_email, - }, - license_info={ - "name": cfg.tekst_info["license"], - "url": cfg.tekst_info["license_url"], - }, - separate_input_output_schemas=False, - ) - return process_openapi_schema(schema=schema, cfg=cfg) - - -def process_openapi_schema(schema: dict[str, Any], cfg: TekstConfig) -> dict[str, Any]: - # schema["components"]["schemas"]["Foo"] = PlatformSettings.schema() - return schema - - -async def generate_openapi_schema( - to_file: bool, output_file: str, indent: int, sort_keys: bool, cfg: TekstConfig -) -> str: # pragma: no cover - """ - Atomic operation for creating and processing the OpenAPI schema from outside of - the app context. This is used in __main__.py - """ - - import json - - from asgi_lifespan import LifespanManager - - # from httpx import AsyncClient - from tekst.app import app - - async with LifespanManager(app): # noqa: SIM117 - schema = app.openapi() - json_dump_args = { - "skipkeys": True, - "indent": indent or None, - "sort_keys": sort_keys, - } - if to_file: - with open(output_file, "w") as f: - json.dump(schema, f, **json_dump_args) - return json.dumps(schema, **json_dump_args) - - # async with AsyncClient(app=app, base_url="http://test") as client: - # resp = await client.get(f"{cfg.doc_openapi_url}") - # if resp.status_code != 200: - # raise HTTPException(resp.status_code, detail=resp.json()) - # else: - # schema = resp.json() - # json_dump_args = { - # "skipkeys": True, - # "indent": indent or None, - # "sort_keys": sort_keys, - # } - # if to_file: - # with open(output_file, "w") as f: - # json.dump(schema, f, **json_dump_args) - # return json.dumps(schema, **json_dump_args) diff --git a/Tekst-API/tekst/openapi/__init__.py b/Tekst-API/tekst/openapi/__init__.py new file mode 100644 index 00000000..2f8a202f --- /dev/null +++ b/Tekst-API/tekst/openapi/__init__.py @@ -0,0 +1,77 @@ +from typing import Any +from urllib.parse import urljoin + +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from tekst.config import TekstConfig, get_config +from tekst.models.settings import PlatformSettings +from tekst.openapi.tags_metadata import get_tags_metadata +from tekst.utils import pick_translation + + +_cfg: TekstConfig = get_config() # get (possibly cached) config data + + +def customize_openapi(app: FastAPI, settings: PlatformSettings): + def _custom_openapi(): + if not app.openapi_schema: + app.openapi_schema = generate_schema(app, settings) + return app.openapi_schema + + app.openapi = _custom_openapi + + +def generate_schema(app: FastAPI, settings: PlatformSettings): + schema = get_openapi( + title=settings.info_platform_name, + version=_cfg.tekst_info["version"], + description=pick_translation(settings.info_subtitle), + routes=app.routes, + servers=[{"url": urljoin(str(_cfg.server_url), str(_cfg.api_path))}], + terms_of_service=settings.info_terms, + tags=get_tags_metadata(documentation_url=_cfg.tekst_info["documentation"]), + contact={ + "name": settings.info_contact_name, + "url": settings.info_contact_url, + "email": settings.info_contact_email, + }, + license_info={ + "name": _cfg.tekst_info["license"], + "url": _cfg.tekst_info["license_url"], + }, + separate_input_output_schemas=False, + ) + return process_openapi_schema(schema=schema) + + +def process_openapi_schema(schema: dict[str, Any]) -> dict[str, Any]: + # schema["components"]["schemas"]["Foo"] = PlatformSettings.schema() + return schema + + +async def generate_openapi_schema( + to_file: bool, output_file: str, indent: int, sort_keys: bool +) -> str: # pragma: no cover + """ + Atomic operation for creating and processing the OpenAPI schema from outside of + the app context. This is used in __main__.py + """ + + import json + + from asgi_lifespan import LifespanManager + + from tekst.app import app + + async with LifespanManager(app): # noqa: SIM117 + schema = app.openapi() + json_dump_args = { + "skipkeys": True, + "indent": indent or None, + "sort_keys": sort_keys, + } + if to_file: + with open(output_file, "w") as f: + json.dump(schema, f, **json_dump_args) + return json.dumps(schema, **json_dump_args) diff --git a/Tekst-API/tekst/openapi/tags_metadata.py b/Tekst-API/tekst/openapi/tags_metadata.py new file mode 100644 index 00000000..ab42efee --- /dev/null +++ b/Tekst-API/tekst/openapi/tags_metadata.py @@ -0,0 +1,86 @@ +from typing import Any + + +def get_tags_metadata(documentation_url: str) -> list[dict[str, Any]]: + return [ + { + "name": "texts", + "description": "Texts configured on this platform", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "locations", + "description": "Text locations (the structural units of a text)", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "resources", + "description": "Resources related to certain texts", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "contents", + "description": "Contents of resources", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "search", + "description": "Search operations and search index maintenance", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "browse", + "description": "Endpoints for effectively browsing the plaform data", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "platform", + "description": "Platform-specific data, infos and operations", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "users", + "description": "Registered users and their accounts", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "bookmarks", + "description": "The current user's bookmarks", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + { + "name": "auth", + "description": "Rregistration, authentication and security", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, + ] diff --git a/Tekst-API/tests/test_api_platform.py b/Tekst-API/tests/test_api_platform.py index f539d3f1..3f28445e 100644 --- a/Tekst-API/tests/test_api_platform.py +++ b/Tekst-API/tests/test_api_platform.py @@ -1,14 +1,14 @@ import pytest from httpx import AsyncClient -from tekst import pkg_meta +from tekst import package_metadata @pytest.mark.anyio async def test_platform_data(test_client: AsyncClient, status_fail_msg): resp = await test_client.get("/platform") assert resp.status_code == 200, status_fail_msg(200, resp) - assert resp.json()["tekst"]["version"] == pkg_meta["version"] + assert resp.json()["tekst"]["version"] == package_metadata["version"] @pytest.mark.anyio