Skip to content

Commit da8f6aa

Browse files
authored
Provide seperate documentation pages for each API version (#577)
* Move addition of versioning to submodule * Make use of the new prefix convention to improve checks * Allow API versions to be mounted with updated openapi schema * Move adding of versions up to the main application * Remove versioning from router move it to higher level * Remove versioning since it's moved up * Remove remainder of versioning * Add support for `root_path` * Fix the middleware router to not rely on request path * Add additional valid redirect urls to work with versioned docs * Allow removing endpoints from a (hard-coded) configuration * Add minimal UI for switching between docs * Add favicon to the doc pages * Add basic styling to buttons - bug with incorrect openapi * Make sure docs point to right openapi spec * Update logging to show configuration * Support for root_path for links to versioned documentation * Allow servers to be absent, fixes bug for main app without root_path * Add gaps between buttons * Do not show 'retired' api docs * Make versioning metadata configurable * update redoc openapi reference * Remove f prefix since there is no interpolation * Add clarification * configure oauth in custom swagger pages so you can log in
1 parent 2f277ed commit da8f6aa

19 files changed

+680
-573
lines changed

authentication/import/aiod-realm.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@
602602
"enabled" : true,
603603
"alwaysDisplayInConsole" : false,
604604
"clientAuthenticatorType" : "client-secret",
605-
"redirectUris" : [ "http://localhost/docs/oauth2-redirect" ],
605+
"redirectUris" : [ "http://localhost/v1/docs/oauth2-redirect", "http://localhost/v3/docs/oauth2-redirect", "http://localhost/docs/oauth2-redirect", "http://localhost/v2/docs/oauth2-redirect" ],
606606
"webOrigins" : [ "http://localhost:8000" ],
607607
"notBefore" : 0,
608608
"bearerOnly" : false,
@@ -1224,7 +1224,7 @@
12241224
"subType" : "authenticated",
12251225
"subComponents" : { },
12261226
"config" : {
1227-
"allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "saml-role-list-mapper", "saml-user-property-mapper" ]
1227+
"allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "saml-role-list-mapper" ]
12281228
}
12291229
}, {
12301230
"id" : "10f8b9b2-1038-4c98-b7a5-a9ac88fed69e",
@@ -1266,7 +1266,7 @@
12661266
"subType" : "anonymous",
12671267
"subComponents" : { },
12681268
"config" : {
1269-
"allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper" ]
1269+
"allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "saml-user-property-mapper" ]
12701270
}
12711271
}, {
12721272
"id" : "1d21f027-e0ae-4b80-b95e-f21d9426f115",

docker-compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ services:
77
container_name: apiserver
88
volumes:
99
- ./src/config.override.toml:/app/config.override.toml:ro
10+
- ./src/versions.toml:/app/versions.toml:ro
1011
environment:
1112
- KEYCLOAK_CLIENT_SECRET=$KEYCLOAK_CLIENT_SECRET
1213
- REVIEWER_ROLE_NAME=$REVIEWER_ROLE_NAME

src/config.default.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
domain = "https://api.aiod.eu/"
55

6+
[configuration]
7+
# paths to other configuration files, relative to this file
8+
versions = "versions.toml"
9+
610
# Information on which database to connect to
711
[database]
812
host = "sqlserver"

src/config.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
import copy
2+
import logging
23
import pathlib
34
import tomllib
4-
from typing import Any
5+
from collections import deque
6+
from typing import Any, Sequence
57

6-
with open(pathlib.Path(__file__).parent / "config.default.toml", "rb") as fh:
8+
9+
# The logger isn't yet configured when we load our configuration,
10+
# since the configuration includes our log settings. Instead, we
11+
# keep track of what we want to log so it can be logged later.
12+
_log_lines: deque[tuple[int, str]] = deque()
13+
logger = logging.getLogger(__file__)
14+
15+
default_config_path = pathlib.Path(__file__).parent / "config.default.toml"
16+
with open(default_config_path, "rb") as fh:
717
DEFAULT_CONFIG = tomllib.load(fh)
18+
_log_lines.append((logging.INFO, f"Loaded default configuration from {default_config_path}"))
19+
logger.info("Actually Foo")
820

921
OVERRIDE_CONFIG_PATH = pathlib.Path(__file__).parent / "config.override.toml"
1022
if OVERRIDE_CONFIG_PATH.exists() and OVERRIDE_CONFIG_PATH.is_file():
1123
with open(OVERRIDE_CONFIG_PATH, "rb") as fh:
1224
OVERRIDE_CONFIG = tomllib.load(fh)
25+
_log_lines.append(
26+
(logging.INFO, f"Loaded configuration overrides from {OVERRIDE_CONFIG_PATH}")
27+
)
1328
else:
1429
OVERRIDE_CONFIG = {}
30+
_log_lines.append((logging.INFO, f"No custom overrides detected in {OVERRIDE_CONFIG_PATH}"))
1531

1632

1733
def _merge_configurations(
@@ -29,6 +45,28 @@ def _merge_configurations(
2945
return merged
3046

3147

48+
def _mask_configuration(
49+
configuration: dict[str, Any], to_mask: Sequence[str] | None = None
50+
) -> dict[str, Any]:
51+
if to_mask is None:
52+
to_mask = ["password"]
53+
masked = copy.copy(configuration)
54+
for key, value in configuration.items():
55+
if key in to_mask:
56+
masked[key] = "****"
57+
elif isinstance(value, dict):
58+
masked[key] = _mask_configuration(value, to_mask)
59+
return masked
60+
61+
62+
def log_configuration():
63+
"""Logs stacked log lines and then outputs the current configuration."""
64+
while _log_lines:
65+
level, message = _log_lines.popleft()
66+
logger.log(level, message)
67+
logger.info(f"Starting with merged configuration: {_mask_configuration(CONFIG)}")
68+
69+
3270
CONFIG = _merge_configurations(DEFAULT_CONFIG, OVERRIDE_CONFIG)
3371
DB_CONFIG = CONFIG.get("database", {})
3472
KEYCLOAK_CONFIG = CONFIG.get("keycloak", {})

src/main.py

Lines changed: 45 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"""
77

88
import argparse
9-
from datetime import datetime, timezone
109
import logging
1110
from pathlib import Path
1211

@@ -15,7 +14,6 @@
1514
from fastapi import Depends, FastAPI, HTTPException
1615
from fastapi.responses import HTMLResponse
1716
from sqlmodel import select, SQLModel
18-
from starlette.requests import Request
1917

2018
from authentication import get_user_or_raise, KeycloakUser, assert_required_settings_configured
2119
from config import KEYCLOAK_CONFIG, DB_CONFIG, DEV_CONFIG
@@ -29,10 +27,10 @@
2927
from database.model.platform.platform_names import PlatformName
3028
from database.session import EngineSingleton, DbSession
3129
from database.setup import create_database, database_exists
30+
from setup_logger import setup_logger
3231
from taxonomies.synchronize_taxonomy import synchronize_taxonomy_from_file
3332
from triggers import disable_review_process, enable_review_process
3433
from error_handling import http_exception_handler
35-
from database.model.agent.agent import Agent
3634
from routers import (
3735
resource_routers,
3836
parent_routers,
@@ -42,13 +40,13 @@
4240
user_router,
4341
bookmark_router,
4442
)
45-
from setup_logger import setup_logger
43+
from versioning import versions, add_version_to_openapi, add_deprecation_and_sunset_middleware
4644

4745

4846
def add_routes(app: FastAPI, url_prefix=""):
4947
"""Add routes to the FastAPI application"""
5048

51-
@app.get(url_prefix + "/", response_class=HTMLResponse)
49+
@app.get(url_prefix + "/", include_in_schema=False, response_class=HTMLResponse)
5250
def home() -> str:
5351
"""Provides a redirect page to the docs."""
5452
return """
@@ -63,23 +61,21 @@ def home() -> str:
6361
</html>
6462
"""
6563

66-
for path in ["/v2/{endpoint}", "/{endpoint}"]:
67-
68-
@app.get(url_prefix + path.format(endpoint="authorization_test"))
69-
def test_authorization(user: KeycloakUser = Depends(get_user_or_raise)) -> KeycloakUser:
70-
"""
71-
Returns the user, if authenticated correctly.
72-
"""
73-
return user
64+
@app.get("/authorization_test")
65+
def test_authorization(user: KeycloakUser = Depends(get_user_or_raise)) -> KeycloakUser:
66+
"""
67+
Returns the user, if authenticated correctly.
68+
"""
69+
return user
7470

75-
@app.get(url_prefix + path.format(endpoint="counts"))
76-
def counts() -> dict:
77-
return {
78-
router.resource_name_plural: count
79-
for router in resource_routers.router_list
80-
if issubclass(router.resource_class, AIoDConcept)
81-
and (count := router.get_resource_count_func()(detailed=True))
82-
}
71+
@app.get("/counts")
72+
def counts() -> dict:
73+
return {
74+
router.resource_name_plural: count
75+
for router in resource_routers.router_list
76+
if issubclass(router.resource_class, AIoDConcept)
77+
and (count := router.get_resource_count_func()(detailed=True))
78+
}
8379

8480
for router in (
8581
resource_routers.router_list
@@ -119,17 +115,15 @@ def create_app() -> FastAPI:
119115

120116

121117
def build_app(*, url_prefix: str = "", version: str = "dev"):
122-
app = FastAPI(
123-
openapi_url=f"{url_prefix}/openapi.json",
124-
docs_url=f"{url_prefix}/docs",
125-
title="AIoD Metadata Catalogue",
118+
kwargs = dict(
119+
docs_url=None, # We override the default pages with custom html
120+
redoc_url=None,
126121
description="This is the REST API documentation of the AIoD Metadata Catalogue. "
127122
"See also our general "
128123
'<a href="https://aiondemand.github.io/AIOD-rest-api/">metadata catalogue documentation</a>, '
129124
"and our "
130125
'<a href="https://github.com/aiondemand/AIOD-rest-api/releases">changelog</a>.',
131-
version=version,
132-
swagger_ui_oauth2_redirect_url=f"{url_prefix}/docs/oauth2-redirect",
126+
swagger_ui_oauth2_redirect_url=f"/docs/oauth2-redirect",
133127
swagger_ui_init_oauth={
134128
"clientId": KEYCLOAK_CONFIG.get("client_id_swagger"),
135129
"realm": KEYCLOAK_CONFIG.get("realm"),
@@ -138,42 +132,30 @@ def build_app(*, url_prefix: str = "", version: str = "dev"):
138132
"scopes": KEYCLOAK_CONFIG.get("scopes"),
139133
},
140134
)
141-
add_routes(app, url_prefix=url_prefix)
142-
app.add_exception_handler(HTTPException, http_exception_handler)
143-
144-
@app.middleware("http")
145-
async def add_deprecation_header(request: Request, call_next):
146-
"""Adds a deprecation header: https://datatracker.ietf.org/doc/html/rfc9745"""
147-
response = await call_next(request)
148-
if "v1" in request.scope["path"]:
149-
deprecation_date = datetime(year=2025, month=5, day=30, tzinfo=timezone.utc)
150-
response.headers["Deprecation"] = f"@{int(deprecation_date.timestamp())}"
151-
deprecation_link = '<https://aiondemand.github.io/AIOD-rest-api/using/migration-v1-v2>; rel="deprecation"; type="text/html"'
152-
if links := response.headers.get("Link"):
153-
response.headers["Link"] = ", ".join([links, deprecation_link])
154-
else:
155-
response.headers["Link"] = deprecation_link
156-
return response
157-
158-
@app.middleware("http")
159-
async def add_sunset_header(request: Request, call_next):
160-
"""Adds a sunset header: https://datatracker.ietf.org/doc/html/rfc8594"""
161-
response = await call_next(request)
162-
if "v1" in request.scope["path"]:
163-
sunset_date = datetime(year=2025, month=6, day=11, tzinfo=timezone.utc)
164-
response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S %Z")
165-
sunset_link = '<https://aiondemand.github.io/AIOD-rest-api/using/migration-v1-v2>; rel="sunset"; type="text/html"'
166-
if links := response.headers.get("Link"):
167-
response.headers["Link"] = ", ".join([links, sunset_link])
168-
else:
169-
response.headers["Link"] = sunset_link
170-
return response
171-
172-
# Adds a visual deprecation style to the generated docs:
173-
for route in app.routes:
174-
if "v1" in route.path:
175-
route.deprecated = True
176-
return app
135+
main_app = FastAPI(
136+
root_path=url_prefix,
137+
title="AI-on-Demand Metadata Catalogue REST API",
138+
version="latest",
139+
**kwargs,
140+
)
141+
add_routes(main_app)
142+
main_app.add_exception_handler(HTTPException, http_exception_handler)
143+
add_version_to_openapi(main_app, root_path=url_prefix)
144+
145+
for version, info in versions.items():
146+
if info.retired:
147+
continue
148+
app = FastAPI(
149+
title=f"AIoD Metadata Catalogue {version}",
150+
version=f"{version}",
151+
**kwargs,
152+
)
153+
add_routes(app)
154+
app.add_exception_handler(HTTPException, http_exception_handler)
155+
add_deprecation_and_sunset_middleware(app)
156+
add_version_to_openapi(app, root_path=url_prefix)
157+
main_app.mount(f"/{version}", app)
158+
return main_app
177159

178160

179161
def build_database(drop_database: bool = False):

0 commit comments

Comments
 (0)