diff --git a/CHANGES.md b/CHANGES.md index 33d4475b..bccd7800 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,12 +6,25 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). Note: Minor version `0.X.0` update might break the API, It's recommended to pin `tipg` to minor version: `tipg>=0.1,<0.2` -## unreleased +## [unreleased] ### Added * `type` query parameter to filter collections based on their type (`Function` or `Table`) +* `catalog_dependency` to retrieve the list of collections (defaults to `tipg.dependencies.CatalogParams`) + +* `additional_collection_links` and `additional_item_links` to be able to infer links + +### Changed + +* `tipg.factory.Endpoints` is now created directly from both `OGCFeaturesFactory` and `OGCTilesFactory` classes + +* factory's `links` method now uses `common|features|tiles_links` sub-methods + +* `conforms_to` use module's variables + + ## [0.2.0] - 2023-06-22 ### Changed diff --git a/tests/test_factories.py b/tests/test_factories.py index 078b618d..588ddadf 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -16,7 +16,7 @@ def test_features_factory(): assert endpoints.with_common assert endpoints.title == "OGC API" assert len(endpoints.router.routes) == 7 - assert len(endpoints.conforms_to) == 6 + assert len(endpoints.conforms_to) == 13 app = FastAPI() app.include_router(endpoints.router) @@ -90,7 +90,7 @@ def test_features_factory(): assert not endpoints.with_common assert endpoints.title == "OGC Features API" assert len(endpoints.router.routes) == 5 - assert len(endpoints.conforms_to) == 6 + assert len(endpoints.conforms_to) == 13 app = FastAPI() app.include_router(endpoints.router) @@ -113,7 +113,7 @@ def test_tiles_factory(): assert endpoints.with_common assert endpoints.title == "OGC API" assert len(endpoints.router.routes) == 14 - assert len(endpoints.conforms_to) == 5 + assert len(endpoints.conforms_to) == 12 app = FastAPI() app.include_router(endpoints.router) @@ -177,7 +177,7 @@ def test_tiles_factory(): assert not endpoints.with_common assert endpoints.title == "OGC Tiles API" assert len(endpoints.router.routes) == 12 - assert len(endpoints.conforms_to) == 5 + assert len(endpoints.conforms_to) == 12 app = FastAPI() app.include_router(endpoints.router) @@ -200,7 +200,9 @@ def test_endpoints_factory(): assert endpoints.with_common assert endpoints.title == "OGC API" assert len(endpoints.router.routes) == 19 - assert len(endpoints.conforms_to) == 11 # 5 from tiles + 6 from features + assert ( + len(endpoints.conforms_to) == 18 + ) # 5 from tiles + 6 from features + 7 from common app = FastAPI() app.include_router(endpoints.router) @@ -239,10 +241,6 @@ def test_endpoints_factory(): assert endpoints.with_common assert endpoints.title == "OGC Full API" assert len(endpoints.router.routes) == 19 - 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") @@ -281,7 +279,7 @@ def test_endpoints_factory(): assert not endpoints.with_common assert endpoints.title == "Tiles and Features API" assert len(endpoints.router.routes) == 17 # 10 from tiles + 5 from features - assert len(endpoints.conforms_to) == 11 # 4 from tiles + 6 from features + assert len(endpoints.conforms_to) == 18 # 4 from tiles + 6 from features app = FastAPI() app.include_router(endpoints.router) diff --git a/tipg/dependencies.py b/tipg/dependencies.py index 1b2b417a..361f2fd0 100644 --- a/tipg/dependencies.py +++ b/tipg/dependencies.py @@ -56,6 +56,15 @@ def CollectionParams( ) +def CatalogParams(request: Request) -> Catalog: + """Return Collections Catalog.""" + collection_catalog: Catalog = getattr(request.app.state, "collection_catalog", None) + if not collection_catalog: + raise MissingCollectionCatalog("Could not find collections catalog.") + + return collection_catalog + + def accept_media_type( accept: str, mediatypes: List[enums.MediaType] ) -> Optional[enums.MediaType]: diff --git a/tipg/factory.py b/tipg/factory.py index ee31cc8d..951369f7 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -29,6 +29,7 @@ from tipg import model from tipg.collections import Catalog, Collection from tipg.dependencies import ( + CatalogParams, CollectionParams, ItemsOutputType, OutputType, @@ -42,12 +43,7 @@ properties_query, sortby_query, ) -from tipg.errors import ( - MissingCollectionCatalog, - MissingGeometryColumn, - NoPrimaryKey, - NotFound, -) +from tipg.errors import MissingGeometryColumn, NoPrimaryKey, NotFound from tipg.resources.enums import MediaType from tipg.resources.response import GeoJSONResponse, SchemaJSONResponse from tipg.settings import FeaturesSettings, MVTSettings, TMSSettings @@ -58,6 +54,7 @@ from starlette.datastructures import QueryParams from starlette.requests import Request from starlette.responses import HTMLResponse, Response, StreamingResponse +from starlette.routing import NoMatchFound from starlette.templating import Jinja2Templates, _TemplateResponse if sys.version_info >= (3, 9): @@ -75,6 +72,31 @@ loader=jinja2.ChoiceLoader([jinja2.PackageLoader(__package__, "templates")]), ) # type:ignore +COMMON_CONFORMS = [ + "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", +] +FEATURES_CONFORMS = [ + "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", +] +TILES_CONFORMS = [ + "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", + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset", + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list", +] + def create_csv_rows(data: Iterable[Dict]) -> Generator[str, None, None]: """Creates an iterator that returns lines of csv from an iterable of dicts.""" @@ -193,6 +215,7 @@ class EndpointsFactory(metaclass=abc.ABCMeta): router: APIRouter = field(default_factory=APIRouter) # collection dependency + catalog_dependency: Callable[..., Catalog] = CatalogParams collection_dependency: Callable[..., Collection] = CollectionParams # Router Prefix is needed to find the path for routes when prefixed @@ -206,12 +229,6 @@ class EndpointsFactory(metaclass=abc.ABCMeta): 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.""" url_path = self.router.url_path_for(name, **path_params) @@ -236,11 +253,6 @@ def _create_html_response( router_prefix=self.router_prefix, ) - @abc.abstractmethod - def register_routes(self): - """Register factory Routes.""" - ... - @property @abc.abstractmethod def conforms_to(self) -> List[str]: @@ -249,15 +261,66 @@ def conforms_to(self) -> List[str]: @abc.abstractmethod def links(self, request: Request) -> List[model.Link]: - """Register factory Routes.""" + """API Links.""" ... + def common_links(self, request: Request) -> List[model.Link]: + """Return Common links.""" + return [ + 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", + ), + ] + + def additional_collection_links( + self, + request: Request, + collection_id: str, + ) -> List[model.Link]: + """Return additional Collection Link.""" + return [] + + def additional_item_links( + self, + request: Request, + collection_id: str, + item_id: str, + ) -> List[model.Link]: + """Return additional Item Link.""" + return [] + + def __post_init__(self): + """Post Init: register route and configure specific options.""" + if self.with_common: + self.register_common_routes() + def register_common_routes(self): """Register Landing (/) and Conformance (/conformance) routes.""" @self.router.get( - "/conformance", - response_model=model.Conformance, + "/", + response_model=model.Landing, response_model_exclude_none=True, response_class=ORJSONResponse, responses={ @@ -270,36 +333,28 @@ def register_common_routes(self): }, tags=["OGC Common"], ) - def conformance( + def landing( request: Request, output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): - """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, - ] + """Get landing page.""" + data = model.Landing( + title=self.title, + links=self.links(request), ) if output_type == MediaType.html: return self._create_html_response( request, data.json(exclude_none=True), - template_name="conformance", + template_name="landing", ) return data @self.router.get( - "/", - response_model=model.Landing, + "/conformance", + response_model=model.Conformance, response_model_exclude_none=True, response_class=ORJSONResponse, responses={ @@ -312,47 +367,20 @@ def conformance( }, tags=["OGC Common"], ) - def landing( + def conformance( request: Request, output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): - """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), - ], + """Get conformance.""" + data = model.Conformance( + conformsTo=self.conforms_to, ) if output_type == MediaType.html: return self._create_html_response( request, data.json(exclude_none=True), - template_name="landing", + template_name="conformance", ) return data @@ -365,16 +393,16 @@ class OGCFeaturesFactory(EndpointsFactory): @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", - ] + return [*COMMON_CONFORMS, *FEATURES_CONFORMS] def links(self, request: Request) -> List[model.Link]: + """OGC API links.""" + if self.with_common: + return [*self.common_links(request), *self.features_links(request)] + + return self.features_links(request) + + def features_links(self, request: Request) -> List[model.Link]: """OGC Features API links.""" return [ model.Link( @@ -422,8 +450,15 @@ def links(self, request: Request) -> List[model.Link]: ), ] - def register_routes(self): # noqa: C901 - """Register OGC Features endpoints.""" + def __post_init__(self): + """Post Init: register route and configure specific options.""" + if self.with_common: + self.register_common_routes() + + self.register_features_routes() + + def register_features_routes(self): # noqa: C901 + """register features endpoints.""" @self.router.get( "/collections", @@ -438,6 +473,7 @@ def register_routes(self): # noqa: C901 } }, }, + tags=["OGC Features API"], ) def collections( # noqa: C901 request: Request, @@ -463,14 +499,9 @@ def collections( # noqa: C901 ), ] = None, output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, + collection_catalog=Depends(self.catalog_dependency), ): """List of collections.""" - collection_catalog: Catalog = getattr( - request.app.state, "collection_catalog", None - ) - if not collection_catalog: - raise MissingCollectionCatalog("Could not find collections catalog.") - collections_list = list(collection_catalog["collections"].values()) limit = limit or 0 @@ -595,6 +626,9 @@ def collections( # noqa: C901 rel="queryables", type=MediaType.schemajson, ), + *self.additional_collection_links( + request, collection.id + ), ], } ) @@ -624,6 +658,7 @@ def collections( # noqa: C901 } }, }, + tags=["OGC Features API"], ) def collection( request: Request, @@ -631,7 +666,6 @@ def collection( output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): """Metadata for a feature collection.""" - data = model.Collection( **{ **collection.dict(), @@ -683,6 +717,7 @@ def collection( rel="queryables", type=MediaType.schemajson, ), + *self.additional_collection_links(request, collection.id), ], } ) @@ -710,6 +745,7 @@ def collection( } }, }, + tags=["OGC Features API"], ) def queryables( request: Request, @@ -758,6 +794,7 @@ def queryables( "model": model.Items, }, }, + tags=["OGC Features API"], ) async def items( # noqa: C901 request: Request, @@ -997,6 +1034,12 @@ async def items( # noqa: C901 rel="item", type=MediaType.json, ).dict(exclude_none=True), + *[ + link.dict(exclude_none=True) + for link in self.additional_item_links( + request, collection.id, feature.get("id") + ) + ], ], } for feature in items["features"] @@ -1038,6 +1081,7 @@ async def items( # noqa: C901 "model": model.Item, }, }, + tags=["OGC Features API"], ) async def item( request: Request, @@ -1178,6 +1222,12 @@ async def item( rel="self", type=MediaType.geojson, ).dict(exclude_none=True), + *[ + link.dict(exclude_none=True) + for link in self.additional_item_links( + request, collection.id, itemId + ) + ], ], } @@ -1203,15 +1253,16 @@ class OGCTilesFactory(EndpointsFactory): @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", - "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset", - "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list", - ] + return [*COMMON_CONFORMS, *TILES_CONFORMS] def links(self, request: Request) -> List[model.Link]: + """OGC API links.""" + if self.with_common: + return [*self.common_links(request), *self.tiles_links(request)] + + return self.tiles_links(request) + + def tiles_links(self, request: Request) -> List[model.Link]: """OGC Tiles API links.""" return [ model.Link( @@ -1269,8 +1320,15 @@ def links(self, request: Request) -> List[model.Link]: ), ] - def register_routes(self): # noqa: C901 - """Register OGC Tiles endpoints.""" + def __post_init__(self): + """Post Init: register route and configure specific options.""" + if self.with_common: + self.register_common_routes() + + self.register_tiles_routes() + + def register_tiles_routes(self): # noqa: C901 + """register tiles endpoints.""" @self.router.get( r"/tileMatrixSets", @@ -1286,6 +1344,7 @@ def register_routes(self): # noqa: C901 }, }, }, + tags=["OGC Tiles API"], ) async def tilematrixsets( request: Request, @@ -1338,6 +1397,7 @@ async def tilematrixsets( }, }, }, + tags=["OGC Tiles API"], ) async def tilematrixset( request: Request, @@ -1371,6 +1431,7 @@ async def tilematrixset( responses={200: {"content": {MediaType.json.value: {}}}}, summary="Retrieve a list of available vector tilesets for the specified collection.", operation_id=".collection.vector.getTileSetsList", + tags=["OGC Tiles API"], ) async def collection_tileset_list( request: Request, @@ -1452,6 +1513,7 @@ async def collection_tileset_list( responses={200: {"content": {MediaType.json.value: {}}}}, summary="Retrieve the vector tileset metadata for the specified collection and tiling scheme (tile matrix set).", operation_id=".collection.vector.getTileSet", + tags=["OGC Tiles API"], ) async def collection_tileset( request: Request, @@ -1559,12 +1621,14 @@ async def collection_tileset( response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, operation_id=".collection.vector.getTileTms", + tags=["OGC Tiles API"], ) @self.router.get( "/collections/{collectionId}/tiles/{z}/{x}/{y}", response_class=Response, responses={200: {"content": {MediaType.mvt.value: {}}}}, operation_id=".collection.vector.getTile", + tags=["OGC Tiles API"], ) async def collection_get_tile( request: Request, @@ -1643,6 +1707,7 @@ async def collection_get_tile( response_model_exclude_none=True, response_class=ORJSONResponse, operation_id=".collection.vector.getTileJSONTms", + tags=["OGC Tiles API"], ) @self.router.get( "/collections/{collectionId}/tilejson.json", @@ -1651,6 +1716,7 @@ async def collection_get_tile( response_model_exclude_none=True, response_class=ORJSONResponse, operation_id=".collection.vector.getTileJSON", + tags=["OGC Tiles API"], ) async def collection_tilejson( request: Request, @@ -1741,6 +1807,7 @@ async def collection_tilejson( response_model_exclude_none=True, response_class=ORJSONResponse, operation_id=".collection.vector.getStyleJSONTms", + tags=["OGC Tiles API"], ) @self.router.get( "/collections/{collectionId}/style.json", @@ -1749,6 +1816,7 @@ async def collection_tilejson( response_model_exclude_none=True, response_class=ORJSONResponse, operation_id=".collection.vector.getStyleJSON", + tags=["OGC Tiles API"], ) async def collection_stylejson( request: Request, @@ -1880,11 +1948,13 @@ async def collection_stylejson( "/collections/{collectionId}/{tileMatrixSetId}/viewer", response_class=HTMLResponse, operation_id=".collection.vector.viewerTms", + tags=["OGC Tiles API"], ) @self.router.get( "/collections/{collectionId}/viewer", response_class=HTMLResponse, operation_id=".collection.vector.viewer", + tags=["OGC Tiles API"], ) def viewer_endpoint( request: Request, @@ -1932,49 +2002,69 @@ def viewer_endpoint( @dataclass -class Endpoints(EndpointsFactory): +class Endpoints(OGCFeaturesFactory, OGCTilesFactory): """OGC Features and Tiles Endpoints Factory.""" - # OGC Tiles dependency - supported_tms: TileMatrixSets = default_tms - with_tiles_viewer: bool = True + def __post_init__(self): + """Post Init: register route and configure specific options.""" + if self.with_common: + self.register_common_routes() - ogc_features: OGCFeaturesFactory = field(init=False) - ogc_tiles: OGCTilesFactory = field(init=False) + self.register_features_routes() + self.register_tiles_routes() @property def conforms_to(self) -> List[str]: """Endpoints conformances.""" - return [ - *self.ogc_features.conforms_to, - *self.ogc_tiles.conforms_to, - ] + return [*COMMON_CONFORMS, *FEATURES_CONFORMS, *TILES_CONFORMS] def links(self, request: Request) -> List[model.Link]: - """List of available links.""" - return [ - *self.ogc_features.links(request), - *self.ogc_tiles.links(request), + """OGC Features API links.""" + if self.with_common: + return [ + *self.common_links(request), + *self.features_links(request), + *self.tiles_links(request), + ] + + return [*self.features_links(request), *self.tiles_links(request)] + + def additional_collection_links( + self, + request: Request, + collection_id: str, + ) -> List[model.Link]: + """add OGC Tiles Links.""" + links = [ + model.Link( + title="Collection Vector Tiles (templated URL)", + href=self.url_for( + request, + "collection_get_tile", + collectionId=collection_id, + z="{z}", + x="{x}", + y="{y}", + ), + type=MediaType.mvt, + rel="data", + ), ] - 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"]) + try: + links.append( + model.Link( + title="Collection Vector Tiles Viewer", + href=self.url_for( + request, + "viewer_endpoint", + collectionId=collection_id, + ), + type=MediaType.html, + rel="data", + ) + ) + except NoMatchFound: + pass - self.ogc_tiles = OGCTilesFactory( - collection_dependency=self.collection_dependency, - router_prefix=self.router_prefix, - templates=self.templates, - supported_tms=self.supported_tms, - with_viewer=self.with_tiles_viewer, - # We do not want `/` and `/conformance` from the factory - with_common=False, - ) - self.router.include_router(self.ogc_tiles.router, tags=["OGC Tiles API"]) + return links diff --git a/tipg/main.py b/tipg/main.py index 2c80edb7..bb2b7c47 100644 --- a/tipg/main.py +++ b/tipg/main.py @@ -85,7 +85,7 @@ async def lifespan(app: FastAPI): ogc_api = Endpoints( title=settings.name, templates=templates, - with_tiles_viewer=settings.add_tiles_viewer, + with_viewer=settings.add_tiles_viewer, ) app.include_router(ogc_api.router)