Skip to content

Commit 7117d96

Browse files
gadomskijkeifer
andauthored
feat: v0.1 conformances (#90)
## What I'm changing - Add STAPI v0.1 conformances ## How I did it - Refactor conformances into API and PRODUCT dictionaries - Use conformance in more places to check for validity of requests and routes, etc - Add a check to make sure at least one geojson conformance is enabled on the product > [!NOTE] > There's some TODOs and FIXMEs in here, so please just request changes if you'd like those resolved before we merge. My thought was to get a release out sortof in-sync w/ v0.1, so this could be considered "good enough"? ## Checklist - [x] Tests pass: `uv run pytest` - [x] Checks pass: `uv run pre-commit --all-files` - [x] CHANGELOG is updated (if necessary) --------- Co-authored-by: Jarrett Keifer <[email protected]>
1 parent 4a0293b commit 7117d96

File tree

10 files changed

+363
-262
lines changed

10 files changed

+363
-262
lines changed

stapi-fastapi/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
2121
- Renamed all exceptions to errors ([#41](https://github.com/stapi-spec/pystapi/pull/41))
2222
- stapi-fastapi is now using stapi-pydantic models, deduplicating code
2323
- Product in stapi-fastapi is now subclass of Product from stapi-pydantic
24+
- How conformances work ([#90](https://github.com/stapi-spec/pystapi/pull/90))
25+
- Async behaviors align with spec changes ([#90](https://github.com/stapi-spec/pystapi/pull/90))
2426

2527
## [0.6.0] - 2025-02-11
2628

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
1+
# This is some slightly strange magic to get "static" structures with
2+
# attributes, to make it pleasant to use in an editor with autocompletion.
3+
4+
import dataclasses
5+
from dataclasses import dataclass
6+
17
from stapi_pydantic.constants import STAPI_VERSION
28

3-
CORE = f"https://stapi.example.com/v{STAPI_VERSION}/core"
4-
OPPORTUNITIES = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities"
5-
ASYNC_OPPORTUNITIES = f"https://stapi.example.com/v{STAPI_VERSION}/async-opportunities"
9+
10+
@dataclass(frozen=True)
11+
class _All:
12+
def all(self) -> list[str]:
13+
return [getattr(self, field.name) for field in dataclasses.fields(self)]
14+
15+
16+
@dataclass(frozen=True)
17+
class _Api(_All):
18+
core: str = f"https://stapi.example.com/v{STAPI_VERSION}/core"
19+
order_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/order-statuses"
20+
searches_opportunity: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity"
21+
searches_opportunity_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity-statuses"
22+
23+
24+
@dataclass(frozen=True)
25+
class _Product(_All):
26+
opportunities: str = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities"
27+
opportunities_async: str = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities-async"
28+
geojson_point: str = "https://geojson.org/schema/Point.json"
29+
geojson_linestring: str = "https://geojson.org/schema/LineString.json"
30+
geojson_polygon: str = "https://geojson.org/schema/Polygon.json"
31+
geojson_multi_point: str = "https://geojson.org/schema/MultiPoint.json"
32+
geojson_multi_polygon: str = "https://geojson.org/schema/MultiPolygon.json"
33+
geojson_multi_linestring: str = "https://geojson.org/schema/MultiLineString.json"
34+
35+
36+
API = _Api()
37+
"""API (top level) conformances"""
38+
39+
PRODUCT = _Product()
40+
"""Product conformances"""

stapi-fastapi/src/stapi_fastapi/routers/product_router.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
Product as ProductPydantic,
3434
)
3535

36+
from stapi_fastapi.conformance import PRODUCT as PRODUCT_CONFORMACES
3637
from stapi_fastapi.constants import TYPE_JSON
3738
from stapi_fastapi.errors import NotFoundError, QueryablesError
3839
from stapi_fastapi.models.product import Product
@@ -66,8 +67,26 @@ def get_prefer(prefer: str | None = Header(None)) -> str | None:
6667
return Prefer(prefer)
6768

6869

70+
def build_conformances(product: Product, root_router: RootRouter) -> list[str]:
71+
# FIXME we can make this check more robust
72+
if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conformsTo):
73+
raise ValueError("product conformance does not contain at least one geojson conformance")
74+
75+
conformances = set(product.conformsTo)
76+
77+
if product.supports_opportunity_search:
78+
conformances.add(PRODUCT_CONFORMACES.opportunities)
79+
80+
if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search:
81+
conformances.add(PRODUCT_CONFORMACES.opportunities)
82+
conformances.add(PRODUCT_CONFORMACES.opportunities_async)
83+
84+
return list(conformances)
85+
86+
6987
class ProductRouter(APIRouter):
70-
def __init__(
88+
# FIXME ruff is complaining that the init is too complex
89+
def __init__( # noqa
7190
self,
7291
product: Product,
7392
root_router: RootRouter,
@@ -76,13 +95,9 @@ def __init__(
7695
) -> None:
7796
super().__init__(*args, **kwargs)
7897

79-
if root_router.supports_async_opportunity_search and not product.supports_async_opportunity_search:
80-
raise ValueError(
81-
f"Product '{product.id}' must support async opportunity search since the root router does",
82-
)
83-
8498
self.product = product
8599
self.root_router = root_router
100+
self.conformances = build_conformances(product, root_router)
86101

87102
self.add_api_route(
88103
path="",
@@ -149,7 +164,9 @@ async def _create_order(
149164
tags=["Products"],
150165
)
151166

152-
if product.supports_opportunity_search or root_router.supports_async_opportunity_search:
167+
if product.supports_opportunity_search or (
168+
self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search
169+
):
153170
self.add_api_route(
154171
path="/opportunities",
155172
endpoint=self.search_opportunities,
@@ -171,7 +188,7 @@ async def _create_order(
171188
tags=["Products"],
172189
)
173190

174-
if root_router.supports_async_opportunity_search:
191+
if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search:
175192
self.add_api_route(
176193
path="/opportunities/{opportunity_collection_id}",
177194
endpoint=self.get_opportunity_collection,
@@ -232,7 +249,9 @@ def get_product(self, request: Request) -> ProductPydantic:
232249
),
233250
]
234251

235-
if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search:
252+
if self.product.supports_opportunity_search or (
253+
self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search
254+
):
236255
links.append(
237256
Link(
238257
href=str(
@@ -258,9 +277,9 @@ async def search_opportunities(
258277
Explore the opportunities available for a particular set of queryables
259278
"""
260279
# sync
261-
if not self.root_router.supports_async_opportunity_search or (
262-
prefer is Prefer.wait and self.product.supports_opportunity_search
263-
):
280+
if not (
281+
self.root_router.supports_async_opportunity_search and self.product.supports_async_opportunity_search
282+
) or (prefer is Prefer.wait and self.product.supports_opportunity_search):
264283
return await self.search_opportunities_sync(
265284
search,
266285
request,
@@ -357,7 +376,7 @@ def get_product_conformance(self) -> Conformance:
357376
"""
358377
Return conformance urls of a specific product
359378
"""
360-
return Conformance.model_validate({"conforms_to": self.product.conformsTo})
379+
return Conformance.model_validate({"conforms_to": self.conformances})
361380

362381
def get_product_queryables(self) -> JsonSchemaModel:
363382
"""

stapi-fastapi/src/stapi_fastapi/routers/root_router.py

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
GetOrders,
2929
GetOrderStatuses,
3030
)
31-
from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE
31+
from stapi_fastapi.conformance import API as API_CONFORMANCE
3232
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
3333
from stapi_fastapi.errors import NotFoundError
3434
from stapi_fastapi.models.product import Product
@@ -54,11 +54,11 @@ def __init__(
5454
self,
5555
get_orders: GetOrders,
5656
get_order: GetOrder,
57-
get_order_statuses: GetOrderStatuses, # type: ignore
57+
get_order_statuses: GetOrderStatuses | None = None, # type: ignore
5858
get_opportunity_search_records: GetOpportunitySearchRecords | None = None,
5959
get_opportunity_search_record: GetOpportunitySearchRecord | None = None,
6060
get_opportunity_search_record_statuses: GetOpportunitySearchRecordStatuses | None = None,
61-
conformances: list[str] = [CORE],
61+
conformances: list[str] = [API_CONFORMANCE.core],
6262
name: str = "root",
6363
openapi_endpoint_name: str = "openapi",
6464
docs_endpoint_name: str = "swagger_ui_html",
@@ -67,21 +67,14 @@ def __init__(
6767
) -> None:
6868
super().__init__(*args, **kwargs)
6969

70-
if ASYNC_OPPORTUNITIES in conformances and (
71-
not get_opportunity_search_records or not get_opportunity_search_record
72-
):
73-
raise ValueError(
74-
"`get_opportunity_search_records` and `get_opportunity_search_record` "
75-
"are required when advertising async opportunity search conformance"
76-
)
70+
_conformances = set(conformances)
7771

7872
self._get_orders = get_orders
7973
self._get_order = get_order
80-
self._get_order_statuses = get_order_statuses
74+
self.__get_order_statuses = get_order_statuses
8175
self.__get_opportunity_search_records = get_opportunity_search_records
8276
self.__get_opportunity_search_record = get_opportunity_search_record
8377
self.__get_opportunity_search_record_statuses = get_opportunity_search_record_statuses
84-
self.conformances = conformances
8578
self.name = name
8679
self.openapi_endpoint_name = openapi_endpoint_name
8780
self.docs_endpoint_name = docs_endpoint_name
@@ -135,15 +128,18 @@ def __init__(
135128
tags=["Orders"],
136129
)
137130

138-
self.add_api_route(
139-
"/orders/{order_id}/statuses",
140-
self.get_order_statuses,
141-
methods=["GET"],
142-
name=f"{self.name}:{LIST_ORDER_STATUSES}",
143-
tags=["Orders"],
144-
)
131+
if self.get_order_statuses is not None:
132+
_conformances.add(API_CONFORMANCE.order_statuses)
133+
self.add_api_route(
134+
"/orders/{order_id}/statuses",
135+
self.get_order_statuses,
136+
methods=["GET"],
137+
name=f"{self.name}:{LIST_ORDER_STATUSES}",
138+
tags=["Orders"],
139+
)
145140

146-
if ASYNC_OPPORTUNITIES in conformances:
141+
if self.supports_async_opportunity_search:
142+
_conformances.add(API_CONFORMANCE.searches_opportunity)
147143
self.add_api_route(
148144
"/searches/opportunities",
149145
self.get_opportunity_search_records,
@@ -162,6 +158,8 @@ def __init__(
162158
tags=["Opportunities"],
163159
)
164160

161+
if self.__get_opportunity_search_record_statuses is not None:
162+
_conformances.add(API_CONFORMANCE.searches_opportunity_statuses)
165163
self.add_api_route(
166164
"/searches/opportunities/{search_record_id}/statuses",
167165
self.get_opportunity_search_record_statuses,
@@ -171,6 +169,8 @@ def __init__(
171169
tags=["Opportunities"],
172170
)
173171

172+
self.conformances = list(_conformances)
173+
174174
def get_root(self, request: Request) -> RootResponse:
175175
links = [
176176
Link(
@@ -468,6 +468,12 @@ def opportunity_search_record_self_link(
468468
type=TYPE_JSON,
469469
)
470470

471+
@property
472+
def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore
473+
if not self.__get_order_statuses:
474+
raise AttributeError("Root router does not support order status history")
475+
return self.__get_order_statuses
476+
471477
@property
472478
def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords:
473479
if not self.__get_opportunity_search_records:
@@ -483,13 +489,9 @@ def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord:
483489
@property
484490
def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordStatuses:
485491
if not self.__get_opportunity_search_record_statuses:
486-
raise AttributeError("Root router does not support async opportunity search")
492+
raise AttributeError("Root router does not support async opportunity search status history")
487493
return self.__get_opportunity_search_record_statuses
488494

489495
@property
490496
def supports_async_opportunity_search(self) -> bool:
491-
return (
492-
ASYNC_OPPORTUNITIES in self.conformances
493-
and self._get_opportunity_search_records is not None
494-
and self._get_opportunity_search_record is not None
495-
)
497+
return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None

stapi-fastapi/tests/application.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any
44

55
from fastapi import FastAPI
6-
from stapi_fastapi.conformance import CORE, OPPORTUNITIES
6+
from stapi_fastapi.conformance import API
77
from stapi_fastapi.routers.root_router import RootRouter
88

99
from tests.backends import (
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
3535
get_order_statuses=mock_get_order_statuses,
3636
get_opportunity_search_records=mock_get_opportunity_search_records,
3737
get_opportunity_search_record=mock_get_opportunity_search_record,
38-
conformances=[CORE, OPPORTUNITIES],
38+
conformances=[API.core],
3939
)
4040
root_router.add_product(product_test_spotlight_sync_opportunity)
4141
root_router.add_product(product_test_satellite_provider_sync_opportunity)

stapi-fastapi/tests/conftest.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88
from fastapi import FastAPI
99
from fastapi.testclient import TestClient
10-
from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES
10+
from stapi_fastapi.conformance import API, PRODUCT
1111
from stapi_fastapi.models.product import (
1212
Product,
1313
)
@@ -75,10 +75,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
7575
get_orders=mock_get_orders,
7676
get_order=mock_get_order,
7777
get_order_statuses=mock_get_order_statuses,
78-
conformances=[CORE],
78+
conformances=[API.core],
7979
)
8080

8181
for mock_product in mock_products:
82+
mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point]
8283
root_router.add_product(mock_product)
8384

8485
app = FastAPI(lifespan=lifespan)
@@ -112,10 +113,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
112113
get_opportunity_search_records=mock_get_opportunity_search_records,
113114
get_opportunity_search_record=mock_get_opportunity_search_record,
114115
get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses,
115-
conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES],
116+
conformances=[
117+
API.core,
118+
API.searches_opportunity,
119+
API.searches_opportunity_statuses,
120+
],
116121
)
117122

118123
for mock_product in mock_products:
124+
mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point]
119125
root_router.add_product(mock_product)
120126

121127
app = FastAPI(lifespan=lifespan)

stapi-fastapi/tests/shared.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from httpx import Response
1313
from pydantic import BaseModel, Field, model_validator
1414
from pytest import fail
15+
from stapi_fastapi.conformance import PRODUCT
1516
from stapi_fastapi.models.product import Product
1617
from stapi_pydantic import (
1718
Opportunity,
@@ -120,6 +121,7 @@ class MyOrderParameters(OrderParameters):
120121
description="A provider for Test data",
121122
roles=[ProviderRole.producer], # Example role
122123
url="https://test-provider.example.com", # Must be a valid URL
124+
conformsTo=[PRODUCT.geojson_point],
123125
)
124126

125127
product_test_spotlight = Product(
@@ -137,6 +139,7 @@ class MyOrderParameters(OrderParameters):
137139
queryables=MyProductQueryables,
138140
opportunity_properties=MyOpportunityProperties,
139141
order_parameters=MyOrderParameters,
142+
conformsTo=[PRODUCT.geojson_point],
140143
)
141144

142145
product_test_spotlight_sync_opportunity = Product(
@@ -154,6 +157,7 @@ class MyOrderParameters(OrderParameters):
154157
queryables=MyProductQueryables,
155158
opportunity_properties=MyOpportunityProperties,
156159
order_parameters=MyOrderParameters,
160+
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities],
157161
)
158162

159163

@@ -172,6 +176,7 @@ class MyOrderParameters(OrderParameters):
172176
queryables=MyProductQueryables,
173177
opportunity_properties=MyOpportunityProperties,
174178
order_parameters=MyOrderParameters,
179+
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities_async],
175180
)
176181

177182
product_test_spotlight_sync_async_opportunity = Product(
@@ -189,6 +194,7 @@ class MyOrderParameters(OrderParameters):
189194
queryables=MyProductQueryables,
190195
opportunity_properties=MyOpportunityProperties,
191196
order_parameters=MyOrderParameters,
197+
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async],
192198
)
193199

194200
product_test_satellite_provider_sync_opportunity = Product(
@@ -206,6 +212,7 @@ class MyOrderParameters(OrderParameters):
206212
queryables=MyProductQueryables,
207213
opportunity_properties=MyOpportunityProperties,
208214
order_parameters=MyOrderParameters,
215+
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities],
209216
)
210217

211218

0 commit comments

Comments
 (0)