Skip to content
This repository was archived by the owner on Jul 21, 2025. It is now read-only.

Commit 671ea52

Browse files
authored
feat: config-based hrefs (#48)
- Closes #46 - Resolves path relative to the config file - Includes a switch to using [lifespan state](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#6-use-lifespan-state-instead-of-appstate) - Refactors to add a factory function which makes testing easier
1 parent 4863140 commit 671ea52

File tree

8 files changed

+183
-86
lines changed

8 files changed

+183
-86
lines changed

data/config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hrefs = ["naip.parquet"]

pyproject.toml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,15 @@ dependencies = [
88
"attr>=0.3.2",
99
"fastapi>=0.115.6",
1010
"geojson-pydantic>=1.2.0",
11+
"pydantic>=2.10.4",
1112
"stac-fastapi-api>=3.0.5",
1213
"stac-fastapi-extensions>=3.0.5",
1314
"stac-fastapi-types>=3.0.5",
1415
"stacrs>=0.4.0",
1516
]
1617

1718
[project.optional-dependencies]
18-
lambda = [
19-
"mangum==0.19.0",
20-
]
19+
lambda = ["mangum==0.19.0"]
2120

2221
[dependency-groups]
2322
dev = [
@@ -40,13 +39,8 @@ deploy = [
4039

4140
[tool.mypy]
4241
strict = true
43-
files = [
44-
"src/**/*.py",
45-
"infrastructure/**/*.py"
46-
]
47-
exclude = [
48-
"infrastructure/aws/cdk.out/.*"
49-
]
42+
files = ["src/**/*.py", "infrastructure/**/*.py"]
43+
exclude = ["infrastructure/aws/cdk.out/.*"]
5044
plugins = "pydantic.mypy"
5145

5246
[[tool.mypy.overrides]]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import tomllib
2+
from contextlib import asynccontextmanager
3+
from pathlib import Path
4+
from typing import Any, AsyncIterator, TypedDict
5+
6+
from fastapi import FastAPI
7+
from stacrs import DuckdbClient
8+
9+
import stac_fastapi.api.models
10+
from stac_fastapi.api.app import StacApi
11+
from stac_fastapi.extensions.core.pagination import OffsetPaginationExtension
12+
13+
from .client import Client
14+
from .search import SearchGetRequest, SearchPostRequest
15+
from .settings import Settings, StacFastapiGeoparquetSettings
16+
17+
GetSearchRequestModel = stac_fastapi.api.models.create_request_model(
18+
model_name="SearchGetRequest",
19+
base_model=SearchGetRequest,
20+
mixins=[OffsetPaginationExtension().GET],
21+
request_type="GET",
22+
)
23+
PostSearchRequestModel = stac_fastapi.api.models.create_request_model(
24+
model_name="SearchPostRequest",
25+
base_model=SearchPostRequest,
26+
mixins=[OffsetPaginationExtension().POST],
27+
request_type="POST",
28+
)
29+
30+
31+
class State(TypedDict):
32+
"""Application state."""
33+
34+
client: DuckdbClient
35+
"""The DuckDB client.
36+
37+
It's just an in-memory DuckDB connection with the spatial extension enabled.
38+
"""
39+
40+
collections: dict[str, dict[str, Any]]
41+
"""A mapping of collection id to collection."""
42+
43+
hrefs: dict[str, str]
44+
"""A mapping of collection id to geoparquet href."""
45+
46+
47+
def create_api(settings: Settings | None = None) -> StacApi:
48+
if settings is None:
49+
settings = Settings()
50+
51+
if settings.stac_fastapi_geoparquet_href.endswith(".toml"):
52+
with open(settings.stac_fastapi_geoparquet_href, "rb") as f:
53+
data = tomllib.load(f)
54+
stac_fastapi_geoparquet_settings = StacFastapiGeoparquetSettings.model_validate(
55+
data
56+
)
57+
config_directory = Path(settings.stac_fastapi_geoparquet_href).parent
58+
hrefs = []
59+
for href in stac_fastapi_geoparquet_settings.hrefs:
60+
if Path(href).is_absolute():
61+
hrefs.append(href)
62+
else:
63+
hrefs.append(str(config_directory.joinpath(href).resolve()))
64+
else:
65+
hrefs = [settings.stac_fastapi_geoparquet_href]
66+
67+
@asynccontextmanager
68+
async def lifespan(app: FastAPI) -> AsyncIterator[State]:
69+
client = DuckdbClient()
70+
collections = dict()
71+
href_dict = dict()
72+
for href in hrefs:
73+
for collection in client.get_collections(href):
74+
if collection["id"] in collections:
75+
raise ValueError(
76+
"cannot have two items in the same collection in different "
77+
"geoparquet files"
78+
)
79+
else:
80+
collections[collection["id"]] = collection
81+
href_dict[collection["id"]] = href
82+
yield {
83+
"client": client,
84+
"collections": collections,
85+
"hrefs": href_dict,
86+
}
87+
88+
api = StacApi(
89+
settings=Settings(
90+
stac_fastapi_landing_id="stac-fastapi-geoparquet",
91+
stac_fastapi_title="stac-geoparquet-geoparquet",
92+
stac_fastapi_description="A stac-fastapi server backend by stac-geoparquet",
93+
),
94+
client=Client(),
95+
app=FastAPI(
96+
lifespan=lifespan,
97+
openapi_url=settings.openapi_url,
98+
docs_url=settings.docs_url,
99+
redoc_url=settings.docs_url,
100+
),
101+
search_get_request_model=GetSearchRequestModel,
102+
search_post_request_model=PostSearchRequestModel,
103+
)
104+
return api
105+
106+
107+
__all__ = [
108+
"create_api",
109+
"Settings",
110+
"SearchGetRequest",
111+
"SearchPostRequest",
112+
"Client",
113+
]

src/stac_fastapi/geoparquet/client.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import copy
22
import json
33
import urllib.parse
4-
from typing import Any
4+
from typing import Any, cast
55

66
from fastapi import HTTPException
77
from pydantic import ValidationError
8+
from stacrs import DuckdbClient
89
from starlette.requests import Request
910

1011
from stac_fastapi.api.models import BaseSearchPostRequest
@@ -18,9 +19,10 @@ class Client(AsyncBaseCoreClient): # type: ignore
1819
"""A stac-fastapi-geoparquet client."""
1920

2021
async def all_collections(self, *, request: Request, **kwargs: Any) -> Collections:
22+
collections = cast(dict[str, dict[str, Any]], request.state.collections)
2123
return Collections(
2224
collections=[
23-
collection_with_links(c, request) for c in request.app.state.collections
25+
collection_with_links(c, request) for c in collections.values()
2426
],
2527
links=[
2628
{
@@ -39,25 +41,29 @@ async def all_collections(self, *, request: Request, **kwargs: Any) -> Collectio
3941
async def get_collection(
4042
self, *, request: Request, collection_id: str, **kwargs: Any
4143
) -> Collection:
42-
if collection := next(
43-
(c for c in request.app.state.collections if c["id"] == collection_id), None
44-
):
44+
collections = cast(dict[str, dict[str, Any]], request.state.collections)
45+
if collection := collections.get(collection_id):
4546
return collection_with_links(collection, request)
4647
else:
4748
raise NotFoundError(f"Collection does not exist: {collection_id}")
4849

4950
async def get_item(
5051
self, *, request: Request, item_id: str, collection_id: str, **kwargs: Any
5152
) -> Item:
52-
item_collection = request.app.state.client.search(
53-
request.app.state.href, ids=[item_id], collections=[collection_id], **kwargs
54-
)
55-
if len(item_collection["features"]) == 1:
56-
return Item(**item_collection["features"][0])
57-
else:
58-
raise NotFoundError(
59-
f"Item does not exist: {item_id} in collection {collection_id}"
53+
client = cast(DuckdbClient, request.state.client)
54+
hrefs = cast(dict[str, str], request.state.hrefs)
55+
if href := hrefs.get(collection_id):
56+
item_collection = client.search(
57+
href, ids=[item_id], collections=[collection_id], **kwargs
6058
)
59+
if len(item_collection["features"]) == 1:
60+
return Item(**item_collection["features"][0])
61+
else:
62+
raise NotFoundError(
63+
f"Item does not exist: {item_id} in collection {collection_id}"
64+
)
65+
else:
66+
raise NotFoundError(f"Collection does not exist: {collection_id}")
6167

6268
async def get_search(
6369
self,
@@ -157,10 +163,30 @@ async def search(
157163
search: BaseSearchPostRequest,
158164
**kwargs: Any,
159165
) -> ItemCollection:
166+
client = cast(DuckdbClient, request.state.client)
167+
hrefs = cast(dict[str, str], request.state.hrefs)
168+
169+
hrefs_to_search = set()
170+
if search.collections:
171+
for collection in search.collections:
172+
if href := hrefs.get(collection):
173+
hrefs_to_search.add(href)
174+
else:
175+
hrefs_to_search.update(hrefs.values())
176+
177+
if len(hrefs) > 1:
178+
raise ValidationError(
179+
"Cannot search multiple geoparquet files (don't know how to page)"
180+
)
181+
elif len(hrefs) == 0:
182+
return ItemCollection()
183+
else:
184+
href = hrefs_to_search.pop()
185+
160186
search_dict = search.model_dump(exclude_none=True)
161187
search_dict.update(**kwargs)
162-
item_collection = request.app.state.client.search(
163-
request.app.state.href,
188+
item_collection = client.search(
189+
href,
164190
**search_dict,
165191
)
166192
num_items = len(item_collection["features"])
Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,4 @@
1-
from collections.abc import AsyncIterator
2-
from contextlib import asynccontextmanager
1+
from stac_fastapi.geoparquet import create_api
32

4-
from fastapi import FastAPI
5-
from stacrs import DuckdbClient
6-
7-
import stac_fastapi.api.models
8-
from stac_fastapi.api.app import StacApi
9-
from stac_fastapi.extensions.core.pagination import OffsetPaginationExtension
10-
11-
from .client import Client
12-
from .search import SearchGetRequest, SearchPostRequest
13-
from .settings import Settings
14-
15-
settings = Settings()
16-
17-
18-
GetSearchRequestModel = stac_fastapi.api.models.create_request_model(
19-
model_name="SearchGetRequest",
20-
base_model=SearchGetRequest,
21-
mixins=[OffsetPaginationExtension().GET],
22-
request_type="GET",
23-
)
24-
PostSearchRequestModel = stac_fastapi.api.models.create_request_model(
25-
model_name="SearchPostRequest",
26-
base_model=SearchPostRequest,
27-
mixins=[OffsetPaginationExtension().POST],
28-
request_type="POST",
29-
)
30-
31-
32-
@asynccontextmanager
33-
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
34-
client = DuckdbClient()
35-
collections = client.get_collections(settings.stac_fastapi_geoparquet_href)
36-
app.state.href = settings.stac_fastapi_geoparquet_href
37-
app.state.collections = collections
38-
app.state.client = client
39-
yield
40-
41-
42-
api = StacApi(
43-
settings=Settings(
44-
stac_fastapi_landing_id="stac-fastapi-geoparquet",
45-
stac_fastapi_title="stac-geoparquet-geoparquet",
46-
stac_fastapi_description="A stac-fastapi server backend by stac-geoparquet",
47-
),
48-
client=Client(),
49-
app=FastAPI(
50-
lifespan=lifespan,
51-
openapi_url=settings.openapi_url,
52-
docs_url=settings.docs_url,
53-
redoc_url=settings.docs_url,
54-
),
55-
search_get_request_model=GetSearchRequestModel,
56-
search_post_request_model=PostSearchRequestModel,
57-
)
3+
api = create_api()
584
app = api.app
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
from pydantic import BaseModel
2+
13
from stac_fastapi.types.config import ApiSettings
24

35

46
class Settings(ApiSettings): # type: ignore
57
"""stac-fastapi-geoparquet settings"""
68

79
stac_fastapi_geoparquet_href: str
10+
"""This can either be the href of a single geoparquet file, or the href of a TOML
11+
configuration file.
12+
"""
13+
14+
15+
class StacFastapiGeoparquetSettings(BaseModel):
16+
hrefs: list[str]
17+
"""Geoparquet hrefs"""

tests/conftest.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@
33

44
import pytest
55
from fastapi.testclient import TestClient
6+
from pytest import FixtureRequest
67

7-
import stac_fastapi.geoparquet.main
8+
import stac_fastapi.geoparquet
9+
from stac_fastapi.geoparquet import Settings
810

9-
naip_items = Path(__file__).parents[1] / "data" / "naip.parquet"
11+
geoparquet_file = Path(__file__).parents[1] / "data" / "naip.parquet"
12+
toml_file = Path(__file__).parents[1] / "data" / "config.toml"
1013

1114

12-
@pytest.fixture()
13-
async def client() -> AsyncIterator[TestClient]:
14-
with TestClient(stac_fastapi.geoparquet.main.app) as client:
15+
@pytest.fixture(params=[geoparquet_file, toml_file])
16+
async def client(request: FixtureRequest) -> AsyncIterator[TestClient]:
17+
settings = Settings(stac_fastapi_geoparquet_href=str(request.param))
18+
api = stac_fastapi.geoparquet.create_api(settings)
19+
with TestClient(api.app) as client:
1520
yield client

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)