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

Commit 59fcd45

Browse files
authored
feat: build out the root endpoint (#33)
- Closes #26
1 parent bf087e6 commit 59fcd45

File tree

20 files changed

+335
-27
lines changed

20 files changed

+335
-27
lines changed

.env.local

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
TISTAC_CATALOG_ID="tistac"
2+
TISTAC_CATALOG_DESCRIPTION="A lightweight STAC API server built on FastAPI"

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
run: sudo apt-get install postgis
1818
- name: Install
1919
run: scripts/install --dev
20+
- name: Copy env file
21+
run: cp .env.local .env
2022
- name: Lint
2123
run: scripts/lint
2224
- name: Test

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ We're building a service to search [stac-geoparquet](https://github.com/stac-uti
66

77
## Usage
88

9-
Get [uv](https://docs.astral.sh/uv/getting-started/installation/)
9+
Get [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
1010

1111
```shell
1212
git clone [email protected]:developmentseed/labs-375-stac-geoparquet-backend.git
1313
cd labs-375-stac-geoparquet-backend
1414
scripts/install
15+
cp .env.local .env
1516
```
1617

1718
Then:

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"pgstacrs>=0.1.0",
1010
"pydantic>=2.10.4",
1111
"pydantic-settings>=2.7.1",
12+
"pystac>=1.11.0",
1213
"stacrs>=0.3.0",
1314
]
1415

@@ -17,7 +18,7 @@ dev = [
1718
"mypy>=1.14.1",
1819
"psycopg[pool]>=3.2.3",
1920
"pypgstac>=0.9.2",
20-
"pystac>=1.11.0",
21+
"pystac[validation]>=1.11.0",
2122
"pytest>=8.3.4",
2223
"pytest-asyncio>=0.25.1",
2324
"pytest-postgresql>=6.1.1",
@@ -27,6 +28,7 @@ dev = [
2728
[tool.mypy]
2829
strict = true
2930
files = ["**/*.py"]
31+
plugins = "pydantic.mypy"
3032

3133
[tool.pytest.ini_options]
3234
asyncio_mode = "auto"

scripts/dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
set -e
44

5-
TISTAC_BACKEND=data/naip.parquet uv run fastapi dev src/tistac/main.py
5+
uv run fastapi dev src/tistac/main.py

src/tistac/backends/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
from abc import ABC, abstractmethod
22

3-
from tistac.models.item_collection import ItemCollection
4-
from tistac.models.search import Search
3+
from tistac.models import Collection, ItemCollection, Search
54

65

76
class Backend(ABC):
87
"""A TiStac backend."""
98

9+
@abstractmethod
10+
async def get_collections(self) -> list[Collection]:
11+
"""Returns all collections in this backend."""
12+
13+
@abstractmethod
14+
async def get_collection(self, collection_id: str) -> Collection | None:
15+
"""Returns a collection."""
16+
1017
@abstractmethod
1118
async def search(self, search: Search) -> ItemCollection:
1219
"""Searches this backend."""

src/tistac/backends/pgstac.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
from pgstacrs import Client
44

55
from tistac.backends import Backend
6-
from tistac.models.item_collection import ItemCollection
7-
from tistac.models.search import Search
6+
from tistac.models import Collection, ItemCollection, Search
87

98

109
class PgstacBackend(Backend):
@@ -18,6 +17,17 @@ async def open(cls, dsn: str) -> PgstacBackend:
1817
def __init__(self, client: Client) -> None:
1918
self.client = client
2019

20+
async def get_collections(self) -> list[Collection]:
21+
return [
22+
Collection.model_validate(d) for d in await self.client.all_collections()
23+
]
24+
25+
async def get_collection(self, collection_id: str) -> Collection | None:
26+
if d := await self.client.get_collection(collection_id):
27+
return Collection.model_validate(d)
28+
else:
29+
return None
30+
2131
async def search(self, search: Search) -> ItemCollection:
2232
item_collection = await self.client.search(limit=search.limit)
2333
return ItemCollection.model_validate(item_collection)
Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,47 @@
11
import stacrs
2+
from pystac import Collection as PystacCollection
3+
from pystac import Extent, Item
24

35
from tistac.backends import Backend
4-
from tistac.models.item_collection import ItemCollection
5-
from tistac.models.search import Search
6+
from tistac.models import Collection, ItemCollection, Search
67

78

89
class StacGeoparquetBackend(Backend):
910
"""A stac-geoparquet backend, using DuckDB under the hood."""
1011

1112
def __init__(self, href: str):
13+
# TODO support multiple collections
14+
# TODO store collection information in the **stac-geoparquet**
15+
items_as_dicts = stacrs.search(href)
16+
collection_ids = set()
17+
items = list()
18+
for item_as_dict in items_as_dicts:
19+
item = Item.from_dict(item_as_dict)
20+
if item.collection_id:
21+
collection_ids.add(item.collection_id)
22+
items.append(item)
23+
if len(collection_ids) != 1:
24+
raise Exception(
25+
"Only one collection id is supported by the "
26+
f"stac-geoparquet backend: {collection_ids}"
27+
)
28+
extent = Extent.from_items(items)
29+
collection = PystacCollection(
30+
id=collection_ids.pop(),
31+
description="An auto-generated stac-geoparquet Collection",
32+
extent=extent,
33+
)
34+
d = collection.to_dict(include_self_link=False, transform_hrefs=False)
35+
d["stac_version"] = "1.1.0"
36+
self.collections = [Collection.model_validate(d)]
1237
self.href = href
1338

39+
async def get_collections(self) -> list[Collection]:
40+
return self.collections
41+
42+
async def get_collection(self, collection_id: str) -> Collection | None:
43+
return next((c for c in self.collections if c.id == collection_id), None)
44+
1445
async def search(self, search: Search) -> ItemCollection:
1546
items = stacrs.search(self.href, limit=search.limit)
1647
return ItemCollection(features=items)

src/tistac/dependencies.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,30 @@
99
from tistac.backends.stac_geoparquet import StacGeoparquetBackend
1010
from tistac.settings import Settings
1111

12+
BACKEND: dict[str, Backend] = {}
13+
1214

1315
@lru_cache
1416
def get_settings() -> Settings:
15-
return Settings() # type: ignore
17+
return Settings()
1618

1719

20+
# I really don't like how this works -- we might want to go back to leaning on
21+
# the starlette state, like stac-fastapi-pgstac does. It's just tricky to
22+
# configure settings w/o using the dependency overrides.
1823
async def get_backend(settings: Annotated[Settings, Depends(get_settings)]) -> Backend:
19-
# TODO share the pgstac connection pool
24+
global BACKEND
25+
2026
url = urllib.parse.urlparse(settings.backend)
2127
if url.scheme == "postgresql":
22-
return await PgstacBackend.open(settings.backend)
28+
if "pgstac" in BACKEND:
29+
return BACKEND["pgstac"]
30+
else:
31+
BACKEND["pgstac"] = await PgstacBackend.open(settings.backend)
32+
return BACKEND["pgstac"]
2333
else:
24-
return StacGeoparquetBackend(settings.backend)
34+
if "stac-geoparquet" in BACKEND:
35+
return BACKEND["stac-geoparquet"]
36+
else:
37+
BACKEND["stac-geoparquet"] = StacGeoparquetBackend(settings.backend)
38+
return BACKEND["stac-geoparquet"]

src/tistac/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import importlib.metadata
2+
13
from fastapi import FastAPI
24

35
import tistac.router
46

5-
app = FastAPI()
7+
app = FastAPI(
8+
version=importlib.metadata.distribution("tistac").version,
9+
)
610
app.include_router(tistac.router.router)

0 commit comments

Comments
 (0)