Skip to content

Commit 15b21b0

Browse files
authored
Use stac-api-validator in ci (#508)
* feat: add validation via stac-api-validator Includes a local validation script and a CI job. * fix: get_item response is geojson * fix, pgstac: self link, item collection endpoint * fix: don't add context extension to landing page * deps: update uvicorn to non-yanked version * fix: exclude optional collection values If we're not using response models, we can't automagically exclude none values. * fix: add required links to item collection sqlalchemy
1 parent da012a6 commit 15b21b0

File tree

13 files changed

+200
-25
lines changed

13 files changed

+200
-25
lines changed

.github/workflows/cicd.yaml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,66 @@ jobs:
121121
POSTGRES_HOST_READER: localhost
122122
POSTGRES_HOST_WRITER: localhost
123123
POSTGRES_PORT: 5432
124+
125+
validate:
126+
runs-on: ubuntu-latest
127+
strategy:
128+
fail-fast: false
129+
matrix:
130+
backend: ["sqlalchemy", "pgstac"]
131+
services:
132+
pgstac:
133+
image: ghcr.io/stac-utils/pgstac:v0.6.11
134+
env:
135+
POSTGRES_USER: username
136+
POSTGRES_PASSWORD: password
137+
POSTGRES_DB: postgis
138+
PGUSER: username
139+
PGPASSWORD: password
140+
PGDATABASE: postgis
141+
options: >-
142+
--health-cmd pg_isready
143+
--health-interval 10s
144+
--health-timeout 5s
145+
--health-retries 5
146+
ports:
147+
- 5432:5432
148+
steps:
149+
- name: Check out repository code
150+
uses: actions/checkout@v3
151+
- name: Setup Python
152+
uses: actions/setup-python@v3
153+
with:
154+
python-version: "3.10"
155+
cache: pip
156+
cache-dependency-path: stac_fastapi/pgstac/setup.cfg
157+
- name: Install stac-fastapi and stac-api-validator
158+
run: pip install ./stac_fastapi/api ./stac_fastapi/types ./stac_fastapi/${{ matrix.backend }}[server] stac-api-validator==0.4.1
159+
- name: Run migration
160+
if: ${{ matrix.backend == 'sqlalchemy' }}
161+
run: cd stac_fastapi/sqlalchemy && alembic upgrade head
162+
env:
163+
POSTGRES_USER: username
164+
POSTGRES_PASS: password
165+
POSTGRES_DBNAME: postgis
166+
POSTGRES_HOST: localhost
167+
POSTGRES_PORT: 5432
168+
- name: Load data and validate
169+
run: python -m stac_fastapi.${{ matrix.backend }}.app & ./scripts/wait-for-it.sh localhost:8080 && python ./scripts/ingest_joplin.py http://localhost:8080 && ./scripts/validate http://localhost:8080
170+
env:
171+
POSTGRES_USER: username
172+
POSTGRES_PASS: password
173+
POSTGRES_DBNAME: postgis
174+
POSTGRES_HOST_READER: localhost
175+
POSTGRES_HOST_WRITER: localhost
176+
POSTGRES_PORT: 5432
177+
PGUSER: username
178+
PGPASSWORD: password
179+
PGHOST: localhost
180+
PGDATABASE: postgis
181+
APP_HOST: 0.0.0.0
182+
APP_PORT: 8080
183+
124184
test-docs:
125185
runs-on: ubuntu-latest
126186
steps:

scripts/validate

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env sh
2+
#
3+
# Validate a STAC server using [stac-api-validator](https://github.com/stac-utils/stac-api-validator).
4+
#
5+
# Assumptions:
6+
#
7+
# - You have stac-api-validator installed, e.g. via `pip install stac-api-validator`
8+
# - You've loaded the joplin data, probably using `python ./scripts/ingest_joplin.py http://localhost:8080``
9+
#
10+
# Currently, item-search is not checked, because it crashes stac-api-validator (probably a problem on our side).
11+
12+
set -e
13+
14+
if [ $# -eq 0 ]; then
15+
root_url=http://localhost:8080
16+
else
17+
root_url="$1"
18+
fi
19+
geometry='{"type":"Polygon","coordinates":[[[-94.6884155,37.0595608],[-94.6884155,37.0332547],[-94.6554565,37.0332547],[-94.6554565,37.0595608],[-94.6884155,37.0595608]]]}'
20+
21+
stac-api-validator --root-url "$root_url" \
22+
--conformance core \
23+
--conformance collections \
24+
--conformance features \
25+
--conformance filter \
26+
--collection joplin \
27+
--geometry "$geometry"
28+
# --conformance item-search # currently breaks stac-api-validator

stac_fastapi/api/stac_fastapi/api/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,12 @@ def register_get_item(self):
158158
name="Get Item",
159159
path="/collections/{collection_id}/items/{item_id}",
160160
response_model=Item if self.settings.enable_response_models else None,
161-
response_class=self.response_class,
161+
response_class=GeoJSONResponse,
162162
response_model_exclude_unset=True,
163163
response_model_exclude_none=True,
164164
methods=["GET"],
165165
endpoint=create_async_endpoint(
166-
self.client.get_item, ItemUri, self.response_class
166+
self.client.get_item, ItemUri, GeoJSONResponse
167167
),
168168
)
169169

stac_fastapi/pgstac/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"httpx",
3232
],
3333
"docs": ["mkdocs", "mkdocs-material", "pdocs"],
34-
"server": ["uvicorn[standard]==0.17.0"],
34+
"server": ["uvicorn[standard]==0.17.0.post1"],
3535
"awslambda": ["mangum"],
3636
}
3737

