351 lines
11 KiB
Python
351 lines
11 KiB
Python
![]() |
"""link helpers."""
|
||
|
|
||
|
from typing import Any, Dict, List, Optional
|
||
|
from urllib.parse import ParseResult, parse_qs, unquote, urlencode, urljoin, urlparse
|
||
|
|
||
|
import attr
|
||
|
from stac_fastapi.types.requests import get_base_url
|
||
|
from stac_pydantic.links import Relations
|
||
|
from stac_pydantic.shared import MimeTypes
|
||
|
from starlette.requests import Request
|
||
|
|
||
|
# 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]
|
||
|
|
||
|
|
||
|
def merge_params(url: str, newparams: Dict) -> str:
|
||
|
"""Merge url parameters."""
|
||
|
u = urlparse(url)
|
||
|
params = parse_qs(u.query)
|
||
|
params.update(newparams)
|
||
|
param_string = unquote(urlencode(params, True))
|
||
|
|
||
|
href = ParseResult(
|
||
|
scheme=u.scheme,
|
||
|
netloc=u.netloc,
|
||
|
path=u.path,
|
||
|
params=u.params,
|
||
|
query=param_string,
|
||
|
fragment=u.fragment,
|
||
|
).geturl()
|
||
|
return href
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class BaseLinks:
|
||
|
"""Create inferred links common to collections and items."""
|
||
|
|
||
|
request: Request = attr.ib()
|
||
|
|
||
|
@property
|
||
|
def base_url(self):
|
||
|
"""Get the base url."""
|
||
|
return get_base_url(self.request)
|
||
|
|
||
|
@property
|
||
|
def url(self):
|
||
|
"""Get the current request url."""
|
||
|
base_url = self.request.base_url
|
||
|
path = self.request.url.path
|
||
|
|
||
|
# root path can be set in the request scope in two different ways:
|
||
|
# - by uvicorn when running with --root-path
|
||
|
# - by FastAPI when running with FastAPI(root_path="...")
|
||
|
#
|
||
|
# When root path is set by uvicorn, request.url.path will have the root path prefix.
|
||
|
# eg. if root path is "/api" and the path is "/collections",
|
||
|
# the request.url.path will be "/api/collections"
|
||
|
#
|
||
|
# We need to remove the root path prefix from the path before
|
||
|
# joining the base_url and path to get the full url to avoid
|
||
|
# having root_path twice in the url
|
||
|
if (
|
||
|
root_path := self.request.scope.get("root_path")
|
||
|
) and not self.request.app.root_path:
|
||
|
# self.request.app.root_path is set by FastAPI when running with FastAPI(root_path="...")
|
||
|
# If self.request.app.root_path is not set but self.request.scope.get("root_path") is set,
|
||
|
# then the root path is set by uvicorn
|
||
|
# So we need to remove the root path prefix from the path before
|
||
|
# joining the base_url and path to get the full url
|
||
|
if path.startswith(root_path):
|
||
|
path = path[len(root_path) :]
|
||
|
|
||
|
url = urljoin(str(base_url), path.lstrip("/"))
|
||
|
if qs := self.request.url.query:
|
||
|
url += f"?{qs}"
|
||
|
|
||
|
return url
|
||
|
|
||
|
def resolve(self, url):
|
||
|
"""Resolve url to the current request url."""
|
||
|
return urljoin(str(self.base_url), str(url))
|
||
|
|
||
|
def link_self(self) -> Dict:
|
||
|
"""Return the self link."""
|
||
|
return {
|
||
|
"rel": Relations.self.value,
|
||
|
"type": MimeTypes.json.value,
|
||
|
"href": self.url,
|
||
|
}
|
||
|
|
||
|
def link_root(self) -> Dict:
|
||
|
"""Return the catalog root."""
|
||
|
return {
|
||
|
"rel": Relations.root.value,
|
||
|
"type": MimeTypes.json.value,
|
||
|
"href": self.base_url,
|
||
|
}
|
||
|
|
||
|
def create_links(self) -> List[Dict[str, Any]]:
|
||
|
"""Return all inferred links."""
|
||
|
links = []
|
||
|
for name in dir(self):
|
||
|
if name.startswith("link_") and callable(getattr(self, name)):
|
||
|
link = getattr(self, name)()
|
||
|
if link is not None:
|
||
|
links.append(link)
|
||
|
return links
|
||
|
|
||
|
async def get_links(
|
||
|
self, extra_links: Optional[List[Dict[str, Any]]] = None
|
||
|
) -> List[Dict[str, Any]]:
|
||
|
"""
|
||
|
Generate all the links.
|
||
|
|
||
|
Get the links object for a stac resource by iterating through
|
||
|
available methods on this class that start with link_.
|
||
|
"""
|
||
|
# TODO: Pass request.json() into function so this doesn't need to be coroutine
|
||
|
if self.request.method == "POST":
|
||
|
self.request.postbody = await self.request.json()
|
||
|
# join passed in links with generated links
|
||
|
# and update relative paths
|
||
|
links = self.create_links()
|
||
|
|
||
|
if extra_links:
|
||
|
# For extra links passed in,
|
||
|
# add links modified with a resolved href.
|
||
|
# Drop any links that are dynamically
|
||
|
# determined by the server (e.g. self, parent, etc.)
|
||
|
# Resolving the href allows for relative paths
|
||
|
# to be stored in pgstac and for the hrefs in the
|
||
|
# links of response STAC objects to be resolved
|
||
|
# to the request url.
|
||
|
links += [
|
||
|
{**link, "href": self.resolve(link["href"])}
|
||
|
for link in extra_links
|
||
|
if link["rel"] not in INFERRED_LINK_RELS
|
||
|
]
|
||
|
|
||
|
return links
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class PagingLinks(BaseLinks):
|
||
|
"""Create links for paging."""
|
||
|
|
||
|
next: Optional[str] = attr.ib(kw_only=True, default=None)
|
||
|
prev: Optional[str] = attr.ib(kw_only=True, default=None)
|
||
|
|
||
|
def link_next(self) -> Optional[Dict[str, Any]]:
|
||
|
"""Create link for next page."""
|
||
|
if self.next is not None:
|
||
|
method = self.request.method
|
||
|
if method == "GET":
|
||
|
href = merge_params(self.url, {"token": f"next:{self.next}"})
|
||
|
link = {
|
||
|
"rel": Relations.next.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": href,
|
||
|
}
|
||
|
return link
|
||
|
|
||
|
if method == "POST":
|
||
|
return {
|
||
|
"rel": Relations.next.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": self.url,
|
||
|
"body": {**self.request.postbody, "token": f"next:{self.next}"},
|
||
|
}
|
||
|
|
||
|
return None
|
||
|
|
||
|
def link_prev(self) -> Optional[Dict[str, Any]]:
|
||
|
"""Create link for previous page."""
|
||
|
if self.prev is not None:
|
||
|
method = self.request.method
|
||
|
if method == "GET":
|
||
|
href = merge_params(self.url, {"token": f"prev:{self.prev}"})
|
||
|
return {
|
||
|
"rel": Relations.previous.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": href,
|
||
|
}
|
||
|
|
||
|
if method == "POST":
|
||
|
return {
|
||
|
"rel": Relations.previous.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": self.url,
|
||
|
"body": {**self.request.postbody, "token": f"prev:{self.prev}"},
|
||
|
}
|
||
|
return None
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class CollectionSearchPagingLinks(BaseLinks):
|
||
|
next: Optional[Dict[str, Any]] = attr.ib(kw_only=True, default=None)
|
||
|
prev: Optional[Dict[str, Any]] = attr.ib(kw_only=True, default=None)
|
||
|
|
||
|
def link_next(self) -> Optional[Dict[str, Any]]:
|
||
|
"""Create link for next page."""
|
||
|
if self.next is not None:
|
||
|
method = self.request.method
|
||
|
if method == "GET":
|
||
|
# if offset is equal to default value (0), drop it
|
||
|
if self.next["body"].get("offset", -1) == 0:
|
||
|
_ = self.next["body"].pop("offset")
|
||
|
|
||
|
href = merge_params(self.url, self.next["body"])
|
||
|
|
||
|
# if next link is equal to this link, skip it
|
||
|
if href == self.url:
|
||
|
return None
|
||
|
|
||
|
return {
|
||
|
"rel": Relations.next.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": href,
|
||
|
}
|
||
|
|
||
|
return None
|
||
|
|
||
|
def link_prev(self):
|
||
|
if self.prev is not None:
|
||
|
method = self.request.method
|
||
|
if method == "GET":
|
||
|
href = merge_params(self.url, self.prev["body"])
|
||
|
|
||
|
# if prev link is equal to this link, skip it
|
||
|
if href == self.url:
|
||
|
return None
|
||
|
|
||
|
return {
|
||
|
"rel": Relations.previous.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"method": method,
|
||
|
"href": href,
|
||
|
}
|
||
|
|
||
|
return None
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class CollectionLinksBase(BaseLinks):
|
||
|
"""Create inferred links specific to collections."""
|
||
|
|
||
|
collection_id: str = attr.ib()
|
||
|
|
||
|
def collection_link(self, rel: str = Relations.collection.value) -> Dict:
|
||
|
"""Create a link to a collection."""
|
||
|
return {
|
||
|
"rel": rel,
|
||
|
"type": MimeTypes.json.value,
|
||
|
"href": self.resolve(f"collections/{self.collection_id}"),
|
||
|
}
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class CollectionLinks(CollectionLinksBase):
|
||
|
"""Create inferred links specific to collections."""
|
||
|
|
||
|
def link_self(self) -> Dict:
|
||
|
"""Return the self link."""
|
||
|
return self.collection_link(rel=Relations.self.value)
|
||
|
|
||
|
def link_parent(self) -> Dict:
|
||
|
"""Create the `parent` link."""
|
||
|
return {
|
||
|
"rel": Relations.parent.value,
|
||
|
"type": MimeTypes.json.value,
|
||
|
"href": self.base_url,
|
||
|
}
|
||
|
|
||
|
def link_items(self) -> Dict:
|
||
|
"""Create the `item` link."""
|
||
|
return {
|
||
|
"rel": "items",
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"href": self.resolve(f"collections/{self.collection_id}/items"),
|
||
|
}
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class SearchLinks(BaseLinks):
|
||
|
"""Create inferred links specific to collections."""
|
||
|
|
||
|
def link_self(self) -> Dict:
|
||
|
"""Return the self link."""
|
||
|
return {
|
||
|
"rel": Relations.self.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"href": self.resolve("search"),
|
||
|
}
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class ItemCollectionLinks(CollectionLinksBase):
|
||
|
"""Create inferred links specific to collections."""
|
||
|
|
||
|
def link_self(self) -> Dict:
|
||
|
"""Return the self link."""
|
||
|
return {
|
||
|
"rel": Relations.self.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"href": self.resolve(f"collections/{self.collection_id}/items"),
|
||
|
}
|
||
|
|
||
|
def link_parent(self) -> Dict:
|
||
|
"""Create the `parent` link."""
|
||
|
return self.collection_link(rel=Relations.parent.value)
|
||
|
|
||
|
def link_collection(self) -> Dict:
|
||
|
"""Create the `collection` link."""
|
||
|
return self.collection_link()
|
||
|
|
||
|
|
||
|
@attr.s
|
||
|
class ItemLinks(CollectionLinksBase):
|
||
|
"""Create inferred links specific to items."""
|
||
|
|
||
|
item_id: str = attr.ib()
|
||
|
|
||
|
def link_self(self) -> Dict:
|
||
|
"""Create the self link."""
|
||
|
return {
|
||
|
"rel": Relations.self.value,
|
||
|
"type": MimeTypes.geojson.value,
|
||
|
"href": self.resolve(
|
||
|
f"collections/{self.collection_id}/items/{self.item_id}"
|
||
|
),
|
||
|
}
|
||
|
|
||
|
def link_parent(self) -> Dict:
|
||
|
"""Create the `parent` link."""
|
||
|
return self.collection_link(rel=Relations.parent.value)
|
||
|
|
||
|
def link_collection(self) -> Dict:
|
||
|
"""Create the `collection` link."""
|
||
|
return self.collection_link()
|