Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* Add hook to allow adding dependencies to routes. ([#295](https://github.com/stac-utils/stac-fastapi/pull/295))
* Ability to POST an ItemCollection to the collections/{collectionId}/items route. ([#367](https://github.com/stac-utils/stac-fastapi/pull/367))
* Add STAC API - Collections conformance class. ([383](https://github.com/stac-utils/stac-fastapi/pull/383))
* Add support for children and browsable STAC apis ([336](https://github.com/stac-utils/stac-fastapi/pull/336))

### Changed

Expand All @@ -17,6 +18,7 @@
* Bulk Transactions object Items iterator now returns the Item objects rather than the string IDs of the Item objects
([#355](https://github.com/stac-utils/stac-fastapi/issues/355))
* docker-compose now runs uvicorn with hot-reloading enabled
* Organize clients to avoid extremely long source files ([336](https://github.com/stac-utils/stac-fastapi/pull/336))

### Removed

Expand Down
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,14 @@ test-sqlalchemy: run-joplin-sqlalchemy
$(run_sqlalchemy) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/sqlalchemy/tests/ && pytest -vvv'

.PHONY: test-pgstac
test-pgstac:
test-pgstac: run-joplin-sqlalchemy
$(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/pgstac/tests/ && pytest -vvv'


.PHONY: test-api
test-api:
$(run_pgstac) /bin/bash -c 'export && ./scripts/wait-for-it.sh database:5432 && cd /app/stac_fastapi/api/tests/ && pytest -vvv'

.PHONY: run-database
run-database:
docker-compose run --rm database
Expand All @@ -59,7 +64,7 @@ run-joplin-pgstac:
docker-compose run --rm loadjoplin-pgstac

.PHONY: test
test: test-sqlalchemy test-pgstac
test: test-sqlalchemy test-pgstac test-api

.PHONY: pybase-install
pybase-install:
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- POSTGRES_HOST_WRITER=database
- POSTGRES_PORT=5432
- WEB_CONCURRENCY=10
- BROWSEABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json
ports:
- "8081:8081"
volumes:
Expand Down Expand Up @@ -50,6 +51,7 @@ services:
- GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
- DB_MIN_CONN_SIZE=1
- DB_MAX_CONN_SIZE=1
- BROWSEABLE_HIERARCHY_DEFINITION=/app/stac_fastapi/testdata/joplin/hierarchy.json
ports:
- "8082:8082"
volumes:
Expand Down
174 changes: 172 additions & 2 deletions stac_fastapi/api/stac_fastapi/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi.openapi.utils import get_openapi
from fastapi.params import Depends
from pydantic import BaseModel
from stac_pydantic import Collection, Item, ItemCollection
from stac_pydantic import Catalog, Collection, Item, ItemCollection
from stac_pydantic.api import ConformanceClasses, LandingPage
from stac_pydantic.api.collections import Collections
from stac_pydantic.version import STAC_VERSION
Expand All @@ -16,6 +16,7 @@
from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from stac_fastapi.api.models import (
APIRequest,
CatalogUri,
CollectionUri,
EmptyRequest,
GeoJSONResponse,
Expand All @@ -33,10 +34,12 @@

# TODO: make this module not depend on `stac_fastapi.extensions`
from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension
from stac_fastapi.types.clients.async_core import AsyncBaseCoreClient
from stac_fastapi.types.clients.sync_core import BaseCoreClient
from stac_fastapi.types.config import ApiSettings, Settings
from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient
from stac_fastapi.types.extension import ApiExtension
from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest
from stac_fastapi.types.stac import Children


@attr.s
Expand Down Expand Up @@ -83,9 +86,15 @@ class StacApi:
api_version: str = attr.ib(default="0.1")
stac_version: str = attr.ib(default=STAC_VERSION)
description: str = attr.ib(default="stac-fastapi")
search_get_request_base_model: Type[BaseSearchGetRequest] = attr.ib(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats the difference between this and search_get_request_model? Same comment for post models.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a potentially ugly and maybe a point where you could provide some counsel. Basically, the need to supply a catalog URI parameter changes the constructed search model so what I'm doing here is passing in the base search_get_request_base_model because it might possibly differ from the fully constructed search_get_request_model used by standard searches. Perhaps the answer is to just construct both of these inside of core.py. Let me know what you think.

default=BaseSearchGetRequest
)
search_get_request_model: Type[BaseSearchGetRequest] = attr.ib(
default=BaseSearchGetRequest
)
search_post_request_base_model: Type[BaseSearchPostRequest] = attr.ib(
default=BaseSearchPostRequest
)
search_post_request_model: Type[BaseSearchPostRequest] = attr.ib(
default=BaseSearchPostRequest
)
Expand Down Expand Up @@ -266,6 +275,44 @@ def register_get_collection(self):
),
)

def register_get_root_children(self):
"""Register get collection children endpoint (GET /children).

Returns:
None
"""
self.router.add_api_route(
name="Get Root Children",
path="/children",
response_model=Children if self.settings.enable_response_models else None,
response_class=self.response_class,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.get_root_children, EmptyRequest, self.response_class
),
)

def register_get_catalog_children(self):
"""Register get collection children endpoint (GET /collection/{collection_id}/children).

Returns:
None
"""
self.router.add_api_route(
name="Get Root Children",
path="/catalogs/{catalog_path:path}/children",
response_model=Children if self.settings.enable_response_models else None,
response_class=self.response_class,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.get_catalog_children, CatalogUri, self.response_class
),
)

def register_get_item_collection(self):
"""Register get item collection endpoint (GET /collection/{collection_id}/items).

Expand Down Expand Up @@ -293,6 +340,119 @@ def register_get_item_collection(self):
),
)

def register_catalog_conformance_classes(self):
"""Register catalog conformance class endpoint (GET /catalogs/{catalog_path}/conformance).

Returns:
None
"""
self.router.add_api_route(
name="Conformance Classes",
path="/catalogs/{catalog_path:path}/conformance",
response_model=ConformanceClasses
if self.settings.enable_response_models
else None,
response_class=self.response_class,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.conformance, EmptyRequest, self.response_class
),
)

def register_post_catalog_search(self):
"""Register search endpoint (POST /search).

Returns:
None
"""
fields_ext = self.get_extension(FieldsExtension)
self.router.add_api_route(
name="Search",
path="/catalogs/{catalog_path:path}/search",
response_model=(ItemCollection if not fields_ext else None)
if self.settings.enable_response_models
else None,
response_class=GeoJSONResponse,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["POST"],
endpoint=self._create_endpoint(
self.client.post_catalog_search,
self.search_post_request_model,
GeoJSONResponse,
),
)

def register_get_catalog_search(self):
"""Register catalog search endpoint (GET /catalogs/{catalog_path}/search).

Returns:
None
"""
fields_ext = self.get_extension(FieldsExtension)
request_model = create_request_model(
"GetSearchWithCatalogUri",
base_model=self.search_get_request_base_model,
extensions=self.extensions,
mixins=[CatalogUri],
)
self.router.add_api_route(
name="Catalog Search",
path="/catalogs/{catalog_path:path}/search",
response_model=(ItemCollection if not fields_ext else None)
if self.settings.enable_response_models
else None,
response_class=GeoJSONResponse,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.get_catalog_search, request_model, GeoJSONResponse
),
)

def register_get_catalog_collections(self):
"""Register get collections endpoint (GET /collections).

Returns:
None
"""
self.router.add_api_route(
name="Get Collections",
path="/catalogs/{catalog_path:path}/collections",
response_model=Collections
if self.settings.enable_response_models
else None,
response_class=self.response_class,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.get_catalog_collections, CatalogUri, self.response_class
),
)

def register_get_catalog(self):
"""Register get collection endpoint (GET /catalog/{catalog_path}).

Returns:
None
"""
self.router.add_api_route(
name="Get Catalog",
path="/catalogs/{catalog_path:path}",
response_model=Catalog if self.settings.enable_response_models else None,
response_class=self.response_class,
response_model_exclude_unset=True,
response_model_exclude_none=True,
methods=["GET"],
endpoint=self._create_endpoint(
self.client.get_catalog, CatalogUri, self.response_class
),
)

def register_core(self):
"""Register core STAC endpoints.

Expand All @@ -319,6 +479,16 @@ def register_core(self):
self.register_get_collection()
self.register_get_item_collection()

# Browseable endpoints
self.register_catalog_conformance_classes()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to hide these behind some sort of flag (disabled by default) for two reasons:

  • These two extensions are really new, and we are the first implementation of it.
  • They increase the surface area of the API quite a bit, and may not be required by most use cases.

self.register_post_catalog_search()
self.register_get_catalog_search()
self.register_get_catalog_collections()
if self.settings.browseable_hierarchy_definition is not None:
self.register_get_root_children()
self.register_get_catalog_children()
self.register_get_catalog()

def customize_openapi(self) -> Optional[Dict[str, Any]]:
"""Customize openapi schema."""
if self.app.openapi_schema:
Expand Down
23 changes: 16 additions & 7 deletions stac_fastapi/api/stac_fastapi/api/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""api request/response models."""

import importlib.util
from typing import Optional, Type, Union
from typing import List, Optional, Type, Union

import attr
from fastapi import Body, Path
Expand All @@ -19,8 +19,8 @@
def create_request_model(
model_name="SearchGetRequest",
base_model: Union[Type[BaseModel], APIRequest] = BaseSearchGetRequest,
extensions: Optional[ApiExtension] = None,
mixins: Optional[Union[BaseModel, APIRequest]] = None,
extensions: Optional[List[ApiExtension]] = None,
mixins: Optional[List[Union[BaseModel, APIRequest]]] = None,
request_type: Optional[str] = "GET",
) -> Union[Type[BaseModel], APIRequest]:
"""Create a pydantic model for validating request bodies."""
Expand Down Expand Up @@ -74,39 +74,48 @@ def create_request_model(


def create_get_request_model(
extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest
extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest, mixins=None
):
"""Wrap create_request_model to create the GET request model."""
return create_request_model(
"SearchGetRequest",
base_model=base_model,
extensions=extensions,
mixins=mixins,
request_type="GET",
)


def create_post_request_model(
extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest
extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest, mixins=None
):
"""Wrap create_request_model to create the POST request model."""
return create_request_model(
"SearchPostRequest",
base_model=base_model,
extensions=extensions,
mixins=mixins,
request_type="POST",
)


@attr.s # type:ignore
class CatalogUri(APIRequest):
"""Catalog Path."""

catalog_path: str = attr.ib(default=Path(..., description="Catalog Path"))


@attr.s # type:ignore
class CollectionUri(APIRequest):
"""Delete collection."""
"""Collection URI."""

collection_id: str = attr.ib(default=Path(..., description="Collection ID"))


@attr.s
class ItemUri(CollectionUri):
"""Delete item."""
"""Item URI."""

item_id: str = attr.ib(default=Path(..., description="Item ID"))

Expand Down
11 changes: 8 additions & 3 deletions stac_fastapi/api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from stac_fastapi.api.app import StacApi
from stac_fastapi.extensions.core import TokenPaginationExtension, TransactionExtension
from stac_fastapi.types import config, core
from stac_fastapi.types import config
from stac_fastapi.types.clients.sync_core import BaseCoreClient
from stac_fastapi.types.clients.transaction import BaseTransactionsClient


class TestRouteDependencies:
Expand Down Expand Up @@ -76,7 +78,7 @@ def test_add_route_dependencies_after_building_api(self):
self._assert_dependency_applied(api, routes)


class DummyCoreClient(core.BaseCoreClient):
class DummyCoreClient(BaseCoreClient):
def all_collections(self, *args, **kwargs):
...

Expand All @@ -95,8 +97,11 @@ def post_search(self, *args, **kwargs):
def item_collection(self, *args, **kwargs):
...

def get_root_children(self, **kwargs):
...


class DummyTransactionsClient(core.BaseTransactionsClient):
class DummyTransactionsClient(BaseTransactionsClient):
"""Defines a pattern for implementing the STAC transaction extension."""

def create_item(self, *args, **kwargs):
Expand Down
Loading