stac_fastapi/pgstac/stac_fastapi/pgstac/core.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from starlette.requests import Request
1919

2020
from stac_fastapi.pgstac.config import Settings
21-
from stac_fastapi.pgstac.models.links import CollectionLinks, ItemLinks, PagingLinks
21+
from stac_fastapi.pgstac.models.links import (
22+
CollectionLinks,
23+
ItemCollectionLinks,
24+
ItemLinks,
25+
PagingLinks,
26+
)
2227
from stac_fastapi.pgstac.types.search import PgstacSearch
2328
from stac_fastapi.pgstac.utils import filter_fields
2429
from stac_fastapi.types.core import AsyncBaseCoreClient
@@ -286,7 +291,7 @@ async def item_collection(
286291
**clean,
287292
)
288293
item_collection = await self._search_base(req, **kwargs)
289-
links = await CollectionLinks(
294+
links = await ItemCollectionLinks(
290295
collection_id=collection_id, request=kwargs["request"]
291296
).get_links(extra_links=item_collection["links"])
292297
item_collection["links"] = links

stac_fastapi/pgstac/stac_fastapi/pgstac/models/links.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,27 @@ def link_items(self) -> Dict:
206206
)
207207

208208

209+
@attr.s
210+
class ItemCollectionLinks(CollectionLinksBase):
211+
"""Create inferred links specific to collections."""
212+
213+
def link_self(self) -> Dict:
214+
"""Return the self link."""
215+
return dict(
216+
rel=Relations.self.value,
217+
type=MimeTypes.geojson.value,
218+
href=self.resolve(f"collections/{self.collection_id}/items"),
219+
)
220+
221+
def link_parent(self) -> Dict:
222+
"""Create the `parent` link."""
223+
return self.collection_link(rel=Relations.parent.value)
224+
225+
def link_collection(self) -> Dict:
226+
"""Create the `collection` link."""
227+
return self.collection_link()
228+
229+
209230
@attr.s
210231
class ItemLinks(CollectionLinksBase):
211232
"""Create inferred links specific to items."""

stac_fastapi/pgstac/tests/api/test_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ async def test_get_features_content_type(app_client, load_test_collection):
5151
assert resp.headers["content-type"] == "application/geo+json"
5252

5353

54+
async def test_get_features_self_link(app_client, load_test_collection):
55+
# https://github.com/stac-utils/stac-fastapi/issues/483
56+
resp = await app_client.get(f"collections/{load_test_collection.id}/items")
57+
assert resp.status_code == 200
58+
resp_json = resp.json()
59+
self_link = next(
60+
(link for link in resp_json["links"] if link["rel"] == "self"), None
61+
)
62+
assert self_link is not None
63+
assert self_link["href"].endswith("/items")
64+
65+
66+
async def test_get_feature_content_type(
67+
app_client, load_test_collection, load_test_item
68+
):
69+
resp = await app_client.get(
70+
f"collections/{load_test_collection.id}/items/{load_test_item.id}"
71+
)
72+
assert resp.headers["content-type"] == "application/geo+json"
73+
74+
5475
async def test_api_headers(app_client):
5576
resp = await app_client.get("/api")
5677
assert (
@@ -71,6 +92,13 @@ async def test_core_router(api_client, app):
7192
assert not core_routes - api_routes
7293

7394

95+
async def test_landing_page_stac_extensions(app_client):
96+
resp = await app_client.get("/")
97+
assert resp.status_code == 200
98+
resp_json = resp.json()
99+
assert not resp_json["stac_extensions"]
100+
101+
74102
async def test_transactions_router(api_client, app):
75103
transaction_routes = set()
76104
for transaction_route in STAC_TRANSACTION_ROUTES:

stac_fastapi/sqlalchemy/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"requests",
3030
],
3131
"docs": ["mkdocs", "mkdocs-material", "pdocs"],
32-
"server": ["uvicorn[standard]==0.17.0"],
32+
"server": ["uvicorn[standard]==0.17.0.post1"],
3333
}
3434

3535

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/core.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,23 @@ def item_collection(
169169
else None
170170
)
171171

