From 87770e402d075ed72a2e1c8fd632967bf0b97fce Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 11 Nov 2024 11:06:44 -0500 Subject: [PATCH] add conformance endpoints --- CHANGELOG.md | 24 +++++++++++++++++++++ bin/server.py | 3 ++- src/stapi_fastapi/models/conformance.py | 17 +++++++++++++++ src/stapi_fastapi/models/root.py | 8 +++++-- src/stapi_fastapi/routers/root_router.py | 27 +++++++++++++++++++++--- tests/conformance_test.py | 15 +++++++++++++ tests/root_test.py | 26 ++++++++++------------- tests/utils.py | 16 +++++++++++++- 8 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 src/stapi_fastapi/models/conformance.py create mode 100644 tests/conformance_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ca42379..66ed33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Conformance endpoint `/conformance` and root body `conformsTo` attribute. + +### Changed + +none + +### Deprecated + +none + +### Removed + +none + +### Fixed + +none + +### Security + +none + ## [v0.1.0] - 2024-10-23 Initial release diff --git a/bin/server.py b/bin/server.py index 907c51f..6cfa3d1 100644 --- a/bin/server.py +++ b/bin/server.py @@ -5,6 +5,7 @@ from stapi_fastapi.backends.product_backend import ProductBackend from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.models.conformance import CORE from stapi_fastapi.models.opportunity import ( Opportunity, OpportunityPropertiesBase, @@ -104,7 +105,7 @@ class TestSpotlightProperties(OpportunityPropertiesBase): backend=product_backend, ) -root_router = RootRouter(root_backend) +root_router = RootRouter(root_backend, conformances=[CORE]) root_router.add_product(product) app: FastAPI = FastAPI() app.include_router(root_router, prefix="") diff --git a/src/stapi_fastapi/models/conformance.py b/src/stapi_fastapi/models/conformance.py new file mode 100644 index 0000000..c94f9ec --- /dev/null +++ b/src/stapi_fastapi/models/conformance.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +CORE = "https://stapi.example.com/v0.1.0/core" +OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities" + + +class Conformance(BaseModel): + conforms_to: list[str] = Field(default_factory=list, alias="conformsTo") + + def __init__( + self, + *args, + conforms_to: list[str], + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + self.conforms_to = conforms_to diff --git a/src/stapi_fastapi/models/root.py b/src/stapi_fastapi/models/root.py index 0374e4e..0a4480c 100644 --- a/src/stapi_fastapi/models/root.py +++ b/src/stapi_fastapi/models/root.py @@ -1,7 +1,11 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from stapi_fastapi.models.shared import Link class RootResponse(BaseModel): - links: list[Link] + id: str + conformsTo: list[str] = Field(default_factory=list) + title: str = "" + description: str = "" + links: list[Link] = Field(default_factory=list) diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 11fe7d7..9f4007d 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -5,6 +5,7 @@ from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.models.conformance import CORE, Conformance from stapi_fastapi.models.order import Order, OrderCollection from stapi_fastapi.models.product import Product, ProductsCollection from stapi_fastapi.models.root import RootResponse @@ -17,15 +18,17 @@ class RootRouter(APIRouter): def __init__( self, backend: RootBackend, + conformances: list[str] = [CORE], name: str = "root", - openapi_endpoint_name="openapi", - docs_endpoint_name="swagger_ui_html", + openapi_endpoint_name: str = "openapi", + docs_endpoint_name: str = "swagger_ui_html", *args, **kwargs, ) -> None: super().__init__(*args, **kwargs) self.backend = backend self.name = name + self.conformances = conformances self.openapi_endpoint_name = openapi_endpoint_name self.docs_endpoint_name = docs_endpoint_name @@ -43,6 +46,14 @@ def __init__( tags=["Root"], ) + self.add_api_route( + "/conformance", + self.get_conformance, + methods=["GET"], + name=f"{self.name}:conformance", + tags=["Conformance"], + ) + self.add_api_route( "/products", self.get_products, @@ -71,12 +82,19 @@ def __init__( def get_root(self, request: Request) -> RootResponse: return RootResponse( + id="STAPI API", + conformsTo=self.conformances, links=[ Link( href=str(request.url_for(f"{self.name}:root")), rel="self", type=TYPE_JSON, ), + Link( + href=str(request.url_for(f"{self.name}:conformance")), + rel="conformance", + type=TYPE_JSON, + ), Link( href=str(request.url_for(f"{self.name}:list-products")), rel="products", @@ -97,9 +115,12 @@ def get_root(self, request: Request) -> RootResponse: rel="service-docs", type="text/html", ), - ] + ], ) + def get_conformance(self, request: Request) -> Conformance: + return Conformance(conforms_to=self.conformances) + def get_products(self, request: Request) -> ProductsCollection: return ProductsCollection( products=[pr.get_product(request) for pr in self.product_routers.values()], diff --git a/tests/conformance_test.py b/tests/conformance_test.py new file mode 100644 index 0000000..465764c --- /dev/null +++ b/tests/conformance_test.py @@ -0,0 +1,15 @@ +from fastapi import status +from fastapi.testclient import TestClient + +from stapi_fastapi.models.conformance import CORE + + +def test_conformance(stapi_client: TestClient) -> None: + res = stapi_client.get("/conformance") + + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + body = res.json() + + assert body["conformsTo"] == [CORE] diff --git a/tests/root_test.py b/tests/root_test.py index 5fa42bf..db2a657 100644 --- a/tests/root_test.py +++ b/tests/root_test.py @@ -1,7 +1,9 @@ from fastapi import status from fastapi.testclient import TestClient -from .utils import find_link +from stapi_fastapi.models.conformance import CORE + +from .utils import assert_link def test_root(stapi_client: TestClient, url_for) -> None: @@ -10,19 +12,13 @@ def test_root(stapi_client: TestClient, url_for) -> None: assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" - data = res.json() - - link = find_link(data["links"], "self") - assert link, "GET / Link[rel=self] should exist" - assert link["type"] == "application/json" - assert link["href"] == url_for("/") + body = res.json() - link = find_link(data["links"], "service-description") - assert link, "GET / Link[rel=service-description] should exist" - assert link["type"] == "application/json" - assert str(link["href"]) == url_for("/openapi.json") + assert body["conformsTo"] == [CORE] - link = find_link(data["links"], "service-docs") - assert link, "GET / Link[rel=service-docs] should exist" - assert link["type"] == "text/html" - assert str(link["href"]) == url_for("/docs") + assert_link("GET /", body, "self", "/", url_for) + assert_link("GET /", body, "service-description", "/openapi.json", url_for) + assert_link("GET /", body, "service-docs", "/docs", url_for, media_type="text/html") + assert_link("GET /", body, "conformance", "/conformance", url_for) + assert_link("GET /", body, "products", "/products", url_for) + assert_link("GET /", body, "orders", "/orders", url_for) diff --git a/tests/utils.py b/tests/utils.py index 20947d4..b8a482d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,21 @@ -from typing import Any +from typing import Any, Callable link_dict = dict[str, Any] def find_link(links: list[link_dict], rel: str) -> link_dict | None: return next((link for link in links if link["rel"] == rel), None) + + +def assert_link( + req: str, + body: dict[str, Any], + rel: str, + path: str, + url_for: Callable[[str], str], + media_type: str = "application/json", +) -> None: + link = find_link(body["links"], rel) + assert link, f"{req} Link[rel={rel}] should exist" + assert link["type"] == media_type + assert link["href"] == url_for(path)