From 4cc27289e292f97ab6fc1be8fc2396327958849e Mon Sep 17 00:00:00 2001 From: geospatial-jeff Date: Sun, 2 Oct 2022 21:41:46 -0600 Subject: [PATCH 1/2] move link generation to api layer --- stac_fastapi/api/routes.py | 19 +-- stac_fastapi/types/core.py | 145 +++++------------- stac_fastapi/types/links.py | 260 +++++++++++++++++++++------------ stac_fastapi/types/requests.py | 14 -- 4 files changed, 218 insertions(+), 220 deletions(-) delete mode 100644 stac_fastapi/types/requests.py diff --git a/stac_fastapi/api/routes.py b/stac_fastapi/api/routes.py index 3c0186564..3aceb2990 100644 --- a/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/routes.py @@ -13,6 +13,7 @@ from starlette.status import HTTP_204_NO_CONTENT from stac_fastapi.api.models import APIRequest +from stac_fastapi.types.links import hydrate_inferred_links def _wrap_response(resp: Any, response_class: Type[Response]) -> Response: @@ -53,9 +54,9 @@ async def _endpoint( request_data: request_model = Depends(), # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request=request, **request_data.kwargs()), response_class - ) + response = await func(**request_data.kwargs()) + response = hydrate_inferred_links(request, response) + return _wrap_response(response, response_class) elif issubclass(request_model, BaseModel): @@ -64,9 +65,9 @@ async def _endpoint( request_data: request_model, # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request_data, request=request), response_class - ) + response = await func(request_data) + response = hydrate_inferred_links(request, response) + return _wrap_response(response, response_class) else: @@ -75,9 +76,9 @@ async def _endpoint( request_data: Dict[str, Any], # type:ignore ): """Endpoint.""" - return _wrap_response( - await func(request_data, request=request), response_class - ) + response = await func(request_data) + response = hydrate_inferred_links(request, response) + return _wrap_response(response, response_class) return _endpoint diff --git a/stac_fastapi/types/core.py b/stac_fastapi/types/core.py index bce7ca2a2..52fa25102 100644 --- a/stac_fastapi/types/core.py +++ b/stac_fastapi/types/core.py @@ -2,10 +2,8 @@ import abc from datetime import datetime from typing import Any, Dict, List, Optional, Union -from urllib.parse import urljoin import attr -from fastapi import Request from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes from stac_pydantic.version import STAC_VERSION @@ -14,7 +12,6 @@ from stac_fastapi.types import stac as stac_types from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.search import BaseSearchPostRequest from stac_fastapi.types.stac import Conformance @@ -28,7 +25,7 @@ class BaseTransactionsClient(abc.ABC): @abc.abstractmethod def create_item( - self, collection_id: str, item: stac_types.Item, **kwargs + self, collection_id: str, item: stac_types.Item ) -> Optional[Union[stac_types.Item, Response]]: """Create a new item. @@ -46,7 +43,7 @@ def create_item( @abc.abstractmethod def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. @@ -65,7 +62,7 @@ def update_item( @abc.abstractmethod def delete_item( - self, item_id: str, collection_id: str, **kwargs + self, item_id: str, collection_id: str ) -> Optional[Union[stac_types.Item, Response]]: """Delete an item from a collection. @@ -82,7 +79,7 @@ def delete_item( @abc.abstractmethod def create_collection( - self, collection: stac_types.Collection, **kwargs + self, collection: stac_types.Collection ) -> Optional[Union[stac_types.Collection, Response]]: """Create a new collection. @@ -98,7 +95,7 @@ def create_collection( @abc.abstractmethod def update_collection( - self, collection: stac_types.Collection, **kwargs + self, collection: stac_types.Collection ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. @@ -117,7 +114,7 @@ def update_collection( @abc.abstractmethod def delete_collection( - self, collection_id: str, **kwargs + self, collection_id: str ) -> Optional[Union[stac_types.Collection, Response]]: """Delete a collection. @@ -138,7 +135,7 @@ class AsyncBaseTransactionsClient(abc.ABC): @abc.abstractmethod async def create_item( - self, collection_id: str, item: stac_types.Item, **kwargs + self, collection_id: str, item: stac_types.Item ) -> Optional[Union[stac_types.Item, Response]]: """Create a new item. @@ -155,7 +152,7 @@ async def create_item( @abc.abstractmethod async def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs + self, collection_id: str, item_id: str, item: stac_types.Item ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. @@ -173,7 +170,7 @@ async def update_item( @abc.abstractmethod async def delete_item( - self, item_id: str, collection_id: str, **kwargs + self, item_id: str, collection_id: str ) -> Optional[Union[stac_types.Item, Response]]: """Delete an item from a collection. @@ -190,7 +187,7 @@ async def delete_item( @abc.abstractmethod async def create_collection( - self, collection: stac_types.Collection, **kwargs + self, collection: stac_types.Collection ) -> Optional[Union[stac_types.Collection, Response]]: """Create a new collection. @@ -206,7 +203,7 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection: stac_types.Collection, **kwargs + self, collection: stac_types.Collection ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. @@ -224,7 +221,7 @@ async def update_collection( @abc.abstractmethod async def delete_collection( - self, collection_id: str, **kwargs + self, collection_id: str ) -> Optional[Union[stac_types.Collection, Response]]: """Delete a collection. @@ -250,7 +247,6 @@ class LandingPageMixin(abc.ABC): def _landing_page( self, - base_url: str, conformance_classes: List[str], extension_schemas: List[str], ) -> stac_types.LandingPage: @@ -265,36 +261,36 @@ def _landing_page( { "rel": Relations.self.value, "type": MimeTypes.json, - "href": base_url, + "href": "/", }, { "rel": Relations.root.value, "type": MimeTypes.json, - "href": base_url, + "href": "/", }, { "rel": "data", "type": MimeTypes.json, - "href": urljoin(base_url, "collections"), + "href": "/collections", }, { "rel": Relations.conformance.value, "type": MimeTypes.json, "title": "STAC/WFS3 conformance classes implemented by this server", - "href": urljoin(base_url, "conformance"), + "href": "/conformance", }, { "rel": Relations.search.value, "type": MimeTypes.geojson, "title": "STAC search", - "href": urljoin(base_url, "search"), + "href": "/search", "method": "GET", }, { "rel": Relations.search.value, "type": MimeTypes.geojson, "title": "STAC search", - "href": urljoin(base_url, "search"), + "href": "/search", "method": "POST", }, ], @@ -341,7 +337,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> stac_types.LandingPage: + def landing_page(self) -> stac_types.LandingPage: """Landing page. Called with `GET /`. @@ -349,56 +345,31 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: Returns: API landing page, serving as an entry point to the API. """ - request: Request = kwargs["request"] - base_url = get_base_url(request) + # request: Request = kwargs["request"] + # base_url = get_base_url(request) extension_schemas = [ schema.schema_href for schema in self.extensions if schema.schema_href ] landing_page = self._landing_page( - base_url=base_url, conformance_classes=self.conformance_classes(), extension_schemas=extension_schemas, ) # Add Collections links - collections = self.all_collections(request=kwargs["request"]) + collections = self.all_collections() for collection in collections["collections"]: landing_page["links"].append( { "rel": Relations.child.value, "type": MimeTypes.json.value, "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), + "href": f"/collections/{collection['id']}", } ) - # Add OpenAPI URL - landing_page["links"].append( - { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", - "title": "OpenAPI service description", - "href": urljoin( - str(request.base_url), request.app.openapi_url.lstrip("/") - ), - } - ) - - # Add human readable service-doc - landing_page["links"].append( - { - "rel": "service-doc", - "type": "text/html", - "title": "OpenAPI service documentation", - "href": urljoin( - str(request.base_url), request.app.docs_url.lstrip("/") - ), - } - ) - return landing_page - def conformance(self, **kwargs) -> stac_types.Conformance: + def conformance(self) -> stac_types.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -410,7 +381,7 @@ def conformance(self, **kwargs) -> stac_types.Conformance: @abc.abstractmethod def post_search( - self, search_request: BaseSearchPostRequest, **kwargs + self, search_request: BaseSearchPostRequest ) -> stac_types.ItemCollection: """Cross catalog search (POST). @@ -436,7 +407,6 @@ def get_search( token: Optional[str] = None, fields: Optional[List[str]] = None, sortby: Optional[str] = None, - **kwargs, ) -> stac_types.ItemCollection: """Cross catalog search (GET). @@ -448,7 +418,7 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + def get_item(self, item_id: str, collection_id: str) -> stac_types.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -463,7 +433,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Ite ... @abc.abstractmethod - def all_collections(self, **kwargs) -> stac_types.Collections: + def all_collections(self) -> stac_types.Collections: """Get all available collections. Called with `GET /collections`. @@ -474,7 +444,7 @@ def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + def get_collection(self, collection_id: str) -> stac_types.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -489,7 +459,7 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: @abc.abstractmethod def item_collection( - self, collection_id: str, limit: int = 10, token: str = None, **kwargs + self, collection_id: str, limit: int = 10, token: str = None ) -> stac_types.ItemCollection: """Get all items from a specific collection. @@ -534,7 +504,7 @@ def extension_is_enabled(self, extension: str) -> bool: """Check if an api extension is enabled.""" return any([type(ext).__name__ == extension for ext in self.extensions]) - async def landing_page(self, **kwargs) -> stac_types.LandingPage: + async def landing_page(self) -> stac_types.LandingPage: """Landing page. Called with `GET /`. @@ -542,54 +512,26 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: Returns: API landing page, serving as an entry point to the API. """ - request: Request = kwargs["request"] - base_url = get_base_url(request) extension_schemas = [ schema.schema_href for schema in self.extensions if schema.schema_href ] landing_page = self._landing_page( - base_url=base_url, conformance_classes=self.conformance_classes(), extension_schemas=extension_schemas, ) - collections = await self.all_collections(request=kwargs["request"]) + collections = await self.all_collections() for collection in collections["collections"]: landing_page["links"].append( { "rel": Relations.child.value, "type": MimeTypes.json.value, "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), + "href": f"collections/{collection['id']}", } ) - - # Add OpenAPI URL - landing_page["links"].append( - { - "rel": "service-desc", - "type": "application/vnd.oai.openapi+json;version=3.0", - "title": "OpenAPI service description", - "href": urljoin( - str(request.base_url), request.app.openapi_url.lstrip("/") - ), - } - ) - - # Add human readable service-doc - landing_page["links"].append( - { - "rel": "service-doc", - "type": "text/html", - "title": "OpenAPI service documentation", - "href": urljoin( - str(request.base_url), request.app.docs_url.lstrip("/") - ), - } - ) - return landing_page - async def conformance(self, **kwargs) -> stac_types.Conformance: + async def conformance(self) -> stac_types.Conformance: """Conformance classes. Called with `GET /conformance`. @@ -601,7 +543,7 @@ async def conformance(self, **kwargs) -> stac_types.Conformance: @abc.abstractmethod async def post_search( - self, search_request: BaseSearchPostRequest, **kwargs + self, search_request: BaseSearchPostRequest ) -> stac_types.ItemCollection: """Cross catalog search (POST). @@ -627,7 +569,6 @@ async def get_search( token: Optional[str] = None, fields: Optional[List[str]] = None, sortby: Optional[str] = None, - **kwargs, ) -> stac_types.ItemCollection: """Cross catalog search (GET). @@ -639,9 +580,7 @@ async def get_search( ... @abc.abstractmethod - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> stac_types.Item: + async def get_item(self, item_id: str, collection_id: str) -> stac_types.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -656,7 +595,7 @@ async def get_item( ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> stac_types.Collections: + async def all_collections(self) -> stac_types.Collections: """Get all available collections. Called with `GET /collections`. @@ -667,9 +606,7 @@ async def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - async def get_collection( - self, collection_id: str, **kwargs - ) -> stac_types.Collection: + async def get_collection(self, collection_id: str) -> stac_types.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -684,7 +621,7 @@ async def get_collection( @abc.abstractmethod async def item_collection( - self, collection_id: str, limit: int = 10, token: str = None, **kwargs + self, collection_id: str, limit: int = 10, token: str = None ) -> stac_types.ItemCollection: """Get all items from a specific collection. @@ -706,7 +643,7 @@ class AsyncBaseFiltersClient(abc.ABC): """Defines a pattern for implementing the STAC filter extension.""" async def get_queryables( - self, collection_id: Optional[str] = None, **kwargs + self, collection_id: Optional[str] = None ) -> Dict[str, Any]: """Get the queryables available for the given collection_id. @@ -732,9 +669,7 @@ async def get_queryables( class BaseFiltersClient(abc.ABC): """Defines a pattern for implementing the STAC filter extension.""" - def get_queryables( - self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: + def get_queryables(self, collection_id: Optional[str] = None) -> Dict[str, Any]: """Get the queryables available for the given collection_id. If collection_id is None, returns the intersection of all diff --git a/stac_fastapi/types/links.py b/stac_fastapi/types/links.py index 0349984b1..ea6848e2e 100644 --- a/stac_fastapi/types/links.py +++ b/stac_fastapi/types/links.py @@ -1,110 +1,186 @@ """link helpers.""" - -from typing import Any, Dict, List +from typing import Dict from urllib.parse import urljoin -import attr from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes - -# These can be inferred from the item/collection so they aren't included in the database -# Instead they are dynamically generated when querying the database using the classes defined below -INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"] - - -def filter_links(links: List[Dict]) -> List[Dict]: - """Remove inferred links.""" - return [link for link in links if link["rel"] not in INFERRED_LINK_RELS] +from starlette.requests import Request -def resolve_links(links: list, base_url: str) -> List[Dict]: - """Convert relative links to absolute links.""" - filtered_links = filter_links(links) - for link in filtered_links: - link.update({"href": urljoin(base_url, link["href"])}) - return filtered_links - - -@attr.s -class BaseLinks: - """Create inferred links common to collections and items.""" - - collection_id: str = attr.ib() - base_url: str = attr.ib() +def get_base_url(request: Request) -> str: + """Get base URL with respect of APIRouter prefix.""" + app = request.app + if not app.state.router_prefix: + return str(request.base_url) + else: + return "{}{}/".format( + str(request.base_url), app.state.router_prefix.lstrip("/") + ) - def root(self) -> Dict[str, Any]: - """Return the catalog root.""" - return dict(rel=Relations.root, type=MimeTypes.json, href=self.base_url) +def create_root_link(request: Request): + """Create link to API root.""" + return dict(rel=Relations.root, type=MimeTypes.json, href=get_base_url(request)) -@attr.s -class CollectionLinks(BaseLinks): - """Create inferred links specific to collections.""" - def self(self) -> Dict[str, Any]: - """Create the `self` link.""" - return dict( +def hydrate_collection_links(request: Request, stac_object: Dict) -> Dict: + """Hydrate collection with inferred links.""" + base_url = get_base_url(request) + links = [ + create_root_link(request), + dict(rel=Relations.parent, type=MimeTypes.json, href=base_url), + dict( rel=Relations.self, type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), - ) - - def parent(self) -> Dict[str, Any]: - """Create the `parent` link.""" - return dict(rel=Relations.parent, type=MimeTypes.json, href=self.base_url) - - def items(self) -> Dict[str, Any]: - """Create the `items` link.""" - return dict( + href=urljoin(base_url, f"collections/{stac_object['id']}"), + ), + dict( rel="items", type=MimeTypes.geojson, - href=urljoin(self.base_url, f"collections/{self.collection_id}/items"), - ) - - def create_links(self) -> List[Dict[str, Any]]: - """Return all inferred links.""" - return [self.self(), self.parent(), self.items(), self.root()] - - -@attr.s -class ItemLinks(BaseLinks): - """Create inferred links specific to items.""" - - item_id: str = attr.ib() - - def self(self) -> Dict[str, Any]: - """Create the `self` link.""" - return dict( - rel=Relations.self, - type=MimeTypes.geojson, - href=urljoin( - self.base_url, - f"collections/{self.collection_id}/items/{self.item_id}", + href=urljoin(base_url, f"collections/{stac_object['id']}/items"), + ), + ] + stac_object["links"].extend(links) + return stac_object + + +def hydrate_catalog_links(request: Request, stac_object: Dict) -> Dict: + """Hydrate catalog with inferred links.""" + base_url = get_base_url(request) + resolved_links = [ + create_root_link(request), + { + "rel": "service-desc", + "type": "application/vnd.oai.openapi+json;version=3.0", + "title": "OpenAPI service description", + "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), + }, + { + "rel": "service-doc", + "type": "text/html", + "title": "OpenAPI service documentation", + "href": urljoin(base_url, request.app.docs_url.lstrip("/")), + }, + ] + # The landing page returns partially resolved links because it requires fetching collections + # through the backend client. These need to be resolved by prepending the base url. + for link in stac_object["links"]: + link["href"] = urljoin(base_url, link["href"]) + resolved_links.append(link) + + stac_object["links"] = resolved_links + return stac_object + + +def hydrate_collections_links(request: Request, stac_object: Dict) -> Dict: + """Hydrate collections with inferred links.""" + base_url = get_base_url(request) + stac_object["collections"] = [ + hydrate_collection_links(request, collection) + for collection in stac_object["collections"] + ] + + links = [ + create_root_link(request), + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "collections"), + }, + ] + stac_object["links"].extend(links) + return stac_object + + +def hydrate_item_links(request: Request, stac_object: Dict) -> Dict: + """Hydrate item with inferred links.""" + base_url = get_base_url(request) + + links = [ + create_root_link(request), + { + "rel": Relations.self, + "type": MimeTypes.geojson, + "href": urljoin( + base_url, + f"collections/{stac_object['collection']}/items/{stac_object['id']}", ), - ) - - def parent(self) -> Dict[str, Any]: - """Create the `parent` link.""" - return dict( - rel=Relations.parent, - type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), - ) - - def collection(self) -> Dict[str, Any]: - """Create the `collection` link.""" - return dict( - rel=Relations.collection, - type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), - ) - - def create_links(self) -> List[Dict[str, Any]]: - """Return all inferred links.""" + }, + { + "rel": Relations.parent, + "type": MimeTypes.json, + "href": urljoin(base_url, f"collections/{stac_object['collection']}"), + }, + { + "rel": Relations.collection, + "type": MimeTypes.json, + "href": urljoin(base_url, f"collections/{stac_object['collection']}"), + }, + ] + + stac_object["links"].extend(links) + return stac_object + + +def hydrate_item_collection_links(request: Request, stac_object: Dict) -> Dict: + """Hydrate item collection with inferred links.""" + base_url = get_base_url(request) + stac_object["features"] = [ + hydrate_item_links(request, item) for item in stac_object["features"] + ] + + # Item collections are returned by both `/items` and `/search` endpoints. + if request.url.path.endswith("/items"): + _, _, collection_name, _ = request.url.path.split("/") + links = [ + create_root_link(request), + { + "rel": Relations.self, + "type": MimeTypes.geojson, + "href": urljoin( + base_url, + f"collections/{collection_name}/items", + ), + }, + { + "rel": Relations.parent, + "type": MimeTypes.json, + "href": urljoin(base_url, f"collections/{collection_name}"), + }, + ] + elif request.url.path.endswith("/search"): links = [ - self.self(), - self.parent(), - self.collection(), - self.root(), + create_root_link(request), + { + "rel": Relations.self, + "type": MimeTypes.geojson, + "href": urljoin(base_url, "search"), + }, ] - return links + stac_object["links"].extend(links) + return stac_object + + +def hydrate_inferred_links(request: Request, stac_object: Dict) -> Dict: + """Infer the stac object type and hydrate with inferred links. + + If the stac object type is not recognized it will be returned as-is. + """ + if stac_object.get("type") == "Catalog": + return hydrate_catalog_links(request, stac_object) + elif stac_object.get("type") == "Collection": + return hydrate_collection_links(request, stac_object) + elif stac_object.get("type") == "Feature": + return hydrate_item_links(request, stac_object) + elif stac_object.get("type") == "FeatureCollection": + return hydrate_item_collection_links(request, stac_object) + elif "collections" in stac_object: + return hydrate_collections_links(request, stac_object) + + # Return the stac object as-is if the object type is not recognized by this function. + return stac_object diff --git a/stac_fastapi/types/requests.py b/stac_fastapi/types/requests.py deleted file mode 100644 index 7ce0e81a4..000000000 --- a/stac_fastapi/types/requests.py +++ /dev/null @@ -1,14 +0,0 @@ -"""requests helpers.""" - -from starlette.requests import Request - - -def get_base_url(request: Request) -> str: - """Get base URL with respect of APIRouter prefix.""" - app = request.app - if not app.state.router_prefix: - return str(request.base_url) - else: - return "{}{}/".format( - str(request.base_url), app.state.router_prefix.lstrip("/") - ) From 2e05fca65cc22e1defa38e617687071b6cfdf203 Mon Sep 17 00:00:00 2001 From: geospatial-jeff Date: Sun, 2 Oct 2022 23:03:38 -0600 Subject: [PATCH 2/2] resolve relative links while skipping absolute ones, implement link precedence --- stac_fastapi/types/links.py | 117 ++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/stac_fastapi/types/links.py b/stac_fastapi/types/links.py index ea6848e2e..8f5ad9213 100644 --- a/stac_fastapi/types/links.py +++ b/stac_fastapi/types/links.py @@ -1,6 +1,6 @@ """link helpers.""" -from typing import Dict -from urllib.parse import urljoin +from typing import Dict, List +from urllib.parse import urljoin, urlparse from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes @@ -18,63 +18,85 @@ def get_base_url(request: Request) -> str: ) +def resolve_relative_links(links: List[Dict], base_url: str) -> List[Dict]: + """Resolve relative links while skipping absolute links.""" + resolved_links = [] + for link in links: + # Skip absolute links + if urlparse(link["href"]).scheme: + resolved_links.append(link) + else: + resolved_links.append({**link, "href": urljoin(base_url, link["href"])}) + return resolved_links + + def create_root_link(request: Request): """Create link to API root.""" - return dict(rel=Relations.root, type=MimeTypes.json, href=get_base_url(request)) + return dict(rel=Relations.root, type=MimeTypes.json, href="/") def hydrate_collection_links(request: Request, stac_object: Dict) -> Dict: """Hydrate collection with inferred links.""" - base_url = get_base_url(request) + # Create inferred links links = [ create_root_link(request), - dict(rel=Relations.parent, type=MimeTypes.json, href=base_url), + dict(rel=Relations.parent, type=MimeTypes.json, href="/"), dict( rel=Relations.self, type=MimeTypes.json, - href=urljoin(base_url, f"collections/{stac_object['id']}"), + href=f"collections/{stac_object['id']}", ), dict( rel="items", type=MimeTypes.geojson, - href=urljoin(base_url, f"collections/{stac_object['id']}/items"), + href=f"collections/{stac_object['id']}/items", ), ] - stac_object["links"].extend(links) + inferred_link_rels = set([link["rel"] for link in links]) + + # Combine with links from the stac object + for link in stac_object["links"]: + if link["rel"] not in inferred_link_rels: + links.append(link) + + base_url = get_base_url(request) + stac_object["links"] = resolve_relative_links(links, base_url) return stac_object def hydrate_catalog_links(request: Request, stac_object: Dict) -> Dict: """Hydrate catalog with inferred links.""" - base_url = get_base_url(request) - resolved_links = [ + links = [ create_root_link(request), { "rel": "service-desc", "type": "application/vnd.oai.openapi+json;version=3.0", "title": "OpenAPI service description", - "href": urljoin(base_url, request.app.openapi_url.lstrip("/")), + "href": request.app.openapi_url.lstrip("/"), }, { "rel": "service-doc", "type": "text/html", "title": "OpenAPI service documentation", - "href": urljoin(base_url, request.app.docs_url.lstrip("/")), + "href": request.app.docs_url.lstrip("/"), }, ] - # The landing page returns partially resolved links because it requires fetching collections - # through the backend client. These need to be resolved by prepending the base url. + + inferred_link_rels = set([link["rel"] for link in links]) + + # Combine with links from the stac object for link in stac_object["links"]: - link["href"] = urljoin(base_url, link["href"]) - resolved_links.append(link) + if link["rel"] not in inferred_link_rels: + links.append(link) + + base_url = get_base_url(request) + stac_object["links"] = resolve_relative_links(links, base_url) - stac_object["links"] = resolved_links return stac_object def hydrate_collections_links(request: Request, stac_object: Dict) -> Dict: """Hydrate collections with inferred links.""" - base_url = get_base_url(request) stac_object["collections"] = [ hydrate_collection_links(request, collection) for collection in stac_object["collections"] @@ -85,51 +107,63 @@ def hydrate_collections_links(request: Request, stac_object: Dict) -> Dict: { "rel": Relations.parent.value, "type": MimeTypes.json, - "href": base_url, + "href": "/", }, { "rel": Relations.self.value, "type": MimeTypes.json, - "href": urljoin(base_url, "collections"), + "href": "/collections", }, ] - stac_object["links"].extend(links) + inferred_link_rels = set([link["rel"] for link in links]) + + # Combine with links from the stac object + for link in stac_object["links"]: + if link["rel"] not in inferred_link_rels: + links.append(link) + + base_url = get_base_url(request) + stac_object["links"] = resolve_relative_links(links, base_url) + return stac_object def hydrate_item_links(request: Request, stac_object: Dict) -> Dict: """Hydrate item with inferred links.""" - base_url = get_base_url(request) - links = [ create_root_link(request), { "rel": Relations.self, "type": MimeTypes.geojson, - "href": urljoin( - base_url, - f"collections/{stac_object['collection']}/items/{stac_object['id']}", - ), + "href": f"/collections/{stac_object['collection']}/items/{stac_object['id']}", }, { "rel": Relations.parent, "type": MimeTypes.json, - "href": urljoin(base_url, f"collections/{stac_object['collection']}"), + "href": f"/collections/{stac_object['collection']}", }, { "rel": Relations.collection, "type": MimeTypes.json, - "href": urljoin(base_url, f"collections/{stac_object['collection']}"), + "href": f"/collections/{stac_object['collection']}", }, ] - stac_object["links"].extend(links) + inferred_link_rels = set([link["rel"] for link in links]) + + # Combine with links from the stac object + for link in stac_object["links"]: + if link["rel"] not in inferred_link_rels: + links.append(link) + + base_url = get_base_url(request) + stac_object["links"] = resolve_relative_links(links, base_url) + return stac_object def hydrate_item_collection_links(request: Request, stac_object: Dict) -> Dict: """Hydrate item collection with inferred links.""" - base_url = get_base_url(request) stac_object["features"] = [ hydrate_item_links(request, item) for item in stac_object["features"] ] @@ -142,15 +176,12 @@ def hydrate_item_collection_links(request: Request, stac_object: Dict) -> Dict: { "rel": Relations.self, "type": MimeTypes.geojson, - "href": urljoin( - base_url, - f"collections/{collection_name}/items", - ), + "href": f"/collections/{collection_name}/items", }, { "rel": Relations.parent, "type": MimeTypes.json, - "href": urljoin(base_url, f"collections/{collection_name}"), + "href": f"/collections/{collection_name}", }, ] elif request.url.path.endswith("/search"): @@ -159,10 +190,20 @@ def hydrate_item_collection_links(request: Request, stac_object: Dict) -> Dict: { "rel": Relations.self, "type": MimeTypes.geojson, - "href": urljoin(base_url, "search"), + "href": "/search", }, ] - stac_object["links"].extend(links) + + inferred_link_rels = set([link["rel"] for link in links]) + + # Combine with links from the stac object + for link in stac_object["links"]: + if link["rel"] not in inferred_link_rels: + links.append(link) + + base_url = get_base_url(request) + stac_object["links"] = resolve_relative_links(links, base_url) + return stac_object