From 5d2b0f8d8d90b530f3dba79e729d69c86ee48075 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 10:00:10 +0100 Subject: [PATCH 01/25] add naked project --- pyproject.toml | 11 ++++++++++- pystapi-schema-generator/CHANGELOG.md | 15 +++++++++++++++ pystapi-schema-generator/README.md | 2 ++ pystapi-schema-generator/pyproject.toml | 18 ++++++++++++++++++ .../src/pystapi_schema_generator/__init__.py | 1 + .../src/pystapi_schema_generator/py.typed | 0 uv.lock | 8 ++++++++ 7 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 pystapi-schema-generator/CHANGELOG.md create mode 100644 pystapi-schema-generator/README.md create mode 100644 pystapi-schema-generator/pyproject.toml create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/__init__.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/py.typed diff --git a/pyproject.toml b/pyproject.toml index 6035214..3789826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.10" dependencies = [ "pystapi-client", "pystapi-validator", + "pystapi-schema-generator", "stapi-pydantic", "stapi-fastapi" ] @@ -31,11 +32,18 @@ docs = [ default-groups = ["dev", "docs"] [tool.uv.workspace] -members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi"] +members = [ + "pystapi-client", + "pystapi-validator", + "pystapi-schema-generator", + "stapi-pydantic", + "stapi-fastapi" +] [tool.uv.sources] pystapi-client.workspace = true pystapi-validator.workspace = true +pystapi-schema-generator.workspace = true stapi-pydantic.workspace = true stapi-fastapi.workspace = true @@ -63,6 +71,7 @@ strict = true files = [ "pystapi-client/src/pystapi_client/**/*.py", "pystapi-validator/src/pystapi_validator/**/*.py", + "pystapi-schema-generator/src/pystapi_schema_generator/**/*.py", "stapi-pydantic/src/stapi_pydantic/**/*.py", "stapi-fastapi/src/stapi_fastapi/**/*.py" ] diff --git a/pystapi-schema-generator/CHANGELOG.md b/pystapi-schema-generator/CHANGELOG.md new file mode 100644 index 0000000..c9f076a --- /dev/null +++ b/pystapi-schema-generator/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1] - 2025-04-01 + +### Added +- A spec product that tightly follows the [STAPI specification](https://github.com/stapi-spec/stapi-spec) +- A STAPI FastAPI application offering the spec product +- A script that creates the FastAPI application to create the OpenAPI schema diff --git a/pystapi-schema-generator/README.md b/pystapi-schema-generator/README.md new file mode 100644 index 0000000..e1ebd78 --- /dev/null +++ b/pystapi-schema-generator/README.md @@ -0,0 +1,2 @@ +# pystapi-schema-generator + diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml new file mode 100644 index 0000000..50c95ae --- /dev/null +++ b/pystapi-schema-generator/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "pystapi-schema-generator" +version = "0.0.1" +description = "Schema Generator for the Satellite Tasking API (STAPI) Specification" +readme = "README.md" +authors = [ + { name = "Justin Trautmann", email = "justin@live-eo.com" }, + { name = "Tobias Rohnstock", email = "tobias.rohnstock@live-eo.com" }, +] +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py @@ -0,0 +1 @@ + diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/py.typed b/pystapi-schema-generator/src/pystapi_schema_generator/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index 42e2766..4ab5ad8 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.12" members = [ "pystapi", "pystapi-client", + "pystapi-schema-generator", "pystapi-validator", "stapi-fastapi", "stapi-pydantic", @@ -1235,6 +1236,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "pystapi-client" }, + { name = "pystapi-schema-generator" }, { name = "pystapi-validator" }, { name = "stapi-fastapi" }, { name = "stapi-pydantic" }, @@ -1258,6 +1260,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "pystapi-client", editable = "pystapi-client" }, + { name = "pystapi-schema-generator", editable = "pystapi-schema-generator" }, { name = "pystapi-validator", editable = "pystapi-validator" }, { name = "stapi-fastapi", editable = "stapi-fastapi" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, @@ -1296,6 +1299,11 @@ requires-dist = [ { name = "stapi-pydantic", editable = "stapi-pydantic" }, ] +[[package]] +name = "pystapi-schema-generator" +version = "0.0.1" +source = { editable = "pystapi-schema-generator" } + [[package]] name = "pystapi-validator" version = "0.1.0" From 79cb3c28315ddab753092afd783289ff2828b925 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 10:05:26 +0100 Subject: [PATCH 02/25] make linter happy --- pystapi-schema-generator/CHANGELOG.md | 4 ++-- pystapi-schema-generator/README.md | 1 - .../src/pystapi_schema_generator/__init__.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pystapi-schema-generator/CHANGELOG.md b/pystapi-schema-generator/CHANGELOG.md index c9f076a..1143a5c 100644 --- a/pystapi-schema-generator/CHANGELOG.md +++ b/pystapi-schema-generator/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] @@ -10,6 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.1] - 2025-04-01 ### Added -- A spec product that tightly follows the [STAPI specification](https://github.com/stapi-spec/stapi-spec) +- A spec product that tightly follows the [STAPI specification](https://github.com/stapi-spec/stapi-spec) - A STAPI FastAPI application offering the spec product - A script that creates the FastAPI application to create the OpenAPI schema diff --git a/pystapi-schema-generator/README.md b/pystapi-schema-generator/README.md index e1ebd78..d170144 100644 --- a/pystapi-schema-generator/README.md +++ b/pystapi-schema-generator/README.md @@ -1,2 +1 @@ # pystapi-schema-generator - diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py index 8b13789..e69de29 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py @@ -1 +0,0 @@ - From 3e6b676ec39adffc39eb598fd9dd08235e9c62af Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 14:34:27 +0100 Subject: [PATCH 03/25] fix md --- pystapi-schema-generator/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/pystapi-schema-generator/CHANGELOG.md b/pystapi-schema-generator/CHANGELOG.md index 1143a5c..2bcabfa 100644 --- a/pystapi-schema-generator/CHANGELOG.md +++ b/pystapi-schema-generator/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.1] - 2025-04-01 ### Added + - A spec product that tightly follows the [STAPI specification](https://github.com/stapi-spec/stapi-spec) - A STAPI FastAPI application offering the spec product - A script that creates the FastAPI application to create the OpenAPI schema From 8d428b9b4b5f0954ca8fe683b27ad1387e5947b2 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 14:36:28 +0100 Subject: [PATCH 04/25] zwischenstand --- .../pystapi_schema_generator/application.py | 38 ++++++++ .../src/pystapi_schema_generator/backend.py | 60 +++++++++++++ .../src/pystapi_schema_generator/product.py | 87 +++++++++++++++++++ stapi-pydantic/src/stapi_pydantic/__init__.py | 2 +- .../src/stapi_pydantic/conformance.py | 2 +- stapi-pydantic/src/stapi_pydantic/order.py | 29 ++++--- stapi-pydantic/src/stapi_pydantic/product.py | 40 +++++---- stapi-pydantic/src/stapi_pydantic/root.py | 9 +- stapi-pydantic/src/stapi_pydantic/shared.py | 53 +++++++---- 9 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/application.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/backend.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/product.py diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py new file mode 100644 index 0000000..35a862a --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from stapi_fastapi.conformance import CORE, OPPORTUNITIES +from stapi_fastapi.routers.root_router import RootRouter + +from .backend import stapi_get_order, stapi_get_order_statuses, stapi_get_orders +from .product import example_product + +router = RootRouter( + get_orders=stapi_get_orders, + get_order=stapi_get_order, + get_order_statuses=stapi_get_order_statuses, + get_opportunity_search_records=None, + get_opportunity_search_record=None, + conformances=[CORE, OPPORTUNITIES], +) +router.add_product(example_product) + +app: FastAPI = FastAPI( + openapi_tags=[ + { + "name": "Core", + "description": "Core endpoints", + }, + { + "name": "Orders", + "description": "Endpoint for creating and managing orders", + }, + { + "name": "Opportunities", + "description": "Endpoint for viewing and accepting opportunities", + }, + { + "name": "Products", + "description": "Products", + }, + ] +) +app.include_router(router, prefix="") diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/backend.py b/pystapi-schema-generator/src/pystapi_schema_generator/backend.py new file mode 100644 index 0000000..96537fd --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/backend.py @@ -0,0 +1,60 @@ +from fastapi import Request +from returns.maybe import Maybe, Nothing, Some +from returns.result import Failure, ResultE, Success +from stapi_pydantic import Order, OrderStatus + + +async def stapi_get_orders( + next: str | None, limit: int, request: Request +) -> ResultE[tuple[list[Order[OrderStatus]], Maybe[str]]]: + """ + Return orders from backend. Handle pagination/limit if applicable + """ + try: + start = 0 + limit = min(limit, 100) + order_ids = [*request.state._orders_db._orders.keys()] + + if next: + start = order_ids.index(next) + end = start + limit + ids = order_ids[start:end] + orders = [request.state._orders_db.get_order(order_id) for order_id in ids] + + if end > 0 and end < len(order_ids): + return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id))) + return Success((orders, Nothing)) + except Exception as e: + return Failure(e) + + +async def stapi_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order[OrderStatus]]]: + """ + Show details for order with `order_id`. + """ + try: + return Success(Maybe.from_optional(request.state._orders_db.get_order(order_id))) + except Exception as e: + return Failure(e) + + +async def stapi_get_order_statuses( + order_id: str, next: str | None, limit: int, request: Request +) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = request.state._orders_db.get_order_statuses(order_id) + if statuses is None: + return Success(Nothing) + + if next: + start = int(next) + end = start + limit + stati = statuses[start:end] + + if end > 0 and end < len(statuses): + return Success(Some((stati, Some(str(end))))) + return Success(Some((stati, Nothing))) + except Exception as e: + return Failure(e) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product.py b/pystapi-schema-generator/src/pystapi_schema_generator/product.py new file mode 100644 index 0000000..3133fdb --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product.py @@ -0,0 +1,87 @@ +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from fastapi import Request +from returns.result import Failure, ResultE, Success +from stapi_fastapi.models.product import Product +from stapi_fastapi.routers.product_router import ProductRouter +from stapi_pydantic import ( + Constraints, + OpportunityProperties, + Order, + OrderParameters, + OrderPayload, + OrderProperties, + OrderSearchParameters, + OrderStatus, + OrderStatusCode, + ProductType, +) + + +class StapiProductConstraints(Constraints): + example_constraint: Any + + +class StapiOpportunityProperties(OpportunityProperties): + example_property: Any + + +class StapiOrderParameters(OrderParameters): + example_parameter: Any + + +async def stapi_create_order( + product_router: ProductRouter, payload: OrderPayload[StapiOrderParameters], request: Request +) -> ResultE[Order[OrderStatus]]: + """ + Create a new order. + """ + try: + status = OrderStatus( + timestamp=datetime.now(timezone.utc), + status_code=OrderStatusCode.received, + ) + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=OrderProperties( + product_id=product_router.product.id, + created=datetime.now(timezone.utc), + status=status, + search_parameters=OrderSearchParameters( + geometry=payload.geometry, + datetime=payload.datetime, + filter=payload.filter, + ), + order_parameters=payload.order_parameters.model_dump(), + opportunity_properties={ + "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", + "off_nadir": 10, + }, + ), + links=[], + ) + + request.state._orders_db.put_order(order) + request.state._orders_db.put_order_status(order.id, status) + return Success(order) + except Exception as e: + return Failure(e) + + +example_product = Product( + type=ProductType.product, + id="{productId}", + description="An example product", + license="CC-BY-4.0", + links=[], + create_order=stapi_create_order, + search_opportunities=None, + search_opportunities_async=None, + get_opportunity_collection=None, + constraints=StapiProductConstraints, + opportunity_properties=StapiOpportunityProperties, + order_parameters=StapiOrderParameters, +) diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index 2eb24a8..37adabc 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -24,7 +24,7 @@ OrderStatusCode, OrderStatuses, ) -from .product import Product, ProductsCollection, Provider, ProviderRole +from .product import Product, ProductsCollection, ProductType, Provider, ProviderRole from .root import RootResponse from .shared import Link diff --git a/stapi-pydantic/src/stapi_pydantic/conformance.py b/stapi-pydantic/src/stapi_pydantic/conformance.py index 2011b4f..f4fdd18 100644 --- a/stapi-pydantic/src/stapi_pydantic/conformance.py +++ b/stapi-pydantic/src/stapi_pydantic/conformance.py @@ -2,4 +2,4 @@ class Conformance(BaseModel): - conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") + conforms_to: list[str] = Field(serialization_alias="conformsTo") diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 9ed02c8..fb15eb0 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -9,9 +9,9 @@ BaseModel, ConfigDict, Field, - StrictStr, field_validator, ) +from pydantic.json_schema import SkipJsonSchema from .datetime_interval import DatetimeInterval from .filter import CQL2Filter @@ -31,15 +31,22 @@ class OrderParameters(BaseModel): class OrderStatusCode(StrEnum): + # Required received = "received" accepted = "accepted" rejected = "rejected" completed = "completed" - canceled = "canceled" + canceled = "cancelled" + failed = "failed" + expired = "expired" + + # Optional scheduled = "scheduled" held = "held" processing = "processing" reserved = "reserved" + + # extensions tasked = "tasked" user_canceled = "user_canceled" @@ -47,11 +54,9 @@ class OrderStatusCode(StrEnum): class OrderStatus(BaseModel): timestamp: AwareDatetime status_code: OrderStatusCode - reason_code: str | None = None - reason_text: str | None = None - links: list[Link] = Field(default_factory=list) - - model_config = ConfigDict(extra="allow") + reason_code: str | SkipJsonSchema[None] = None + reason_text: str | SkipJsonSchema[None] = None + links: list[Link] class OrderStatuses[T: OrderStatus](BaseModel): @@ -82,14 +87,16 @@ class OrderProperties[T: OrderStatus](BaseModel): class Order[T: OrderStatus](_GeoJsonBase): # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API - id: StrictStr - type: Literal["Feature"] = "Feature" + id: str | SkipJsonSchema[None] = None + user: str | SkipJsonSchema[None] = None + status: T | SkipJsonSchema[None] = None + created: AwareDatetime | SkipJsonSchema[None] = None + links: list[Link] = Field(default_factory=list) + type: Literal["Feature"] = "Feature" geometry: Geometry = Field(...) properties: OrderProperties[T] = Field(...) - links: list[Link] = Field(default_factory=list) - __geojson_exclude_if_none__ = {"bbox", "id"} @field_validator("geometry", mode="before") diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index 533a55d..cd1c91b 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -1,7 +1,8 @@ from enum import StrEnum -from typing import Any, Literal, Self +from typing import Any, Self from pydantic import AnyHttpUrl, BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from .shared import Link @@ -9,17 +10,17 @@ class ProviderRole(StrEnum): - licensor = "licensor" producer = "producer" + licensor = "licensor" processor = "processor" host = "host" class Provider(BaseModel): name: str - description: str | None = None - roles: list[ProviderRole] - url: AnyHttpUrl + description: str | SkipJsonSchema[None] = None + roles: list[ProviderRole] | SkipJsonSchema[None] = None + url: AnyHttpUrl | SkipJsonSchema[None] = None # redefining init is a hack to get str type to validate for `url`, # as str is ultimately coerced into an AnyHttpUrl automatically anyway @@ -27,16 +28,19 @@ def __init__(self, url: AnyHttpUrl | str, **kwargs: Any) -> None: super().__init__(url=url, **kwargs) +class ProductType(StrEnum): + product = "Product" + + class Product(BaseModel): - type_: Literal["Product"] = Field(default="Product", alias="type") - conformsTo: list[str] = Field(default_factory=list) + type_: ProductType | SkipJsonSchema[None] = Field(alias="type") id: str - title: str = "" - description: str = "" - keywords: list[str] = Field(default_factory=list) + title: str | SkipJsonSchema[None] = None + description: str + keywords: list[str] | SkipJsonSchema[None] = None license: str - providers: list[Provider] = Field(default_factory=list) - links: list[Link] = Field(default_factory=list) + providers: list[Provider] | SkipJsonSchema[None] = None + links: list[Link] def with_links(self, links: list[Link] | None = None) -> Self: if not links: @@ -48,6 +52,12 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") - links: list[Link] = Field(default_factory=list) - products: list[Product] + links: list[Link] + products: list[Product] = Field( + description=( + "STAPI Product objects are represented in JSON format and are very flexible. " + "Any JSON object that contains all the required fields is a valid STAPI Product. " + "A Product object contains a minimal set of required properties to be valid and can be extended " + "through the use of queryables and parameters." + ) + ) diff --git a/stapi-pydantic/src/stapi_pydantic/root.py b/stapi-pydantic/src/stapi_pydantic/root.py index e42efae..df47435 100644 --- a/stapi-pydantic/src/stapi_pydantic/root.py +++ b/stapi-pydantic/src/stapi_pydantic/root.py @@ -1,11 +1,12 @@ from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from .shared import Link class RootResponse(BaseModel): + conforms_to: list[str] = Field(serialization_alias="conformsTo") id: str - conformsTo: list[str] = Field(default_factory=list) - title: str = "" - description: str = "" - links: list[Link] = Field(default_factory=list) + title: str | SkipJsonSchema[None] = None + description: str + links: list[Link] diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 5564a79..1fba041 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -1,32 +1,49 @@ +from enum import StrEnum from typing import Any from pydantic import ( AnyUrl, BaseModel, - ConfigDict, - SerializerFunctionWrapHandler, - model_serializer, + Field, ) +from pydantic.json_schema import SkipJsonSchema -class Link(BaseModel): - href: AnyUrl - rel: str - type: str | None = None - title: str | None = None - method: str | None = None - headers: dict[str, str | list[str]] | None = None - body: Any = None +class RequestMethod(StrEnum): + GET = "GET" + POST = "POST" + - model_config = ConfigDict(extra="allow") +class Link(BaseModel): + href: AnyUrl = Field(description="The location of the resource") + rel: str = Field(description="Relation type of the link") + type: str | SkipJsonSchema[None] = Field(default=None, description="The media type of the resource") + title: str | SkipJsonSchema[None] = Field(default=None, description="Title of the resource") + method: RequestMethod | SkipJsonSchema[None] = Field( + default=RequestMethod.GET, + description="Specifies the HTTP method that the resource expects", + ) + headers: dict[str, str | list[str]] | SkipJsonSchema[None] = Field( + default=None, + description="Object key values pairs they map to headers", + ) + body: Any = Field( + default=None, + description="For POST requests, the resource can specify the HTTP body as a JSON object.", + ) + merge: bool = Field( + default=False, + description=( + "This is only valid when the server is responding to POST request. " + "If merge is true, the client is expected to merge the body value into the current request body before " + "following the link. This avoids passing large post bodies back and forth when following links, particularly " + "for navigating pages through the POST /search endpoint. " + "NOTE: To support form encoding it is expected that a client be able to merge in the key value pairs " + "specified as JSON {'next': 'token'} will become &next=token." + ), + ) # redefining init is a hack to get str type to validate for `href`, # as str is ultimately coerced into an AnyUrl automatically anyway def __init__(self, href: AnyUrl | str, **kwargs: Any) -> None: super().__init__(href=href, **kwargs) - - # overriding the default serialization to filter None field values from - # dumped json - @model_serializer(mode="wrap", when_used="json") - def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - return {k: v for k, v in handler(self).items() if v is not None} From a15e6359f0bfec7d1d2711cda1507483a7641059 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 14:43:41 +0100 Subject: [PATCH 05/25] move from fastapi to schema-generator --- .../pystapi_schema_generator/application.py | 2 +- .../product_router.py | 435 ++++++++++++++++ .../src/pystapi_schema_generator/router.py | 465 ++++++++++++++++++ 3 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/product_router.py create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/router.py diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 35a862a..1c4bc4b 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,9 +1,9 @@ from fastapi import FastAPI from stapi_fastapi.conformance import CORE, OPPORTUNITIES -from stapi_fastapi.routers.root_router import RootRouter from .backend import stapi_get_order, stapi_get_order_statuses, stapi_get_orders from .product import example_product +from .router import RootRouter router = RootRouter( get_orders=stapi_get_orders, diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py new file mode 100644 index 0000000..47a3a68 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import logging +import traceback +from typing import TYPE_CHECKING, Any + +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Request, + Response, + status, +) +from fastapi.responses import JSONResponse +from geojson_pydantic.geometries import Geometry +from returns.maybe import Maybe, Some +from returns.result import Failure, Success +from stapi_pydantic import ( + JsonSchemaModel, + Link, + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, + Order, + OrderPayload, + OrderStatus, + Prefer, +) + +from stapi_fastapi.constants import TYPE_JSON +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException +from stapi_fastapi.models.product import Product +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.routers.route_names import ( + CREATE_ORDER, + GET_CONSTRAINTS, + GET_OPPORTUNITY_COLLECTION, + GET_ORDER_PARAMETERS, + GET_PRODUCT, + SEARCH_OPPORTUNITIES, +) + +if TYPE_CHECKING: + from stapi_fastapi.routers import RootRouter + +logger = logging.getLogger(__name__) + + +def get_prefer(prefer: str | None = Header(None)) -> str | None: + if prefer is None: + return None + + if prefer not in Prefer: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid Prefer header value: {prefer}", + ) + + return Prefer(prefer) + + +class ProductRouter(APIRouter): + def __init__( + self, + product: Product, + root_router: RootRouter, + *args: Any, + **kwargs: Any, + ) -> 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.add_api_route( + path="", + endpoint=self.get_product, + methods=["GET"], + tags=["Products"], + summary="describe the product with id `productId`", + description="...", + ) + + self.add_api_route( + path="/queryables", + endpoint=self.get_product_queryables, + methods=["GET"], + tags=["Products"], + summary="describe the queryables for a product", + description="...", + ) + + self.add_api_route( + path="/order-parameters", + endpoint=self.get_product_order_parameters, + methods=["GET"], + tags=["Products"], + summary="describe the order parameters for a product", + description="...", + ) + + # This wraps `self.create_order` to explicitly parameterize `OrderRequest` + # for this Product. This must be done programmatically instead of with a type + # annotation because it's setting the type dynamically instead of statically, and + # pydantic needs this type annotation when doing object conversion. This cannot be done + # directly to `self.create_order` because doing it there changes + # the annotation on every `ProductRouter` instance's `create_order`, not just + # this one's. + async def _create_order( + payload: OrderPayload, # type: ignore + request: Request, + response: Response, + ) -> Order[OrderStatus]: + return await self.create_order(payload, request, response) + + _create_order.__annotations__["payload"] = OrderPayload[ + self.product.order_parameters # type: ignore + ] + + self.add_api_route( + path="/orders", + endpoint=_create_order, + name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + methods=["POST"], + response_class=GeoJSONResponse, + status_code=status.HTTP_201_CREATED, + summary="Create an order for the product", + tags=["Products"], + ) + + if product.supports_opportunity_search or root_router.supports_async_opportunity_search: + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + name=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", + methods=["POST"], + response_class=GeoJSONResponse, + # unknown why mypy can't see the constraints property on Product, ignoring + response_model=OpportunityCollection[ + Geometry, + self.product.opportunity_properties, # type: ignore + ], + responses={ + 201: { + "model": OpportunitySearchRecord, + "content": {TYPE_JSON: {}}, + } + }, + summary="Search Opportunities for the product", + tags=["Products"], + ) + + if root_router.supports_async_opportunity_search: + self.add_api_route( + path="/opportunities/{opportunity_collection_id}", + endpoint=self.get_opportunity_collection, + name=f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + methods=["GET"], + response_class=GeoJSONResponse, + summary="Get an Opportunity Collection by ID", + tags=["Products"], + ) + + def get_product(self, request: Request) -> Product: + links = [ + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", + ), + ), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", + ), + ), + rel="constraints", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", + ), + ), + rel="order-parameters", + type=TYPE_JSON, + ), + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + ), + ), + rel="create-order", + type=TYPE_JSON, + method="POST", + ), + ] + + if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search: + links.append( + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", + ), + ), + rel="opportunities", + type=TYPE_JSON, + ), + ) + + return self.product.with_links(links=links) + + async def search_opportunities( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None = Depends(get_prefer), + ) -> OpportunityCollection | Response: # type: ignore + """ + Explore the opportunities available for a particular set of constraints + """ + # sync + if not self.root_router.supports_async_opportunity_search or ( + prefer is Prefer.wait and self.product.supports_opportunity_search + ): + return await self.search_opportunities_sync( + search, + request, + response, + prefer, + ) + + # async + if ( + prefer is None + or prefer is Prefer.respond_async + or (prefer is Prefer.wait and not self.product.supports_opportunity_search) + ): + return await self.search_opportunities_async(search, request, prefer) + + raise AssertionError("Expected code to be unreachable") + + async def search_opportunities_sync( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None, + ) -> OpportunityCollection: # type: ignore + links: list[Link] = [] + match await self.product.search_opportunities( + self, + search, + search.next, + search.limit, + request, + ): + case Success((features, maybe_pagination_token)): + links.append(self.order_link(request, search)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, search, x)) + case Maybe.empty: + pass + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while searching opportunities: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error searching opportunities", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: + response.headers["Preference-Applied"] = "wait" + + return OpportunityCollection(features=features, links=links) + + async def search_opportunities_async( + self, + search: OpportunityPayload, + request: Request, + prefer: Prefer | None, + ) -> JSONResponse: + match await self.product.search_opportunities_async(self, search, request): + case Success(search_record): + search_record.links.append(self.root_router.opportunity_search_record_self_link(search_record, request)) + headers = {} + headers["Location"] = str( + self.root_router.generate_opportunity_search_record_href(request, search_record.id) + ) + if prefer is not None: + headers["Preference-Applied"] = "respond-async" + return JSONResponse( + status_code=201, + content=search_record.model_dump(mode="json"), + headers=headers, + ) + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while initiating an asynchronous opportunity search: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error initiating an asynchronous opportunity search", + ) + case x: + raise AssertionError(f"Expected code to be unreachable: {x}") + + def get_product_queryables(self) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return self.product.queryables + + def get_product_order_parameters(self) -> JsonSchemaModel: + """ + Return supported constraints of a specific product + """ + return self.product.order_parameters + + async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + """ + Create a new order. + """ + match await self.product.create_order( + self, + payload, + request, + ): + case Success(order): + order.links.extend(self.root_router.order_links(order, request)) + location = str(self.root_router.generate_order_href(request, order.id)) + response.headers["Location"] = location + return order # type: ignore + case Failure(e) if isinstance(e, ConstraintsException): + raise e + case Failure(e): + logger.error( + "An error occurred while creating order: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error creating order", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") + + def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: + return Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", + ), + ), + rel="create-order", + type=TYPE_JSON, + method="POST", + body=opp_req.search_body(), + ) + + def pagination_link(self, request: Request, opp_req: OpportunityPayload, pagination_token: str) -> Link: + body = opp_req.body() + body["next"] = pagination_token + return Link( + href=str(request.url), + rel="next", + type=TYPE_JSON, + method="POST", + body=body, + ) + + async def get_opportunity_collection( + self, opportunity_collection_id: str, request: Request + ) -> OpportunityCollection: # type: ignore + """ + Fetch an opportunity collection generated by an asynchronous opportunity search. + """ + match await self.product.get_opportunity_collection( + self, + opportunity_collection_id, + request, + ): + case Success(Some(opportunity_collection)): + opportunity_collection.links.append( + Link( + href=str( + request.url_for( + f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", + opportunity_collection_id=opportunity_collection_id, + ), + ), + rel="self", + type=TYPE_JSON, + ), + ) + return opportunity_collection # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Opportunity Collection not found") + case Failure(e): + logger.error( + "An error occurred while fetching opportunity collection: '%s': %s", + opportunity_collection_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error fetching Opportunity Collection", + ) + case x: + raise AssertionError(f"Expected code to be unreachable {x}") diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py new file mode 100644 index 0000000..2243ed2 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -0,0 +1,465 @@ +import logging +import traceback +from typing import Any + +from fastapi import APIRouter, HTTPException, Path, Request, status +from fastapi.datastructures import URL +from returns.maybe import Maybe, Some +from returns.result import Failure, Success +from stapi_fastapi.backends.root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) +from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.exceptions import NotFoundException +from stapi_fastapi.models.product import Product +from stapi_fastapi.responses import GeoJSONResponse +from stapi_fastapi.routers.route_names import ( + CONFORMANCE, + GET_OPPORTUNITY_SEARCH_RECORD, + GET_ORDER, + LIST_OPPORTUNITY_SEARCH_RECORDS, + LIST_ORDER_STATUSES, + LIST_ORDERS, + LIST_PRODUCTS, + ROOT, +) +from stapi_pydantic import ( + Conformance, + Link, + OpportunitySearchRecord, + OpportunitySearchRecords, + Order, + OrderCollection, + OrderStatus, + OrderStatuses, + ProductsCollection, + RootResponse, +) + +from .product_router import ProductRouter + +logger = logging.getLogger(__name__) + + +class RootRouter(APIRouter): + def __init__( + self, + get_orders: GetOrders, + get_order: GetOrder, + get_order_statuses: GetOrderStatuses, # type: ignore + get_opportunity_search_records: GetOpportunitySearchRecords | None = None, + get_opportunity_search_record: GetOpportunitySearchRecord | None = None, + conformances: list[str] = [CORE], + name: str = "root", + openapi_endpoint_name: str = "openapi", + docs_endpoint_name: str = "swagger_ui_html", + *args: Any, + **kwargs: Any, + ) -> 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" + ) + + self._get_orders = get_orders + self._get_order = get_order + 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.conformances = conformances + self.name = name + self.openapi_endpoint_name = openapi_endpoint_name + self.docs_endpoint_name = docs_endpoint_name + self.product_ids: list[str] = [] + + # A dict is used to track the product routers so we can ensure + # idempotentcy in case a product is added multiple times, and also to + # manage clobbering if multiple products with the same product_id are + # added. + self.product_routers: dict[str, ProductRouter] = {} + + # Core endpoints + self.add_api_route( + "/", + self.get_root, + methods=["GET"], + tags=["Core"], + summary="landing page", + description="...", + ) + + self.add_api_route( + "/conformance", + self.get_conformance, + methods=["GET"], + tags=["Core"], + summary="information about specifications that this API conforms to", + description="A list of all conformance classes specified in a standard that the server conforms to.", + ) + + # Orders endpoints - w/o specific {productId}/orders endpoints + self.add_api_route( + "/orders", + self.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="a list of orders", + description="...", + ) + + self.add_api_route( + "/orders/{orderId}", + self.get_order, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="describe the order with id `orderId`", + description="...", + ) + + self.add_api_route( + "/orders/{orderId}/statuses", + self.get_order_statuses, + methods=["GET"], + tags=["Orders"], + summary="describe the statuses that the order with id `orderId` has had", + description="...", + ) + + # Products endpoints + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + tags=["Products"], + summary="the products in the dataset", + description="...", + ) + + if ASYNC_OPPORTUNITIES in conformances: + self.add_api_route( + "/searches/opportunities", + self.get_opportunity_search_records, + methods=["GET"], + name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", + summary="List all Opportunity Search Records", + tags=["Opportunities"], + ) + + self.add_api_route( + "/searches/opportunities/{search_record_id}", + self.get_opportunity_search_record, + methods=["GET"], + name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", + summary="Get an Opportunity Search Record by ID", + tags=["Opportunities"], + ) + + def get_root(self, request: Request) -> RootResponse: + links = [ + Link( + href=str(request.url_for(f"{self.name}:{ROOT}")), + rel="self", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.openapi_endpoint_name)), + rel="service-description", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(self.docs_endpoint_name)), + rel="service-docs", + type="text/html", + ), + Link( + href=str(request.url_for(f"{self.name}:{CONFORMANCE}")), + rel="conformance", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), + rel="products", + type=TYPE_JSON, + ), + Link( + href=str(request.url_for(f"{self.name}:{LIST_ORDERS}")), + rel="orders", + type=TYPE_GEOJSON, + ), + ] + + if self.supports_async_opportunity_search: + links.append( + Link( + href=str(request.url_for(f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}")), + rel="opportunity-search-records", + type=TYPE_JSON, + ), + ) + + return RootResponse( + id="STAPI API", + title="landing page", + conformsTo=self.conformances, + links=links, + ) + + def get_conformance(self) -> Conformance: + return Conformance(conforms_to=self.conformances) + + def get_products(self, request: Request) -> ProductsCollection: + start = 0 + limit = min(limit, 100) + try: + if next: + start = self.product_ids.index(next) + except ValueError: + logger.exception("An error occurred while retrieving products") + raise NotFoundException(detail="Error finding pagination token for products") from None + end = start + limit + ids = self.product_ids[start:end] + links = [ + Link( + href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), + rel="self", + type=TYPE_JSON, + ), + ] + if end > 0 and end < len(self.product_ids): + links.append(self.pagination_link(request, self.product_ids[end], limit)) + return ProductsCollection( + products=[self.product_routers[product_id].get_product(request) for product_id in ids], + links=links, + ) + + async def get_orders( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OrderCollection[OrderStatus]: + links: list[Link] = [] + match await self._get_orders(next, limit, request): + case Success((orders, maybe_pagination_token)): + for order in orders: + order.links.extend(self.order_links(order, request)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving orders: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Orders", + ) + case _: + raise AssertionError("Expected code to be unreachable") + return OrderCollection(features=orders, links=links) + + async def get_order( + self, + request: Request, + order_id: str = Path(alias="orderId", description="local identifier of an order"), + ) -> Order: + """ + Get details for order with `order_id`. + """ + match await self._get_order(order_id, request): + case Success(Some(order)): + order.links.extend(self.order_links(order, request)) + return order # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Order not found") + case Failure(e): + logger.error( + "An error occurred while retrieving order '%s': %s", + order_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Order", + ) + case _: + raise AssertionError("Expected code to be unreachable") + + async def get_order_statuses( + self, + order_id: str, + request: Request, + next: str | None = None, + limit: int = 10, + ) -> OrderStatuses: # type: ignore + links: list[Link] = [] + match await self._get_order_statuses(order_id, next, limit, request): + case Success(Some((statuses, maybe_pagination_token))): + links.append(self.order_statuses_link(request, order_id)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Success(Maybe.empty): + raise NotFoundException("Order not found") + case Failure(ValueError()): + raise NotFoundException("Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving order statuses: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Order Statuses", + ) + case _: + raise AssertionError("Expected code to be unreachable") + return OrderStatuses(statuses=statuses, links=links) + + def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + # Give the include a prefix from the product router + product_router = ProductRouter(product, self, *args, **kwargs) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + self.product_ids = [*self.product_routers.keys()] + + def generate_order_href(self, request: Request, order_id: str) -> URL: + return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) + + def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: + return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) + + def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: + return [ + Link( + href=str(self.generate_order_href(request, order.id)), + rel="self", + type=TYPE_GEOJSON, + ), + Link( + href=str(self.generate_order_statuses_href(request, order.id)), + rel="monitor", + type=TYPE_JSON, + ), + ] + + def order_statuses_link(self, request: Request, order_id: str) -> Link: + return Link( + href=str( + request.url_for( + f"{self.name}:{LIST_ORDER_STATUSES}", + order_id=order_id, + ) + ), + rel="self", + type=TYPE_JSON, + ) + + def pagination_link(self, request: Request, pagination_token: str, limit: int) -> Link: + return Link( + href=str(request.url.include_query_params(next=pagination_token, limit=limit)), + rel="next", + type=TYPE_JSON, + ) + + async def get_opportunity_search_records( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OpportunitySearchRecords: + links: list[Link] = [] + match await self._get_opportunity_search_records(next, limit, request): + case Success((records, maybe_pagination_token)): + for record in records: + record.links.append(self.opportunity_search_record_self_link(record, request)) + match maybe_pagination_token: + case Some(x): + links.append(self.pagination_link(request, x, limit)) + case Maybe.empty: + pass + case Failure(ValueError()): + raise NotFoundException(detail="Error finding pagination token") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search records: %s", + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Opportunity Search Records", + ) + case _: + raise AssertionError("Expected code to be unreachable") + return OpportunitySearchRecords(search_records=records, links=links) + + async def get_opportunity_search_record(self, search_record_id: str, request: Request) -> OpportunitySearchRecord: + """ + Get the Opportunity Search Record with `search_record_id`. + """ + match await self._get_opportunity_search_record(search_record_id, request): + case Success(Some(search_record)): + search_record.links.append(self.opportunity_search_record_self_link(search_record, request)) + return search_record # type: ignore + case Success(Maybe.empty): + raise NotFoundException("Opportunity Search Record not found") + case Failure(e): + logger.error( + "An error occurred while retrieving opportunity search record '%s': %s", + search_record_id, + traceback.format_exception(e), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error finding Opportunity Search Record", + ) + case _: + raise AssertionError("Expected code to be unreachable") + + def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: + return request.url_for( + f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", + search_record_id=search_record_id, + ) + + def opportunity_search_record_self_link( + self, opportunity_search_record: OpportunitySearchRecord, request: Request + ) -> Link: + return Link( + href=str(self.generate_opportunity_search_record_href(request, opportunity_search_record.id)), + rel="self", + type=TYPE_JSON, + ) + + @property + def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: + if not self.__get_opportunity_search_records: + raise AttributeError("Root router does not support async opportunity search") + return self.__get_opportunity_search_records + + @property + def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: + if not self.__get_opportunity_search_record: + raise AttributeError("Root router does not support async opportunity search") + return self.__get_opportunity_search_record + + @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 + ) From 118e93dca42268a50e19f3401fab9ac2b3edb2d2 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 14:56:35 +0100 Subject: [PATCH 06/25] resolve merge conflicts --- .../src/pystapi_schema_generator/product.py | 8 +++----- stapi-pydantic/src/stapi_pydantic/__init__.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product.py b/pystapi-schema-generator/src/pystapi_schema_generator/product.py index 3133fdb..f29f30a 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import uuid4 @@ -16,7 +16,6 @@ OrderSearchParameters, OrderStatus, OrderStatusCode, - ProductType, ) @@ -40,7 +39,7 @@ async def stapi_create_order( """ try: status = OrderStatus( - timestamp=datetime.now(timezone.utc), + timestamp=datetime.now(UTC), status_code=OrderStatusCode.received, ) order = Order( @@ -48,7 +47,7 @@ async def stapi_create_order( geometry=payload.geometry, properties=OrderProperties( product_id=product_router.product.id, - created=datetime.now(timezone.utc), + created=datetime.now(UTC), status=status, search_parameters=OrderSearchParameters( geometry=payload.geometry, @@ -72,7 +71,6 @@ async def stapi_create_order( example_product = Product( - type=ProductType.product, id="{productId}", description="An example product", license="CC-BY-4.0", diff --git a/stapi-pydantic/src/stapi_pydantic/__init__.py b/stapi-pydantic/src/stapi_pydantic/__init__.py index cd0a485..0ee1f63 100644 --- a/stapi-pydantic/src/stapi_pydantic/__init__.py +++ b/stapi-pydantic/src/stapi_pydantic/__init__.py @@ -25,7 +25,7 @@ OrderStatusCode, OrderStatuses, ) -from .product import Product, ProductsCollection, ProductType, Provider, ProviderRole +from .product import Product, ProductsCollection, Provider, ProviderRole from .root import RootResponse from .shared import Link From 8d2b9729d9110f69346468d21d87afe114af8ef8 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 15:15:38 +0100 Subject: [PATCH 07/25] top level orders --- .../product_router.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 47a3a68..24770e4 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -17,18 +17,6 @@ from geojson_pydantic.geometries import Geometry from returns.maybe import Maybe, Some from returns.result import Failure, Success -from stapi_pydantic import ( - JsonSchemaModel, - Link, - OpportunityCollection, - OpportunityPayload, - OpportunitySearchRecord, - Order, - OrderPayload, - OrderStatus, - Prefer, -) - from stapi_fastapi.constants import TYPE_JSON from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.product import Product @@ -41,6 +29,17 @@ GET_PRODUCT, SEARCH_OPPORTUNITIES, ) +from stapi_pydantic import ( + JsonSchemaModel, + Link, + OpportunityCollection, + OpportunityPayload, + OpportunitySearchRecord, + Order, + OrderPayload, + OrderStatus, + Prefer, +) if TYPE_CHECKING: from stapi_fastapi.routers import RootRouter @@ -127,12 +126,22 @@ async def _create_order( self.add_api_route( path="/orders", endpoint=_create_order, - name=f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", methods=["POST"], response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, - summary="Create an order for the product", - tags=["Products"], + tags=["Orders"], + summary="create a new order for product with id `productId`", + description="...", + ) + + self.add_api_route( + path="/orders", + endpoint=root_router.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="get a list of orders for the specific product", + description="...", ) if product.supports_opportunity_search or root_router.supports_async_opportunity_search: From 0006581f482ab9569bb8067c9c72cb3c5c33dc23 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 15:48:15 +0100 Subject: [PATCH 08/25] reduce product router --- .../product_router.py | 354 ++---------------- 1 file changed, 27 insertions(+), 327 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 24770e4..0856437 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -1,41 +1,24 @@ from __future__ import annotations import logging -import traceback from typing import TYPE_CHECKING, Any from fastapi import ( APIRouter, - Depends, Header, HTTPException, Request, Response, status, ) -from fastapi.responses import JSONResponse -from geojson_pydantic.geometries import Geometry -from returns.maybe import Maybe, Some -from returns.result import Failure, Success -from stapi_fastapi.constants import TYPE_JSON -from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse -from stapi_fastapi.routers.route_names import ( - CREATE_ORDER, - GET_CONSTRAINTS, - GET_OPPORTUNITY_COLLECTION, - GET_ORDER_PARAMETERS, - GET_PRODUCT, - SEARCH_OPPORTUNITIES, -) from stapi_pydantic import ( JsonSchemaModel, - Link, OpportunityCollection, OpportunityPayload, - OpportunitySearchRecord, Order, + OrderCollection, OrderPayload, OrderStatus, Prefer, @@ -70,14 +53,10 @@ 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 + # Product endpoints self.add_api_route( path="", endpoint=self.get_product, @@ -105,27 +84,10 @@ def __init__( description="...", ) - # This wraps `self.create_order` to explicitly parameterize `OrderRequest` - # for this Product. This must be done programmatically instead of with a type - # annotation because it's setting the type dynamically instead of statically, and - # pydantic needs this type annotation when doing object conversion. This cannot be done - # directly to `self.create_order` because doing it there changes - # the annotation on every `ProductRouter` instance's `create_order`, not just - # this one's. - async def _create_order( - payload: OrderPayload, # type: ignore - request: Request, - response: Response, - ) -> Order[OrderStatus]: - return await self.create_order(payload, request, response) - - _create_order.__annotations__["payload"] = OrderPayload[ - self.product.order_parameters # type: ignore - ] - + # Orders endpoints self.add_api_route( path="/orders", - endpoint=_create_order, + endpoint=self.create_order, methods=["POST"], response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, @@ -136,7 +98,7 @@ async def _create_order( self.add_api_route( path="/orders", - endpoint=root_router.get_orders, + endpoint=self.get_orders, methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], @@ -144,301 +106,39 @@ async def _create_order( description="...", ) - if product.supports_opportunity_search or root_router.supports_async_opportunity_search: - self.add_api_route( - path="/opportunities", - endpoint=self.search_opportunities, - name=f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", - methods=["POST"], - response_class=GeoJSONResponse, - # unknown why mypy can't see the constraints property on Product, ignoring - response_model=OpportunityCollection[ - Geometry, - self.product.opportunity_properties, # type: ignore - ], - responses={ - 201: { - "model": OpportunitySearchRecord, - "content": {TYPE_JSON: {}}, - } - }, - summary="Search Opportunities for the product", - tags=["Products"], - ) - - if root_router.supports_async_opportunity_search: - self.add_api_route( - path="/opportunities/{opportunity_collection_id}", - endpoint=self.get_opportunity_collection, - name=f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", - methods=["GET"], - response_class=GeoJSONResponse, - summary="Get an Opportunity Collection by ID", - tags=["Products"], - ) + # Opportunities endpoints + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + methods=["POST"], + tags=["Opportunities"], + summary="create a new opportunity request for product with id `productId`", + description="...", + ) def get_product(self, request: Request) -> Product: - links = [ - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{GET_PRODUCT}", - ), - ), - rel="self", - type=TYPE_JSON, - ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{GET_CONSTRAINTS}", - ), - ), - rel="constraints", - type=TYPE_JSON, - ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{GET_ORDER_PARAMETERS}", - ), - ), - rel="order-parameters", - type=TYPE_JSON, - ), - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", - ), - ), - rel="create-order", - type=TYPE_JSON, - method="POST", - ), - ] + return None # type: ignore - if self.product.supports_opportunity_search or self.root_router.supports_async_opportunity_search: - links.append( - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{SEARCH_OPPORTUNITIES}", - ), - ), - rel="opportunities", - type=TYPE_JSON, - ), - ) + def get_product_queryables(self) -> JsonSchemaModel: + return None # type: ignore - return self.product.with_links(links=links) + def get_product_order_parameters(self) -> JsonSchemaModel: + return None # type: ignore - async def search_opportunities( - self, - search: OpportunityPayload, - request: Request, - response: Response, - prefer: Prefer | None = Depends(get_prefer), - ) -> OpportunityCollection | Response: # type: ignore - """ - Explore the opportunities available for a particular set of constraints - """ - # sync - if not self.root_router.supports_async_opportunity_search or ( - prefer is Prefer.wait and self.product.supports_opportunity_search - ): - return await self.search_opportunities_sync( - search, - request, - response, - prefer, - ) + def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + return None # type: ignore - # async - if ( - prefer is None - or prefer is Prefer.respond_async - or (prefer is Prefer.wait and not self.product.supports_opportunity_search) - ): - return await self.search_opportunities_async(search, request, prefer) + def get_orders(self, request: Request, next: str | None = None, limit: int = 10) -> OrderCollection[OrderStatus]: + return None # type: ignore - raise AssertionError("Expected code to be unreachable") + def get_opportunity_collection(self, opportunity_collection_id: str, request: Request) -> OpportunityCollection: # type: ignore + return None # type: ignore - async def search_opportunities_sync( + def search_opportunities( self, search: OpportunityPayload, request: Request, response: Response, prefer: Prefer | None, ) -> OpportunityCollection: # type: ignore - links: list[Link] = [] - match await self.product.search_opportunities( - self, - search, - search.next, - search.limit, - request, - ): - case Success((features, maybe_pagination_token)): - links.append(self.order_link(request, search)) - match maybe_pagination_token: - case Some(x): - links.append(self.pagination_link(request, search, x)) - case Maybe.empty: - pass - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while searching opportunities: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error searching opportunities", - ) - case x: - raise AssertionError(f"Expected code to be unreachable {x}") - - if prefer is Prefer.wait and self.root_router.supports_async_opportunity_search: - response.headers["Preference-Applied"] = "wait" - - return OpportunityCollection(features=features, links=links) - - async def search_opportunities_async( - self, - search: OpportunityPayload, - request: Request, - prefer: Prefer | None, - ) -> JSONResponse: - match await self.product.search_opportunities_async(self, search, request): - case Success(search_record): - search_record.links.append(self.root_router.opportunity_search_record_self_link(search_record, request)) - headers = {} - headers["Location"] = str( - self.root_router.generate_opportunity_search_record_href(request, search_record.id) - ) - if prefer is not None: - headers["Preference-Applied"] = "respond-async" - return JSONResponse( - status_code=201, - content=search_record.model_dump(mode="json"), - headers=headers, - ) - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while initiating an asynchronous opportunity search: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error initiating an asynchronous opportunity search", - ) - case x: - raise AssertionError(f"Expected code to be unreachable: {x}") - - def get_product_queryables(self) -> JsonSchemaModel: - """ - Return supported constraints of a specific product - """ - return self.product.queryables - - def get_product_order_parameters(self) -> JsonSchemaModel: - """ - Return supported constraints of a specific product - """ - return self.product.order_parameters - - async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore - """ - Create a new order. - """ - match await self.product.create_order( - self, - payload, - request, - ): - case Success(order): - order.links.extend(self.root_router.order_links(order, request)) - location = str(self.root_router.generate_order_href(request, order.id)) - response.headers["Location"] = location - return order # type: ignore - case Failure(e) if isinstance(e, ConstraintsException): - raise e - case Failure(e): - logger.error( - "An error occurred while creating order: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error creating order", - ) - case x: - raise AssertionError(f"Expected code to be unreachable {x}") - - def order_link(self, request: Request, opp_req: OpportunityPayload) -> Link: - return Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{CREATE_ORDER}", - ), - ), - rel="create-order", - type=TYPE_JSON, - method="POST", - body=opp_req.search_body(), - ) - - def pagination_link(self, request: Request, opp_req: OpportunityPayload, pagination_token: str) -> Link: - body = opp_req.body() - body["next"] = pagination_token - return Link( - href=str(request.url), - rel="next", - type=TYPE_JSON, - method="POST", - body=body, - ) - - async def get_opportunity_collection( - self, opportunity_collection_id: str, request: Request - ) -> OpportunityCollection: # type: ignore - """ - Fetch an opportunity collection generated by an asynchronous opportunity search. - """ - match await self.product.get_opportunity_collection( - self, - opportunity_collection_id, - request, - ): - case Success(Some(opportunity_collection)): - opportunity_collection.links.append( - Link( - href=str( - request.url_for( - f"{self.root_router.name}:{self.product.id}:{GET_OPPORTUNITY_COLLECTION}", - opportunity_collection_id=opportunity_collection_id, - ), - ), - rel="self", - type=TYPE_JSON, - ), - ) - return opportunity_collection # type: ignore - case Success(Maybe.empty): - raise NotFoundException("Opportunity Collection not found") - case Failure(e): - logger.error( - "An error occurred while fetching opportunity collection: '%s': %s", - opportunity_collection_id, - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error fetching Opportunity Collection", - ) - case x: - raise AssertionError(f"Expected code to be unreachable {x}") + return None # type: ignore From 4f0ffdf563a0136f5d41adfef64259fa4d5bfdb5 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 16:05:48 +0100 Subject: [PATCH 09/25] reduce router code --- .../pystapi_schema_generator/application.py | 10 +- .../src/pystapi_schema_generator/backend.py | 60 --- .../product_router.py | 2 +- .../src/pystapi_schema_generator/router.py | 362 +----------------- .../src/stapi_fastapi/routers/root_router.py | 7 +- stapi-pydantic/src/stapi_pydantic/shared.py | 4 +- 6 files changed, 26 insertions(+), 419 deletions(-) delete mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/backend.py diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 1c4bc4b..41ecbe9 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,18 +1,10 @@ from fastapi import FastAPI from stapi_fastapi.conformance import CORE, OPPORTUNITIES -from .backend import stapi_get_order, stapi_get_order_statuses, stapi_get_orders from .product import example_product from .router import RootRouter -router = RootRouter( - get_orders=stapi_get_orders, - get_order=stapi_get_order, - get_order_statuses=stapi_get_order_statuses, - get_opportunity_search_records=None, - get_opportunity_search_record=None, - conformances=[CORE, OPPORTUNITIES], -) +router = RootRouter(conformances=[CORE, OPPORTUNITIES]) router.add_product(example_product) app: FastAPI = FastAPI( diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/backend.py b/pystapi-schema-generator/src/pystapi_schema_generator/backend.py deleted file mode 100644 index 96537fd..0000000 --- a/pystapi-schema-generator/src/pystapi_schema_generator/backend.py +++ /dev/null @@ -1,60 +0,0 @@ -from fastapi import Request -from returns.maybe import Maybe, Nothing, Some -from returns.result import Failure, ResultE, Success -from stapi_pydantic import Order, OrderStatus - - -async def stapi_get_orders( - next: str | None, limit: int, request: Request -) -> ResultE[tuple[list[Order[OrderStatus]], Maybe[str]]]: - """ - Return orders from backend. Handle pagination/limit if applicable - """ - try: - start = 0 - limit = min(limit, 100) - order_ids = [*request.state._orders_db._orders.keys()] - - if next: - start = order_ids.index(next) - end = start + limit - ids = order_ids[start:end] - orders = [request.state._orders_db.get_order(order_id) for order_id in ids] - - if end > 0 and end < len(order_ids): - return Success((orders, Some(request.state._orders_db._orders[order_ids[end]].id))) - return Success((orders, Nothing)) - except Exception as e: - return Failure(e) - - -async def stapi_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order[OrderStatus]]]: - """ - Show details for order with `order_id`. - """ - try: - return Success(Maybe.from_optional(request.state._orders_db.get_order(order_id))) - except Exception as e: - return Failure(e) - - -async def stapi_get_order_statuses( - order_id: str, next: str | None, limit: int, request: Request -) -> ResultE[Maybe[tuple[list[OrderStatus], Maybe[str]]]]: - try: - start = 0 - limit = min(limit, 100) - statuses = request.state._orders_db.get_order_statuses(order_id) - if statuses is None: - return Success(Nothing) - - if next: - start = int(next) - end = start + limit - stati = statuses[start:end] - - if end > 0 and end < len(statuses): - return Success(Some((stati, Some(str(end))))) - return Success(Some((stati, Nothing))) - except Exception as e: - return Failure(e) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 0856437..e2aea38 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -25,7 +25,7 @@ ) if TYPE_CHECKING: - from stapi_fastapi.routers import RootRouter + from .router import RootRouter logger = logging.getLogger(__name__) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py index 2243ed2..bd31f8a 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -1,38 +1,12 @@ import logging -import traceback from typing import Any -from fastapi import APIRouter, HTTPException, Path, Request, status -from fastapi.datastructures import URL -from returns.maybe import Maybe, Some -from returns.result import Failure, Success -from stapi_fastapi.backends.root_backend import ( - GetOpportunitySearchRecord, - GetOpportunitySearchRecords, - GetOrder, - GetOrders, - GetOrderStatuses, -) -from stapi_fastapi.conformance import ASYNC_OPPORTUNITIES, CORE -from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import NotFoundException +from fastapi import APIRouter, Path, Request +from stapi_fastapi.conformance import CORE from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse -from stapi_fastapi.routers.route_names import ( - CONFORMANCE, - GET_OPPORTUNITY_SEARCH_RECORD, - GET_ORDER, - LIST_OPPORTUNITY_SEARCH_RECORDS, - LIST_ORDER_STATUSES, - LIST_ORDERS, - LIST_PRODUCTS, - ROOT, -) from stapi_pydantic import ( Conformance, - Link, - OpportunitySearchRecord, - OpportunitySearchRecords, Order, OrderCollection, OrderStatus, @@ -49,11 +23,6 @@ class RootRouter(APIRouter): def __init__( self, - get_orders: GetOrders, - get_order: GetOrder, - get_order_statuses: GetOrderStatuses, # type: ignore - get_opportunity_search_records: GetOpportunitySearchRecords | None = None, - get_opportunity_search_record: GetOpportunitySearchRecord | None = None, conformances: list[str] = [CORE], name: str = "root", openapi_endpoint_name: str = "openapi", @@ -63,19 +32,6 @@ 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" - ) - - self._get_orders = get_orders - self._get_order = get_order - 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.conformances = conformances self.name = name self.openapi_endpoint_name = openapi_endpoint_name @@ -147,319 +103,37 @@ def __init__( description="...", ) - if ASYNC_OPPORTUNITIES in conformances: - self.add_api_route( - "/searches/opportunities", - self.get_opportunity_search_records, - methods=["GET"], - name=f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}", - summary="List all Opportunity Search Records", - tags=["Opportunities"], - ) - - self.add_api_route( - "/searches/opportunities/{search_record_id}", - self.get_opportunity_search_record, - methods=["GET"], - name=f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", - summary="Get an Opportunity Search Record by ID", - tags=["Opportunities"], - ) + def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + # Give the include a prefix from the product router + product_router = ProductRouter(product, self, *args, **kwargs) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + self.product_ids = [*self.product_routers.keys()] def get_root(self, request: Request) -> RootResponse: - links = [ - Link( - href=str(request.url_for(f"{self.name}:{ROOT}")), - rel="self", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.openapi_endpoint_name)), - rel="service-description", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(self.docs_endpoint_name)), - rel="service-docs", - type="text/html", - ), - Link( - href=str(request.url_for(f"{self.name}:{CONFORMANCE}")), - rel="conformance", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), - rel="products", - type=TYPE_JSON, - ), - Link( - href=str(request.url_for(f"{self.name}:{LIST_ORDERS}")), - rel="orders", - type=TYPE_GEOJSON, - ), - ] - - if self.supports_async_opportunity_search: - links.append( - Link( - href=str(request.url_for(f"{self.name}:{LIST_OPPORTUNITY_SEARCH_RECORDS}")), - rel="opportunity-search-records", - type=TYPE_JSON, - ), - ) - - return RootResponse( - id="STAPI API", - title="landing page", - conformsTo=self.conformances, - links=links, - ) + return None # type: ignore def get_conformance(self) -> Conformance: - return Conformance(conforms_to=self.conformances) + return None # type: ignore def get_products(self, request: Request) -> ProductsCollection: - start = 0 - limit = min(limit, 100) - try: - if next: - start = self.product_ids.index(next) - except ValueError: - logger.exception("An error occurred while retrieving products") - raise NotFoundException(detail="Error finding pagination token for products") from None - end = start + limit - ids = self.product_ids[start:end] - links = [ - Link( - href=str(request.url_for(f"{self.name}:{LIST_PRODUCTS}")), - rel="self", - type=TYPE_JSON, - ), - ] - if end > 0 and end < len(self.product_ids): - links.append(self.pagination_link(request, self.product_ids[end], limit)) - return ProductsCollection( - products=[self.product_routers[product_id].get_product(request) for product_id in ids], - links=links, - ) + return None # type: ignore - async def get_orders( - self, request: Request, next: str | None = None, limit: int = 10 - ) -> OrderCollection[OrderStatus]: - links: list[Link] = [] - match await self._get_orders(next, limit, request): - case Success((orders, maybe_pagination_token)): - for order in orders: - order.links.extend(self.order_links(order, request)) - match maybe_pagination_token: - case Some(x): - links.append(self.pagination_link(request, x, limit)) - case Maybe.empty: - pass - case Failure(ValueError()): - raise NotFoundException(detail="Error finding pagination token") - case Failure(e): - logger.error( - "An error occurred while retrieving orders: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error finding Orders", - ) - case _: - raise AssertionError("Expected code to be unreachable") - return OrderCollection(features=orders, links=links) + def get_orders(self, request: Request) -> OrderCollection[OrderStatus]: + return None # type: ignore - async def get_order( + def get_order( self, request: Request, order_id: str = Path(alias="orderId", description="local identifier of an order"), - ) -> Order: - """ - Get details for order with `order_id`. - """ - match await self._get_order(order_id, request): - case Success(Some(order)): - order.links.extend(self.order_links(order, request)) - return order # type: ignore - case Success(Maybe.empty): - raise NotFoundException("Order not found") - case Failure(e): - logger.error( - "An error occurred while retrieving order '%s': %s", - order_id, - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error finding Order", - ) - case _: - raise AssertionError("Expected code to be unreachable") + ) -> Order[OrderStatus]: + return None # type: ignore - async def get_order_statuses( + def get_order_statuses( self, order_id: str, request: Request, next: str | None = None, limit: int = 10, ) -> OrderStatuses: # type: ignore - links: list[Link] = [] - match await self._get_order_statuses(order_id, next, limit, request): - case Success(Some((statuses, maybe_pagination_token))): - links.append(self.order_statuses_link(request, order_id)) - match maybe_pagination_token: - case Some(x): - links.append(self.pagination_link(request, x, limit)) - case Maybe.empty: - pass - case Success(Maybe.empty): - raise NotFoundException("Order not found") - case Failure(ValueError()): - raise NotFoundException("Error finding pagination token") - case Failure(e): - logger.error( - "An error occurred while retrieving order statuses: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error finding Order Statuses", - ) - case _: - raise AssertionError("Expected code to be unreachable") - return OrderStatuses(statuses=statuses, links=links) - - def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: - # Give the include a prefix from the product router - product_router = ProductRouter(product, self, *args, **kwargs) - self.include_router(product_router, prefix=f"/products/{product.id}") - self.product_routers[product.id] = product_router - self.product_ids = [*self.product_routers.keys()] - - def generate_order_href(self, request: Request, order_id: str) -> URL: - return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) - - def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: - return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) - - def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: - return [ - Link( - href=str(self.generate_order_href(request, order.id)), - rel="self", - type=TYPE_GEOJSON, - ), - Link( - href=str(self.generate_order_statuses_href(request, order.id)), - rel="monitor", - type=TYPE_JSON, - ), - ] - - def order_statuses_link(self, request: Request, order_id: str) -> Link: - return Link( - href=str( - request.url_for( - f"{self.name}:{LIST_ORDER_STATUSES}", - order_id=order_id, - ) - ), - rel="self", - type=TYPE_JSON, - ) - - def pagination_link(self, request: Request, pagination_token: str, limit: int) -> Link: - return Link( - href=str(request.url.include_query_params(next=pagination_token, limit=limit)), - rel="next", - type=TYPE_JSON, - ) - - async def get_opportunity_search_records( - self, request: Request, next: str | None = None, limit: int = 10 - ) -> OpportunitySearchRecords: - links: list[Link] = [] - match await self._get_opportunity_search_records(next, limit, request): - case Success((records, maybe_pagination_token)): - for record in records: - record.links.append(self.opportunity_search_record_self_link(record, request)) - match maybe_pagination_token: - case Some(x): - links.append(self.pagination_link(request, x, limit)) - case Maybe.empty: - pass - case Failure(ValueError()): - raise NotFoundException(detail="Error finding pagination token") - case Failure(e): - logger.error( - "An error occurred while retrieving opportunity search records: %s", - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error finding Opportunity Search Records", - ) - case _: - raise AssertionError("Expected code to be unreachable") - return OpportunitySearchRecords(search_records=records, links=links) - - async def get_opportunity_search_record(self, search_record_id: str, request: Request) -> OpportunitySearchRecord: - """ - Get the Opportunity Search Record with `search_record_id`. - """ - match await self._get_opportunity_search_record(search_record_id, request): - case Success(Some(search_record)): - search_record.links.append(self.opportunity_search_record_self_link(search_record, request)) - return search_record # type: ignore - case Success(Maybe.empty): - raise NotFoundException("Opportunity Search Record not found") - case Failure(e): - logger.error( - "An error occurred while retrieving opportunity search record '%s': %s", - search_record_id, - traceback.format_exception(e), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error finding Opportunity Search Record", - ) - case _: - raise AssertionError("Expected code to be unreachable") - - def generate_opportunity_search_record_href(self, request: Request, search_record_id: str) -> URL: - return request.url_for( - f"{self.name}:{GET_OPPORTUNITY_SEARCH_RECORD}", - search_record_id=search_record_id, - ) - - def opportunity_search_record_self_link( - self, opportunity_search_record: OpportunitySearchRecord, request: Request - ) -> Link: - return Link( - href=str(self.generate_opportunity_search_record_href(request, opportunity_search_record.id)), - rel="self", - type=TYPE_JSON, - ) - - @property - def _get_opportunity_search_records(self) -> GetOpportunitySearchRecords: - if not self.__get_opportunity_search_records: - raise AttributeError("Root router does not support async opportunity search") - return self.__get_opportunity_search_records - - @property - def _get_opportunity_search_record(self) -> GetOpportunitySearchRecord: - if not self.__get_opportunity_search_record: - raise AttributeError("Root router does not support async opportunity search") - return self.__get_opportunity_search_record - - @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 None # type: ignore diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index d428e24..229cbe5 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -202,7 +202,8 @@ def get_root(self, request: Request) -> RootResponse: return RootResponse( id="STAPI API", - conformsTo=self.conformances, + description="STAPI API", + conforms_to=self.conformances, links=links, ) @@ -325,10 +326,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: self.product_routers[product.id] = product_router self.product_ids = [*self.product_routers.keys()] - def generate_order_href(self, request: Request, order_id: str) -> URL: + def generate_order_href(self, request: Request, order_id: str | None) -> URL: return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) - def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: + def generate_order_statuses_href(self, request: Request, order_id: str | None) -> URL: return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 1fba041..c473e50 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -36,8 +36,8 @@ class Link(BaseModel): description=( "This is only valid when the server is responding to POST request. " "If merge is true, the client is expected to merge the body value into the current request body before " - "following the link. This avoids passing large post bodies back and forth when following links, particularly " - "for navigating pages through the POST /search endpoint. " + "following the link. This avoids passing large post bodies back and forth when following links, " + "particularly for navigating pages through the POST /search endpoint. " "NOTE: To support form encoding it is expected that a client be able to merge in the key value pairs " "specified as JSON {'next': 'token'} will become &next=token." ), From ae3c3749249f8b59c64ce26cb61da1643e4922b0 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 16:12:25 +0100 Subject: [PATCH 10/25] further reduce --- .../pystapi_schema_generator/application.py | 13 ++- .../src/pystapi_schema_generator/product.py | 85 ------------------- .../product_router.py | 25 +----- .../src/pystapi_schema_generator/router.py | 5 +- 4 files changed, 15 insertions(+), 113 deletions(-) delete mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/product.py diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 41ecbe9..b42fd47 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,11 +1,20 @@ from fastapi import FastAPI from stapi_fastapi.conformance import CORE, OPPORTUNITIES +from stapi_pydantic import Product -from .product import example_product from .router import RootRouter router = RootRouter(conformances=[CORE, OPPORTUNITIES]) -router.add_product(example_product) + + +router.add_product( + Product( + id="{productId}", + description="An example product", + license="CC-BY-4.0", + links=[], + ) +) app: FastAPI = FastAPI( openapi_tags=[ diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product.py b/pystapi-schema-generator/src/pystapi_schema_generator/product.py deleted file mode 100644 index f29f30a..0000000 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product.py +++ /dev/null @@ -1,85 +0,0 @@ -from datetime import UTC, datetime -from typing import Any -from uuid import uuid4 - -from fastapi import Request -from returns.result import Failure, ResultE, Success -from stapi_fastapi.models.product import Product -from stapi_fastapi.routers.product_router import ProductRouter -from stapi_pydantic import ( - Constraints, - OpportunityProperties, - Order, - OrderParameters, - OrderPayload, - OrderProperties, - OrderSearchParameters, - OrderStatus, - OrderStatusCode, -) - - -class StapiProductConstraints(Constraints): - example_constraint: Any - - -class StapiOpportunityProperties(OpportunityProperties): - example_property: Any - - -class StapiOrderParameters(OrderParameters): - example_parameter: Any - - -async def stapi_create_order( - product_router: ProductRouter, payload: OrderPayload[StapiOrderParameters], request: Request -) -> ResultE[Order[OrderStatus]]: - """ - Create a new order. - """ - try: - status = OrderStatus( - timestamp=datetime.now(UTC), - status_code=OrderStatusCode.received, - ) - order = Order( - id=str(uuid4()), - geometry=payload.geometry, - properties=OrderProperties( - product_id=product_router.product.id, - created=datetime.now(UTC), - status=status, - search_parameters=OrderSearchParameters( - geometry=payload.geometry, - datetime=payload.datetime, - filter=payload.filter, - ), - order_parameters=payload.order_parameters.model_dump(), - opportunity_properties={ - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, - ), - links=[], - ) - - request.state._orders_db.put_order(order) - request.state._orders_db.put_order_status(order.id, status) - return Success(order) - except Exception as e: - return Failure(e) - - -example_product = Product( - id="{productId}", - description="An example product", - license="CC-BY-4.0", - links=[], - create_order=stapi_create_order, - search_opportunities=None, - search_opportunities_async=None, - get_opportunity_collection=None, - constraints=StapiProductConstraints, - opportunity_properties=StapiOpportunityProperties, - order_parameters=StapiOrderParameters, -) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index e2aea38..788e156 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -1,17 +1,13 @@ from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Any +from typing import Any from fastapi import ( APIRouter, - Header, - HTTPException, Request, Response, status, ) -from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse from stapi_pydantic import ( JsonSchemaModel, @@ -22,25 +18,10 @@ OrderPayload, OrderStatus, Prefer, + Product, ) -if TYPE_CHECKING: - from .router import RootRouter - -logger = logging.getLogger(__name__) - - -def get_prefer(prefer: str | None = Header(None)) -> str | None: - if prefer is None: - return None - - if prefer not in Prefer: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid Prefer header value: {prefer}", - ) - - return Prefer(prefer) +from .router import RootRouter class ProductRouter(APIRouter): diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py index bd31f8a..ff67973 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -1,9 +1,7 @@ -import logging from typing import Any from fastapi import APIRouter, Path, Request from stapi_fastapi.conformance import CORE -from stapi_fastapi.models.product import Product from stapi_fastapi.responses import GeoJSONResponse from stapi_pydantic import ( Conformance, @@ -11,14 +9,13 @@ OrderCollection, OrderStatus, OrderStatuses, + Product, ProductsCollection, RootResponse, ) from .product_router import ProductRouter -logger = logging.getLogger(__name__) - class RootRouter(APIRouter): def __init__( From 6982182467e2f0ac2b8485e9d0190c9269547b3e Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 16:15:12 +0100 Subject: [PATCH 11/25] fix --- .../src/pystapi_schema_generator/product_router.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 788e156..afa6a7e 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from fastapi import ( APIRouter, @@ -21,7 +21,8 @@ Product, ) -from .router import RootRouter +if TYPE_CHECKING: + from .router import RootRouter class ProductRouter(APIRouter): From e619a1f4e969a29a06eed6335da7fa3fa5cde77d Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Thu, 3 Apr 2025 17:25:24 +0100 Subject: [PATCH 12/25] add script to generate schema file --- pystapi-schema-generator/pyproject.toml | 4 +-- .../pystapi_schema_generator/application.py | 28 ++++++++++++++-- uv.lock | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml index 50c95ae..d14267c 100644 --- a/pystapi-schema-generator/pyproject.toml +++ b/pystapi-schema-generator/pyproject.toml @@ -8,10 +8,10 @@ authors = [ { name = "Tobias Rohnstock", email = "tobias.rohnstock@live-eo.com" }, ] requires-python = ">=3.10" -dependencies = [] +dependencies = ["PyYAML>=6"] [project.scripts] - +stapi-schema-generator = "pystapi_schema_generator.application:main" [build-system] requires = ["hatchling"] diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index b42fd47..c8a06c0 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -2,11 +2,9 @@ from stapi_fastapi.conformance import CORE, OPPORTUNITIES from stapi_pydantic import Product -from .router import RootRouter +from pystapi_schema_generator.router import RootRouter router = RootRouter(conformances=[CORE, OPPORTUNITIES]) - - router.add_product( Product( id="{productId}", @@ -37,3 +35,27 @@ ] ) app.include_router(router, prefix="") + + +def main() -> None: + import argparse + + import yaml + + parser = argparse.ArgumentParser(description="Generate OpenAPI schema for STAPI") + parser.add_argument( + "--output", + "-o", + default="openapi.yml", + help="Output file path for the OpenAPI schema (default: openapi.yml)", + ) + args = parser.parse_args() + + with open(args.output, "w") as f: + yaml.dump(app.openapi(), f) + + print(f"OpenAPI schema saved to '{args.output}'.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 6cff2bc..caa3b78 100644 --- a/uv.lock +++ b/uv.lock @@ -129,6 +129,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, @@ -357,6 +369,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, + { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, + { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, + { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, ] [[package]] @@ -1559,6 +1575,12 @@ requires-dist = [ name = "pystapi-schema-generator" version = "0.0.1" source = { editable = "pystapi-schema-generator" } +dependencies = [ + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6" }] [[package]] name = "pystapi-validator" @@ -2517,6 +2539,17 @@ version = "1.17.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 } wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 }, { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 }, { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 }, { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 }, From 34608104659924a27870f39d0de797cd4479e55e Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Wed, 30 Apr 2025 23:10:11 +0200 Subject: [PATCH 13/25] update dependecies --- pystapi-schema-generator/pyproject.toml | 9 +++++++-- uv.lock | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml index d14267c..4bcb41b 100644 --- a/pystapi-schema-generator/pyproject.toml +++ b/pystapi-schema-generator/pyproject.toml @@ -4,11 +4,16 @@ version = "0.0.1" description = "Schema Generator for the Satellite Tasking API (STAPI) Specification" readme = "README.md" authors = [ - { name = "Justin Trautmann", email = "justin@live-eo.com" }, { name = "Tobias Rohnstock", email = "tobias.rohnstock@live-eo.com" }, ] requires-python = ">=3.10" -dependencies = ["PyYAML>=6"] +dependencies = [ + "uvicorn>=0.34", + "fastapi>=0.115", + "pydantic>=2.10", + "geojson-pydantic>=1.2", + "stapi-pydantic>=0.0.3", +] [project.scripts] stapi-schema-generator = "pystapi_schema_generator.application:main" diff --git a/uv.lock b/uv.lock index d24fdd3..2f5948b 100644 --- a/uv.lock +++ b/uv.lock @@ -1590,11 +1590,21 @@ name = "pystapi-schema-generator" version = "0.0.1" source = { editable = "pystapi-schema-generator" } dependencies = [ - { name = "pyyaml" }, + { name = "fastapi" }, + { name = "geojson-pydantic" }, + { name = "pydantic" }, + { name = "stapi-pydantic" }, + { name = "uvicorn" }, ] [package.metadata] -requires-dist = [{ name = "pyyaml", specifier = ">=6" }] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "geojson-pydantic", specifier = ">=1.2" }, + { name = "pydantic", specifier = ">=2.10" }, + { name = "stapi-pydantic", editable = "stapi-pydantic" }, + { name = "uvicorn", specifier = ">=0.34" }, +] [[package]] name = "pystapi-validator" From dfdafa827a97628de4a8636c176aa35a0902aba5 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Wed, 30 Apr 2025 23:11:33 +0200 Subject: [PATCH 14/25] some progress --- pystapi-schema-generator/pyproject.toml | 1 + .../pystapi_schema_generator/application.py | 3 +- .../src/pystapi_schema_generator/router.py | 63 +++++++------------ .../src/stapi_pydantic/conformance.py | 2 +- stapi-pydantic/src/stapi_pydantic/root.py | 9 ++- 5 files changed, 30 insertions(+), 48 deletions(-) diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml index 4bcb41b..9c9f79c 100644 --- a/pystapi-schema-generator/pyproject.toml +++ b/pystapi-schema-generator/pyproject.toml @@ -5,6 +5,7 @@ description = "Schema Generator for the Satellite Tasking API (STAPI) Specificat readme = "README.md" authors = [ { name = "Tobias Rohnstock", email = "tobias.rohnstock@live-eo.com" }, + { name = "Justin Trautmann", email = "justin@live-eo.com" }, ] requires-python = ">=3.10" dependencies = [ diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index c8a06c0..c73513d 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,10 +1,9 @@ from fastapi import FastAPI -from stapi_fastapi.conformance import CORE, OPPORTUNITIES from stapi_pydantic import Product from pystapi_schema_generator.router import RootRouter -router = RootRouter(conformances=[CORE, OPPORTUNITIES]) +router = RootRouter() router.add_product( Product( id="{productId}", diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py index ff67973..1bd0834 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -1,7 +1,6 @@ from typing import Any -from fastapi import APIRouter, Path, Request -from stapi_fastapi.conformance import CORE +from fastapi import APIRouter, Path from stapi_fastapi.responses import GeoJSONResponse from stapi_pydantic import ( Conformance, @@ -18,27 +17,8 @@ class RootRouter(APIRouter): - def __init__( - self, - conformances: list[str] = [CORE], - name: str = "root", - openapi_endpoint_name: str = "openapi", - docs_endpoint_name: str = "swagger_ui_html", - *args: Any, - **kwargs: Any, - ) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - - self.conformances = conformances - self.name = name - self.openapi_endpoint_name = openapi_endpoint_name - self.docs_endpoint_name = docs_endpoint_name - self.product_ids: list[str] = [] - - # A dict is used to track the product routers so we can ensure - # idempotentcy in case a product is added multiple times, and also to - # manage clobbering if multiple products with the same product_id are - # added. self.product_routers: dict[str, ProductRouter] = {} # Core endpoints @@ -47,8 +27,13 @@ def __init__( self.get_root, methods=["GET"], tags=["Core"], - summary="landing page", - description="...", + summary="STAPI root endpoint for API discovery and metadata", + description=( + "This endpoint serves as the entry point for API discovery and navigation. " + "Returns the STAPI root endpoint response containing the API's metadata: " + "a unique identifier, descriptive text, implemented conformance classes, " + "and hypermedia links to available resources and documentation." + ), ) self.add_api_route( @@ -56,8 +41,15 @@ def __init__( self.get_conformance, methods=["GET"], tags=["Core"], - summary="information about specifications that this API conforms to", - description="A list of all conformance classes specified in a standard that the server conforms to.", + summary="List of implemented STAPI and OGC conformance classes", + description=( + "Returns a list of conformance classes implemented by this API, following " + "the OGC API Features conformance structure. While the core STAPI " + "conformance classes are already communicated in the root endpoint, " + "OGC API requires this duplicate conformance information at this " + "/conformance endpoint. Includes both STAPI-specific conformance classes " + "(e.g., core, order statuses, searches) and relevant OGC conformance classes." + ), ) # Orders endpoints - w/o specific {productId}/orders endpoints @@ -105,32 +97,23 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: product_router = ProductRouter(product, self, *args, **kwargs) self.include_router(product_router, prefix=f"/products/{product.id}") self.product_routers[product.id] = product_router - self.product_ids = [*self.product_routers.keys()] - def get_root(self, request: Request) -> RootResponse: + def get_root(self) -> RootResponse: return None # type: ignore def get_conformance(self) -> Conformance: return None # type: ignore - def get_products(self, request: Request) -> ProductsCollection: + def get_products(self) -> ProductsCollection: return None # type: ignore - def get_orders(self, request: Request) -> OrderCollection[OrderStatus]: + def get_orders(self) -> OrderCollection[OrderStatus]: return None # type: ignore def get_order( - self, - request: Request, - order_id: str = Path(alias="orderId", description="local identifier of an order"), + self, order_id: str = Path(alias="orderId", description="local identifier of an order") ) -> Order[OrderStatus]: return None # type: ignore - def get_order_statuses( - self, - order_id: str, - request: Request, - next: str | None = None, - limit: int = 10, - ) -> OrderStatuses: # type: ignore + def get_order_statuses(self, order_id: str, next: str | None = None, limit: int = 10) -> OrderStatuses: # type: ignore return None # type: ignore diff --git a/stapi-pydantic/src/stapi_pydantic/conformance.py b/stapi-pydantic/src/stapi_pydantic/conformance.py index f4fdd18..2011b4f 100644 --- a/stapi-pydantic/src/stapi_pydantic/conformance.py +++ b/stapi-pydantic/src/stapi_pydantic/conformance.py @@ -2,4 +2,4 @@ class Conformance(BaseModel): - conforms_to: list[str] = Field(serialization_alias="conformsTo") + conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") diff --git a/stapi-pydantic/src/stapi_pydantic/root.py b/stapi-pydantic/src/stapi_pydantic/root.py index df47435..e42efae 100644 --- a/stapi-pydantic/src/stapi_pydantic/root.py +++ b/stapi-pydantic/src/stapi_pydantic/root.py @@ -1,12 +1,11 @@ from pydantic import BaseModel, Field -from pydantic.json_schema import SkipJsonSchema from .shared import Link class RootResponse(BaseModel): - conforms_to: list[str] = Field(serialization_alias="conformsTo") id: str - title: str | SkipJsonSchema[None] = None - description: str - links: list[Link] + conformsTo: list[str] = Field(default_factory=list) + title: str = "" + description: str = "" + links: list[Link] = Field(default_factory=list) From 3b8c757215dcc12c0a7dd1cbd811c70a05e16838 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Wed, 30 Apr 2025 23:30:35 +0200 Subject: [PATCH 15/25] some more progress --- .../pystapi_schema_generator/application.py | 8 ++--- .../product_router.py | 28 +++++++++++---- .../src/pystapi_schema_generator/router.py | 34 ++++++++++--------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index c73513d..88cf4a7 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -19,6 +19,10 @@ "name": "Core", "description": "Core endpoints", }, + { + "name": "Products", + "description": "Products", + }, { "name": "Orders", "description": "Endpoint for creating and managing orders", @@ -27,10 +31,6 @@ "name": "Opportunities", "description": "Endpoint for viewing and accepting opportunities", }, - { - "name": "Products", - "description": "Products", - }, ] ) app.include_router(router, prefix="") diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index afa6a7e..b9b346a 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -10,15 +10,17 @@ ) from stapi_fastapi.responses import GeoJSONResponse from stapi_pydantic import ( - JsonSchemaModel, + Conformance, OpportunityCollection, OpportunityPayload, Order, OrderCollection, + OrderParameters, OrderPayload, OrderStatus, Prefer, Product, + Queryables, ) if TYPE_CHECKING: @@ -48,9 +50,18 @@ def __init__( description="...", ) + self.add_api_route( + path="/conformance", + endpoint=self.get_conformance, + methods=["GET"], + tags=["Products"], + summary="describe the conformance for a product", + description="...", + ) + self.add_api_route( path="/queryables", - endpoint=self.get_product_queryables, + endpoint=self.get_queryables, methods=["GET"], tags=["Products"], summary="describe the queryables for a product", @@ -59,7 +70,7 @@ def __init__( self.add_api_route( path="/order-parameters", - endpoint=self.get_product_order_parameters, + endpoint=self.get_order_parameters, methods=["GET"], tags=["Products"], summary="describe the order parameters for a product", @@ -98,15 +109,20 @@ def __init__( description="...", ) - def get_product(self, request: Request) -> Product: + # Product endpoints + def get_product(self) -> Product: + return None # type: ignore + + def get_conformance(self) -> Conformance: return None # type: ignore - def get_product_queryables(self) -> JsonSchemaModel: + def get_queryables(self) -> Queryables: return None # type: ignore - def get_product_order_parameters(self) -> JsonSchemaModel: + def get_order_parameters(self) -> OrderParameters: return None # type: ignore + # Orders endpoints def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore return None # type: ignore diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py index 1bd0834..0891ff7 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -52,6 +52,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ), ) + # Products endpoints + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + tags=["Products"], + summary="the products in the dataset", + description="...", + ) + # Orders endpoints - w/o specific {productId}/orders endpoints self.add_api_route( "/orders", @@ -82,31 +92,23 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: description="...", ) - # Products endpoints - self.add_api_route( - "/products", - self.get_products, - methods=["GET"], - tags=["Products"], - summary="the products in the dataset", - description="...", - ) - - def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: - # Give the include a prefix from the product router - product_router = ProductRouter(product, self, *args, **kwargs) - self.include_router(product_router, prefix=f"/products/{product.id}") - self.product_routers[product.id] = product_router - + # Core endpoints def get_root(self) -> RootResponse: return None # type: ignore def get_conformance(self) -> Conformance: return None # type: ignore + # Products endpoints def get_products(self) -> ProductsCollection: return None # type: ignore + def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + product_router = ProductRouter(product, self, *args, **kwargs) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + + # Orders endpoints - w/o specific {productId}/orders endpoints def get_orders(self) -> OrderCollection[OrderStatus]: return None # type: ignore From 60033e7d586706f264e1d9bab432a302f5bfd47c Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Wed, 30 Apr 2025 23:38:27 +0200 Subject: [PATCH 16/25] make linter happy --- pystapi-schema-generator/pyproject.toml | 2 ++ .../src/stapi_fastapi/routers/root_router.py | 7 +------ uv.lock | 13 +++++++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml index 9c9f79c..ae02a6f 100644 --- a/pystapi-schema-generator/pyproject.toml +++ b/pystapi-schema-generator/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "uvicorn>=0.34", "fastapi>=0.115", "pydantic>=2.10", + "PyYAML>=6", + "types-PyYAML>=6", "geojson-pydantic>=1.2", "stapi-pydantic>=0.0.3", ] diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index 7e860d9..bb25bb8 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -214,12 +214,7 @@ def get_root(self, request: Request) -> RootResponse: ), ) - return RootResponse( - id="STAPI API", - description="STAPI API", - conforms_to=self.conformances, - links=links, - ) + return RootResponse(id="STAPI API", description="STAPI API", links=links) def get_conformance(self) -> Conformance: return Conformance(conforms_to=self.conformances) diff --git a/uv.lock b/uv.lock index 2f5948b..aa30d29 100644 --- a/uv.lock +++ b/uv.lock @@ -1593,7 +1593,9 @@ dependencies = [ { name = "fastapi" }, { name = "geojson-pydantic" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "stapi-pydantic" }, + { name = "types-pyyaml" }, { name = "uvicorn" }, ] @@ -1602,7 +1604,9 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "geojson-pydantic", specifier = ">=1.2" }, { name = "pydantic", specifier = ">=2.10" }, + { name = "pyyaml", specifier = ">=6" }, { name = "stapi-pydantic", editable = "stapi-pydantic" }, + { name = "types-pyyaml", specifier = ">=6" }, { name = "uvicorn", specifier = ">=0.34" }, ] @@ -2308,6 +2312,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/b3/ca41df24db5eb99b00d97f89d7674a90cb6b3134c52fb8121b6d8d30f15c/types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53", size = 14384 }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250402" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/68/609eed7402f87c9874af39d35942744e39646d1ea9011765ec87b01b2a3c/types_pyyaml-6.0.12.20250402.tar.gz", hash = "sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075", size = 17282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/56/1fe61db05685fbb512c07ea9323f06ea727125951f1eb4dff110b3311da3/types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681", size = 20329 }, +] + [[package]] name = "typing-extensions" version = "4.13.2" From d9ffe561f82f86e048a90360c5ce17b2acd612e5 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Fri, 2 May 2025 20:12:32 +0200 Subject: [PATCH 17/25] reset to main --- stapi-pydantic/src/stapi_pydantic/order.py | 14 ++---- stapi-pydantic/src/stapi_pydantic/product.py | 31 +++++------- stapi-pydantic/src/stapi_pydantic/shared.py | 53 +++++++------------- 3 files changed, 34 insertions(+), 64 deletions(-) diff --git a/stapi-pydantic/src/stapi_pydantic/order.py b/stapi-pydantic/src/stapi_pydantic/order.py index 952b83c..e2d9837 100644 --- a/stapi-pydantic/src/stapi_pydantic/order.py +++ b/stapi-pydantic/src/stapi_pydantic/order.py @@ -9,9 +9,9 @@ BaseModel, ConfigDict, Field, + StrictStr, field_validator, ) -from pydantic.json_schema import SkipJsonSchema from .constants import STAPI_VERSION from .datetime_interval import DatetimeInterval @@ -32,7 +32,6 @@ class OrderParameters(BaseModel): class OrderStatusCode(StrEnum): - # Required received = "received" accepted = "accepted" rejected = "rejected" @@ -42,8 +41,6 @@ class OrderStatusCode(StrEnum): held = "held" processing = "processing" reserved = "reserved" - - # extensions tasked = "tasked" user_cancelled = "user_cancelled" expired = "expired" @@ -91,12 +88,7 @@ class OrderProperties(BaseModel, Generic[T]): class Order(_GeoJsonBase, Generic[T]): # We need to enforce that orders have an id defined, as that is required to # retrieve them via the API - id: str | SkipJsonSchema[None] = None - user: str | SkipJsonSchema[None] = None - status: T | SkipJsonSchema[None] = None - created: AwareDatetime | SkipJsonSchema[None] = None - links: list[Link] = Field(default_factory=list) - + id: StrictStr type: Literal["Feature"] = "Feature" stapi_type: Literal["Order"] = "Order" stapi_version: str = STAPI_VERSION @@ -104,6 +96,8 @@ class Order(_GeoJsonBase, Generic[T]): geometry: Geometry = Field(...) properties: OrderProperties[T] = Field(...) + links: list[Link] = Field(default_factory=list) + __geojson_exclude_if_none__ = {"bbox", "id"} @field_validator("geometry", mode="before") diff --git a/stapi-pydantic/src/stapi_pydantic/product.py b/stapi-pydantic/src/stapi_pydantic/product.py index f636d21..54b946f 100644 --- a/stapi-pydantic/src/stapi_pydantic/product.py +++ b/stapi-pydantic/src/stapi_pydantic/product.py @@ -2,24 +2,23 @@ from typing import Any, Literal, Self from pydantic import AnyHttpUrl, BaseModel, Field -from pydantic.json_schema import SkipJsonSchema from .constants import STAPI_VERSION from .shared import Link class ProviderRole(StrEnum): - producer = "producer" licensor = "licensor" + producer = "producer" processor = "processor" host = "host" class Provider(BaseModel): name: str - description: str | SkipJsonSchema[None] = None - roles: list[ProviderRole] | SkipJsonSchema[None] = None - url: AnyHttpUrl | SkipJsonSchema[None] = None + description: str | None = None + roles: list[ProviderRole] + url: AnyHttpUrl # redefining init is a hack to get str type to validate for `url`, # as str is ultimately coerced into an AnyHttpUrl automatically anyway @@ -33,12 +32,12 @@ class Product(BaseModel): stapi_version: str = STAPI_VERSION conformsTo: list[str] = Field(default_factory=list) id: str - title: str | SkipJsonSchema[None] = None - description: str - keywords: list[str] | SkipJsonSchema[None] = None + title: str = "" + description: str = "" + keywords: list[str] = Field(default_factory=list) license: str - providers: list[Provider] | SkipJsonSchema[None] = None - links: list[Link] + providers: list[Provider] = Field(default_factory=list) + links: list[Link] = Field(default_factory=list) def with_links(self, links: list[Link] | None = None) -> Self: if not links: @@ -50,12 +49,6 @@ def with_links(self, links: list[Link] | None = None) -> Self: class ProductsCollection(BaseModel): - links: list[Link] - products: list[Product] = Field( - description=( - "STAPI Product objects are represented in JSON format and are very flexible. " - "Any JSON object that contains all the required fields is a valid STAPI Product. " - "A Product object contains a minimal set of required properties to be valid and can be extended " - "through the use of queryables and parameters." - ) - ) + type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") + links: list[Link] = Field(default_factory=list) + products: list[Product] diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index c473e50..5564a79 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -1,49 +1,32 @@ -from enum import StrEnum from typing import Any from pydantic import ( AnyUrl, BaseModel, - Field, + ConfigDict, + SerializerFunctionWrapHandler, + model_serializer, ) -from pydantic.json_schema import SkipJsonSchema - - -class RequestMethod(StrEnum): - GET = "GET" - POST = "POST" class Link(BaseModel): - href: AnyUrl = Field(description="The location of the resource") - rel: str = Field(description="Relation type of the link") - type: str | SkipJsonSchema[None] = Field(default=None, description="The media type of the resource") - title: str | SkipJsonSchema[None] = Field(default=None, description="Title of the resource") - method: RequestMethod | SkipJsonSchema[None] = Field( - default=RequestMethod.GET, - description="Specifies the HTTP method that the resource expects", - ) - headers: dict[str, str | list[str]] | SkipJsonSchema[None] = Field( - default=None, - description="Object key values pairs they map to headers", - ) - body: Any = Field( - default=None, - description="For POST requests, the resource can specify the HTTP body as a JSON object.", - ) - merge: bool = Field( - default=False, - description=( - "This is only valid when the server is responding to POST request. " - "If merge is true, the client is expected to merge the body value into the current request body before " - "following the link. This avoids passing large post bodies back and forth when following links, " - "particularly for navigating pages through the POST /search endpoint. " - "NOTE: To support form encoding it is expected that a client be able to merge in the key value pairs " - "specified as JSON {'next': 'token'} will become &next=token." - ), - ) + href: AnyUrl + rel: str + type: str | None = None + title: str | None = None + method: str | None = None + headers: dict[str, str | list[str]] | None = None + body: Any = None + + model_config = ConfigDict(extra="allow") # redefining init is a hack to get str type to validate for `href`, # as str is ultimately coerced into an AnyUrl automatically anyway def __init__(self, href: AnyUrl | str, **kwargs: Any) -> None: super().__init__(href=href, **kwargs) + + # overriding the default serialization to filter None field values from + # dumped json + @model_serializer(mode="wrap", when_used="json") + def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + return {k: v for k, v in handler(self).items() if v is not None} From 0d79f43086a68ac4c3bfac6ba7fec3424352277a Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Fri, 2 May 2025 20:13:07 +0200 Subject: [PATCH 18/25] reset t omain --- stapi-fastapi/src/stapi_fastapi/routers/root_router.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index bb25bb8..c238c5a 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -214,7 +214,11 @@ def get_root(self, request: Request) -> RootResponse: ), ) - return RootResponse(id="STAPI API", description="STAPI API", links=links) + return RootResponse( + id="STAPI API", + conformsTo=self.conformances, + links=links, + ) def get_conformance(self) -> Conformance: return Conformance(conforms_to=self.conformances) @@ -335,10 +339,10 @@ def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: self.product_routers[product.id] = product_router self.product_ids = [*self.product_routers.keys()] - def generate_order_href(self, request: Request, order_id: str | None) -> URL: + def generate_order_href(self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:{GET_ORDER}", order_id=order_id) - def generate_order_statuses_href(self, request: Request, order_id: str | None) -> URL: + def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: From bee0ab5da5808a96a5b8b6ea3419477a4a64da72 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 19:50:55 +0200 Subject: [PATCH 19/25] progress --- .../product_router.py | 207 ++++++++++++-- .../pystapi_schema_generator/root_router.py | 267 ++++++++++++++++++ .../src/pystapi_schema_generator/router.py | 121 -------- 3 files changed, 454 insertions(+), 141 deletions(-) create mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/root_router.py delete mode 100644 pystapi-schema-generator/src/pystapi_schema_generator/router.py diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index b9b346a..f402dec 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -4,15 +4,19 @@ from fastapi import ( APIRouter, + Path, + Query, Request, Response, status, ) +from geojson_pydantic import Polygon from stapi_fastapi.responses import GeoJSONResponse from stapi_pydantic import ( Conformance, OpportunityCollection, OpportunityPayload, + OpportunityProperties, Order, OrderCollection, OrderParameters, @@ -24,7 +28,7 @@ ) if TYPE_CHECKING: - from .router import RootRouter + from .root_router import RootRouter class ProductRouter(APIRouter): @@ -46,8 +50,36 @@ def __init__( endpoint=self.get_product, methods=["GET"], tags=["Products"], - summary="describe the product with id `productId`", - description="...", + summary="Get details of a specific product", + description=( + "Returns detailed information about a specific product. The response includes " + "all product metadata, including required fields (type, id, title, description, " + "license, providers, links) and optional fields (keywords, queryables, parameters, " + "properties). The parameters field defines what can be ordered for this product, " + "while the properties field describes inherent characteristics of the product." + ), + response_model=Product, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "type": "Product", + "id": "multispectral", + "title": "Multispectral", + "description": "Full color EO image", + "license": "proprietary", + "providers": [ + {"name": "Example Provider", "roles": ["producer"], "url": "https://example.com"} + ], + "links": [], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) self.add_api_route( @@ -55,8 +87,30 @@ def __init__( endpoint=self.get_conformance, methods=["GET"], tags=["Products"], - summary="describe the conformance for a product", - description="...", + summary="Get conformance classes for a specific product", + description=( + "Returns the conformance classes that apply specifically to this product. " + "These classes indicate which features and capabilities are supported by " + "this product, such as supported geometry types, parameter types, and " + "other product-specific capabilities." + ), + response_model=Conformance, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "conformsTo": [ + "https://stapi.example.com/v0.1.0/core", + "https://geojson.org/schema/Polygon.json", + ] + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) self.add_api_route( @@ -64,8 +118,29 @@ def __init__( endpoint=self.get_queryables, methods=["GET"], tags=["Products"], - summary="describe the queryables for a product", - description="...", + summary="Get queryable properties for a specific product", + description=( + "Returns a JSON Schema definition of the properties that can be used to " + "filter opportunities and orders for this product. These queryables define " + "the constraints that can be applied when searching for or ordering this " + "product, such as cloud cover limits, resolution requirements, or other " + "product-specific parameters." + ), + response_model=Queryables, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "type": "object", + "properties": {"eo:cloud_cover": {"type": "number", "minimum": 0, "maximum": 100}}, + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) self.add_api_route( @@ -73,8 +148,28 @@ def __init__( endpoint=self.get_order_parameters, methods=["GET"], tags=["Products"], - summary="describe the order parameters for a product", - description="...", + summary="Get order parameters for a specific product", + description=( + "Returns a JSON Schema definition of the parameters that can be specified " + "when creating an order for this product. These parameters define the " + "configurable options for the order, such as delivery format, processing " + "level, or other product-specific options." + ), + response_model=OrderParameters, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "type": "object", + "properties": {"format": {"type": "string", "enum": ["GeoTIFF", "JPEG2000"]}}, + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) # Orders endpoints @@ -85,8 +180,35 @@ def __init__( response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, tags=["Orders"], - summary="create a new order for product with id `productId`", - description="...", + summary="Create a new order for a specific product", + description=( + "Creates a new order for this product. The request must include the required " + "fields (datetime, geometry) and may include optional fields (queryables, " + "order_parameters). The datetime field specifies the temporal extent of the " + "order, while the geometry field defines its spatial extent. The response " + "is a GeoJSON Feature representing the created order." + ), + response_model=Order[OrderStatus], + responses={ + status.HTTP_201_CREATED: { + "description": "Order created successfully", + "content": { + "application/geo+json": { + "example": { + "type": "Feature", + "id": "order-123", + "properties": { + "product_id": "multispectral", + "created": "2024-01-01T00:00:00Z", + "status": "received", + }, + } + } + }, + }, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid order request"}, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) self.add_api_route( @@ -95,8 +217,23 @@ def __init__( methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], - summary="get a list of orders for the specific product", - description="...", + summary="Get orders for a specific product", + description=( + "Returns a collection of orders for this product. Each order is a GeoJSON " + "Feature containing the order details, including status, parameters, and " + "metadata. The response is a GeoJSON FeatureCollection and includes " + "pagination links for navigating through the order collection." + ), + response_model=OrderCollection[OrderStatus], + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/geo+json": {"example": {"type": "FeatureCollection", "features": [], "links": []}} + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) # Opportunities endpoints @@ -105,8 +242,23 @@ def __init__( endpoint=self.search_opportunities, methods=["POST"], tags=["Opportunities"], - summary="create a new opportunity request for product with id `productId`", - description="...", + summary="Search for opportunities for a specific product", + description=( + "Searches for potential acquisition opportunities for this product based on " + "the provided search criteria. The request must include the required fields " + "(datetime, geometry) and may include optional fields (queryables). The " + "response is a collection of opportunities that match the search criteria, " + "each representing a potential acquisition that could fulfill an order." + ), + response_model=OpportunityCollection, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": {"application/json": {"example": {"opportunities": [], "links": []}}}, + }, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid search request"}, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, ) # Product endpoints @@ -123,13 +275,26 @@ def get_order_parameters(self) -> OrderParameters: return None # type: ignore # Orders endpoints - def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore + def create_order( + self, payload: OrderPayload[OrderParameters], request: Request, response: Response + ) -> Order[OrderStatus]: return None # type: ignore - def get_orders(self, request: Request, next: str | None = None, limit: int = 10) -> OrderCollection[OrderStatus]: + def get_orders( + self, + request: Request, + next: str | None = Query(default=None, description="Token for pagination to the next page of results"), + limit: int = Query(default=10, ge=1, le=100, description="Maximum number of orders to return per page"), + ) -> OrderCollection[OrderStatus]: return None # type: ignore - def get_opportunity_collection(self, opportunity_collection_id: str, request: Request) -> OpportunityCollection: # type: ignore + def get_opportunity_collection( + self, + request: Request, + opportunity_collection_id: str = Path( + description="Unique identifier of the opportunity collection", example="opp-col-123" + ), + ) -> OpportunityCollection[Polygon, OpportunityProperties]: return None # type: ignore def search_opportunities( @@ -137,6 +302,8 @@ def search_opportunities( search: OpportunityPayload, request: Request, response: Response, - prefer: Prefer | None, - ) -> OpportunityCollection: # type: ignore + prefer: Prefer | None = Query( + default=None, description="Preference for synchronous or asynchronous processing" + ), + ) -> OpportunityCollection[Polygon, OpportunityProperties]: return None # type: ignore diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py new file mode 100644 index 0000000..99444a5 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -0,0 +1,267 @@ +from typing import Any + +from fastapi import APIRouter, Path, Query, status +from stapi_fastapi.responses import GeoJSONResponse +from stapi_pydantic import ( + Conformance, + Order, + OrderCollection, + OrderStatus, + OrderStatuses, + Product, + ProductsCollection, + RootResponse, +) + +from .product_router import ProductRouter + + +class RootRouter(APIRouter): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.product_routers: dict[str, ProductRouter] = {} + + # Core endpoints + self.add_api_route( + "/", + self.get_root, + methods=["GET"], + tags=["Core"], + summary="STAPI root endpoint for API discovery and metadata", + description=( + "This endpoint serves as the entry point for API discovery and navigation. " + "Returns the STAPI root endpoint response containing the API's metadata: " + "a unique identifier, descriptive text, implemented conformance classes, " + "and hypermedia links to available resources and documentation." + ), + response_model=RootResponse, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "id": "stapi-example", + "title": "STAPI Example API", + "description": "Example implementation of the STAPI specification", + "conformsTo": [ + "https://stapi.example.com/v0.1.0/core", + "https://stapi.example.com/v0.1.0/order-statuses", + ], + "links": [ + {"rel": "self", "type": "application/json", "href": "https://stapi.example.com/"} + ], + } + } + }, + } + }, + ) + + self.add_api_route( + "/conformance", + self.get_conformance, + methods=["GET"], + tags=["Core"], + summary="List of implemented STAPI and OGC conformance classes", + description=( + "Returns a list of conformance classes implemented by this API, following " + "the OGC API Features conformance structure. While the core STAPI " + "conformance classes are already communicated in the root endpoint, " + "OGC API requires this duplicate conformance information at this " + "/conformance endpoint. Includes both STAPI-specific conformance classes " + "(e.g., core, order statuses, searches) and relevant OGC conformance classes." + ), + response_model=Conformance, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "conformsTo": [ + "https://stapi.example.com/v0.1.0/core", + "https://stapi.example.com/v0.1.0/order-statuses", + ] + } + } + }, + } + }, + ) + + # Products endpoints + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + tags=["Products"], + summary="List of available products from the provider", + description=( + "Returns a collection of products offered by the provider. Each product contains " + "required fields (type, id, title, description, license, providers, links) and " + "optional fields (keywords, queryables, parameters, properties). The parameters " + "field defines what can be ordered for each product (e.g., cloud cover limits), " + "while the properties field describes inherent characteristics of the product " + "(e.g., sensor type, frequency band). The response is represented as a GeoJSON " + "FeatureCollection and includes pagination links for navigating through the " + "product collection." + ), + response_model=ProductsCollection, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "products": [ + { + "type": "Product", + "id": "multispectral", + "title": "Multispectral", + "description": "Full color EO image", + "license": "proprietary", + "links": [], + } + ], + "links": [], + } + } + }, + } + }, + ) + + # Orders endpoints - w/o specific {productId}/orders endpoints + self.add_api_route( + "/orders", + self.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="List of orders in the system", + description=( + "Returns a collection of orders in the system. Each order contains required fields " + "(datetime, geometry) and optional fields (queryables). The datetime field specifies " + "the temporal extent of the order, while the geometry field defines its spatial extent. " + "The queryables field contains the constraints specified for the order. The response is " + "represented as a GeoJSON FeatureCollection and includes pagination links for navigating " + "through the order collection." + ), + response_model=OrderCollection[OrderStatus], + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/geo+json": {"example": {"type": "FeatureCollection", "features": [], "links": []}} + }, + } + }, + ) + + self.add_api_route( + "/orders/{orderId}", + self.get_order, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="Get details of a specific order", + description=( + "Returns detailed information about a specific order. The order contains required " + "fields (datetime, geometry) defining its temporal and spatial extent, and optional " + "fields (queryables) containing the order constraints. The response is represented as " + "a GeoJSON Feature and may include additional metadata and links to related resources." + ), + response_model=Order[OrderStatus], + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/geo+json": { + "example": { + "type": "Feature", + "id": "order-123", + "properties": { + "product_id": "multispectral", + "created": "2024-01-01T00:00:00Z", + "status": "accepted", + }, + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Order not found"}, + }, + ) + + self.add_api_route( + "/orders/{orderId}/statuses", + self.get_order_statuses, + methods=["GET"], + tags=["Orders"], + summary="Get status history of an order", + description=( + "Returns the history of status changes for a specific order. The response includes " + "a chronological list of status updates, each containing the status value, timestamp, " + "and any associated message or metadata. Supports pagination through the next and limit " + "parameters to navigate through the status history." + ), + response_model=OrderStatuses[OrderStatus], + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "statuses": [ + { + "timestamp": "2024-01-01T00:00:00Z", + "status_code": "accepted", + "reason_text": "Order accepted for processing", + } + ], + "links": [], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Order not found"}, + }, + ) + + # Core endpoints + def get_root(self) -> RootResponse: + return None # type: ignore + + def get_conformance(self) -> Conformance: + return None # type: ignore + + # Products endpoints + def get_products(self) -> ProductsCollection: + return None # type: ignore + + def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + product_router = ProductRouter(product, self, *args, **kwargs) + self.include_router(product_router, prefix=f"/products/{product.id}") + self.product_routers[product.id] = product_router + + # Orders endpoints - w/o specific {productId}/orders endpoints + def get_orders( + self, + next: str | None = Query(default=None, description="Token for pagination to the next page of results"), + limit: int = Query(default=10, ge=1, le=100, description="Maximum number of orders to return per page"), + ) -> OrderCollection[OrderStatus]: + return None # type: ignore + + def get_order( + self, order_id: str = Path(alias="orderId", description="Unique identifier of the order", example="order-123") + ) -> Order[OrderStatus]: + return None # type: ignore + + def get_order_statuses( + self, + order_id: str = Path(alias="orderId", description="Unique identifier of the order", example="order-123"), + next: str | None = Query(default=None, description="Token for pagination to the next page of status history"), + limit: int = Query(default=10, ge=1, le=100, description="Maximum number of status entries to return per page"), + ) -> OrderStatuses[OrderStatus]: + return None # type: ignore diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py deleted file mode 100644 index 0891ff7..0000000 --- a/pystapi-schema-generator/src/pystapi_schema_generator/router.py +++ /dev/null @@ -1,121 +0,0 @@ -from typing import Any - -from fastapi import APIRouter, Path -from stapi_fastapi.responses import GeoJSONResponse -from stapi_pydantic import ( - Conformance, - Order, - OrderCollection, - OrderStatus, - OrderStatuses, - Product, - ProductsCollection, - RootResponse, -) - -from .product_router import ProductRouter - - -class RootRouter(APIRouter): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.product_routers: dict[str, ProductRouter] = {} - - # Core endpoints - self.add_api_route( - "/", - self.get_root, - methods=["GET"], - tags=["Core"], - summary="STAPI root endpoint for API discovery and metadata", - description=( - "This endpoint serves as the entry point for API discovery and navigation. " - "Returns the STAPI root endpoint response containing the API's metadata: " - "a unique identifier, descriptive text, implemented conformance classes, " - "and hypermedia links to available resources and documentation." - ), - ) - - self.add_api_route( - "/conformance", - self.get_conformance, - methods=["GET"], - tags=["Core"], - summary="List of implemented STAPI and OGC conformance classes", - description=( - "Returns a list of conformance classes implemented by this API, following " - "the OGC API Features conformance structure. While the core STAPI " - "conformance classes are already communicated in the root endpoint, " - "OGC API requires this duplicate conformance information at this " - "/conformance endpoint. Includes both STAPI-specific conformance classes " - "(e.g., core, order statuses, searches) and relevant OGC conformance classes." - ), - ) - - # Products endpoints - self.add_api_route( - "/products", - self.get_products, - methods=["GET"], - tags=["Products"], - summary="the products in the dataset", - description="...", - ) - - # Orders endpoints - w/o specific {productId}/orders endpoints - self.add_api_route( - "/orders", - self.get_orders, - methods=["GET"], - response_class=GeoJSONResponse, - tags=["Orders"], - summary="a list of orders", - description="...", - ) - - self.add_api_route( - "/orders/{orderId}", - self.get_order, - methods=["GET"], - response_class=GeoJSONResponse, - tags=["Orders"], - summary="describe the order with id `orderId`", - description="...", - ) - - self.add_api_route( - "/orders/{orderId}/statuses", - self.get_order_statuses, - methods=["GET"], - tags=["Orders"], - summary="describe the statuses that the order with id `orderId` has had", - description="...", - ) - - # Core endpoints - def get_root(self) -> RootResponse: - return None # type: ignore - - def get_conformance(self) -> Conformance: - return None # type: ignore - - # Products endpoints - def get_products(self) -> ProductsCollection: - return None # type: ignore - - def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: - product_router = ProductRouter(product, self, *args, **kwargs) - self.include_router(product_router, prefix=f"/products/{product.id}") - self.product_routers[product.id] = product_router - - # Orders endpoints - w/o specific {productId}/orders endpoints - def get_orders(self) -> OrderCollection[OrderStatus]: - return None # type: ignore - - def get_order( - self, order_id: str = Path(alias="orderId", description="local identifier of an order") - ) -> Order[OrderStatus]: - return None # type: ignore - - def get_order_statuses(self, order_id: str, next: str | None = None, limit: int = 10) -> OrderStatuses: # type: ignore - return None # type: ignore From 31f91e97983ad1b893a0c0890f93a3b6ffe10c54 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 19:51:05 +0200 Subject: [PATCH 20/25] fix --- .../src/pystapi_schema_generator/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 88cf4a7..5ad09c6 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from stapi_pydantic import Product -from pystapi_schema_generator.router import RootRouter +from pystapi_schema_generator.root_router import RootRouter router = RootRouter() router.add_product( From 9cf55bcd6f442ccc746d0e52c1b381debf8087ca Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 20:30:23 +0200 Subject: [PATCH 21/25] progress --- .../pystapi_schema_generator/application.py | 72 +++++++++++-------- .../product_router.py | 52 +++++++++++++- .../pystapi_schema_generator/root_router.py | 27 +++++-- 3 files changed, 110 insertions(+), 41 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 5ad09c6..da2c409 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,42 +1,52 @@ from fastapi import FastAPI -from stapi_pydantic import Product +from stapi_pydantic import Product, Provider from pystapi_schema_generator.root_router import RootRouter -router = RootRouter() -router.add_product( - Product( - id="{productId}", - description="An example product", - license="CC-BY-4.0", - links=[], + +def create_app() -> FastAPI: + """Create and configure the FastAPI application for OpenAPI spec generation.""" + app = FastAPI( + title="STAPI API", + description=( + "Implementation of the STAPI specification. This API provides endpoints for discovering remote " + "sensing data products, creating orders, and searching for acquisition opportunities across various " + "remote sensing platforms and sensors." + ), + version="0.1.0", + openapi_tags=[ + {"name": "Core", "description": "Core endpoints for API discovery and metadata"}, + {"name": "Products", "description": "Endpoints for discovering remote sensing data products"}, + {"name": "Orders", "description": "Endpoints for creating and tracking remote sensing data orders"}, + { + "name": "Opportunities", + "description": "Endpoints for searching remote sensing acquisition opportunities", + }, + ], + ) + + router = RootRouter() + router.add_product( + Product( + id="{productId}", + title="A product", + description="A product description", + license="CC-BY-4.0", + providers=[Provider(name="A Provider", roles=["producer"], url="https://example.com")], + links=[], + ) ) -) - -app: FastAPI = FastAPI( - openapi_tags=[ - { - "name": "Core", - "description": "Core endpoints", - }, - { - "name": "Products", - "description": "Products", - }, - { - "name": "Orders", - "description": "Endpoint for creating and managing orders", - }, - { - "name": "Opportunities", - "description": "Endpoint for viewing and accepting opportunities", - }, - ] -) -app.include_router(router, prefix="") + app.include_router(router, prefix="") + + return app + + +# Create the FastAPI application instance +app = create_app() def main() -> None: + """Generate OpenAPI schema for STAPI.""" import argparse import yaml diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index f402dec..265d778 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -32,6 +32,8 @@ class ProductRouter(APIRouter): + """Router for product-specific endpoints.""" + def __init__( self, product: Product, @@ -40,10 +42,12 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) - self.product = product self.root_router = root_router + self._setup_routes() + def _setup_routes(self) -> None: + """Set up all routes for the product router.""" # Product endpoints self.add_api_route( path="", @@ -229,7 +233,13 @@ def __init__( status.HTTP_200_OK: { "description": "Successful response", "content": { - "application/geo+json": {"example": {"type": "FeatureCollection", "features": [], "links": []}} + "application/geo+json": { + "example": { + "type": "FeatureCollection", + "features": [], + "links": [], + } + } }, }, status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, @@ -254,13 +264,49 @@ def __init__( responses={ status.HTTP_200_OK: { "description": "Successful response", - "content": {"application/json": {"example": {"opportunities": [], "links": []}}}, + "content": { + "application/json": { + "example": { + "opportunities": [], + "links": [], + } + } + }, }, status.HTTP_400_BAD_REQUEST: {"description": "Invalid search request"}, status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, }, ) + self.add_api_route( + path="/opportunities/{opportunity_collection_id}", + endpoint=self.get_opportunity_collection, + methods=["GET"], + tags=["Opportunities"], + summary="Get details of a specific opportunity collection", + description=( + "Returns detailed information about a specific opportunity collection. The response " + "includes all opportunities in the collection, their properties, and any associated " + "metadata. This endpoint is used to retrieve the results of an asynchronous " + "opportunity search." + ), + response_model=OpportunityCollection[Polygon, OpportunityProperties], + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "opportunities": [], + "links": [], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Opportunity collection not found"}, + }, + ) + # Product endpoints def get_product(self) -> Product: return None # type: ignore diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py index 99444a5..ed8c0ac 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, ClassVar from fastapi import APIRouter, Path, Query, status from stapi_fastapi.responses import GeoJSONResponse @@ -17,10 +17,16 @@ class RootRouter(APIRouter): + """Root router for STAPI endpoints.""" + + product_routers: ClassVar[dict[str, ProductRouter]] = {} + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.product_routers: dict[str, ProductRouter] = {} + self._setup_routes() + def _setup_routes(self) -> None: + """Set up all routes for the root router.""" # Core endpoints self.add_api_route( "/", @@ -42,8 +48,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: "application/json": { "example": { "id": "stapi-example", - "title": "STAPI Example API", - "description": "Example implementation of the STAPI specification", + "title": "STAPI API", + "description": "Implementation of the STAPI specification", "conformsTo": [ "https://stapi.example.com/v0.1.0/core", "https://stapi.example.com/v0.1.0/order-statuses", @@ -132,7 +138,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: }, ) - # Orders endpoints - w/o specific {productId}/orders endpoints + # Orders endpoints self.add_api_route( "/orders", self.get_orders, @@ -153,7 +159,13 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: status.HTTP_200_OK: { "description": "Successful response", "content": { - "application/geo+json": {"example": {"type": "FeatureCollection", "features": [], "links": []}} + "application/geo+json": { + "example": { + "type": "FeatureCollection", + "features": [], + "links": [], + } + } }, } }, @@ -241,11 +253,12 @@ def get_products(self) -> ProductsCollection: return None # type: ignore def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: + """Add a product router to the root router.""" product_router = ProductRouter(product, self, *args, **kwargs) self.include_router(product_router, prefix=f"/products/{product.id}") self.product_routers[product.id] = product_router - # Orders endpoints - w/o specific {productId}/orders endpoints + # Orders endpoints def get_orders( self, next: str | None = Query(default=None, description="Token for pagination to the next page of results"), From 6cc8b868e78d338be7d05d805fde9ffcaef46952 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 22:01:20 +0200 Subject: [PATCH 22/25] a lot of progress --- .../src/pystapi_schema_generator/__init__.py | 7 + .../pystapi_schema_generator/application.py | 65 +++- .../product_router.py | 323 +++++++++++++++--- .../pystapi_schema_generator/root_router.py | 180 ++++++++-- 4 files changed, 501 insertions(+), 74 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py index e69de29..677f9b6 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/__init__.py @@ -0,0 +1,7 @@ +"""STAPI Schema Generator package.""" + +import importlib.metadata + +STAPI_VERSION = importlib.metadata.version("stapi_pydantic") +STAPI_BASE_URL = "https://stapi.example.com" +STAPI_EXAMPLE_URL = "https://api.example.com" diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index da2c409..3c7d8b6 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from stapi_pydantic import Product, Provider +from pystapi_schema_generator import STAPI_BASE_URL, STAPI_VERSION from pystapi_schema_generator.root_router import RootRouter @@ -11,31 +12,77 @@ def create_app() -> FastAPI: description=( "Implementation of the STAPI specification. This API provides endpoints for discovering remote " "sensing data products, creating orders, and searching for acquisition opportunities across various " - "remote sensing platforms and sensors." + "remote sensing platforms and sensors. The API follows the STAPI specification for standardized " + "interaction with remote sensing data providers." ), - version="0.1.0", + version=STAPI_VERSION, openapi_tags=[ - {"name": "Core", "description": "Core endpoints for API discovery and metadata"}, - {"name": "Products", "description": "Endpoints for discovering remote sensing data products"}, - {"name": "Orders", "description": "Endpoints for creating and tracking remote sensing data orders"}, + { + "name": "Core", + "description": "Core endpoints for API discovery and metadata", + "externalDocs": { + "description": "STAPI Core Specification", + "url": "https://github.com/stapi-spec/stapi-spec/blob/main/core/README.md", + }, + }, + { + "name": "Products", + "description": "Endpoints for discovering remote sensing data products", + "externalDocs": { + "description": "STAPI Product Specification", + "url": "https://github.com/stapi-spec/stapi-spec/blob/main/product/README.md", + }, + }, + { + "name": "Orders", + "description": "Endpoints for creating and tracking remote sensing data orders", + "externalDocs": { + "description": "STAPI Order Specification", + "url": "https://github.com/stapi-spec/stapi-spec/blob/main/order/README.md", + }, + }, { "name": "Opportunities", "description": "Endpoints for searching remote sensing acquisition opportunities", + "externalDocs": { + "description": "STAPI Opportunity Specification", + "url": "https://github.com/stapi-spec/stapi-spec/blob/main/opportunity/README.md", + }, }, ], + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", ) router = RootRouter() router.add_product( Product( id="{productId}", - title="A product", - description="A product description", - license="CC-BY-4.0", - providers=[Provider(name="A Provider", roles=["producer"], url="https://example.com")], + title="Example Product", + description=( + "This is an example product that demonstrates the STAPI specification. " + "Implementers should replace this with their actual product definitions." + ), + license="proprietary", + providers=[ + Provider( + name="Example Provider", + roles=["producer"], + url="https://example.com/provider", + description="Example provider for demonstration purposes", + ) + ], links=[], + stapi_type="Product", + stapi_version=STAPI_VERSION, + conformsTo=[ + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", + "https://geojson.org/schema/Polygon.json", + ], ) ) + app.include_router(router, prefix="") return app diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 265d778..c18a0e4 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -27,6 +27,8 @@ Queryables, ) +from pystapi_schema_generator import STAPI_BASE_URL, STAPI_EXAMPLE_URL, STAPI_VERSION + if TYPE_CHECKING: from .root_router import RootRouter @@ -60,7 +62,9 @@ def _setup_routes(self) -> None: "all product metadata, including required fields (type, id, title, description, " "license, providers, links) and optional fields (keywords, queryables, parameters, " "properties). The parameters field defines what can be ordered for this product, " - "while the properties field describes inherent characteristics of the product." + "while the properties field describes inherent characteristics of the product. " + "The response includes links to related endpoints such as queryables, order " + "parameters, and conformance information." ), response_model=Product, responses={ @@ -70,14 +74,46 @@ def _setup_routes(self) -> None: "application/json": { "example": { "type": "Product", - "id": "multispectral", - "title": "Multispectral", - "description": "Full color EO image", + "stapi_type": "Product", + "stapi_version": STAPI_VERSION, + "id": "{productId}", + "title": "Example Product", + "description": "Example product for demonstration purposes", "license": "proprietary", "providers": [ - {"name": "Example Provider", "roles": ["producer"], "url": "https://example.com"} + { + "name": "Example Provider", + "roles": ["producer"], + "url": "https://example.com/provider", + "description": "Example provider for demonstration purposes", + } + ], + "conformsTo": [ + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", + "https://geojson.org/schema/Polygon.json", + ], + "links": [ + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}", + }, + { + "rel": "queryables", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/queryables", + }, + { + "rel": "order-parameters", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/order-parameters", + }, + { + "rel": "conformance", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/conformance", + }, ], - "links": [], } } }, @@ -96,7 +132,8 @@ def _setup_routes(self) -> None: "Returns the conformance classes that apply specifically to this product. " "These classes indicate which features and capabilities are supported by " "this product, such as supported geometry types, parameter types, and " - "other product-specific capabilities." + "other product-specific capabilities. The conformance classes help clients " + "understand what operations and parameters are available for this product." ), response_model=Conformance, responses={ @@ -106,8 +143,11 @@ def _setup_routes(self) -> None: "application/json": { "example": { "conformsTo": [ - "https://stapi.example.com/v0.1.0/core", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", "https://geojson.org/schema/Polygon.json", + "https://geojson.org/schema/Point.json", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/opportunities", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/opportunities-async", ] } } @@ -128,7 +168,8 @@ def _setup_routes(self) -> None: "filter opportunities and orders for this product. These queryables define " "the constraints that can be applied when searching for or ordering this " "product, such as cloud cover limits, resolution requirements, or other " - "product-specific parameters." + "product-specific parameters. The schema follows JSON Schema draft-07 and " + "provides detailed information about each queryable property." ), response_model=Queryables, responses={ @@ -138,7 +179,24 @@ def _setup_routes(self) -> None: "application/json": { "example": { "type": "object", - "properties": {"eo:cloud_cover": {"type": "number", "minimum": 0, "maximum": 100}}, + "properties": { + "eo:cloud_cover": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Maximum cloud cover percentage", + }, + "eo:resolution": { + "type": "number", + "minimum": 0, + "description": "Minimum ground resolution in meters", + }, + "datetime": { + "type": "string", + "format": "date-time", + "description": "Acquisition time window", + }, + }, } } }, @@ -157,7 +215,8 @@ def _setup_routes(self) -> None: "Returns a JSON Schema definition of the parameters that can be specified " "when creating an order for this product. These parameters define the " "configurable options for the order, such as delivery format, processing " - "level, or other product-specific options." + "level, or other product-specific options. The schema follows JSON Schema " + "draft-07 and provides detailed information about each parameter." ), response_model=OrderParameters, responses={ @@ -167,7 +226,23 @@ def _setup_routes(self) -> None: "application/json": { "example": { "type": "object", - "properties": {"format": {"type": "string", "enum": ["GeoTIFF", "JPEG2000"]}}, + "properties": { + "format": { + "type": "string", + "enum": ["GeoTIFF", "JPEG2000"], + "description": "Output file format", + }, + "processing_level": { + "type": "string", + "enum": ["L1C", "L2A"], + "description": "Processing level", + }, + "delivery_method": { + "type": "string", + "enum": ["download", "s3", "azure"], + "description": "Delivery method", + }, + }, } } }, @@ -190,7 +265,8 @@ def _setup_routes(self) -> None: "fields (datetime, geometry) and may include optional fields (queryables, " "order_parameters). The datetime field specifies the temporal extent of the " "order, while the geometry field defines its spatial extent. The response " - "is a GeoJSON Feature representing the created order." + "is a GeoJSON Feature representing the created order. The order will be " + "processed according to the specified parameters and constraints." ), response_model=Order[OrderStatus], responses={ @@ -202,10 +278,32 @@ def _setup_routes(self) -> None: "type": "Feature", "id": "order-123", "properties": { - "product_id": "multispectral", + "product_id": "{productId}", "created": "2024-01-01T00:00:00Z", "status": "received", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "order_parameters": { + "format": "GeoTIFF", + "processing_level": "L2A", + "delivery_method": "download", + }, }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123", + }, + { + "rel": "monitor", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123/statuses", + }, + ], } } }, @@ -226,7 +324,9 @@ def _setup_routes(self) -> None: "Returns a collection of orders for this product. Each order is a GeoJSON " "Feature containing the order details, including status, parameters, and " "metadata. The response is a GeoJSON FeatureCollection and includes " - "pagination links for navigating through the order collection." + "pagination links for navigating through the order collection. Orders can " + "be filtered by various parameters and support pagination for efficient " + "retrieval of large result sets." ), response_model=OrderCollection[OrderStatus], responses={ @@ -236,8 +336,30 @@ def _setup_routes(self) -> None: "application/geo+json": { "example": { "type": "FeatureCollection", - "features": [], - "links": [], + "features": [ + { + "type": "Feature", + "id": "order-123", + "properties": { + "product_id": "{productId}", + "created": "2024-01-01T00:00:00Z", + "status": "accepted", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "order_parameters": {"format": "GeoTIFF", "processing_level": "L2A"}, + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + } + ], + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/orders", + } + ], } } }, @@ -251,24 +373,99 @@ def _setup_routes(self) -> None: path="/opportunities", endpoint=self.search_opportunities, methods=["POST"], + response_class=GeoJSONResponse, tags=["Opportunities"], - summary="Search for opportunities for a specific product", + summary="Search for acquisition opportunities", description=( - "Searches for potential acquisition opportunities for this product based on " - "the provided search criteria. The request must include the required fields " - "(datetime, geometry) and may include optional fields (queryables). The " - "response is a collection of opportunities that match the search criteria, " - "each representing a potential acquisition that could fulfill an order." + "Searches for potential acquisition opportunities for this product. The request " + "must include the required fields (datetime, geometry) and may include optional " + "fields (filter). The datetime field specifies the temporal extent of the search, " + "while the geometry field defines its spatial extent. The filter field allows " + "specifying additional constraints using CQL2 JSON. The response is a GeoJSON " + "FeatureCollection containing the matching opportunities. Supports both " + "synchronous and asynchronous search modes." ), - response_model=OpportunityCollection, + response_model=OpportunityCollection[Polygon, OpportunityProperties], responses={ status.HTTP_200_OK: { - "description": "Successful response", + "description": "Successful synchronous response", + "content": { + "application/geo+json": { + "example": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "opp-123", + "properties": { + "product_id": "{productId}", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "eo:cloud_cover": 10, + "eo:resolution": 10, + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + "links": [ + { + "rel": "create-order", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/orders", + "method": "POST", + "body": { + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + }, + } + ], + } + ], + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/opportunities", + } + ], + } + } + }, + }, + status.HTTP_201_CREATED: { + "description": "Asynchronous search initiated", "content": { "application/json": { "example": { - "opportunities": [], - "links": [], + "id": "search-123", + "product_id": "{productId}", + "request": { + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + }, + "status": { + "timestamp": "2024-01-01T00:00:00Z", + "status_code": "running", + "reason_text": "Search in progress", + }, + "links": [ + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/searches/opportunities/search-123", + }, + { + "rel": "monitor", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/searches/opportunities/search-123/statuses", + }, + ], } } }, @@ -279,26 +476,70 @@ def _setup_routes(self) -> None: ) self.add_api_route( - path="/opportunities/{opportunity_collection_id}", + path="/opportunities/{opportunityCollectionId}", endpoint=self.get_opportunity_collection, methods=["GET"], + response_class=GeoJSONResponse, tags=["Opportunities"], - summary="Get details of a specific opportunity collection", + summary="Get opportunity collection for async search", description=( - "Returns detailed information about a specific opportunity collection. The response " - "includes all opportunities in the collection, their properties, and any associated " - "metadata. This endpoint is used to retrieve the results of an asynchronous " - "opportunity search." + "Returns the opportunity collection for an asynchronous search. This endpoint " + "is used to retrieve the results of an asynchronous opportunity search. The " + "response is a GeoJSON FeatureCollection containing the matching opportunities. " + "The collection may be paginated if there are many results." ), response_model=OpportunityCollection[Polygon, OpportunityProperties], responses={ status.HTTP_200_OK: { "description": "Successful response", "content": { - "application/json": { + "application/geo+json": { "example": { - "opportunities": [], - "links": [], + "type": "FeatureCollection", + "id": "opp-col-123", + "features": [ + { + "type": "Feature", + "id": "opp-123", + "properties": { + "product_id": "{productId}", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "eo:cloud_cover": 10, + "eo:resolution": 10, + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + "links": [ + { + "rel": "create-order", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/orders", + "method": "POST", + "body": { + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + }, + } + ], + } + ], + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/opportunities/opp-col-123", + }, + { + "rel": "search-record", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/searches/opportunities/search-123", + }, + ], } } }, @@ -307,23 +548,26 @@ def _setup_routes(self) -> None: }, ) - # Product endpoints def get_product(self) -> Product: + """Get details of this product.""" return None # type: ignore def get_conformance(self) -> Conformance: + """Get conformance classes for this product.""" return None # type: ignore def get_queryables(self) -> Queryables: + """Get queryable properties for this product.""" return None # type: ignore def get_order_parameters(self) -> OrderParameters: + """Get order parameters for this product.""" return None # type: ignore - # Orders endpoints def create_order( self, payload: OrderPayload[OrderParameters], request: Request, response: Response ) -> Order[OrderStatus]: + """Create a new order for this product.""" return None # type: ignore def get_orders( @@ -332,6 +576,7 @@ def get_orders( next: str | None = Query(default=None, description="Token for pagination to the next page of results"), limit: int = Query(default=10, ge=1, le=100, description="Maximum number of orders to return per page"), ) -> OrderCollection[OrderStatus]: + """Get orders for this product.""" return None # type: ignore def get_opportunity_collection( @@ -341,6 +586,7 @@ def get_opportunity_collection( description="Unique identifier of the opportunity collection", example="opp-col-123" ), ) -> OpportunityCollection[Polygon, OpportunityProperties]: + """Get opportunity collection for async search.""" return None # type: ignore def search_opportunities( @@ -352,4 +598,5 @@ def search_opportunities( default=None, description="Preference for synchronous or asynchronous processing" ), ) -> OpportunityCollection[Polygon, OpportunityProperties]: + """Search for acquisition opportunities.""" return None # type: ignore diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py index ed8c0ac..5b9cbd6 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -13,6 +13,8 @@ RootResponse, ) +from pystapi_schema_generator import STAPI_BASE_URL, STAPI_EXAMPLE_URL, STAPI_VERSION + from .product_router import ProductRouter @@ -38,7 +40,9 @@ def _setup_routes(self) -> None: "This endpoint serves as the entry point for API discovery and navigation. " "Returns the STAPI root endpoint response containing the API's metadata: " "a unique identifier, descriptive text, implemented conformance classes, " - "and hypermedia links to available resources and documentation." + "and hypermedia links to available resources and documentation. " + "The response includes links to all available products, orders, and " + "opportunities endpoints." ), response_model=RootResponse, responses={ @@ -49,13 +53,31 @@ def _setup_routes(self) -> None: "example": { "id": "stapi-example", "title": "STAPI API", - "description": "Implementation of the STAPI specification", + "description": "Implementation of the STAPI specification for remote sensing data", "conformsTo": [ - "https://stapi.example.com/v0.1.0/core", - "https://stapi.example.com/v0.1.0/order-statuses", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/order-statuses", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/opportunities", ], "links": [ - {"rel": "self", "type": "application/json", "href": "https://stapi.example.com/"} + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/", + "title": "STAPI API Root", + }, + { + "rel": "products", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products", + "title": "Available Products", + }, + { + "rel": "orders", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/orders", + "title": "Order Management", + }, ], } } @@ -76,7 +98,9 @@ def _setup_routes(self) -> None: "conformance classes are already communicated in the root endpoint, " "OGC API requires this duplicate conformance information at this " "/conformance endpoint. Includes both STAPI-specific conformance classes " - "(e.g., core, order statuses, searches) and relevant OGC conformance classes." + "(e.g., core, order statuses, searches) and relevant OGC conformance classes. " + "This endpoint helps clients understand which features and capabilities " + "are supported by the API implementation." ), response_model=Conformance, responses={ @@ -86,8 +110,12 @@ def _setup_routes(self) -> None: "application/json": { "example": { "conformsTo": [ - "https://stapi.example.com/v0.1.0/core", - "https://stapi.example.com/v0.1.0/order-statuses", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/order-statuses", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/opportunities", + f"{STAPI_BASE_URL}/{STAPI_VERSION}/opportunities-async", + "https://geojson.org/schema/Polygon.json", + "https://geojson.org/schema/Point.json", ] } } @@ -111,7 +139,8 @@ def _setup_routes(self) -> None: "while the properties field describes inherent characteristics of the product " "(e.g., sensor type, frequency band). The response is represented as a GeoJSON " "FeatureCollection and includes pagination links for navigating through the " - "product collection." + "product collection. Products may support different capabilities and parameters, " + "which are indicated by their conformance classes." ), response_model=ProductsCollection, responses={ @@ -123,14 +152,44 @@ def _setup_routes(self) -> None: "products": [ { "type": "Product", - "id": "multispectral", - "title": "Multispectral", - "description": "Full color EO image", + "stapi_type": "Product", + "stapi_version": STAPI_VERSION, + "id": "{productId}", + "title": "Example Product", + "description": "Example product for demonstration purposes", "license": "proprietary", - "links": [], + "providers": [ + { + "name": "Example Provider", + "roles": ["producer"], + "url": "https://example.com/provider", + } + ], + "conformsTo": [ + f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", + "https://geojson.org/schema/Polygon.json", + ], + "links": [ + { + "rel": "self", + "type": "application/json", + "href": "https://stapi.example.com/products/{productId}", + }, + { + "rel": "queryables", + "type": "application/json", + "href": "https://stapi.example.com/products/{productId}/queryables", + }, + ], + } + ], + "links": [ + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products", } ], - "links": [], } } }, @@ -152,7 +211,8 @@ def _setup_routes(self) -> None: "the temporal extent of the order, while the geometry field defines its spatial extent. " "The queryables field contains the constraints specified for the order. The response is " "represented as a GeoJSON FeatureCollection and includes pagination links for navigating " - "through the order collection." + "through the order collection. Orders can be filtered by various parameters and support " + "pagination for efficient retrieval of large result sets." ), response_model=OrderCollection[OrderStatus], responses={ @@ -162,8 +222,29 @@ def _setup_routes(self) -> None: "application/geo+json": { "example": { "type": "FeatureCollection", - "features": [], - "links": [], + "features": [ + { + "type": "Feature", + "id": "order-123", + "properties": { + "product_id": "{productId}", + "created": "2024-01-01T00:00:00Z", + "status": "accepted", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], + }, + } + ], + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/orders", + } + ], } } }, @@ -182,7 +263,8 @@ def _setup_routes(self) -> None: "Returns detailed information about a specific order. The order contains required " "fields (datetime, geometry) defining its temporal and spatial extent, and optional " "fields (queryables) containing the order constraints. The response is represented as " - "a GeoJSON Feature and may include additional metadata and links to related resources." + "a GeoJSON Feature and may include additional metadata and links to related resources. " + "The order status and history can be accessed through the statuses endpoint." ), response_model=Order[OrderStatus], responses={ @@ -194,10 +276,28 @@ def _setup_routes(self) -> None: "type": "Feature", "id": "order-123", "properties": { - "product_id": "multispectral", + "product_id": "{productId}", "created": "2024-01-01T00:00:00Z", "status": "accepted", + "datetime": "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + "order_parameters": {"format": "GeoTIFF", "processing_level": "L2A"}, + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], }, + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123", + }, + { + "rel": "monitor", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123/statuses", + }, + ], } } }, @@ -216,7 +316,8 @@ def _setup_routes(self) -> None: "Returns the history of status changes for a specific order. The response includes " "a chronological list of status updates, each containing the status value, timestamp, " "and any associated message or metadata. Supports pagination through the next and limit " - "parameters to navigate through the status history." + "parameters to navigate through the status history. The status history provides a " + "detailed audit trail of the order's processing and delivery." ), response_model=OrderStatuses[OrderStatus], responses={ @@ -228,11 +329,34 @@ def _setup_routes(self) -> None: "statuses": [ { "timestamp": "2024-01-01T00:00:00Z", + "status_code": "received", + "reason_text": "Order received and validated", + }, + { + "timestamp": "2024-01-01T00:05:00Z", "status_code": "accepted", "reason_text": "Order accepted for processing", + }, + { + "timestamp": "2024-01-02T00:00:00Z", + "status_code": "completed", + "reason_text": "Order completed successfully", + "links": [ + { + "rel": "delivery", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/deliveries/delivery-123", + } + ], + }, + ], + "links": [ + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123/statuses", } ], - "links": [], } } }, @@ -241,34 +365,35 @@ def _setup_routes(self) -> None: }, ) - # Core endpoints def get_root(self) -> RootResponse: + """Get the root endpoint response.""" return None # type: ignore def get_conformance(self) -> Conformance: + """Get the conformance classes supported by this API.""" return None # type: ignore - # Products endpoints def get_products(self) -> ProductsCollection: + """Get the list of available products.""" return None # type: ignore def add_product(self, product: Product, *args: Any, **kwargs: Any) -> None: """Add a product router to the root router.""" - product_router = ProductRouter(product, self, *args, **kwargs) - self.include_router(product_router, prefix=f"/products/{product.id}") - self.product_routers[product.id] = product_router + self.product_routers[product.id] = ProductRouter(product, self, *args, **kwargs) + self.include_router(self.product_routers[product.id], prefix=f"/products/{product.id}") - # Orders endpoints def get_orders( self, next: str | None = Query(default=None, description="Token for pagination to the next page of results"), limit: int = Query(default=10, ge=1, le=100, description="Maximum number of orders to return per page"), ) -> OrderCollection[OrderStatus]: + """Get the list of orders with pagination support.""" return None # type: ignore def get_order( self, order_id: str = Path(alias="orderId", description="Unique identifier of the order", example="order-123") ) -> Order[OrderStatus]: + """Get details of a specific order.""" return None # type: ignore def get_order_statuses( @@ -277,4 +402,5 @@ def get_order_statuses( next: str | None = Query(default=None, description="Token for pagination to the next page of status history"), limit: int = Query(default=10, ge=1, le=100, description="Maximum number of status entries to return per page"), ) -> OrderStatuses[OrderStatus]: + """Get the status history of a specific order.""" return None # type: ignore From 2ddbf4a733913184f5057fee392c5d3c4778e06b Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 22:16:56 +0200 Subject: [PATCH 23/25] ++ --- .../pystapi_schema_generator/application.py | 26 +++++++++++++++---- .../product_router.py | 16 ++++++------ .../pystapi_schema_generator/root_router.py | 19 ++++++-------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 3c7d8b6..8faadf1 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -19,7 +19,10 @@ def create_app() -> FastAPI: openapi_tags=[ { "name": "Core", - "description": "Core endpoints for API discovery and metadata", + "description": ( + "Core endpoints for API discovery and metadata. These endpoints provide " + "essential information about the API's capabilities and available resources." + ), "externalDocs": { "description": "STAPI Core Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/core/README.md", @@ -27,7 +30,11 @@ def create_app() -> FastAPI: }, { "name": "Products", - "description": "Endpoints for discovering remote sensing data products", + "description": ( + "Endpoints for discovering and accessing remote sensing data products. " + "Each product endpoint provides detailed metadata, queryable properties, " + "and order parameters specific to that product." + ), "externalDocs": { "description": "STAPI Product Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/product/README.md", @@ -35,7 +42,11 @@ def create_app() -> FastAPI: }, { "name": "Orders", - "description": "Endpoints for creating and tracking remote sensing data orders", + "description": ( + "Endpoints for creating and managing remote sensing data orders. " + "Supports order creation, status tracking, and delivery management " + "with comprehensive state machine implementation." + ), "externalDocs": { "description": "STAPI Order Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/order/README.md", @@ -43,7 +54,11 @@ def create_app() -> FastAPI: }, { "name": "Opportunities", - "description": "Endpoints for searching remote sensing acquisition opportunities", + "description": ( + "Endpoints for searching remote sensing acquisition opportunities. " + "Supports both synchronous and asynchronous search modes with " + "spatial, temporal, and property-based filtering." + ), "externalDocs": { "description": "STAPI Opportunity Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/opportunity/README.md", @@ -62,7 +77,8 @@ def create_app() -> FastAPI: title="Example Product", description=( "This is an example product that demonstrates the STAPI specification. " - "Implementers should replace this with their actual product definitions." + "Implementers should replace this with their actual product definitions, " + "including specific metadata, queryable properties, and order parameters." ), license="proprietary", providers=[ diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index c18a0e4..8e0441b 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -56,7 +56,7 @@ def _setup_routes(self) -> None: endpoint=self.get_product, methods=["GET"], tags=["Products"], - summary="Get details of a specific product", + summary="Get product details", description=( "Returns detailed information about a specific product. The response includes " "all product metadata, including required fields (type, id, title, description, " @@ -127,7 +127,7 @@ def _setup_routes(self) -> None: endpoint=self.get_conformance, methods=["GET"], tags=["Products"], - summary="Get conformance classes for a specific product", + summary="Get product conformance", description=( "Returns the conformance classes that apply specifically to this product. " "These classes indicate which features and capabilities are supported by " @@ -162,7 +162,7 @@ def _setup_routes(self) -> None: endpoint=self.get_queryables, methods=["GET"], tags=["Products"], - summary="Get queryable properties for a specific product", + summary="Get queryable properties", description=( "Returns a JSON Schema definition of the properties that can be used to " "filter opportunities and orders for this product. These queryables define " @@ -210,7 +210,7 @@ def _setup_routes(self) -> None: endpoint=self.get_order_parameters, methods=["GET"], tags=["Products"], - summary="Get order parameters for a specific product", + summary="Get order parameters", description=( "Returns a JSON Schema definition of the parameters that can be specified " "when creating an order for this product. These parameters define the " @@ -259,7 +259,7 @@ def _setup_routes(self) -> None: response_class=GeoJSONResponse, status_code=status.HTTP_201_CREATED, tags=["Orders"], - summary="Create a new order for a specific product", + summary="Create order", description=( "Creates a new order for this product. The request must include the required " "fields (datetime, geometry) and may include optional fields (queryables, " @@ -319,7 +319,7 @@ def _setup_routes(self) -> None: methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], - summary="Get orders for a specific product", + summary="List product orders", description=( "Returns a collection of orders for this product. Each order is a GeoJSON " "Feature containing the order details, including status, parameters, and " @@ -375,7 +375,7 @@ def _setup_routes(self) -> None: methods=["POST"], response_class=GeoJSONResponse, tags=["Opportunities"], - summary="Search for acquisition opportunities", + summary="Search opportunities", description=( "Searches for potential acquisition opportunities for this product. The request " "must include the required fields (datetime, geometry) and may include optional " @@ -481,7 +481,7 @@ def _setup_routes(self) -> None: methods=["GET"], response_class=GeoJSONResponse, tags=["Opportunities"], - summary="Get opportunity collection for async search", + summary="Get opportunity collection", description=( "Returns the opportunity collection for an asynchronous search. This endpoint " "is used to retrieve the results of an asynchronous opportunity search. The " diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py index 5b9cbd6..5e60323 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -35,14 +35,11 @@ def _setup_routes(self) -> None: self.get_root, methods=["GET"], tags=["Core"], - summary="STAPI root endpoint for API discovery and metadata", + summary="API root", description=( - "This endpoint serves as the entry point for API discovery and navigation. " "Returns the STAPI root endpoint response containing the API's metadata: " "a unique identifier, descriptive text, implemented conformance classes, " - "and hypermedia links to available resources and documentation. " - "The response includes links to all available products, orders, and " - "opportunities endpoints." + "and hypermedia links to available resources and documentation." ), response_model=RootResponse, responses={ @@ -91,7 +88,7 @@ def _setup_routes(self) -> None: self.get_conformance, methods=["GET"], tags=["Core"], - summary="List of implemented STAPI and OGC conformance classes", + summary="API conformance", description=( "Returns a list of conformance classes implemented by this API, following " "the OGC API Features conformance structure. While the core STAPI " @@ -173,12 +170,12 @@ def _setup_routes(self) -> None: { "rel": "self", "type": "application/json", - "href": "https://stapi.example.com/products/{productId}", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}", }, { "rel": "queryables", "type": "application/json", - "href": "https://stapi.example.com/products/{productId}/queryables", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/queryables", }, ], } @@ -204,7 +201,7 @@ def _setup_routes(self) -> None: methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], - summary="List of orders in the system", + summary="List orders", description=( "Returns a collection of orders in the system. Each order contains required fields " "(datetime, geometry) and optional fields (queryables). The datetime field specifies " @@ -258,7 +255,7 @@ def _setup_routes(self) -> None: methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], - summary="Get details of a specific order", + summary="Get order details", description=( "Returns detailed information about a specific order. The order contains required " "fields (datetime, geometry) defining its temporal and spatial extent, and optional " @@ -311,7 +308,7 @@ def _setup_routes(self) -> None: self.get_order_statuses, methods=["GET"], tags=["Orders"], - summary="Get status history of an order", + summary="Get order status history", description=( "Returns the history of status changes for a specific order. The response includes " "a chronological list of status updates, each containing the status value, timestamp, " From 79e62bfdb7900f2cff25c4dafea36e112f11457e Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 22:17:51 +0200 Subject: [PATCH 24/25] + --- .../src/pystapi_schema_generator/root_router.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py index 5e60323..343789c 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -35,11 +35,14 @@ def _setup_routes(self) -> None: self.get_root, methods=["GET"], tags=["Core"], - summary="API root", + summary="STAPI root endpoint for API discovery and metadata", description=( + "This endpoint serves as the entry point for API discovery and navigation. " "Returns the STAPI root endpoint response containing the API's metadata: " "a unique identifier, descriptive text, implemented conformance classes, " - "and hypermedia links to available resources and documentation." + "and hypermedia links to available resources and documentation. " + "The response includes links to all available products, orders, and " + "opportunities endpoints." ), response_model=RootResponse, responses={ From 5568c1accdf413ab5f44042d4ba705cdbfd28643 Mon Sep 17 00:00:00 2001 From: Tobias Rohnstock Date: Mon, 5 May 2025 22:32:05 +0200 Subject: [PATCH 25/25] enough --- .../pystapi_schema_generator/application.py | 31 ++----- .../product_router.py | 76 ++++++--------- .../pystapi_schema_generator/root_router.py | 92 ++++++++++--------- 3 files changed, 81 insertions(+), 118 deletions(-) diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/application.py b/pystapi-schema-generator/src/pystapi_schema_generator/application.py index 8faadf1..b2923b1 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/application.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -10,19 +10,16 @@ def create_app() -> FastAPI: app = FastAPI( title="STAPI API", description=( - "Implementation of the STAPI specification. This API provides endpoints for discovering remote " - "sensing data products, creating orders, and searching for acquisition opportunities across various " - "remote sensing platforms and sensors. The API follows the STAPI specification for standardized " - "interaction with remote sensing data providers." + "The Sensor Tasking API (STAPI) defines a JSON-based web API to query for " + "spatio-temporal analytic and data products derived from remote sensing " + "(satellite or airborne) providers. The specification supports both products " + "derived from new tasking and products from provider archives." ), version=STAPI_VERSION, openapi_tags=[ { "name": "Core", - "description": ( - "Core endpoints for API discovery and metadata. These endpoints provide " - "essential information about the API's capabilities and available resources." - ), + "description": "Core endpoints for API discovery and metadata.", "externalDocs": { "description": "STAPI Core Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/core/README.md", @@ -30,11 +27,7 @@ def create_app() -> FastAPI: }, { "name": "Products", - "description": ( - "Endpoints for discovering and accessing remote sensing data products. " - "Each product endpoint provides detailed metadata, queryable properties, " - "and order parameters specific to that product." - ), + "description": "Endpoints for discovering and accessing remote sensing data products.", "externalDocs": { "description": "STAPI Product Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/product/README.md", @@ -42,11 +35,7 @@ def create_app() -> FastAPI: }, { "name": "Orders", - "description": ( - "Endpoints for creating and managing remote sensing data orders. " - "Supports order creation, status tracking, and delivery management " - "with comprehensive state machine implementation." - ), + "description": "Endpoints for creating and managing remote sensing data orders.", "externalDocs": { "description": "STAPI Order Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/order/README.md", @@ -54,11 +43,7 @@ def create_app() -> FastAPI: }, { "name": "Opportunities", - "description": ( - "Endpoints for searching remote sensing acquisition opportunities. " - "Supports both synchronous and asynchronous search modes with " - "spatial, temporal, and property-based filtering." - ), + "description": "Endpoints for searching remote sensing acquisition opportunities.", "externalDocs": { "description": "STAPI Opportunity Specification", "url": "https://github.com/stapi-spec/stapi-spec/blob/main/opportunity/README.md", diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py index 8e0441b..4a472e0 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -56,15 +56,10 @@ def _setup_routes(self) -> None: endpoint=self.get_product, methods=["GET"], tags=["Products"], - summary="Get product details", + summary="Get product", description=( - "Returns detailed information about a specific product. The response includes " - "all product metadata, including required fields (type, id, title, description, " - "license, providers, links) and optional fields (keywords, queryables, parameters, " - "properties). The parameters field defines what can be ordered for this product, " - "while the properties field describes inherent characteristics of the product. " - "The response includes links to related endpoints such as queryables, order " - "parameters, and conformance information." + "Returns detailed information about a specific product, including its metadata, " + "capabilities, and configuration options." ), response_model=Product, responses={ @@ -73,7 +68,7 @@ def _setup_routes(self) -> None: "content": { "application/json": { "example": { - "type": "Product", + "type": "Collection", "stapi_type": "Product", "stapi_version": STAPI_VERSION, "id": "{productId}", @@ -86,7 +81,13 @@ def _setup_routes(self) -> None: "roles": ["producer"], "url": "https://example.com/provider", "description": "Example provider for demonstration purposes", - } + }, + { + "name": "Example Host", + "roles": ["host"], + "url": "https://example.com/host", + "description": "Example host for demonstration purposes", + }, ], "conformsTo": [ f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", @@ -129,11 +130,8 @@ def _setup_routes(self) -> None: tags=["Products"], summary="Get product conformance", description=( - "Returns the conformance classes that apply specifically to this product. " - "These classes indicate which features and capabilities are supported by " - "this product, such as supported geometry types, parameter types, and " - "other product-specific capabilities. The conformance classes help clients " - "understand what operations and parameters are available for this product." + "Returns the conformance classes that apply specifically to this product, " + "indicating which features and capabilities are supported." ), response_model=Conformance, responses={ @@ -162,14 +160,10 @@ def _setup_routes(self) -> None: endpoint=self.get_queryables, methods=["GET"], tags=["Products"], - summary="Get queryable properties", + summary="Get queryables", description=( - "Returns a JSON Schema definition of the properties that can be used to " - "filter opportunities and orders for this product. These queryables define " - "the constraints that can be applied when searching for or ordering this " - "product, such as cloud cover limits, resolution requirements, or other " - "product-specific parameters. The schema follows JSON Schema draft-07 and " - "provides detailed information about each queryable property." + "Returns a JSON Schema definition of the properties that can be used to filter " + "opportunities and orders for this product." ), response_model=Queryables, responses={ @@ -212,11 +206,8 @@ def _setup_routes(self) -> None: tags=["Products"], summary="Get order parameters", description=( - "Returns a JSON Schema definition of the parameters that can be specified " - "when creating an order for this product. These parameters define the " - "configurable options for the order, such as delivery format, processing " - "level, or other product-specific options. The schema follows JSON Schema " - "draft-07 and provides detailed information about each parameter." + "Returns a JSON Schema definition of the parameters that can be specified when " + "creating an order for this product." ), response_model=OrderParameters, responses={ @@ -261,12 +252,8 @@ def _setup_routes(self) -> None: tags=["Orders"], summary="Create order", description=( - "Creates a new order for this product. The request must include the required " - "fields (datetime, geometry) and may include optional fields (queryables, " - "order_parameters). The datetime field specifies the temporal extent of the " - "order, while the geometry field defines its spatial extent. The response " - "is a GeoJSON Feature representing the created order. The order will be " - "processed according to the specified parameters and constraints." + "Creates a new order for this product using the parameters defined in the product " + "or provided through the opportunities endpoint." ), response_model=Order[OrderStatus], responses={ @@ -321,12 +308,8 @@ def _setup_routes(self) -> None: tags=["Orders"], summary="List product orders", description=( - "Returns a collection of orders for this product. Each order is a GeoJSON " - "Feature containing the order details, including status, parameters, and " - "metadata. The response is a GeoJSON FeatureCollection and includes " - "pagination links for navigating through the order collection. Orders can " - "be filtered by various parameters and support pagination for efficient " - "retrieval of large result sets." + "Returns a collection of orders for this product. The response includes pagination " + "links for navigating through the order collection." ), response_model=OrderCollection[OrderStatus], responses={ @@ -377,13 +360,8 @@ def _setup_routes(self) -> None: tags=["Opportunities"], summary="Search opportunities", description=( - "Searches for potential acquisition opportunities for this product. The request " - "must include the required fields (datetime, geometry) and may include optional " - "fields (filter). The datetime field specifies the temporal extent of the search, " - "while the geometry field defines its spatial extent. The filter field allows " - "specifying additional constraints using CQL2 JSON. The response is a GeoJSON " - "FeatureCollection containing the matching opportunities. Supports both " - "synchronous and asynchronous search modes." + "Explores the opportunities available for this product based on the provided " + "parameters. Supports both synchronous and asynchronous search modes." ), response_model=OpportunityCollection[Polygon, OpportunityProperties], responses={ @@ -483,10 +461,8 @@ def _setup_routes(self) -> None: tags=["Opportunities"], summary="Get opportunity collection", description=( - "Returns the opportunity collection for an asynchronous search. This endpoint " - "is used to retrieve the results of an asynchronous opportunity search. The " - "response is a GeoJSON FeatureCollection containing the matching opportunities. " - "The collection may be paginated if there are many results." + "Returns the opportunity collection for an asynchronous search. The response " + "includes pagination links for navigating through the opportunity collection." ), response_model=OpportunityCollection[Polygon, OpportunityProperties], responses={ diff --git a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py index 343789c..9e7f5ad 100644 --- a/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -35,16 +35,14 @@ def _setup_routes(self) -> None: self.get_root, methods=["GET"], tags=["Core"], - summary="STAPI root endpoint for API discovery and metadata", + summary="API root", description=( - "This endpoint serves as the entry point for API discovery and navigation. " - "Returns the STAPI root endpoint response containing the API's metadata: " - "a unique identifier, descriptive text, implemented conformance classes, " - "and hypermedia links to available resources and documentation. " - "The response includes links to all available products, orders, and " - "opportunities endpoints." + "Returns the STAPI landing page with essential metadata about the API implementation. " + "The response includes a unique identifier, descriptive text, and hypermedia links to " + "related endpoints." ), response_model=RootResponse, + response_description="STAPI landing page with API metadata and links", responses={ status.HTTP_200_OK: { "description": "Successful response", @@ -78,6 +76,12 @@ def _setup_routes(self) -> None: "href": f"{STAPI_EXAMPLE_URL}/orders", "title": "Order Management", }, + { + "rel": "create-order", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/orders", + "title": "Create Order", + }, ], } } @@ -93,16 +97,12 @@ def _setup_routes(self) -> None: tags=["Core"], summary="API conformance", description=( - "Returns a list of conformance classes implemented by this API, following " - "the OGC API Features conformance structure. While the core STAPI " - "conformance classes are already communicated in the root endpoint, " - "OGC API requires this duplicate conformance information at this " - "/conformance endpoint. Includes both STAPI-specific conformance classes " - "(e.g., core, order statuses, searches) and relevant OGC conformance classes. " - "This endpoint helps clients understand which features and capabilities " - "are supported by the API implementation." + "Returns the list of conformance classes implemented by this API. While the core " + "STAPI conformance classes are already communicated in the root endpoint, OGC API " + "requires this duplicate conformance information at this endpoint." ), response_model=Conformance, + response_description="List of implemented conformance classes", responses={ status.HTTP_200_OK: { "description": "Successful response", @@ -130,19 +130,13 @@ def _setup_routes(self) -> None: self.get_products, methods=["GET"], tags=["Products"], - summary="List of available products from the provider", + summary="List products", description=( - "Returns a collection of products offered by the provider. Each product contains " - "required fields (type, id, title, description, license, providers, links) and " - "optional fields (keywords, queryables, parameters, properties). The parameters " - "field defines what can be ordered for each product (e.g., cloud cover limits), " - "while the properties field describes inherent characteristics of the product " - "(e.g., sensor type, frequency band). The response is represented as a GeoJSON " - "FeatureCollection and includes pagination links for navigating through the " - "product collection. Products may support different capabilities and parameters, " - "which are indicated by their conformance classes." + "Returns a collection of products offered by the provider. This endpoint helps " + "users discover which queryables are available for each product." ), response_model=ProductsCollection, + response_description="Collection of available products", responses={ status.HTTP_200_OK: { "description": "Successful response", @@ -151,7 +145,7 @@ def _setup_routes(self) -> None: "example": { "products": [ { - "type": "Product", + "type": "Collection", "stapi_type": "Product", "stapi_version": STAPI_VERSION, "id": "{productId}", @@ -163,7 +157,14 @@ def _setup_routes(self) -> None: "name": "Example Provider", "roles": ["producer"], "url": "https://example.com/provider", - } + "description": "Example provider for demonstration purposes", + }, + { + "name": "Example Host", + "roles": ["host"], + "url": "https://example.com/host", + "description": "Example host for demonstration purposes", + }, ], "conformsTo": [ f"{STAPI_BASE_URL}/{STAPI_VERSION}/core", @@ -180,6 +181,16 @@ def _setup_routes(self) -> None: "type": "application/json", "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/queryables", }, + { + "rel": "order-parameters", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/order-parameters", + }, + { + "rel": "conformance", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/conformance", + }, ], } ], @@ -206,15 +217,11 @@ def _setup_routes(self) -> None: tags=["Orders"], summary="List orders", description=( - "Returns a collection of orders in the system. Each order contains required fields " - "(datetime, geometry) and optional fields (queryables). The datetime field specifies " - "the temporal extent of the order, while the geometry field defines its spatial extent. " - "The queryables field contains the constraints specified for the order. The response is " - "represented as a GeoJSON FeatureCollection and includes pagination links for navigating " - "through the order collection. Orders can be filtered by various parameters and support " - "pagination for efficient retrieval of large result sets." + "Returns a collection of orders in the system. " + "The response includes pagination links for navigating through the collection." ), response_model=OrderCollection[OrderStatus], + response_description="Collection of orders", responses={ status.HTTP_200_OK: { "description": "Successful response", @@ -258,15 +265,12 @@ def _setup_routes(self) -> None: methods=["GET"], response_class=GeoJSONResponse, tags=["Orders"], - summary="Get order details", + summary="Get order", description=( - "Returns detailed information about a specific order. The order contains required " - "fields (datetime, geometry) defining its temporal and spatial extent, and optional " - "fields (queryables) containing the order constraints. The response is represented as " - "a GeoJSON Feature and may include additional metadata and links to related resources. " - "The order status and history can be accessed through the statuses endpoint." + "Returns detailed information about a specific order, including its status, parameters, and metadata." ), response_model=Order[OrderStatus], + response_description="Order details", responses={ status.HTTP_200_OK: { "description": "Successful response", @@ -311,15 +315,13 @@ def _setup_routes(self) -> None: self.get_order_statuses, methods=["GET"], tags=["Orders"], - summary="Get order status history", + summary="Get order statuses", description=( "Returns the history of status changes for a specific order. The response includes " - "a chronological list of status updates, each containing the status value, timestamp, " - "and any associated message or metadata. Supports pagination through the next and limit " - "parameters to navigate through the status history. The status history provides a " - "detailed audit trail of the order's processing and delivery." + "pagination links for navigating through the status history." ), response_model=OrderStatuses[OrderStatus], + response_description="Collection of order status updates", responses={ status.HTTP_200_OK: { "description": "Successful response",