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

Commit 30e4266

Browse files
authored
refactor: depend on stac-fastapi (#39)
- Closes #38 cc @vincentsarago @ceholden
1 parent 72e9231 commit 30e4266

28 files changed

+408
-879
lines changed

.env.local

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
TISTAC_CATALOG_ID="tistac"
2-
TISTAC_CATALOG_DESCRIPTION="A lightweight STAC API server built on FastAPI"
1+
STAC_FASTAPI_GEOPARQUET_HREF = "data/naip.parquet"

Dockerfile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
55

66
WORKDIR /app
77

8+
RUN apt-get update && apt-get install -y git curl build-essential && apt-get clean
9+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal
10+
ENV PATH="/root/.cargo/bin:$PATH"
811
RUN --mount=type=cache,target=/root/.cache/uv \
912
--mount=type=bind,source=uv.lock,target=uv.lock \
1013
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
11-
uv sync --frozen --no-install-project --no-build-package stacrs --refresh
14+
uv sync --frozen --no-install-project --refresh
1215

1316
ADD . /app
1417

@@ -18,4 +21,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \
1821
FROM python:3.12-slim
1922
COPY --from=builder --chown=app:app /app/.venv /app/.venv
2023

21-
CMD [ "/app/.venv/bin/uvicorn", "tistac.main:app" ]
24+
CMD [ "/app/.venv/bin/uvicorn", "stac_fastapi.geoparquet.main:app" ]

README.md

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,7 @@ Then:
2121
scripts/dev
2222
```
2323

24-
This will start the **stac-fastapi** server on <http://127.0.0.1:8000/>.
25-
26-
To start both a **pgstac** and **stac-fastapi** server:
27-
28-
```shell
29-
docker compose up
30-
```
31-
32-
This will start **pgstac** on <http://127.0.0.1:8000> and **stac-fastapi** on <http://127.0.0.1:8001>.
33-
34-
### **pgstac**
35-
36-
Right now we don't auto-load data to **pgstac**.
37-
To populate the **pgstac** database, `uv pip install stacrs-cli` and make sure you've got `docker compose up`, then:
38-
39-
```shell
40-
curl http://localhost:8001/collections/naip | pypgstac --dsn postgresql://username:password@localhost:5432/pgstac load collections stdin
41-
stacrs translate data/naip.parquet | jq -c '.features.[]' | pypgstac --dsn postgresql://username:password@localhost:5432/pgstac load items stdin
42-
```
43-
44-
To run just the **pgstac** dev server:
45-
46-
```shell
47-
docker compose up -d pgstac
48-
TISTAC_BACKEND=postgresql://username:password@localhost:5432/pgstac fastapi dev src/tistac/main.py
49-
```
24+
This will start the **stac-fastapi-geoparquet** server on <http://127.0.0.1:8000/>.
5025

5126
## Developing
5227

@@ -72,13 +47,10 @@ scripts/lint # Doesn't fix things
7247
## Core assumptions
7348

7449
- Everything we build should either be in this repo or in an already-existing one ... we shouldn't stand up any new repos.
75-
- We're _not_ going to use [stac-fastapi](https://github.com/stac-utils/stac-fastapi) or [stac-fastapi-pgstac](https://github.com/stac-utils/stac-fastapi-pgstac).
76-
We might end up porting over code or ideas later, but @gadomski wants this to be green-field.
7750
- We want to be public-by-default (with appropriate throttling) with all of our services.
7851
We want to show this off to the world, not keep it secret.
79-
- We'd like to use [stac-rs](https://github.com/stac-utils/stac-rs) (and its Python friends, [stacrs](https://github.com/gadomski/stacrs) and [pgstacrs](https://github.com/stac-utils/pgstacrs)) as much as possible.
52+
- We'd like to use [stac-rs](https://github.com/stac-utils/stac-rs) and its Python friend, [stacrs](https://github.com/gadomski/stacrs), as much as possible.
8053
This is partially a sop to @gadomski, but we think that the performance and reusability benefits of Rust will be part of what makes this project special.
81-
- APIs should be FastAPI, which is the de-facto standard here at Development Seed for good reason.
8254

8355
## Project management
8456

docker-compose.yml

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,12 @@
11
services:
2-
pgstac:
3-
container_name: pgstac
4-
image: ghcr.io/stac-utils/pgstac:v0.9.1
5-
environment:
6-
- POSTGRES_USER=username
7-
- POSTGRES_PASSWORD=password
8-
- POSTGRES_DB=pgstac
9-
- PGUSER=username
10-
- PGPASSWORD=password
11-
- PGDATABASE=pgstac
12-
ports:
13-
- 5432:5432
14-
healthcheck:
15-
test: ["CMD-SHELL", "pg_isready", "-d", "pgstac"]
16-
interval: 1s
17-
timeout: 20s
18-
retries: 20
19-
tistac-pgstac:
20-
container_name: tistac-pgstac
2+
stac-fastapi-geoparquet:
3+
container_name: stac-fastapi-geoparquet
214
build: .
225
environment:
23-
- TISTAC_BACKEND=postgresql://username:password@pgstac:5432/pgstac
24-
- TISTAC_CATALOG_ID=tistac-pgstac
25-
- TISTAC_CATALOG_DESCRIPTION="tistac with a pgstac backend"
6+
- STAC_FASTAPI_GEOPARQUET_HREF=/app/data/naip.parquet
267
- UVICORN_HOST=0.0.0.0
278
- UVICORN_PORT=8000
289
ports:
2910
- 8000:8000
30-
depends_on:
31-
pgstac:
32-
condition: service_healthy
33-
tistac-stac-fastapi:
34-
container_name: tistac-stac-fastapi
35-
build: .
36-
environment:
37-
- TISTAC_BACKEND=/app/data/naip.parquet
38-
- TISTAC_CATALOG_ID=tistac-stac-geoparquet
39-
- TISTAC_CATALOG_DESCRIPTION="tistac with a stac-geoparquet backend"
40-
- UVICORN_HOST=0.0.0.0
41-
- UVICORN_PORT=8001
42-
ports:
43-
- 8001:8001
4411
volumes:
4512
- ./data:/app/data

pyproject.toml

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,44 @@
11
[project]
2-
name = "tistac"
2+
name = "stac-fastapi-geoparquet"
33
version = "0.1.0"
4-
description = "A small multi-backend STAC API server built on FastAPI."
4+
description = "stac-geoparquet backend for stac-fastapi"
55
readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
8-
"fastapi[standard]>=0.115.6",
9-
"pgstacrs>=0.1.0",
10-
"pydantic>=2.10.4",
11-
"pydantic-settings>=2.7.1",
12-
"pystac>=1.11.0",
8+
"fastapi>=0.115.6",
9+
"geojson-pydantic>=1.2.0",
10+
"stac-fastapi-api>=3.0.0",
11+
"stac-fastapi-extensions>=3.0.4",
12+
"stac-fastapi-types>=3.0.0",
1313
"stacrs>=0.3.0",
1414
]
1515

1616
[dependency-groups]
1717
dev = [
18+
"fastapi[standard]>=0.115.6",
19+
"httpx>=0.28.1",
1820
"mypy>=1.14.1",
19-
"psycopg[pool]>=3.2.3",
20-
"pypgstac>=0.9.2",
2121
"pystac[validation]>=1.11.0",
2222
"pytest>=8.3.4",
2323
"pytest-asyncio>=0.25.1",
24-
"pytest-postgresql>=6.1.1",
25-
"ruff>=0.8.5",
24+
"ruff>=0.8.6",
2625
]
2726

27+
[tool.uv.sources]
28+
stacrs = { git = "https://github.com/gadomski/stacrs.git" }
29+
2830
[tool.mypy]
2931
strict = true
3032
files = ["**/*.py"]
3133
plugins = "pydantic.mypy"
3234

35+
[[tool.mypy.overrides]]
36+
module = ["stac_fastapi.*"]
37+
ignore_missing_imports = true
38+
3339
[tool.pytest.ini_options]
3440
asyncio_mode = "auto"
35-
asyncio_default_fixture_loop_scope = "session"
41+
asyncio_default_fixture_loop_scope = "function"
3642
filterwarnings = [
3743
"error",
3844
'ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated and scheduled for removal in a future version.:DeprecationWarning',
@@ -41,10 +47,10 @@ filterwarnings = [
4147
[tool.ruff.lint]
4248
select = ["E", "F", "I"]
4349

44-
[tool.uv.sources]
45-
pgstacrs = { git = "https://github.com/stac-utils/pgstacrs.git" }
46-
stacrs = { git = "https://github.com/gadomski/stacrs.git" }
47-
4850
[build-system]
49-
requires = ["hatchling"]
50-
build-backend = "hatchling.build"
51+
requires = ["setuptools"]
52+
build-backend = "setuptools.build_meta"
53+
54+
[tool.setuptools.packages.find]
55+
where = ["src/"]
56+
include = ["stac_fastapi.geoparquet"]

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-
uv run fastapi dev src/tistac/main.py
5+
STAC_FASTAPI_GEOPARQUET_HREF=data/naip.parquet uv run fastapi dev src/stac_fastapi/geoparquet/main.py

src/stac_fastapi/geoparquet/__init__.py

Whitespace-only changes.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import copy
2+
import urllib.parse
3+
from typing import Any
4+
5+
from geojson_pydantic.geometries import Geometry
6+
from starlette.requests import Request
7+
8+
from stac_fastapi.api.models import BaseSearchPostRequest
9+
from stac_fastapi.types.core import AsyncBaseCoreClient
10+
from stac_fastapi.types.errors import NotFoundError
11+
from stac_fastapi.types.rfc3339 import DateTimeType
12+
from stac_fastapi.types.stac import BBox, Collection, Collections, Item, ItemCollection
13+
14+
15+
class Client(AsyncBaseCoreClient): # type: ignore
16+
"""A stac-fastapi-geoparquet client."""
17+
18+
async def all_collections(self, *, request: Request, **kwargs: Any) -> Collections:
19+
return Collections(collections=request.app.state.collections)
20+
21+
async def get_collection(
22+
self, *, request: Request, collection_id: str, **kwargs: Any
23+
) -> Collection:
24+
if collection := next(
25+
(c for c in request.app.state.collections if c["id"] == collection_id), None
26+
):
27+
return Collection(**collection)
28+
else:
29+
raise NotFoundError(f"Collection does not exist: {collection_id}")
30+
31+
async def get_item(
32+
self, *, request: Request, item_id: str, collection_id: str, **kwargs: Any
33+
) -> Item:
34+
item_collection = request.app.state.client.search(
35+
request.app.state.href, ids=[item_id], collections=[collection_id], **kwargs
36+
)
37+
if len(item_collection["features"]) == 1:
38+
return Item(**item_collection["features"][0])
39+
else:
40+
raise NotFoundError(
41+
f"Item does not exist: {item_id} in collection {collection_id}"
42+
)
43+
44+
async def get_search(
45+
self,
46+
*,
47+
request: Request,
48+
collections: list[str] | None = None,
49+
ids: list[str] | None = None,
50+
bbox: BBox | None = None,
51+
intersects: Geometry | None = None,
52+
datetime: DateTimeType | None = None,
53+
limit: int | None = 10,
54+
offset: int | None = 0,
55+
**kwargs: str,
56+
) -> ItemCollection:
57+
search = BaseSearchPostRequest(
58+
collections=collections,
59+
ids=ids,
60+
bbox=bbox,
61+
intersects=intersects,
62+
datetime=datetime,
63+
limit=limit,
64+
)
65+
return await self.search(
66+
request=request, search=search, offset=offset, **kwargs
67+
)
68+
69+
async def item_collection(
70+
self,
71+
*,
72+
request: Request,
73+
bbox: BBox | None = None,
74+
datetime: DateTimeType | None = None,
75+
limit: int = 10,
76+
offset: int = 0,
77+
**kwargs: str,
78+
) -> ItemCollection:
79+
search = BaseSearchPostRequest(
80+
bbox=bbox, datetime=datetime, limit=limit, offset=offset
81+
)
82+
return await self.search(request=request, search=search, **kwargs)
83+
84+
async def post_search(
85+
self, search_request: BaseSearchPostRequest, *, request: Request, **kwargs: Any
86+
) -> ItemCollection:
87+
return await self.search(search=search_request, request=request, **kwargs)
88+
89+
async def search(
90+
self,
91+
*,
92+
request: Request,
93+
search: BaseSearchPostRequest,
94+
**kwargs: Any,
95+
) -> ItemCollection:
96+
search_dict = search.model_dump(exclude_none=True)
97+
search_dict.update(**kwargs)
98+
item_collection = request.app.state.client.search(
99+
request.app.state.href,
100+
**search_dict,
101+
)
102+
num_items = len(item_collection["features"])
103+
limit = int(search_dict.get("limit", None) or num_items)
104+
offset = int(search_dict.get("offset", None) or 0)
105+
106+
if limit <= num_items:
107+
next_search = copy.deepcopy(search_dict)
108+
next_search["limit"] = limit
109+
next_search["offset"] = offset + limit
110+
else:
111+
next_search = None
112+
113+
links = []
114+
url = request.url_for("Search")
115+
if next_search:
116+
if request.method == "GET":
117+
links.append(
118+
{
119+
"href": str(url) + "?" + urllib.parse.urlencode(next_search),
120+
"rel": "next",
121+
"type": "application/geo+json",
122+
"method": "GET",
123+
}
124+
)
125+
else:
126+
links.append(
127+
{
128+
"href": str(url),
129+
"rel": "next",
130+
"type": "application/geo+json",
131+
"method": "POST",
132+
"body": next_search,
133+
}
134+
)
135+
136+
item_collection["links"] = links
137+
return ItemCollection(**item_collection)

0 commit comments

Comments
 (0)