diff --git a/README.md b/README.md index 95b31b6..b6a968b 100644 --- a/README.md +++ b/README.md @@ -76,36 +76,9 @@ If you wish to introduce backwards incompatible changes, you must create a new v To create a new version: - create a new directory under `app/versions` with the name of the next version (eg: v2, v3, etc.) -- create a new `FastAPI` app in that directory and add any routes, models, etc. -- in `app/main.py` import the app from the new version and append it to the `VERSIONS` constant. -- the `VERSIONS` constant contains tuples where the first value is the version prefix (eg: `/v2`, `/v3`, etc.) - and the second value contains the corresponding app. - -Note that all applications in the `VERSIONS` constant will implement any routes defined in previous versions -(ie. versions that are listed earlier in `VERSIONS`). - -If a route should not be made available in later versions, add the `@last_version` decorator to it. - -For example, if v1 defines: - -```python -@app.get("/test") -def test(): - ... -``` - -Then the `/test` route will be available in versions `/v2`, `/v3`, etc. - -If v2 then redefines it as: - -```python -@app.get("/test") -@last_version -def test(): - ... -``` - -Then the `/test` route will not be available from `/v3` onwards. +- create a new `fastapi.APIRouter` router in that directory and add any routes, models, etc. +- the new router should have a prefix of the form `vX` where `X` is the version number (eg: `/v2`, `/v3`, etc.) +- in `app/main.py` import the app from the new version and add it to the app with `.include_router`. ## Testing diff --git a/marble_api/app.py b/marble_api/app.py index 36d8e4a..55cbf02 100644 --- a/marble_api/app.py +++ b/marble_api/app.py @@ -1,35 +1,50 @@ +from importlib import metadata + from fastapi import FastAPI, Request -from marble_api.utils.routing import get_routes -from marble_api.versions.v1.app import app as v1_app -from marble_api.versions.versioning import add_fallback_routes +from marble_api.versions.v1.app import router as v1_router -VERSIONS = [("/v1", v1_app)] +_metadata = metadata.metadata("marble_api").json +app = FastAPI( + title=_metadata["name"], + version=_metadata["version"], + description=_metadata["summary"], + docs_url=None, + redoc_url="/docs", +) -app = FastAPI() +app.include_router(v1_router) -@app.get("/") +@app.get("/", tags=["root"]) async def root(request: Request) -> dict: - """Return information about all routes.""" - return { - "routes": [ - { - "path": f"{m.path if (m := info.get('mount')) else ''}{info['route'].path}", - "methods": info["route"].methods, - } - for info in get_routes(request.app, included_in_schema_only=True) - ] - } - - -def _mount_versions() -> None: - """Mount all implemented versions of this API under app.""" - for i, (prefix, version_app) in enumerate(VERSIONS): - app.mount(prefix, version_app) - previous_version = VERSIONS[i - 1][1] if i else app - add_fallback_routes(version_app, previous_version) - - -_mount_versions() + """Return app information.""" + return {"name": request.app.title, "version": request.app.version, "description": request.app.description} + + +_original_openapi = app.openapi + + +def openapi_with_tag_groups() -> dict: + """Update openapi schema with x-tagGroup data for nested versions.""" + if app.openapi_schema: + return app.openapi_schema + + _original_openapi() + groups = {"/": {"root"}} + for path, path_schema in app.openapi_schema["paths"].items(): + path_version = path.split("/")[1] + if path_version.startswith("v"): + version_tag = f"Version {path_version[1:]}" + if version_tag not in groups: + groups[version_tag] = set() + for route_schema in path_schema.values(): + if "tags" in route_schema: + route_schema["tags"] = [f"{path_version}: {tag}" for tag in route_schema["tags"]] + groups[version_tag].update(route_schema["tags"]) + app.openapi_schema["x-tagGroups"] = [{"name": group, "tags": list(tags)} for group, tags in groups.items()] + return app.openapi_schema + + +app.openapi = openapi_with_tag_groups diff --git a/marble_api/utils/routing.py b/marble_api/utils/routing.py index 9c46887..6d708c9 100644 --- a/marble_api/utils/routing.py +++ b/marble_api/utils/routing.py @@ -12,7 +12,7 @@ def get_routes( This includes FastAPI applications recursively mounted as well. If included_in_schema_only is True, do not include routes who are not included in the schema - (ie. their included_in_schema attribute is False)._in_ + (ie. their included_in_schema attribute is False). """ for route in app_.routes: if isinstance(route, Mount): diff --git a/marble_api/versions/v1/app.py b/marble_api/versions/v1/app.py index f973eb8..6948f77 100644 --- a/marble_api/versions/v1/app.py +++ b/marble_api/versions/v1/app.py @@ -1,9 +1,9 @@ -from fastapi import FastAPI +from fastapi import APIRouter from marble_api.versions.v1.data_request.routes import admin_router as data_request_admin_router from marble_api.versions.v1.data_request.routes import user_router as data_request_user_router -app = FastAPI(version="1") +router = APIRouter(prefix="/v1") -app.include_router(data_request_user_router) -app.include_router(data_request_admin_router) +router.include_router(data_request_user_router) +router.include_router(data_request_admin_router) diff --git a/marble_api/versions/versioning.py b/marble_api/versions/versioning.py deleted file mode 100644 index 4f94960..0000000 --- a/marble_api/versions/versioning.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import TYPE_CHECKING - -from fastapi import FastAPI - -from marble_api.utils.routing import get_routes - -if TYPE_CHECKING: - from typing import Callable, Iterable - - -def _get_app_methods(app_: FastAPI) -> "Iterable[tuple[str, str]]": - for info in get_routes(app_): - route = info["route"] - for method in route.methods: - yield (method, route.path) - - -def add_fallback_routes(app_: FastAPI, previous_version: FastAPI) -> None: - """Add routes from a previous version to app_.""" - current_routes = set(_get_app_methods(app_)) - for info in get_routes(previous_version): - route = info["route"] - previous_routes = {(method, route.path) for method in route.methods} - if ( - info.get("mount") is None - and not getattr(route.endpoint, "_last_version", False) - and not previous_routes & current_routes - ): - app_.router.routes.append(route) - - -def last_version() -> "Callable": - """Indicate that the decorated route should not be included in subsequent versions.""" - - def _(func: "Callable") -> "Callable": - func._last_version = True - return func - - return _ diff --git a/pyproject.toml b/pyproject.toml index d1b94ba..7e79e15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", ] dependencies = [ - "fastapi<0.137", # TODO: handle breaking changes https://github.com/fastapi/fastapi/pull/15745 + "fastapi~=0.137", "pymongo~=4.14", "geojson-pydantic~=2.0", "stac-pydantic~=3.4", diff --git a/test/integration/test_app.py b/test/integration/test_app.py index ba405c8..4142273 100644 --- a/test/integration/test_app.py +++ b/test/integration/test_app.py @@ -1,16 +1,10 @@ import pytest -from marble_api.app import VERSIONS - pytestmark = [pytest.mark.anyio, pytest.mark.no_db_cleanup] async def test_root_in_root(async_client): resp = await async_client.get("/") - assert {"methods": ["GET"], "path": "/"} in resp.json()["routes"] - - -async def test_version_roots_in_root(async_client): - resp = await async_client.get("/") - for version, _ in VERSIONS: - assert {"methods": ["GET"], "path": f"{version}/"} in resp.json()["routes"] + assert "name" in resp.json() + assert "version" in resp.json() + assert "description" in resp.json() diff --git a/test/unit/versions/test_versioning.py b/test/unit/versions/test_versioning.py deleted file mode 100644 index be8872a..0000000 --- a/test/unit/versions/test_versioning.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: test this when we have a version 2 to test