diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fbdb0cff..df65a649 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -19,7 +19,8 @@ extra: nav: - TiPg: "index.md" - User Guide: - - "Endpoints": endpoints.md + - "Endpoints documentation": endpoints.md + - "Endpoints Factories": advanced/factories.md - API: - db: api/tipg/db.md - dbmodel: api/tipg/dbmodel.md diff --git a/docs/src/advanced/factories.md b/docs/src/advanced/factories.md new file mode 100644 index 00000000..5297f0e7 --- /dev/null +++ b/docs/src/advanced/factories.md @@ -0,0 +1,168 @@ + +`tipg` creates endpoints using *Endpoint Factories classes* which abstract the definition of input dependency for all the endpoints. + +```python +# pseudo code +class Factory: + + collection_dependency: Callable + + def __init__(self, collection_dependency: Callable): + self.collection_dependency = collection_dependency + self.router = APIRouter() + + self.register_routes() + + def register_routes(self): + + @self.router.get("/collections/{collectionId}") + def collection( + request: Request, + collection=Depends(self.collection_dependency), + ): + ... + + @self.router.get("/collections/{collectionId}/items") + def items( + request: Request, + collection=Depends(self.collection_dependency), + ): + ... + + @self.router.get("/collections/{collectionId}/items/{itemId}") + def item( + request: Request, + collection=Depends(self.collection_dependency), + itemId: str = Path(..., description="Item identifier"), + ): + ... + + + +# Create FastAPI Application +app = FastAPI() + +# Create a Factory instance +endpoints = Factory(collection_dependency=lambda: ["collection1", "collection2"]) + +# Register the factory router (with the registered endpoints) to the application +app.include_router(endpoints.router) +``` + +## OGC Features API Factory + +```python +from tipg.factory import OGCFeaturesFactory + +app = FastAPI() +endpoints = OGCFeaturesFactory(with_common=True) +app.include_router(endpoints.router, tags=["OGC Features API"]) +``` + +#### Creation Options + +- **collection_dependency** (Callable[..., tipg.dbmodel.Collection]): Callable which return a Collection instance + +- **with_common** (bool, optional): Create Full OGC Features API set of endpoints with OGC Common endpoints (landing `/` and conformance `/conformance`). Defaults to `True` + +- **router** (fastapi.APIRouter, optional): FastAPI + +- **router_prefix** (str, optional): *prefix* for the whole set of endpoints + +- **templates** (starlette.templating.Jinja2Templates, optional): Templates to be used in endpoint's responses + +- **title** (str, optional): Title of for the endpoints (only used if `with_common=True`) + +#### Endpoints + +| Method | Path | Output | Description +| ------ | --------------------------------------------------------------- |-------------------------------------------------- |-------------- +| `GET` | `/collections` | HTML / JSON | list of available collections +| `GET` | `/collections/{collectionId}` | HTML / JSON | collection's metadata +| `GET` | `/collections/{collectionId}/queryables` | HTML / SchemaJSON | available queryable for a collection +| `GET` | `/collections/{collectionId}/items` | HTML / JSON / NDJSON / GeoJSON/ GeoJSONSeq / CSV | a set of items for a collection +| `GET` | `/collections/{collectionId}/items/{itemId}` | HTML / JSON/GeoJSON | one collection's item +| `GET` | `/conformance` | HTML / JSON | conformance class landing Page +| `GET` | `/` | HTML / JSON | landing page + + +## OGC Tiles API Factory + +```python +from tipg.factory import OGCTilesFactory + +app = FastAPI() +endpoints = OGCTilesFactory(with_common=True) +app.include_router(endpoints.router, tags=["OGC Tiles API"]) +``` + +#### Creation Options + +- **collection_dependency** (Callable[..., tipg.dbmodel.Collection]): Callable which return a Collection instance + +- **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents) + +- **with_common** (bool, optional): Create Full OGC Features API set of endpoints with OGC Common endpoints (landing `/` and conformance `/conformance`). Defaults to `True` + +- **router** (fastapi.APIRouter, optional): FastAPI + +- **router_prefix** (str, optional): *prefix* for the whole set of endpoints + +- **templates** (starlette.templating.Jinja2Templates, optional): Templates to be used in endpoint's responses + +- **title** (str, optional): Title of for the endpoints (only used if `with_common=True`) + +#### Endpoints + +| Method | Path | Output | Description +| ------ | ---------------------------------------------------------------------------------------- |------------------------------ |-------------- +| `GET` | `/collections/{collectionId}/tiles[/{TileMatrixSetId}]/{tileMatrix}/{tileCol}/{tileRow}` | Mapbox Vector Tile (Protobuf) | create a web map vector tile from collection's items +| `GET` | `/collections/{collectionId}[/{TileMatrixSetId}]/tilejson.json` | JSON | Mapbox TileJSON document +| `GET` | `/collections/{collectionId}[/{TileMatrixSetId}]/viewer` | HTML | simple map viewer +| `GET` | `/tileMatrixSets` | JSON | list of available TileMatrixSets +| `GET` | `/tileMatrixSets/{tileMatrixSetId}` | JSON | TileMatrixSet document +| `GET` | `/conformance` | HTML / JSON | conformance class landing Page +| `GET` | `/` | HTML / JSON | landing page + +## OGC Features + Tiles API Factory + +```python +from tipg.factory import Endpoints + +app = FastAPI() +endpoints = Endpoints() +app.include_router(endpoints.router) +``` + +#### Creation Options + +- **collection_dependency** (Callable[..., tipg.dbmodel.Collection]): Callable which return a Collection instance + +- **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents) + +- **with_common** (bool, optional): Create Full OGC Features API set of endpoints with OGC Common endpoints (landing `/` and conformance `/conformance`). Defaults to `True` + +- **router** (fastapi.APIRouter, optional): FastAPI + +- **router_prefix** (str, optional): *prefix* for the whole set of endpoints + +- **templates** (starlette.templating.Jinja2Templates, optional): Templates to be used in endpoint's responses + +- **title** (str, optional): Title of for the endpoints (only used if `with_common=True`) + +#### Endpoints + +| Method | Path | Output | Description +| ------ | ---------------------------------------------------------------------------------------- |------------------------------ |-------------- +| `GET` | `/collections` | HTML / JSON | list of available collections +| `GET` | `/collections/{collectionId}` | HTML / JSON | collection's metadata +| `GET` | `/collections/{collectionId}/queryables` | HTML / SchemaJSON | available queryable for a collection +| `GET` | `/collections/{collectionId}/items` | HTML / JSON / NDJSON / GeoJSON/ GeoJSONSeq / CSV | a set of items for a collection +| `GET` | `/collections/{collectionId}/items/{itemId}` | HTML / JSON/GeoJSON | one collection's item +| `GET` | `/collections/{collectionId}/tiles[/{TileMatrixSetId}]/{tileMatrix}/{tileCol}/{tileRow}` | Mapbox Vector Tile (Protobuf) | create a web map vector tile from collection's items +| `GET` | `/collections/{collectionId}[/{TileMatrixSetId}]/tilejson.json` | JSON | Mapbox TileJSON document +| `GET` | `/collections/{collectionId}[/{TileMatrixSetId}]/viewer` | HTML | simple map viewer +| `GET` | `/tileMatrixSets` | JSON | list of available TileMatrixSets +| `GET` | `/tileMatrixSets/{tileMatrixSetId}` | JSON | TileMatrixSet document +| `GET` | `/conformance` | HTML / JSON | conformance class landing Page +| `GET` | `/` | HTML / JSON | landing page diff --git a/tests/conftest.py b/tests/conftest.py index 4f6c60ef..3b2c090f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,8 +90,6 @@ def app(database_url, monkeypatch): app.user_middleware = [] app.middleware_stack = app.build_middleware_stack() - # register functions to app.state.function_catalog here - with TestClient(app) as app: yield app diff --git a/tests/test_factories.py b/tests/test_factories.py new file mode 100644 index 00000000..d58174cd --- /dev/null +++ b/tests/test_factories.py @@ -0,0 +1,293 @@ +"""test endpoint factories.""" + +from fastapi import FastAPI + +from starlette.testclient import TestClient + + +def test_features_factory(): + """test OGC Feature Factory.""" + + # We import the factory here to make sure they do not mess with the env setting set in conftest + # ref: https://github.com/developmentseed/tipg/issues/38 + from tipg.factory import OGCFeaturesFactory + + endpoints = OGCFeaturesFactory() + assert endpoints.with_common + assert endpoints.title == "OGC API" + assert len(endpoints.router.routes) == 7 + assert len(endpoints.conforms_to) == 6 + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC API" + links = response.json()["links"] + assert len(links) == 9 # 5 from features + 4 from common + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/" + queryables_link = [ + link for link in links if link["title"] == "Collection queryables" + ][0] + assert ( + queryables_link["href"] + == "http://testserver/collections/{collectionId}/queryables" + ) + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 6 + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + endpoints = OGCFeaturesFactory( + router_prefix="/features", title="OGC Features API", with_common=True + ) + assert endpoints.router_prefix == "/features" + assert endpoints.with_common + assert endpoints.title == "OGC Features API" + assert len(endpoints.router.routes) == 7 + + app = FastAPI() + app.include_router(endpoints.router, prefix="/features") + with TestClient(app) as client: + response = client.get("/features/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC Features API" + links = response.json()["links"] + assert len(links) == 9 + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/features/" + queryables_link = [ + link for link in links if link["title"] == "Collection queryables" + ][0] + assert ( + queryables_link["href"] + == "http://testserver/features/collections/{collectionId}/queryables" + ) + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/features/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 6 + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + endpoints = OGCFeaturesFactory(title="OGC Features API", with_common=False) + assert not endpoints.with_common + assert endpoints.title == "OGC Features API" + assert len(endpoints.router.routes) == 5 + assert len(endpoints.conforms_to) == 6 + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 404 + + response = client.get("/conformance") + assert response.status_code == 404 + + +def test_tiles_factory(): + """test OGC Tiles Factory.""" + + # We import the factory here to make sure they do not mess with the env setting set in conftest + # ref: https://github.com/developmentseed/tipg/issues/38 + from tipg.factory import OGCTilesFactory + + endpoints = OGCTilesFactory() + assert endpoints.with_common + assert endpoints.title == "OGC API" + assert len(endpoints.router.routes) == 10 + assert len(endpoints.conforms_to) == 3 + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC API" + links = response.json()["links"] + assert len(links) == 7 # 3 from tiles + 4 from common + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/" + tms_link = [link for link in links if link["title"] == "TileMatrixSets"][0] + assert tms_link["href"] == "http://testserver/tileMatrixSets" + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 3 + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + endpoints = OGCTilesFactory( + router_prefix="/map", title="OGC Tiles API", with_common=True + ) + assert endpoints.router_prefix == "/map" + assert endpoints.with_common + assert endpoints.title == "OGC Tiles API" + assert len(endpoints.router.routes) == 10 + + app = FastAPI() + app.include_router(endpoints.router, prefix="/map") + with TestClient(app) as client: + response = client.get("/map/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC Tiles API" + links = response.json()["links"] + assert len(links) == 7 + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/map/" + tms_link = [link for link in links if link["title"] == "TileMatrixSets"][0] + assert tms_link["href"] == "http://testserver/map/tileMatrixSets" + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/map/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 6 + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + endpoints = OGCTilesFactory(title="OGC Tiles API", with_common=False) + assert not endpoints.with_common + assert endpoints.title == "OGC Tiles API" + assert len(endpoints.router.routes) == 8 + assert len(endpoints.conforms_to) == 3 + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 404 + + response = client.get("/conformance") + assert response.status_code == 404 + + +def test_endpoints_factory(): + """test OGC Features+Tiles Factory.""" + + # We import the factory here to make sure they do not mess with the env setting set in conftest + # ref: https://github.com/developmentseed/tipg/issues/38 + from tipg.factory import Endpoints + + endpoints = Endpoints() + assert endpoints.with_common + assert endpoints.title == "OGC API" + assert len(endpoints.router.routes) == 15 + assert len(endpoints.conforms_to) == 9 # 3 from tiles + 6 from features + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC API" + links = response.json()["links"] + assert len(links) == 12 # 3 from tiles + 5 from features + 4 from common + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/" + queryables_link = [ + link for link in links if link["title"] == "Collection queryables" + ][0] + assert ( + queryables_link["href"] + == "http://testserver/collections/{collectionId}/queryables" + ) + tms_link = [link for link in links if link["title"] == "TileMatrixSets"][0] + assert tms_link["href"] == "http://testserver/tileMatrixSets" + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 9 # 3 from tiles + 6 from features + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + endpoints = Endpoints(router_prefix="/ogc", title="OGC Full API", with_common=True) + assert endpoints.router_prefix == "/ogc" + assert endpoints.with_common + assert endpoints.title == "OGC Full API" + assert len(endpoints.router.routes) == 15 + assert not endpoints.ogc_features.with_common + assert endpoints.ogc_features.router_prefix == "/ogc" + assert not endpoints.ogc_tiles.with_common + assert endpoints.ogc_tiles.router_prefix == "/ogc" + + app = FastAPI() + app.include_router(endpoints.router, prefix="/ogc") + with TestClient(app) as client: + response = client.get("/ogc/") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json()["title"] == "OGC Full API" + links = response.json()["links"] + assert len(links) == 12 + landing_link = [link for link in links if link["title"] == "Landing Page"][0] + assert landing_link["href"] == "http://testserver/ogc/" + queryables_link = [ + link for link in links if link["title"] == "Collection queryables" + ][0] + assert ( + queryables_link["href"] + == "http://testserver/ogc/collections/{collectionId}/queryables" + ) + tms_link = [link for link in links if link["title"] == "TileMatrixSets"][0] + assert tms_link["href"] == "http://testserver/ogc/tileMatrixSets" + doc_link = [link for link in links if link["title"] == "the API documentation"][ + 0 + ] + assert doc_link["href"] == "http://testserver/docs" + + response = client.get("/ogc/conformance") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + body = response.json()["conformsTo"] + assert len(body) > 9 + assert "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core" in body + + # Create Endpoints without landing and conformance + endpoints = Endpoints(title="Tiles and Features API", with_common=False) + assert not endpoints.with_common + assert endpoints.title == "Tiles and Features API" + assert len(endpoints.router.routes) == 13 # 8 from tiles + 5 from features + assert len(endpoints.conforms_to) == 9 # 3 from tiles + 6 from features + + app = FastAPI() + app.include_router(endpoints.router) + with TestClient(app) as client: + response = client.get("/") + assert response.status_code == 404 + + response = client.get("/conformance") + assert response.status_code == 404 diff --git a/tipg/factory.py b/tipg/factory.py index af13590e..d6ea56ec 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -1,5 +1,6 @@ """tipg.factory: router factories.""" +import abc import csv from dataclasses import dataclass, field from typing import ( @@ -126,8 +127,52 @@ def t_intersects(interval: List[str], temporal_extent: List[str]) -> bool: return False -@dataclass -class Endpoints: +def create_html_response( + request: Request, + data: str, + templates: Jinja2Templates, + template_name: str, + router_prefix: Optional[str] = None, +) -> _TemplateResponse: + """Create Template response.""" + urlpath = request.url.path + crumbs = [] + baseurl = str(request.base_url).rstrip("/") + + crumbpath = str(baseurl) + for crumb in urlpath.split("/"): + crumbpath = crumbpath.rstrip("/") + part = crumb + if part is None or part == "": + part = "Home" + crumbpath += f"/{crumb}" + crumbs.append({"url": crumbpath.rstrip("/"), "part": part.capitalize()}) + + if router_prefix: + baseurl += router_prefix + + return templates.TemplateResponse( + f"{template_name}.html", + { + "request": request, + "response": orjson.loads(data), + "template": { + "api_root": baseurl, + "params": request.query_params, + "title": "", + }, + "crumbs": crumbs, + "url": str(request.url), + "baseurl": baseurl, + "urlpath": str(request.url.path), + "urlparams": str(request.url.query), + }, + ) + + +# ref: https://github.com/python/mypy/issues/5374 +@dataclass # type: ignore +class EndpointsFactory(metaclass=abc.ABCMeta): """Endpoints Factory.""" # FastAPI router @@ -140,19 +185,18 @@ class Endpoints: # e.g if you mount the route with `/foo` prefix, set router_prefix to foo router_prefix: str = "" - title: str = "TiPG API" - templates: Jinja2Templates = DEFAULT_TEMPLATES - # OGC Tiles dependency - supported_tms: TileMatrixSets = default_tms + # Full application with Landing and Conformance + with_common: bool = True + + title: str = "OGC API" def __post_init__(self): """Post Init: register route and configure specific options.""" - self.register_landing() - self.register_conformance() - self.register_collections() - self.register_tiles() + self.register_routes() + if self.with_common: + self.register_common_routes() def url_for(self, request: Request, name: str, **path_params: Any) -> str: """Return full url (with prefix) for a specific handler.""" @@ -170,43 +214,74 @@ def _create_html_response( data: str, template_name: str, ) -> _TemplateResponse: - """Create Template response.""" - urlpath = request.url.path - crumbs = [] - baseurl = str(request.base_url).rstrip("/") - - crumbpath = str(baseurl) - for crumb in urlpath.split("/"): - crumbpath = crumbpath.rstrip("/") - part = crumb - if part is None or part == "": - part = "Home" - crumbpath += f"/{crumb}" - crumbs.append({"url": crumbpath.rstrip("/"), "part": part.capitalize()}) + return create_html_response( + request, + data, + templates=self.templates, + template_name=template_name, + router_prefix=self.router_prefix, + ) - if self.router_prefix: - baseurl += self.router_prefix - - return self.templates.TemplateResponse( - f"{template_name}.html", - { - "request": request, - "response": orjson.loads(data), - "template": { - "api_root": baseurl, - "params": request.query_params, - "title": "", + @abc.abstractmethod + def register_routes(self): + """Register factory Routes.""" + ... + + @property + @abc.abstractmethod + def conforms_to(self) -> List[str]: + """Endpoints conformances.""" + ... + + @abc.abstractmethod + def links(self, request: Request) -> List[model.Link]: + """Register factory Routes.""" + ... + + def register_common_routes(self): + """Register Landing (/) and Conformance (/conformance) routes.""" + + @self.router.get( + "/conformance", + response_model=model.Conformance, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } }, - "crumbs": crumbs, - "url": str(request.url), - "baseurl": baseurl, - "urlpath": str(request.url.path), - "urlparams": str(request.url.query), }, + tags=["OGC Common"], ) + def conformance( + request: Request, + output_type: Optional[MediaType] = Depends(OutputType), + ): + """Get conformance.""" + data = model.Conformance( + conformsTo=[ + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landingPage", + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30", + *self.conforms_to, + ] + ) - def register_landing(self) -> None: - """Register landing endpoint.""" + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="conformance", + ) + + return data @self.router.get( "/", @@ -221,6 +296,7 @@ def register_landing(self) -> None: } }, }, + tags=["OGC Common"], ) def landing( request: Request, @@ -254,83 +330,7 @@ def landing( type=MediaType.json, rel="conformance", ), - model.Link( - title="List of Collections", - href=self.url_for(request, "collections"), - type=MediaType.json, - rel="data", - ), - model.Link( - title="Collection metadata", - href=self.url_for( - request, - "collection", - collectionId="{collectionId}", - ), - type=MediaType.json, - rel="data", - ), - model.Link( - title="Collection queryables", - href=self.url_for( - request, - "queryables", - collectionId="{collectionId}", - ), - type=MediaType.schemajson, - rel="queryables", - ), - model.Link( - title="Collection Features", - href=self.url_for( - request, "items", collectionId="{collectionId}" - ), - type=MediaType.geojson, - rel="data", - ), - model.Link( - title="Collection Vector Tiles", - href=self.url_for( - request, - "tile", - collectionId="{collectionId}", - tileMatrix="{tileMatrix}", - tileCol="{tileCol}", - tileRow="{tileRow}", - ), - type=MediaType.mvt, - rel="data", - ), - model.Link( - title="Collection Feature", - href=self.url_for( - request, - "item", - collectionId="{collectionId}", - itemId="{itemId}", - ), - type=MediaType.geojson, - rel="data", - ), - model.Link( - title="TileMatrixSets", - href=self.url_for( - request, - "tilematrixsets", - ), - type=MediaType.json, - rel="data", - ), - model.Link( - title="TileMatrixSet", - href=self.url_for( - request, - "tilematrixset", - tileMatrixSetId="{tileMatrixSetId}", - ), - type=MediaType.json, - rel="data", - ), + *self.links(request), ], ) @@ -343,63 +343,73 @@ def landing( return data - def register_conformance(self) -> None: - """Register conformance endpoint.""" - @self.router.get( - "/conformance", - response_model=model.Conformance, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, - ) - def conformance( - request: Request, - output_type: Optional[MediaType] = Depends(OutputType), - ): - """Get conformance.""" - data = model.Conformance( - conformsTo=[ - # OGC Common - "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", - "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landingPage", - "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", - "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query", - "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", - "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html", - "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30", - # OGC Features - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", - "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter", - "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", - # OGC Tiles (WIP) - "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core", - "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30", - "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt", - ] - ) - - if output_type == MediaType.html: - return self._create_html_response( +@dataclass +class OGCFeaturesFactory(EndpointsFactory): + """OGC Features Endpoints Factory.""" + + @property + def conforms_to(self) -> List[str]: + """Factory conformances.""" + return [ + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter", + "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter", + ] + + def links(self, request: Request) -> List[model.Link]: + """OGC Features API links.""" + return [ + model.Link( + title="List of Collections", + href=self.url_for(request, "collections"), + type=MediaType.json, + rel="data", + ), + model.Link( + title="Collection metadata", + href=self.url_for( request, - data.json(exclude_none=True), - template_name="conformance", - ) - - return data + "collection", + collectionId="{collectionId}", + ), + type=MediaType.json, + rel="data", + ), + model.Link( + title="Collection queryables", + href=self.url_for( + request, + "queryables", + collectionId="{collectionId}", + ), + type=MediaType.schemajson, + rel="queryables", + ), + model.Link( + title="Collection Features", + href=self.url_for(request, "items", collectionId="{collectionId}"), + type=MediaType.geojson, + rel="data", + ), + model.Link( + title="Collection Feature", + href=self.url_for( + request, + "item", + collectionId="{collectionId}", + itemId="{itemId}", + ), + type=MediaType.geojson, + rel="data", + ), + ] - def register_collections(self): # noqa - """Register Collections endpoints.""" + def register_routes(self): # noqa: C901 + """Register OGC Features endpoints.""" @self.router.get( "/collections", @@ -467,11 +477,6 @@ def collections( items_returned = len(collections_list) links: list = [ - model.Link( - href=self.url_for(request, "landing"), - rel="parent", - type=MediaType.json, - ), model.Link( href=self.url_for(request, "collections"), rel="self", @@ -1121,8 +1126,61 @@ async def item( # Default to GeoJSON Response return GeoJSONResponse(data) - def register_tiles(self): # noqa - """Register Tile endpoints.""" + +@dataclass +class OGCTilesFactory(EndpointsFactory): + """OGC Tiles Endpoints Factory.""" + + supported_tms: TileMatrixSets = default_tms + + @property + def conforms_to(self) -> List[str]: + """Factory conformances.""" + return [ + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt", + ] + + def links(self, request: Request) -> List[model.Link]: + """OGC Tiles API links.""" + return [ + model.Link( + title="Collection Vector Tiles", + href=self.url_for( + request, + "tile", + collectionId="{collectionId}", + tileMatrix="{tileMatrix}", + tileCol="{tileCol}", + tileRow="{tileRow}", + ), + type=MediaType.mvt, + rel="data", + ), + model.Link( + title="TileMatrixSets", + href=self.url_for( + request, + "tilematrixsets", + ), + type=MediaType.json, + rel="data", + ), + model.Link( + title="TileMatrixSet", + href=self.url_for( + request, + "tilematrixset", + tileMatrixSetId="{tileMatrixSetId}", + ), + type=MediaType.json, + rel="data", + ), + ] + + def register_routes(self): # noqa: C901 + """Register OGC Tiles endpoints.""" @self.router.get( "/collections/{collectionId}/tiles/{tileMatrixSetId}/{tileMatrix}/{tileCol}/{tileRow}", @@ -1273,7 +1331,8 @@ async def tilejson( response_class=HTMLResponse, ) @self.router.get( - "/collections/{collectionId}/viewer", response_class=HTMLResponse + "/collections/{collectionId}/viewer", + response_class=HTMLResponse, ) def viewer_endpoint( request: Request, @@ -1364,3 +1423,50 @@ async def tilematrixset( OGC Specification: http://docs.opengeospatial.org/per/19-069.html#_tilematrixset """ return self.supported_tms.get(tileMatrixSetId) + + +@dataclass +class Endpoints(EndpointsFactory): + """OGC Features and Tiles Endpoints Factory.""" + + # OGC Tiles dependency + supported_tms: TileMatrixSets = default_tms + + ogc_features: OGCFeaturesFactory = field(init=False) + ogc_tiles: OGCTilesFactory = field(init=False) + + @property + def conforms_to(self) -> List[str]: + """Endpoints conformances.""" + return [ + *self.ogc_features.conforms_to, + *self.ogc_tiles.conforms_to, + ] + + def links(self, request: Request) -> List[model.Link]: + """List of available links.""" + return [ + *self.ogc_features.links(request), + *self.ogc_tiles.links(request), + ] + + def register_routes(self): + """Register factory Routes.""" + self.ogc_features = OGCFeaturesFactory( + collection_dependency=self.collection_dependency, + router_prefix=self.router_prefix, + templates=self.templates, + # We do not want `/` and `/conformance` from the factory + with_common=False, + ) + self.router.include_router(self.ogc_features.router, tags=["OGC Features API"]) + + self.ogc_tiles = OGCTilesFactory( + collection_dependency=self.collection_dependency, + router_prefix=self.router_prefix, + templates=self.templates, + supported_tms=self.supported_tms, + # We do not want `/` and `/conformance` from the factory + with_common=False, + ) + self.router.include_router(self.ogc_tiles.router, tags=["OGC Tiles API"]) diff --git a/tipg/main.py b/tipg/main.py index 45e2bced..bb8969ad 100644 --- a/tipg/main.py +++ b/tipg/main.py @@ -43,9 +43,8 @@ loader=jinja2.ChoiceLoader(templates_location), ) # type: ignore -# Register endpoints. -endpoints = Endpoints(title=settings.name, templates=templates) -app.include_router(endpoints.router, tags=["OGC API"]) +ogc_api = Endpoints(title=settings.name, templates=templates) +app.include_router(ogc_api.router) # Set all CORS enabled origins if settings.cors_origins: @@ -100,12 +99,12 @@ def ping(): if settings.DEBUG: - @app.get("/rawcatalog") + @app.get("/rawcatalog", tags=["debug"]) async def raw_catalog(request: Request): """Return parsed catalog data for testing.""" return request.app.state.collection_catalog - @app.get("/refresh") + @app.get("/refresh", tags=["debug"]) async def refresh(request: Request): """Return parsed catalog data for testing.""" await startup_event()