172-
links = []
172+
links = [
173+
{
174+
"rel": Relations.self.value,
175+
"type": "application/geo+json",
176+
"href": str(kwargs["request"].url),
177+
},
178+
{
179+
"rel": Relations.root.value,
180+
"type": "application/json",
181+
"href": str(kwargs["request"].base_url),
182+
},
183+
{
184+
"rel": Relations.parent.value,
185+
"type": "application/json",
186+
"href": str(kwargs["request"].base_url),
187+
},
188+
]
173189
if page.next:
174190
links.append(
175191
{

stac_fastapi/sqlalchemy/stac_fastapi/sqlalchemy/serializers.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,22 +146,28 @@ def db_to_stac(cls, db_model: database.Collection, base_url: str) -> TypedDict:
146146
if db_links:
147147
collection_links += resolve_links(db_links, base_url)
148148

149-
stac_extensions = db_model.stac_extensions or []
150-
151-
return stac_types.Collection(
149+
collection = stac_types.Collection(
152150
type="Collection",
153151
id=db_model.id,
154-
stac_extensions=stac_extensions,
155152
stac_version=db_model.stac_version,
156-
title=db_model.title,
157153
description=db_model.description,
158-
keywords=db_model.keywords,
159154
license=db_model.license,
160-
providers=db_model.providers,
161-
summaries=db_model.summaries,
162155
extent=db_model.extent,
163156
links=collection_links,
164157
)
158+
# We need to manually include optional values to ensure they are
159+
# excluded if we're not using response models.
160+
if db_model.stac_extensions:
161+
collection["stac_extensions"] = db_model.stac_extensions
162+
if db_model.title:
163+
collection["title"] = db_model.title
164+
if db_model.keywords:
165+
collection["keywords"] = db_model.keywords
166+
if db_model.providers:
167+
collection["providers"] = db_model.providers
168+
if db_model.summaries:
169+
collection["summaries"] = db_model.summaries
170+
return collection
165171

166172
@classmethod
167173
def stac_to_db(

stac_fastapi/sqlalchemy/tests/api/test_api.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def test_core_router(api_client):
5353
assert not core_routes - api_routes
5454

5555

56+
def test_landing_page_stac_extensions(app_client):
57+
resp = app_client.get("/")
58+
assert resp.status_code == 200
59+
resp_json = resp.json()
60+
assert not resp_json["stac_extensions"]
61+
62+
5663
def test_transactions_router(api_client):
5764
transaction_routes = set(STAC_TRANSACTION_ROUTES)
5865
api_routes = set(
@@ -445,9 +452,18 @@ def test_app_search_response_duplicate_forwarded_headers(
445452
assert link["href"].startswith("https://testserver:1234/")
446453

447454

448-
async def test_get_features_content_type(app_client, load_test_data):
455+
def test_get_features_content_type(app_client, load_test_data):
449456
item = load_test_data("test_item.json")
450-
resp = await app_client.get(f"collections/{item['collection']}/items")
457+
resp = app_client.get(f"collections/{item['collection']}/items")
458+
assert resp.headers["content-type"] == "application/geo+json"
459+
460+
461+
def test_get_feature_content_type(app_client, load_test_data, postgres_transactions):
462+
item = load_test_data("test_item.json")
463+
postgres_transactions.create_item(
464+
item["collection"], item, request=MockStarletteRequest
465+
)
466+
resp = app_client.get(f"collections/{item['collection']}/items/{item['id']}")
451467
assert resp.headers["content-type"] == "application/geo+json"
452468

453469

stac_fastapi/sqlalchemy/tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def load_file(filename: str) -> Dict:
7070

7171
class MockStarletteRequest:
7272
base_url = "http://test-server"
73+
url = "http://test-server/some/endpoint"
7374

7475

7576
@pytest.fixture

stac_fastapi/types/stac_fastapi/types/core.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,10 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage:
351351
"""
352352
request: Request = kwargs["request"]
353353
base_url = get_base_url(request)
354-
extension_schemas = [
355-
schema.schema_href for schema in self.extensions if schema.schema_href
356-
]
357354
landing_page = self._landing_page(
358355
base_url=base_url,
359356
conformance_classes=self.conformance_classes(),
360-
extension_schemas=extension_schemas,
357+
extension_schemas=[],
361358
)
362359

363360
# Add Collections links
@@ -550,13 +547,10 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage:
550547
"""
551548
request: Request = kwargs["request"]
552549
base_url = get_base_url(request)
553-
extension_schemas = [
554-
schema.schema_href for schema in self.extensions if schema.schema_href
555-
]
556550
landing_page = self._landing_page(
557551
base_url=base_url,
558552
conformance_classes=self.conformance_classes(),
559-
extension_schemas=extension_schemas,
553+
extension_schemas=[],
560554
)
561555
collections = await self.all_collections(request=kwargs["request"])
562556
for collection in collections["collections"]:

0 commit comments

Comments
 (0)