From c8aea341fe4b66be94c7e5aebfadf42636d874f7 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 28 Feb 2023 09:35:09 +0100 Subject: [PATCH 01/11] split OGC Features and Tiles endpoints factories --- tipg/factory.py | 496 ++++++++++++++++++++++++------------------------ tipg/main.py | 200 ++++++++++++++++++- 2 files changed, 446 insertions(+), 250 deletions(-) diff --git a/tipg/factory.py b/tipg/factory.py index a660dc77..e6f57e60 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,11 @@ 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 - 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() def url_for(self, request: Request, name: str, **path_params: Any) -> str: """Return full url (with prefix) for a specific handler.""" @@ -170,236 +207,46 @@ 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()}) - - 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": "", - }, - "crumbs": crumbs, - "url": str(request.url), - "baseurl": baseurl, - "urlpath": str(request.url.path), - "urlparams": str(request.url.query), - }, + return create_html_response( + request, + data, + templates=self.templates, + template_name=template_name, + router_prefix=self.router_prefix, ) - def register_landing(self) -> None: - """Register landing endpoint.""" - - @self.router.get( - "/", - response_model=model.Landing, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, - ) - def landing( - request: Request, - output_type: Optional[MediaType] = Depends(OutputType), - ): - """Get landing page.""" - data = model.Landing( - title=self.title, - links=[ - model.Link( - title="Landing Page", - href=self.url_for(request, "landing"), - type=MediaType.json, - rel="self", - ), - model.Link( - title="the API definition (JSON)", - href=request.url_for("openapi"), - type=MediaType.openapi30_json, - rel="service-desc", - ), - model.Link( - title="the API documentation", - href=request.url_for("swagger_ui_html"), - type=MediaType.html, - rel="service-doc", - ), - model.Link( - title="Conformance", - href=self.url_for(request, "conformance"), - 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", - ), - ], - ) - - if output_type == MediaType.html: - return self._create_html_response( - request, - data.json(exclude_none=True), - template_name="landing", - ) - - return data - - def register_conformance(self) -> None: - """Register conformance endpoint.""" + @abc.abstractmethod + def register_routes(self): + """Register factory 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: {}, - } - }, - }, - ) - 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( - request, - data.json(exclude_none=True), - template_name="conformance", - ) + @property + @abc.abstractmethod + def conforms_to(self) -> List[str]: + """Endpoints conformances.""" + ... - return data - def register_collections(self): # noqa - """Register Collections endpoints.""" +@dataclass +class OGCFeaturesFactory(EndpointsFactory): + """OGC Features Endpoints Factory.""" + + full: bool = False + + @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 register_routes(self): # noqa: C901 + """Register OGC Features endpoints.""" @self.router.get( "/collections", @@ -467,11 +314,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 +963,176 @@ async def item( # Default to GeoJSON Response return GeoJSONResponse(data) - def register_tiles(self): # noqa - """Register Tile endpoints.""" + # NOTE: The OGC Features API specification includes /conformances and / endpoints + # In TiPG application those are defined in the main application. + # For self defined OGC Features API you can use OGCFeaturesFactory(full=True) + # to automatically set `/` and `/conformance` endpoints + if self.full: + + @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", + *self.conforms_to, + ] + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="conformance", + ) + + return data + + @self.router.get( + "/", + response_model=model.Landing, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } + }, + }, + ) + def landing( + request: Request, + output_type: Optional[MediaType] = Depends(OutputType), + ): + """Get landing page.""" + data = model.Landing( + title="OGC Features API", + links=[ + model.Link( + title="Landing Page", + href=self.url_for(request, "landing"), + type=MediaType.json, + rel="self", + ), + model.Link( + title="the API definition (JSON)", + href=request.url_for("openapi"), + type=MediaType.openapi30_json, + rel="service-desc", + ), + model.Link( + title="the API documentation", + href=request.url_for("swagger_ui_html"), + type=MediaType.html, + rel="service-doc", + ), + model.Link( + title="Conformance", + href=self.url_for(request, "conformance"), + 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 Feature", + href=self.url_for( + request, + "item", + collectionId="{collectionId}", + itemId="{itemId}", + ), + type=MediaType.geojson, + rel="data", + ), + ], + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="landing", + ) + + return data + + +@dataclass +class OGCTilesFactory(EndpointsFactory): + """OGC Tiles Endpoints Factory.""" + + # OGC Tiles dependency + 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 register_routes(self): + """Register OGC Features endpoints.""" @self.router.get( "/collections/{collectionId}/tiles/{tileMatrixSetId}/{tileMatrix}/{tileCol}/{tileRow}", diff --git a/tipg/main.py b/tipg/main.py index 45e2bced..24fdb431 100644 --- a/tipg/main.py +++ b/tipg/main.py @@ -1,17 +1,21 @@ """tipg app.""" -from typing import Any, List +from typing import Any, List, Optional import jinja2 from tipg import __version__ as tipg_version from tipg.db import close_db_connection, connect_to_db, register_collection_catalog +from tipg.dependencies import OutputType from tipg.errors import DEFAULT_STATUS_CODES, add_exception_handlers -from tipg.factory import Endpoints +from tipg.factory import OGCFeaturesFactory, OGCTilesFactory, create_html_response from tipg.middleware import CacheControlMiddleware +from tipg.model import Conformance, Landing, Link +from tipg.resources.enums import MediaType from tipg.settings import APISettings, DatabaseSettings, PostgresSettings -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request +from fastapi.responses import ORJSONResponse from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -44,8 +48,190 @@ ) # type: ignore # Register endpoints. -endpoints = Endpoints(title=settings.name, templates=templates) -app.include_router(endpoints.router, tags=["OGC API"]) +ogc_features = OGCFeaturesFactory(templates=templates) +app.include_router(ogc_features.router, tags=["OGC Features API"]) + +ogc_tiles = OGCTilesFactory(templates=templates) +app.include_router(ogc_tiles.router, tags=["OGC Tiles API"]) + + +@app.get( + "/conformance", + response_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 = 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.conforms_to, + *ogc_tiles.conforms_to, + ] + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data.json(exclude_none=True), + templates=templates, + template_name="conformance", + ) + + return data + + +@app.get( + "/", + response_model=Landing, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } + }, + }, +) +def landing(request: Request, output_type: Optional[MediaType] = Depends(OutputType)): + """Get landing page.""" + data = Landing( + title=settings.name, + links=[ + Link( + title="Landing Page", + href=request.url_for("landing"), + type=MediaType.json, + rel="self", + ), + Link( + title="the API definition (JSON)", + href=request.url_for("openapi"), + type=MediaType.openapi30_json, + rel="service-desc", + ), + Link( + title="the API documentation", + href=request.url_for("swagger_ui_html"), + type=MediaType.html, + rel="service-doc", + ), + Link( + title="Conformance", + href=request.url_for("conformance"), + type=MediaType.json, + rel="conformance", + ), + Link( + title="List of Collections", + href=ogc_features.url_for(request, "collections"), + type=MediaType.json, + rel="data", + ), + Link( + title="Collection metadata", + href=ogc_features.url_for( + request, + "collection", + collectionId="{collectionId}", + ), + type=MediaType.json, + rel="data", + ), + Link( + title="Collection queryables", + href=ogc_features.url_for( + request, + "queryables", + collectionId="{collectionId}", + ), + type=MediaType.schemajson, + rel="queryables", + ), + Link( + title="Collection Features", + href=ogc_features.url_for( + request, "items", collectionId="{collectionId}" + ), + type=MediaType.geojson, + rel="data", + ), + Link( + title="Collection Vector Tiles", + href=ogc_tiles.url_for( + request, + "tile", + collectionId="{collectionId}", + tileMatrix="{tileMatrix}", + tileCol="{tileCol}", + tileRow="{tileRow}", + ), + type=MediaType.mvt, + rel="data", + ), + Link( + title="Collection Feature", + href=ogc_features.url_for( + request, + "item", + collectionId="{collectionId}", + itemId="{itemId}", + ), + type=MediaType.geojson, + rel="data", + ), + Link( + title="TileMatrixSets", + href=ogc_tiles.url_for( + request, + "tilematrixsets", + ), + type=MediaType.json, + rel="data", + ), + Link( + title="TileMatrixSet", + href=ogc_tiles.url_for( + request, + "tilematrixset", + tileMatrixSetId="{tileMatrixSetId}", + ), + type=MediaType.json, + rel="data", + ), + ], + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data.json(exclude_none=True), + templates=templates, + template_name="landing", + ) + + return data + # Set all CORS enabled origins if settings.cors_origins: @@ -100,12 +286,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() From 92139ca0f0ae5915ac483bc714ad9dd9afeceda6 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 14 Mar 2023 10:24:55 +0100 Subject: [PATCH 02/11] update isort --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc93148a..026ced8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: language_version: python - repo: https://github.com/PyCQA/isort - rev: 5.4.2 + rev: 5.12.0 hooks: - id: isort language_version: python From 990c40e55c8c00426bf5a8fa01821b6245083a0b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Mar 2023 09:19:04 +0100 Subject: [PATCH 03/11] fix URL type after starlette update --- tipg/factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tipg/factory.py b/tipg/factory.py index e6f57e60..fcb13003 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -199,7 +199,7 @@ def url_for(self, request: Request, name: str, **path_params: Any) -> str: if self.router_prefix: base_url += self.router_prefix.lstrip("/") - return url_path.make_absolute_url(base_url=base_url) + return str(url_path.make_absolute_url(base_url=base_url)) def _create_html_response( self, @@ -1041,13 +1041,13 @@ def landing( ), model.Link( title="the API definition (JSON)", - href=request.url_for("openapi"), + href=str(request.url_for("openapi")), type=MediaType.openapi30_json, rel="service-desc", ), model.Link( title="the API documentation", - href=request.url_for("swagger_ui_html"), + href=str(request.url_for("swagger_ui_html")), type=MediaType.html, rel="service-doc", ), From 966e1b376503b3d4436d0a28f27a6a765920f87c Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Mar 2023 09:53:12 +0100 Subject: [PATCH 04/11] add Main Factory --- tipg/factory.py | 235 +++++++++++++++++++++++++++++++++++++++++++++++- tipg/main.py | 197 ++-------------------------------------- 2 files changed, 239 insertions(+), 193 deletions(-) diff --git a/tipg/factory.py b/tipg/factory.py index fcb13003..b52959b0 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -232,6 +232,7 @@ class OGCFeaturesFactory(EndpointsFactory): """OGC Features Endpoints Factory.""" full: bool = False + tags: Optional[List[str]] = None @property def conforms_to(self) -> List[str]: @@ -261,6 +262,7 @@ def register_routes(self): # noqa: C901 } }, }, + tags=self.tags, ) def collections( request: Request, @@ -426,6 +428,7 @@ def collections( } }, }, + tags=self.tags, ) def collection( request: Request, @@ -512,6 +515,7 @@ def collection( } }, }, + tags=self.tags, ) def queryables( request: Request, @@ -560,6 +564,7 @@ def queryables( "model": model.Items, }, }, + tags=self.tags, ) async def items( request: Request, @@ -817,6 +822,7 @@ async def items( "model": model.Item, }, }, + tags=self.tags, ) async def item( request: Request, @@ -1121,6 +1127,7 @@ class OGCTilesFactory(EndpointsFactory): # OGC Tiles dependency supported_tms: TileMatrixSets = default_tms + tags: Optional[List[str]] = None @property def conforms_to(self) -> List[str]: @@ -1138,11 +1145,13 @@ def register_routes(self): "/collections/{collectionId}/tiles/{tileMatrixSetId}/{tileMatrix}/{tileCol}/{tileRow}", response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, + tags=self.tags, ) @self.router.get( "/collections/{collectionId}/tiles/{tileMatrix}/{tileCol}/{tileRow}", response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, + tags=self.tags, ) async def tile( request: Request, @@ -1202,12 +1211,14 @@ async def tile( response_model=model.TileJSON, responses={200: {"description": "Return a tilejson"}}, response_model_exclude_none=True, + tags=self.tags, ) @self.router.get( "/collections/{collectionId}/tilejson.json", response_model=model.TileJSON, responses={200: {"description": "Return a tilejson"}}, response_model_exclude_none=True, + tags=self.tags, ) async def tilejson( request: Request, @@ -1281,9 +1292,12 @@ async def tilejson( @self.router.get( "/collections/{collectionId}/{tileMatrixSetId}/viewer", response_class=HTMLResponse, + tags=self.tags, ) @self.router.get( - "/collections/{collectionId}/viewer", response_class=HTMLResponse + "/collections/{collectionId}/viewer", + response_class=HTMLResponse, + tags=self.tags, ) def viewer_endpoint( request: Request, @@ -1331,6 +1345,7 @@ def viewer_endpoint( response_model_exclude_none=True, summary="Retrieve the list of available tiling schemes (tile matrix sets).", operation_id="getTileMatrixSetsList", + tags=self.tags, ) async def tilematrixsets(request: Request): """ @@ -1363,6 +1378,7 @@ async def tilematrixsets(request: Request): response_model_exclude_none=True, summary="Retrieve the definition of the specified tiling scheme (tile matrix set).", operation_id="getTileMatrixSet", + tags=self.tags, ) async def tilematrixset( tileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Path( @@ -1374,3 +1390,220 @@ 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.""" + + name: str = "TiPg: OGC Features and Tiles API" + + # OGC Tiles dependency + supported_tms: TileMatrixSets = default_tms + + ogc_features: OGCFeaturesFactory = field(init=False) + ogc_tiles: OGCTilesFactory = field(init=False) + + def __post_init__(self): + """Post Init: register route and configure specific options.""" + self.ogc_features = OGCFeaturesFactory( + router=self.router, + collection_dependency=self.collection_dependency, + router_prefix=self.router_prefix, + templates=self.templates, + full=False, + tags=["OGC Features API"], + ) + self.ogc_tiles = OGCTilesFactory( + router=self.router, + collection_dependency=self.collection_dependency, + router_prefix=self.router_prefix, + templates=self.templates, + supported_tms=self.supported_tms, + tags=["OGC Tiles API"], + ) + self.register_routes() + + @property + def conforms_to(self) -> List[str]: + """Endpoints conformances.""" + return [ + # 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", + *self.ogc_features.conforms_to, + *self.ogc_tiles.conforms_to, + ] + + def register_routes(self): + """Register factory 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: {}, + } + }, + }, + ) + def conformance( + request: Request, output_type: Optional[MediaType] = Depends(OutputType) + ): + """Get conformance.""" + data = model.Conformance(conformsTo=self.conforms_to) + + if output_type == MediaType.html: + return create_html_response( + request, + data.json(exclude_none=True), + templates=self.templates, + template_name="conformance", + ) + + return data + + @self.router.get( + "/", + response_model=model.Landing, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } + }, + }, + ) + def landing( + request: Request, output_type: Optional[MediaType] = Depends(OutputType) + ): + """Get landing page.""" + data = model.Landing( + title=self.name, + links=[ + model.Link( + title="Landing Page", + href=str(request.url_for("landing")), + type=MediaType.json, + rel="self", + ), + model.Link( + title="the API definition (JSON)", + href=str(request.url_for("openapi")), + type=MediaType.openapi30_json, + rel="service-desc", + ), + model.Link( + title="the API documentation", + href=str(request.url_for("swagger_ui_html")), + type=MediaType.html, + rel="service-doc", + ), + model.Link( + title="Conformance", + href=self.url_for(request, "conformance"), + 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", + ), + ], + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data.json(exclude_none=True), + templates=self.templates, + template_name="landing", + ) + + return data diff --git a/tipg/main.py b/tipg/main.py index 24fdb431..5da21196 100644 --- a/tipg/main.py +++ b/tipg/main.py @@ -1,21 +1,17 @@ """tipg app.""" -from typing import Any, List, Optional +from typing import Any, List import jinja2 from tipg import __version__ as tipg_version from tipg.db import close_db_connection, connect_to_db, register_collection_catalog -from tipg.dependencies import OutputType from tipg.errors import DEFAULT_STATUS_CODES, add_exception_handlers -from tipg.factory import OGCFeaturesFactory, OGCTilesFactory, create_html_response +from tipg.factory import Endpoints from tipg.middleware import CacheControlMiddleware -from tipg.model import Conformance, Landing, Link -from tipg.resources.enums import MediaType from tipg.settings import APISettings, DatabaseSettings, PostgresSettings -from fastapi import Depends, FastAPI, Request -from fastapi.responses import ORJSONResponse +from fastapi import FastAPI, Request from starlette.middleware.cors import CORSMiddleware from starlette.templating import Jinja2Templates @@ -47,191 +43,8 @@ loader=jinja2.ChoiceLoader(templates_location), ) # type: ignore -# Register endpoints. -ogc_features = OGCFeaturesFactory(templates=templates) -app.include_router(ogc_features.router, tags=["OGC Features API"]) - -ogc_tiles = OGCTilesFactory(templates=templates) -app.include_router(ogc_tiles.router, tags=["OGC Tiles API"]) - - -@app.get( - "/conformance", - response_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 = 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.conforms_to, - *ogc_tiles.conforms_to, - ] - ) - - if output_type == MediaType.html: - return create_html_response( - request, - data.json(exclude_none=True), - templates=templates, - template_name="conformance", - ) - - return data - - -@app.get( - "/", - response_model=Landing, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, -) -def landing(request: Request, output_type: Optional[MediaType] = Depends(OutputType)): - """Get landing page.""" - data = Landing( - title=settings.name, - links=[ - Link( - title="Landing Page", - href=request.url_for("landing"), - type=MediaType.json, - rel="self", - ), - Link( - title="the API definition (JSON)", - href=request.url_for("openapi"), - type=MediaType.openapi30_json, - rel="service-desc", - ), - Link( - title="the API documentation", - href=request.url_for("swagger_ui_html"), - type=MediaType.html, - rel="service-doc", - ), - Link( - title="Conformance", - href=request.url_for("conformance"), - type=MediaType.json, - rel="conformance", - ), - Link( - title="List of Collections", - href=ogc_features.url_for(request, "collections"), - type=MediaType.json, - rel="data", - ), - Link( - title="Collection metadata", - href=ogc_features.url_for( - request, - "collection", - collectionId="{collectionId}", - ), - type=MediaType.json, - rel="data", - ), - Link( - title="Collection queryables", - href=ogc_features.url_for( - request, - "queryables", - collectionId="{collectionId}", - ), - type=MediaType.schemajson, - rel="queryables", - ), - Link( - title="Collection Features", - href=ogc_features.url_for( - request, "items", collectionId="{collectionId}" - ), - type=MediaType.geojson, - rel="data", - ), - Link( - title="Collection Vector Tiles", - href=ogc_tiles.url_for( - request, - "tile", - collectionId="{collectionId}", - tileMatrix="{tileMatrix}", - tileCol="{tileCol}", - tileRow="{tileRow}", - ), - type=MediaType.mvt, - rel="data", - ), - Link( - title="Collection Feature", - href=ogc_features.url_for( - request, - "item", - collectionId="{collectionId}", - itemId="{itemId}", - ), - type=MediaType.geojson, - rel="data", - ), - Link( - title="TileMatrixSets", - href=ogc_tiles.url_for( - request, - "tilematrixsets", - ), - type=MediaType.json, - rel="data", - ), - Link( - title="TileMatrixSet", - href=ogc_tiles.url_for( - request, - "tilematrixset", - tileMatrixSetId="{tileMatrixSetId}", - ), - type=MediaType.json, - rel="data", - ), - ], - ) - - if output_type == MediaType.html: - return create_html_response( - request, - data.json(exclude_none=True), - templates=templates, - template_name="landing", - ) - - return data - +ogc_api = Endpoints(templates=templates, name=settings.name) +app.include_router(ogc_api.router) # Set all CORS enabled origins if settings.cors_origins: From 571130f901982daf8b4fa463171c4c1671fd9af6 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Mar 2023 09:55:44 +0100 Subject: [PATCH 05/11] name -> title --- tipg/factory.py | 4 ++-- tipg/main.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tipg/factory.py b/tipg/factory.py index b52959b0..c872ee26 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -1396,7 +1396,7 @@ async def tilematrixset( class Endpoints(EndpointsFactory): """OGC Features and Tiles Endpoints Factory.""" - name: str = "TiPg: OGC Features and Tiles API" + title: str = "TiPg: OGC Features and Tiles API" # OGC Tiles dependency supported_tms: TileMatrixSets = default_tms @@ -1492,7 +1492,7 @@ def landing( ): """Get landing page.""" data = model.Landing( - title=self.name, + title=self.title, links=[ model.Link( title="Landing Page", diff --git a/tipg/main.py b/tipg/main.py index 5da21196..bb8969ad 100644 --- a/tipg/main.py +++ b/tipg/main.py @@ -43,7 +43,7 @@ loader=jinja2.ChoiceLoader(templates_location), ) # type: ignore -ogc_api = Endpoints(templates=templates, name=settings.name) +ogc_api = Endpoints(title=settings.name, templates=templates) app.include_router(ogc_api.router) # Set all CORS enabled origins From cd5ef52d50904b0ff7edc7cc878b2de6f555b88b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Mar 2023 17:15:49 +0100 Subject: [PATCH 06/11] add full OGC tiles options + docs --- docs/mkdocs.yml | 3 +- docs/src/advanced/factories.md | 160 +++++++++++++++++++++++++++++++++ tipg/factory.py | 154 +++++++++++++++++++++++++++++-- 3 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 docs/src/advanced/factories.md 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..52448d60 --- /dev/null +++ b/docs/src/advanced/factories.md @@ -0,0 +1,160 @@ + +`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 Factory + +```python +from tipg.factory import OGCFeaturesFactory + +app = FastAPI() +endpoints = OGCFeaturesFactory(full=True) +app.include_router(endpoints.router, tags=["OGC Features API"]) +``` + +#### Creation Options + +- **collection_dependency** (Callable[..., tipg.dbmodel.Collection]): Callable which return a Collection instance + +- **full** (bool, optional): Create Full OGC Features API set of endpoints (with landing `/` and conformance `/conformance` endpoints). 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 + +#### 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 Factory + +```python +from tipg.factory import OGCTilesFactory + +app = FastAPI() +endpoints = OGCTilesFactory(full=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) + +- **full** (bool, optional): Create Full OGC Tiles API set of endpoints (with landing `/` and conformance `/conformance` endpoints). 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 + +#### 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 + Tile 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) + +- **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 + +#### 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/tipg/factory.py b/tipg/factory.py index c872ee26..bcf8506b 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -231,7 +231,8 @@ def conforms_to(self) -> List[str]: class OGCFeaturesFactory(EndpointsFactory): """OGC Features Endpoints Factory.""" - full: bool = False + full: bool = True + tags: Optional[List[str]] = None @property @@ -969,10 +970,8 @@ async def item( # Default to GeoJSON Response return GeoJSONResponse(data) - # NOTE: The OGC Features API specification includes /conformances and / endpoints - # In TiPG application those are defined in the main application. - # For self defined OGC Features API you can use OGCFeaturesFactory(full=True) - # to automatically set `/` and `/conformance` endpoints + # NOTE: The OGC Features API specification includes `/conformances` and `/` endpoints + # Those two endpoints are optional in the factory and only added if `full=True`. if self.full: @self.router.get( @@ -1125,8 +1124,10 @@ def landing( class OGCTilesFactory(EndpointsFactory): """OGC Tiles Endpoints Factory.""" - # OGC Tiles dependency supported_tms: TileMatrixSets = default_tms + + full: bool = True + tags: Optional[List[str]] = None @property @@ -1138,7 +1139,7 @@ def conforms_to(self) -> List[str]: "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt", ] - def register_routes(self): + def register_routes(self): # noqa: C901 """Register OGC Features endpoints.""" @self.router.get( @@ -1391,6 +1392,142 @@ async def tilematrixset( """ return self.supported_tms.get(tileMatrixSetId) + # NOTE: The OGC Tiles API specification includes `/conformances` and `/` endpoints + # Those two endpoints are optional in the factory and only added if `full=True`. + if self.full: + + @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", + *self.conforms_to, + ] + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="conformance", + ) + + return data + + @self.router.get( + "/", + response_model=model.Landing, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } + }, + }, + ) + def landing( + request: Request, + output_type: Optional[MediaType] = Depends(OutputType), + ): + """Get landing page.""" + data = model.Landing( + title="OGC Features API", + links=[ + model.Link( + title="Landing Page", + href=self.url_for(request, "landing"), + type=MediaType.json, + rel="self", + ), + model.Link( + title="the API definition (JSON)", + href=str(request.url_for("openapi")), + type=MediaType.openapi30_json, + rel="service-desc", + ), + model.Link( + title="the API documentation", + href=str(request.url_for("swagger_ui_html")), + type=MediaType.html, + rel="service-doc", + ), + model.Link( + title="Conformance", + href=self.url_for(request, "conformance"), + type=MediaType.json, + rel="conformance", + ), + 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", + ), + ], + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="landing", + ) + + return data + @dataclass class Endpoints(EndpointsFactory): @@ -1411,6 +1548,7 @@ def __post_init__(self): collection_dependency=self.collection_dependency, router_prefix=self.router_prefix, templates=self.templates, + # We do not want `/` and `/conformance` from the factory full=False, tags=["OGC Features API"], ) @@ -1420,6 +1558,8 @@ def __post_init__(self): router_prefix=self.router_prefix, templates=self.templates, supported_tms=self.supported_tms, + # We do not want `/` and `/conformance` from the factory + full=False, tags=["OGC Tiles API"], ) self.register_routes() From 98316f066a30066c354342d8cb3ec247462580ae Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Mar 2023 19:18:04 +0100 Subject: [PATCH 07/11] davids comments --- docs/src/advanced/factories.md | 12 +- tipg/factory.py | 725 ++++++++++----------------------- 2 files changed, 234 insertions(+), 503 deletions(-) diff --git a/docs/src/advanced/factories.md b/docs/src/advanced/factories.md index 52448d60..c623c782 100644 --- a/docs/src/advanced/factories.md +++ b/docs/src/advanced/factories.md @@ -63,7 +63,7 @@ app.include_router(endpoints.router, tags=["OGC Features API"]) - **collection_dependency** (Callable[..., tipg.dbmodel.Collection]): Callable which return a Collection instance -- **full** (bool, optional): Create Full OGC Features API set of endpoints (with landing `/` and conformance `/conformance` endpoints). Defaults to `True` +- **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 @@ -71,6 +71,8 @@ app.include_router(endpoints.router, tags=["OGC Features API"]) - **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 @@ -100,7 +102,7 @@ app.include_router(endpoints.router, tags=["OGC Tiles API"]) - **supported_tms** (morecantile.TileMatrixSets): morecantile TileMatrixSets instance (holds a set of TileMatrixSet documents) -- **full** (bool, optional): Create Full OGC Tiles API set of endpoints (with landing `/` and conformance `/conformance` endpoints). Defaults to `True` +- **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 @@ -108,6 +110,8 @@ app.include_router(endpoints.router, tags=["OGC Tiles API"]) - **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 @@ -136,12 +140,16 @@ app.include_router(endpoints.router) - **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 diff --git a/tipg/factory.py b/tipg/factory.py index bcf8506b..08ad364a 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -187,9 +187,16 @@ class EndpointsFactory(metaclass=abc.ABCMeta): templates: Jinja2Templates = DEFAULT_TEMPLATES + # 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_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.""" @@ -226,15 +233,121 @@ 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: {}, + } + }, + }, + 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, + ] + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="conformance", + ) + + return data + + @self.router.get( + "/", + response_model=model.Landing, + response_model_exclude_none=True, + response_class=ORJSONResponse, + responses={ + 200: { + "content": { + MediaType.json.value: {}, + MediaType.html.value: {}, + } + }, + }, + tags=["OGC Common"], + ) + def landing( + request: Request, + output_type: Optional[MediaType] = Depends(OutputType), + ): + """Get landing page.""" + data = model.Landing( + title=self.title, + links=[ + model.Link( + title="Landing Page", + href=self.url_for(request, "landing"), + type=MediaType.json, + rel="self", + ), + model.Link( + title="the API definition (JSON)", + href=str(request.url_for("openapi")), + type=MediaType.openapi30_json, + rel="service-desc", + ), + model.Link( + title="the API documentation", + href=str(request.url_for("swagger_ui_html")), + type=MediaType.html, + rel="service-doc", + ), + model.Link( + title="Conformance", + href=self.url_for(request, "conformance"), + type=MediaType.json, + rel="conformance", + ), + *self.links(request), + ], + ) + + if output_type == MediaType.html: + return self._create_html_response( + request, + data.json(exclude_none=True), + template_name="landing", + ) + + return data + @dataclass class OGCFeaturesFactory(EndpointsFactory): """OGC Features Endpoints Factory.""" - full: bool = True - - tags: Optional[List[str]] = None - @property def conforms_to(self) -> List[str]: """Factory conformances.""" @@ -247,6 +360,54 @@ def conforms_to(self) -> List[str]: "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, + "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_routes(self): # noqa: C901 """Register OGC Features endpoints.""" @@ -263,7 +424,6 @@ def register_routes(self): # noqa: C901 } }, }, - tags=self.tags, ) def collections( request: Request, @@ -429,7 +589,6 @@ def collections( } }, }, - tags=self.tags, ) def collection( request: Request, @@ -516,7 +675,6 @@ def collection( } }, }, - tags=self.tags, ) def queryables( request: Request, @@ -565,7 +723,6 @@ def queryables( "model": model.Items, }, }, - tags=self.tags, ) async def items( request: Request, @@ -823,7 +980,6 @@ async def items( "model": model.Item, }, }, - tags=self.tags, ) async def item( request: Request, @@ -970,155 +1126,6 @@ async def item( # Default to GeoJSON Response return GeoJSONResponse(data) - # NOTE: The OGC Features API specification includes `/conformances` and `/` endpoints - # Those two endpoints are optional in the factory and only added if `full=True`. - if self.full: - - @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", - *self.conforms_to, - ] - ) - - if output_type == MediaType.html: - return self._create_html_response( - request, - data.json(exclude_none=True), - template_name="conformance", - ) - - return data - - @self.router.get( - "/", - response_model=model.Landing, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, - ) - def landing( - request: Request, - output_type: Optional[MediaType] = Depends(OutputType), - ): - """Get landing page.""" - data = model.Landing( - title="OGC Features API", - links=[ - model.Link( - title="Landing Page", - href=self.url_for(request, "landing"), - type=MediaType.json, - rel="self", - ), - model.Link( - title="the API definition (JSON)", - href=str(request.url_for("openapi")), - type=MediaType.openapi30_json, - rel="service-desc", - ), - model.Link( - title="the API documentation", - href=str(request.url_for("swagger_ui_html")), - type=MediaType.html, - rel="service-doc", - ), - model.Link( - title="Conformance", - href=self.url_for(request, "conformance"), - 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 Feature", - href=self.url_for( - request, - "item", - collectionId="{collectionId}", - itemId="{itemId}", - ), - type=MediaType.geojson, - rel="data", - ), - ], - ) - - if output_type == MediaType.html: - return self._create_html_response( - request, - data.json(exclude_none=True), - template_name="landing", - ) - - return data - @dataclass class OGCTilesFactory(EndpointsFactory): @@ -1126,10 +1133,6 @@ class OGCTilesFactory(EndpointsFactory): supported_tms: TileMatrixSets = default_tms - full: bool = True - - tags: Optional[List[str]] = None - @property def conforms_to(self) -> List[str]: """Factory conformances.""" @@ -1139,6 +1142,43 @@ def conforms_to(self) -> List[str]: "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 Features endpoints.""" @@ -1146,13 +1186,11 @@ def register_routes(self): # noqa: C901 "/collections/{collectionId}/tiles/{tileMatrixSetId}/{tileMatrix}/{tileCol}/{tileRow}", response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, - tags=self.tags, ) @self.router.get( "/collections/{collectionId}/tiles/{tileMatrix}/{tileCol}/{tileRow}", response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, - tags=self.tags, ) async def tile( request: Request, @@ -1212,14 +1250,12 @@ async def tile( response_model=model.TileJSON, responses={200: {"description": "Return a tilejson"}}, response_model_exclude_none=True, - tags=self.tags, ) @self.router.get( "/collections/{collectionId}/tilejson.json", response_model=model.TileJSON, responses={200: {"description": "Return a tilejson"}}, response_model_exclude_none=True, - tags=self.tags, ) async def tilejson( request: Request, @@ -1293,12 +1329,10 @@ async def tilejson( @self.router.get( "/collections/{collectionId}/{tileMatrixSetId}/viewer", response_class=HTMLResponse, - tags=self.tags, ) @self.router.get( "/collections/{collectionId}/viewer", response_class=HTMLResponse, - tags=self.tags, ) def viewer_endpoint( request: Request, @@ -1346,7 +1380,6 @@ def viewer_endpoint( response_model_exclude_none=True, summary="Retrieve the list of available tiling schemes (tile matrix sets).", operation_id="getTileMatrixSetsList", - tags=self.tags, ) async def tilematrixsets(request: Request): """ @@ -1379,7 +1412,6 @@ async def tilematrixsets(request: Request): response_model_exclude_none=True, summary="Retrieve the definition of the specified tiling scheme (tile matrix set).", operation_id="getTileMatrixSet", - tags=self.tags, ) async def tilematrixset( tileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Path( @@ -1392,358 +1424,49 @@ async def tilematrixset( """ return self.supported_tms.get(tileMatrixSetId) - # NOTE: The OGC Tiles API specification includes `/conformances` and `/` endpoints - # Those two endpoints are optional in the factory and only added if `full=True`. - if self.full: - - @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", - *self.conforms_to, - ] - ) - - if output_type == MediaType.html: - return self._create_html_response( - request, - data.json(exclude_none=True), - template_name="conformance", - ) - - return data - - @self.router.get( - "/", - response_model=model.Landing, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, - ) - def landing( - request: Request, - output_type: Optional[MediaType] = Depends(OutputType), - ): - """Get landing page.""" - data = model.Landing( - title="OGC Features API", - links=[ - model.Link( - title="Landing Page", - href=self.url_for(request, "landing"), - type=MediaType.json, - rel="self", - ), - model.Link( - title="the API definition (JSON)", - href=str(request.url_for("openapi")), - type=MediaType.openapi30_json, - rel="service-desc", - ), - model.Link( - title="the API documentation", - href=str(request.url_for("swagger_ui_html")), - type=MediaType.html, - rel="service-doc", - ), - model.Link( - title="Conformance", - href=self.url_for(request, "conformance"), - type=MediaType.json, - rel="conformance", - ), - 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", - ), - ], - ) - - if output_type == MediaType.html: - return self._create_html_response( - request, - data.json(exclude_none=True), - template_name="landing", - ) - - return data - @dataclass class Endpoints(EndpointsFactory): """OGC Features and Tiles Endpoints Factory.""" - title: str = "TiPg: OGC Features and Tiles API" - # OGC Tiles dependency supported_tms: TileMatrixSets = default_tms ogc_features: OGCFeaturesFactory = field(init=False) ogc_tiles: OGCTilesFactory = field(init=False) - def __post_init__(self): - """Post Init: register route and configure specific options.""" - self.ogc_features = OGCFeaturesFactory( - router=self.router, - collection_dependency=self.collection_dependency, - router_prefix=self.router_prefix, - templates=self.templates, - # We do not want `/` and `/conformance` from the factory - full=False, - tags=["OGC Features API"], - ) - self.ogc_tiles = OGCTilesFactory( - router=self.router, - 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 - full=False, - tags=["OGC Tiles API"], - ) - self.register_routes() - @property def conforms_to(self) -> List[str]: """Endpoints conformances.""" return [ - # 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", *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.router.get( - "/conformance", - response_model=model.Conformance, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, + 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, ) - def conformance( - request: Request, output_type: Optional[MediaType] = Depends(OutputType) - ): - """Get conformance.""" - data = model.Conformance(conformsTo=self.conforms_to) - - if output_type == MediaType.html: - return create_html_response( - request, - data.json(exclude_none=True), - templates=self.templates, - template_name="conformance", - ) - - return data + self.router.include_router(self.ogc_features.router, tags=["OGC Features API"]) - @self.router.get( - "/", - response_model=model.Landing, - response_model_exclude_none=True, - response_class=ORJSONResponse, - responses={ - 200: { - "content": { - MediaType.json.value: {}, - MediaType.html.value: {}, - } - }, - }, + 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, ) - def landing( - request: Request, output_type: Optional[MediaType] = Depends(OutputType) - ): - """Get landing page.""" - data = model.Landing( - title=self.title, - links=[ - model.Link( - title="Landing Page", - href=str(request.url_for("landing")), - type=MediaType.json, - rel="self", - ), - model.Link( - title="the API definition (JSON)", - href=str(request.url_for("openapi")), - type=MediaType.openapi30_json, - rel="service-desc", - ), - model.Link( - title="the API documentation", - href=str(request.url_for("swagger_ui_html")), - type=MediaType.html, - rel="service-doc", - ), - model.Link( - title="Conformance", - href=self.url_for(request, "conformance"), - 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", - ), - ], - ) - - if output_type == MediaType.html: - return create_html_response( - request, - data.json(exclude_none=True), - templates=self.templates, - template_name="landing", - ) - - return data + self.router.include_router(self.ogc_tiles.router, tags=["OGC Tiles API"]) From 6992ca02c561162ed302d0864c64d98de38a2a5e Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 16 Mar 2023 10:22:28 +0100 Subject: [PATCH 08/11] add factories tests --- tests/conftest.py | 2 - tests/test_factories.py | 293 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 tests/test_factories.py 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 From c03d0451ca2a81a19d6ef20e966dee121d33b8dc Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 16 Mar 2023 10:24:28 +0100 Subject: [PATCH 09/11] fix docs --- docs/src/advanced/factories.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/advanced/factories.md b/docs/src/advanced/factories.md index c623c782..28783fc1 100644 --- a/docs/src/advanced/factories.md +++ b/docs/src/advanced/factories.md @@ -55,7 +55,7 @@ app.include_router(endpoints.router) from tipg.factory import OGCFeaturesFactory app = FastAPI() -endpoints = OGCFeaturesFactory(full=True) +endpoints = OGCFeaturesFactory(with_common=True) app.include_router(endpoints.router, tags=["OGC Features API"]) ``` @@ -92,7 +92,7 @@ app.include_router(endpoints.router, tags=["OGC Features API"]) from tipg.factory import OGCTilesFactory app = FastAPI() -endpoints = OGCTilesFactory(full=True) +endpoints = OGCTilesFactory(with_common=True) app.include_router(endpoints.router, tags=["OGC Tiles API"]) ``` From fca4d92e799153580bbd9a44595affba1d182455 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 16 Mar 2023 10:25:21 +0100 Subject: [PATCH 10/11] docs --- docs/src/advanced/factories.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/advanced/factories.md b/docs/src/advanced/factories.md index 28783fc1..5297f0e7 100644 --- a/docs/src/advanced/factories.md +++ b/docs/src/advanced/factories.md @@ -49,7 +49,7 @@ endpoints = Factory(collection_dependency=lambda: ["collection1", "collection2"] app.include_router(endpoints.router) ``` -## OGC Features Factory +## OGC Features API Factory ```python from tipg.factory import OGCFeaturesFactory @@ -86,7 +86,7 @@ app.include_router(endpoints.router, tags=["OGC Features API"]) | `GET` | `/` | HTML / JSON | landing page -## OGC Tiles Factory +## OGC Tiles API Factory ```python from tipg.factory import OGCTilesFactory @@ -124,7 +124,7 @@ app.include_router(endpoints.router, tags=["OGC Tiles API"]) | `GET` | `/conformance` | HTML / JSON | conformance class landing Page | `GET` | `/` | HTML / JSON | landing page -## OGC Features + Tile Factory +## OGC Features + Tiles API Factory ```python from tipg.factory import Endpoints From 9599bd5a1f36821a3e6329c6e0f4b310b684c737 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 16 Mar 2023 15:13:56 +0100 Subject: [PATCH 11/11] Update tipg/factory.py --- tipg/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tipg/factory.py b/tipg/factory.py index 08ad364a..d6ea56ec 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -1180,7 +1180,7 @@ def links(self, request: Request) -> List[model.Link]: ] def register_routes(self): # noqa: C901 - """Register OGC Features endpoints.""" + """Register OGC Tiles endpoints.""" @self.router.get( "/collections/{collectionId}/tiles/{tileMatrixSetId}/{tileMatrix}/{tileCol}/{tileRow}",