Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions stapi-fastapi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a
- Renamed all exceptions to errors ([#41](https://github.com/stapi-spec/pystapi/pull/41))
- stapi-fastapi is now using stapi-pydantic models, deduplicating code
- Product in stapi-fastapi is now subclass of Product from stapi-pydantic
- How conformances work ([#90](https://github.com/stapi-spec/pystapi/pull/90))
- Async behaviors align with spec changes ([#90](https://github.com/stapi-spec/pystapi/pull/90))

## [0.6.0] - 2025-02-11

Expand Down
41 changes: 38 additions & 3 deletions stapi-fastapi/src/stapi_fastapi/conformance.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# This is some slightly strange magic to get "static" structures with
# attributes, to make it pleasant to use in an editor with autocompletion.

import dataclasses
from dataclasses import dataclass

from stapi_pydantic.constants import STAPI_VERSION

CORE = f"https://stapi.example.com/v{STAPI_VERSION}/core"
OPPORTUNITIES = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities"
ASYNC_OPPORTUNITIES = f"https://stapi.example.com/v{STAPI_VERSION}/async-opportunities"

@dataclass(frozen=True)
class _All:
def all(self) -> list[str]:
return [getattr(self, field.name) for field in dataclasses.fields(self)]


@dataclass(frozen=True)
class _Api(_All):
core: str = f"https://stapi.example.com/v{STAPI_VERSION}/core"
order_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/order-statuses"
searches_opportunity: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity"
searches_opportunity_statuses: str = f"https://stapi.example.com/v{STAPI_VERSION}/searches-opportunity-statuses"


@dataclass(frozen=True)
class _Product(_All):
opportunities: str = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities"
opportunities_async: str = f"https://stapi.example.com/v{STAPI_VERSION}/opportunities-async"
geojson_point: str = "https://geojson.org/schema/Point.json"
geojson_linestring: str = "https://geojson.org/schema/LineString.json"
geojson_polygon: str = "https://geojson.org/schema/Polygon.json"
geojson_multi_point: str = "https://geojson.org/schema/MultiPoint.json"
geojson_multi_polygon: str = "https://geojson.org/schema/MultiPolygon.json"
geojson_multi_linestring: str = "https://geojson.org/schema/MultiLineString.json"


API = _Api()
"""API (top level) conformances"""

PRODUCT = _Product()
"""Product conformances"""
45 changes: 32 additions & 13 deletions stapi-fastapi/src/stapi_fastapi/routers/product_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
Product as ProductPydantic,
)

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


def build_conformances(product: Product, root_router: RootRouter) -> list[str]:
# FIXME we can make this check more robust
if not any(conformance.startswith("https://geojson.org/schema/") for conformance in product.conformsTo):
raise ValueError("product conformance does not contain at least one geojson conformance")

conformances = set(product.conformsTo)

if product.supports_opportunity_search:
conformances.add(PRODUCT_CONFORMACES.opportunities)

if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search:
conformances.add(PRODUCT_CONFORMACES.opportunities)
conformances.add(PRODUCT_CONFORMACES.opportunities_async)

return list(conformances)


class ProductRouter(APIRouter):
def __init__(
# FIXME ruff is complaining that the init is too complex
def __init__( # noqa
self,
product: Product,
root_router: RootRouter,
Expand All @@ -76,13 +95,9 @@ def __init__(
) -> None:
super().__init__(*args, **kwargs)

if root_router.supports_async_opportunity_search and not product.supports_async_opportunity_search:
raise ValueError(
f"Product '{product.id}' must support async opportunity search since the root router does",
)

self.product = product
self.root_router = root_router
self.conformances = build_conformances(product, root_router)

self.add_api_route(
path="",
Expand Down Expand Up @@ -149,7 +164,9 @@ async def _create_order(
tags=["Products"],
)

if product.supports_opportunity_search or root_router.supports_async_opportunity_search:
if product.supports_opportunity_search or (
self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search
):
self.add_api_route(
path="/opportunities",
endpoint=self.search_opportunities,
Expand All @@ -171,7 +188,7 @@ async def _create_order(
tags=["Products"],
)

if root_router.supports_async_opportunity_search:
if product.supports_async_opportunity_search and root_router.supports_async_opportunity_search:
self.add_api_route(
path="/opportunities/{opportunity_collection_id}",
endpoint=self.get_opportunity_collection,
Expand Down Expand Up @@ -232,7 +249,9 @@ def get_product(self, request: Request) -> ProductPydantic:
),
]

if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search:
if self.product.supports_opportunity_search or (
self.product.supports_async_opportunity_search and self.root_router.supports_async_opportunity_search
):
links.append(
Link(
href=str(
Expand All @@ -258,9 +277,9 @@ async def search_opportunities(
Explore the opportunities available for a particular set of queryables
"""
# sync
if not self.root_router.supports_async_opportunity_search or (
prefer is Prefer.wait and self.product.supports_opportunity_search
):
if not (
self.root_router.supports_async_opportunity_search and self.product.supports_async_opportunity_search
) or (prefer is Prefer.wait and self.product.supports_opportunity_search):
return await self.search_opportunities_sync(
search,
request,
Expand Down Expand Up @@ -357,7 +376,7 @@ def get_product_conformance(self) -> Conformance:
"""
Return conformance urls of a specific product
"""
return Conformance.model_validate({"conforms_to": self.product.conformsTo})
return Conformance.model_validate({"conforms_to": self.conformances})

def get_product_queryables(self) -> JsonSchemaModel:
"""
Expand Down
54 changes: 28 additions & 26 deletions stapi-fastapi/src/stapi_fastapi/routers/root_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
GetOrders,
GetOrderStatuses,
)
from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE
from stapi_fastapi.conformance import API as API_CONFORMANCE
from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON
from stapi_fastapi.errors import NotFoundError
from stapi_fastapi.models.product import Product
Expand All @@ -54,11 +54,11 @@ def __init__(
self,
get_orders: GetOrders,
get_order: GetOrder,
get_order_statuses: GetOrderStatuses, # type: ignore
get_order_statuses: GetOrderStatuses | None = None, # type: ignore
get_opportunity_search_records: GetOpportunitySearchRecords | None = None,
get_opportunity_search_record: GetOpportunitySearchRecord | None = None,
get_opportunity_search_record_statuses: GetOpportunitySearchRecordStatuses | None = None,
conformances: list[str] = [CORE],
conformances: list[str] = [API_CONFORMANCE.core],
name: str = "root",
openapi_endpoint_name: str = "openapi",
docs_endpoint_name: str = "swagger_ui_html",
Expand All @@ -67,21 +67,14 @@ def __init__(
) -> None:
super().__init__(*args, **kwargs)

if ASYNC_OPPORTUNITIES in conformances and (
not get_opportunity_search_records or not get_opportunity_search_record
):
raise ValueError(
"`get_opportunity_search_records` and `get_opportunity_search_record` "
"are required when advertising async opportunity search conformance"
)
_conformances = set(conformances)

self._get_orders = get_orders
self._get_order = get_order
self._get_order_statuses = get_order_statuses
self.__get_order_statuses = get_order_statuses
self.__get_opportunity_search_records = get_opportunity_search_records
self.__get_opportunity_search_record = get_opportunity_search_record
self.__get_opportunity_search_record_statuses = get_opportunity_search_record_statuses
self.conformances = conformances
self.name = name
self.openapi_endpoint_name = openapi_endpoint_name
self.docs_endpoint_name = docs_endpoint_name
Expand Down Expand Up @@ -135,15 +128,18 @@ def __init__(
tags=["Orders"],
)

self.add_api_route(
"/orders/{order_id}/statuses",
self.get_order_statuses,
methods=["GET"],
name=f"{self.name}:{LIST_ORDER_STATUSES}",
tags=["Orders"],
)
if self.get_order_statuses is not None:
_conformances.add(API_CONFORMANCE.order_statuses)
self.add_api_route(
"/orders/{order_id}/statuses",
self.get_order_statuses,
methods=["GET"],
name=f"{self.name}:{LIST_ORDER_STATUSES}",
tags=["Orders"],
)

if ASYNC_OPPORTUNITIES in conformances:
if self.supports_async_opportunity_search:
_conformances.add(API_CONFORMANCE.searches_opportunity)
self.add_api_route(
"/searches/opportunities",
self.get_opportunity_search_records,
Expand All @@ -162,6 +158,8 @@ def __init__(
tags=["Opportunities"],
)

if self.__get_opportunity_search_record_statuses is not None:
_conformances.add(API_CONFORMANCE.searches_opportunity_statuses)
self.add_api_route(
"/searches/opportunities/{search_record_id}/statuses",
self.get_opportunity_search_record_statuses,
Expand All @@ -171,6 +169,8 @@ def __init__(
tags=["Opportunities"],
)

self.conformances = list(_conformances)

def get_root(self, request: Request) -> RootResponse:
links = [
Link(
Expand Down Expand Up @@ -468,6 +468,12 @@ def opportunity_search_record_self_link(
type=TYPE_JSON,
)

@property
def _get_order_statuses(self) -> GetOrderStatuses: # type: ignore
if not self.__get_order_statuses:
raise AttributeError("Root router does not support order status history")
return self.__get_order_statuses

@property
def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords:
if not self.__get_opportunity_search_records:
Expand All @@ -483,13 +489,9 @@ def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord:
@property
def _get_opportunity_search_record_statuses(self) -> GetOpportunitySearchRecordStatuses:
if not self.__get_opportunity_search_record_statuses:
raise AttributeError("Root router does not support async opportunity search")
raise AttributeError("Root router does not support async opportunity search status history")
return self.__get_opportunity_search_record_statuses

@property
def supports_async_opportunity_search(self) -> bool:
return (
ASYNC_OPPORTUNITIES in self.conformances
and self._get_opportunity_search_records is not None
and self._get_opportunity_search_record is not None
)
return self.__get_opportunity_search_records is not None and self.__get_opportunity_search_record is not None
4 changes: 2 additions & 2 deletions stapi-fastapi/tests/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any

from fastapi import FastAPI
from stapi_fastapi.conformance import CORE, OPPORTUNITIES
from stapi_fastapi.conformance import API
from stapi_fastapi.routers.root_router import RootRouter

from tests.backends import (
Expand Down Expand Up @@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
get_order_statuses=mock_get_order_statuses,
get_opportunity_search_records=mock_get_opportunity_search_records,
get_opportunity_search_record=mock_get_opportunity_search_record,
conformances=[CORE, OPPORTUNITIES],
conformances=[API.core],
)
root_router.add_product(product_test_spotlight_sync_opportunity)
root_router.add_product(product_test_satellite_provider_sync_opportunity)
Expand Down
12 changes: 9 additions & 3 deletions stapi-fastapi/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES
from stapi_fastapi.conformance import API, PRODUCT
from stapi_fastapi.models.product import (
Product,
)
Expand Down Expand Up @@ -75,10 +75,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
get_orders=mock_get_orders,
get_order=mock_get_order,
get_order_statuses=mock_get_order_statuses,
conformances=[CORE],
conformances=[API.core],
)

for mock_product in mock_products:
mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point]
root_router.add_product(mock_product)

app = FastAPI(lifespan=lifespan)
Expand Down Expand Up @@ -112,10 +113,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
get_opportunity_search_records=mock_get_opportunity_search_records,
get_opportunity_search_record=mock_get_opportunity_search_record,
get_opportunity_search_record_statuses=mock_get_opportunity_search_record_statuses,
conformances=[CORE, OPPORTUNITIES, ASYNC_OPPORTUNITIES],
conformances=[
API.core,
API.searches_opportunity,
API.searches_opportunity_statuses,
],
)

for mock_product in mock_products:
mock_product.conformsTo = [PRODUCT.opportunities, PRODUCT.opportunities_async, PRODUCT.geojson_point]
root_router.add_product(mock_product)

app = FastAPI(lifespan=lifespan)
Expand Down
7 changes: 7 additions & 0 deletions stapi-fastapi/tests/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from httpx import Response
from pydantic import BaseModel, Field, model_validator
from pytest import fail
from stapi_fastapi.conformance import PRODUCT
from stapi_fastapi.models.product import Product
from stapi_pydantic import (
Opportunity,
Expand Down Expand Up @@ -120,6 +121,7 @@ class MyOrderParameters(OrderParameters):
description="A provider for Test data",
roles=[ProviderRole.producer], # Example role
url="https://test-provider.example.com", # Must be a valid URL
conformsTo=[PRODUCT.geojson_point],
)

product_test_spotlight = Product(
Expand All @@ -137,6 +139,7 @@ class MyOrderParameters(OrderParameters):
queryables=MyProductQueryables,
opportunity_properties=MyOpportunityProperties,
order_parameters=MyOrderParameters,
conformsTo=[PRODUCT.geojson_point],
)

product_test_spotlight_sync_opportunity = Product(
Expand All @@ -154,6 +157,7 @@ class MyOrderParameters(OrderParameters):
queryables=MyProductQueryables,
opportunity_properties=MyOpportunityProperties,
order_parameters=MyOrderParameters,
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities],
)


Expand All @@ -172,6 +176,7 @@ class MyOrderParameters(OrderParameters):
queryables=MyProductQueryables,
opportunity_properties=MyOpportunityProperties,
order_parameters=MyOrderParameters,
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities_async],
)

product_test_spotlight_sync_async_opportunity = Product(
Expand All @@ -189,6 +194,7 @@ class MyOrderParameters(OrderParameters):
queryables=MyProductQueryables,
opportunity_properties=MyOpportunityProperties,
order_parameters=MyOrderParameters,
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities, PRODUCT.opportunities_async],
)

product_test_satellite_provider_sync_opportunity = Product(
Expand All @@ -206,6 +212,7 @@ class MyOrderParameters(OrderParameters):
queryables=MyProductQueryables,
opportunity_properties=MyOpportunityProperties,
order_parameters=MyOrderParameters,
conformsTo=[PRODUCT.geojson_point, PRODUCT.opportunities],
)


Expand Down
Loading