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

Commit 72e9231

Browse files
authored
feat: paging (#37)
- Closes #30
1 parent 59fcd45 commit 72e9231

File tree

13 files changed

+129
-141
lines changed

13 files changed

+129
-141
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ docker compose up
3131

3232
This will start **pgstac** on <http://127.0.0.1:8000> and **stac-fastapi** on <http://127.0.0.1:8001>.
3333

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+
```
50+
3451
## Developing
3552

3653
Get [yarn](https://yarnpkg.com/getting-started/install), then:

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ services:
2121
build: .
2222
environment:
2323
- TISTAC_BACKEND=postgresql://username:password@pgstac:5432/pgstac
24+
- TISTAC_CATALOG_ID=tistac-pgstac
25+
- TISTAC_CATALOG_DESCRIPTION="tistac with a pgstac backend"
2426
- UVICORN_HOST=0.0.0.0
2527
- UVICORN_PORT=8000
2628
ports:
@@ -33,6 +35,8 @@ services:
3335
build: .
3436
environment:
3537
- TISTAC_BACKEND=/app/data/naip.parquet
38+
- TISTAC_CATALOG_ID=tistac-stac-geoparquet
39+
- TISTAC_CATALOG_DESCRIPTION="tistac with a stac-geoparquet backend"
3640
- UVICORN_HOST=0.0.0.0
3741
- UVICORN_PORT=8001
3842
ports:

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ filterwarnings = [
4141
[tool.ruff.lint]
4242
select = ["E", "F", "I"]
4343

44+
[tool.uv.sources]
45+
pgstacrs = { git = "https://github.com/stac-utils/pgstacrs.git" }
46+
stacrs = { git = "https://github.com/gadomski/stacrs.git" }
47+
4448
[build-system]
4549
requires = ["hatchling"]
4650
build-backend = "hatchling.build"

src/tistac/backends/pgstac.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ class PgstacBackend(Backend):
1010
"""A PgSTAC backend."""
1111

1212
@classmethod
13-
async def open(cls, dsn: str) -> PgstacBackend:
13+
async def open(cls, dsn: str, base_url: str) -> PgstacBackend:
1414
client = await Client.open(dsn)
15+
await client.set_setting("base_url", base_url)
1516
return PgstacBackend(client)
1617

1718
def __init__(self, client: Client) -> None:
@@ -29,5 +30,5 @@ async def get_collection(self, collection_id: str) -> Collection | None:
2930
return None
3031

3132
async def search(self, search: Search) -> ItemCollection:
32-
item_collection = await self.client.search(limit=search.limit)
33+
item_collection = await self.client.search(**search.model_dump())
3334
return ItemCollection.model_validate(item_collection)

src/tistac/backends/stac_geoparquet.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import stacrs
22
from pystac import Collection as PystacCollection
33
from pystac import Extent, Item
4+
from stacrs import DuckdbClient
45

56
from tistac.backends import Backend
6-
from tistac.models import Collection, ItemCollection, Search
7+
from tistac.models import Collection, ItemCollection, Link, Search
78

89

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

12-
def __init__(self, href: str):
13+
def __init__(self, href: str, base_url: str):
1314
# TODO support multiple collections
1415
# TODO store collection information in the **stac-geoparquet**
1516
items_as_dicts = stacrs.search(href)
@@ -35,6 +36,8 @@ def __init__(self, href: str):
3536
d["stac_version"] = "1.1.0"
3637
self.collections = [Collection.model_validate(d)]
3738
self.href = href
39+
self.base_url = base_url
40+
self.client = DuckdbClient()
3841

3942
async def get_collections(self) -> list[Collection]:
4043
return self.collections
@@ -43,5 +46,27 @@ async def get_collection(self, collection_id: str) -> Collection | None:
4346
return next((c for c in self.collections if c.id == collection_id), None)
4447

4548
async def search(self, search: Search) -> ItemCollection:
46-
items = stacrs.search(self.href, limit=search.limit)
47-
return ItemCollection(features=items)
49+
item_collection = self.client.search(self.href, **search.model_dump())
50+
number_returned = len(item_collection["features"])
51+
assert search.limit, "Search should always have a limit at this point"
52+
links = []
53+
if number_returned >= search.limit:
54+
links.append(
55+
self.next_link(
56+
search.limit,
57+
search.offset or 0,
58+
number_returned,
59+
)
60+
)
61+
item_collection["numberReturned"] = number_returned
62+
item_collection["links"] = links
63+
return ItemCollection.model_validate(item_collection)
64+
65+
def next_link(self, limit: int, offset: int, num_items: int) -> Link:
66+
# TODO support POST
67+
return Link(
68+
rel="next",
69+
type="application/geo+json",
70+
href=self.base_url + f"search?limit={limit}&offset={offset + num_items}",
71+
method="GET",
72+
)

src/tistac/dependencies.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from functools import lru_cache
33
from typing import Annotated
44

5-
from fastapi import Depends
5+
from fastapi import Depends, Request
66

77
from tistac.backends import Backend
88
from tistac.backends.pgstac import PgstacBackend
@@ -20,19 +20,26 @@ def get_settings() -> Settings:
2020
# I really don't like how this works -- we might want to go back to leaning on
2121
# the starlette state, like stac-fastapi-pgstac does. It's just tricky to
2222
# configure settings w/o using the dependency overrides.
23-
async def get_backend(settings: Annotated[Settings, Depends(get_settings)]) -> Backend:
23+
async def get_backend(
24+
settings: Annotated[Settings, Depends(get_settings)],
25+
request: Request,
26+
) -> Backend:
2427
global BACKEND
2528

2629
url = urllib.parse.urlparse(settings.backend)
2730
if url.scheme == "postgresql":
2831
if "pgstac" in BACKEND:
2932
return BACKEND["pgstac"]
3033
else:
31-
BACKEND["pgstac"] = await PgstacBackend.open(settings.backend)
34+
BACKEND["pgstac"] = await PgstacBackend.open(
35+
settings.backend, str(request.url_for("root"))
36+
)
3237
return BACKEND["pgstac"]
3338
else:
3439
if "stac-geoparquet" in BACKEND:
3540
return BACKEND["stac-geoparquet"]
3641
else:
37-
BACKEND["stac-geoparquet"] = StacGeoparquetBackend(settings.backend)
42+
BACKEND["stac-geoparquet"] = StacGeoparquetBackend(
43+
settings.backend, str(request.url_for("root"))
44+
)
3845
return BACKEND["stac-geoparquet"]

src/tistac/models/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from tistac.models.collection import Collection
22
from tistac.models.item_collection import ItemCollection
33
from tistac.models.link import Link
4-
from tistac.models.page import Page
54
from tistac.models.root import Root
65
from tistac.models.search import GetSearch, Search
76

@@ -10,7 +9,6 @@
109
"GetSearch",
1110
"ItemCollection",
1211
"Link",
13-
"Page",
1412
"Root",
1513
"Search",
1614
]
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from typing import Any
1+
from typing import Any, Literal
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
4+
5+
from tistac.models.link import Link
46

57

68
class ItemCollection(BaseModel):
@@ -10,4 +12,7 @@ class ItemCollection(BaseModel):
1012
extension is used.
1113
"""
1214

15+
type: Literal["FeatureCollection"] = Field(default="FeatureCollection")
16+
links: list[Link]
17+
number_returned: int = Field(alias="numberReturned")
1318
features: list[dict[str, Any]]

src/tistac/models/page.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/tistac/models/search.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
1-
from pydantic import BaseModel, Field
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, ConfigDict, Field
4+
5+
from tistac import Settings
26

37

48
class Search(BaseModel):
59
"""A STAC API POST search."""
610

11+
model_config = ConfigDict(extra="allow")
12+
713
limit: int | None = Field(default=None)
8-
"""The maximum number of items per page."""
14+
offset: int | None = Field(default=None)
15+
16+
def with_settings(self, settings: Settings) -> Search:
17+
if self.limit is None:
18+
self.limit = settings.default_limit
19+
return self
920

1021

1122
class GetSearch(BaseModel):
1223
"""A STAC API GET search."""
1324

25+
model_config = ConfigDict(extra="allow")
26+
1427
def into_search(self) -> Search:
1528
"""Converts this GET search into a POST search."""
16-
return Search()
29+
return Search(**self.model_dump())

0 commit comments

Comments
 (0)