diff --git a/pyproject.toml b/pyproject.toml index 0d227cf..53af378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" dependencies = [ "pystapi-client", "pystapi-validator", + "pystapi-schema-generator", "stapi-pydantic", "stapi-fastapi", ] @@ -34,11 +35,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 @@ -66,6 +74,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..2bcabfa --- /dev/null +++ b/pystapi-schema-generator/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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..d170144 --- /dev/null +++ b/pystapi-schema-generator/README.md @@ -0,0 +1 @@ +# pystapi-schema-generator diff --git a/pystapi-schema-generator/pyproject.toml b/pystapi-schema-generator/pyproject.toml new file mode 100644 index 0000000..f6c2a0c --- /dev/null +++ b/pystapi-schema-generator/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "pystapi-schema-generator" +version = "0.0.1" +description = "Schema Generator for the Satellite Tasking API (STAPI) Specification" +readme = "README.md" +authors = [ + { name = "Tobias Rohnstock", email = "tobias.rohnstock@live-eo.com" }, + { name = "Justin Trautmann", email = "justin@live-eo.com" }, +] +requires-python = ">=3.11" +dependencies = [ + "uvicorn>=0.34", + "fastapi>=0.115", + "pydantic>=2.10", + "PyYAML>=6", + "types-PyYAML>=6", + "geojson-pydantic>=1.2", + "stapi-pydantic>=0.0.3", +] + +[project.scripts] +stapi-schema-generator = "pystapi_schema_generator.application:main" + +[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..677f9b6 --- /dev/null +++ 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 new file mode 100644 index 0000000..60fe68e --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -0,0 +1,142 @@ +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 + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application for OpenAPI spec generation.""" + app = FastAPI( + title="STAPI API", + description=( + "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.", + "externalDocs": { + "description": "STAPI Core Specification", + "url": "https://github.com/stapi-spec/stapi-spec/blob/main/core/README.md", + }, + }, + { + "name": "Products", + "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", + }, + }, + { + "name": "Orders", + "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", + }, + }, + { + "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", + # Enhanced OpenAPI configuration + openapi_extra={ + "info": { + "contact": { + "name": "STAPI Specification Organization", + "url": "https://github.com/stapi-spec", + } + }, + "externalDocs": { + "description": "STAPI Specification Documentation", + "url": "https://github.com/stapi-spec/stapi-spec", + }, + }, + # Swagger UI customization + swagger_ui_parameters={ + "deepLinking": True, + "docExpansion": "list", # list endpoints but details are collapsed + "defaultModelsExpandDepth": 0, # Show schemas at the bottom but collapsed + "supportedSubmitMethods": [], # Disable all submit methods to prevent "Try it out" + }, + # ReDoc customization + redoc_ui_parameters={ + # TODO: Add Redoc customization parameters here if needed + }, + ) + + router = RootRouter() + router.add_product( + Product( + id="{productId}", + title="Example Product", + description=( + "This is an example product that demonstrates the STAPI specification. " + "Implementers should replace this with their actual product definitions, " + "including specific metadata, queryable properties, and order parameters." + ), + 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 + + +# Create the FastAPI application instance +app = create_app() + + +def main() -> None: + """Generate OpenAPI schema for STAPI.""" + 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/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..4a472e0 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +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, + OrderPayload, + OrderStatus, + Prefer, + Product, + Queryables, +) + +from pystapi_schema_generator import STAPI_BASE_URL, STAPI_EXAMPLE_URL, STAPI_VERSION + +if TYPE_CHECKING: + from .root_router import RootRouter + + +class ProductRouter(APIRouter): + """Router for product-specific endpoints.""" + + def __init__( + self, + product: Product, + root_router: RootRouter, + *args: Any, + **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="", + endpoint=self.get_product, + methods=["GET"], + tags=["Products"], + summary="Get product", + description=( + "Returns detailed information about a specific product, including its metadata, " + "capabilities, and configuration options." + ), + response_model=Product, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "type": "Collection", + "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/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", + "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", + }, + ], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + self.add_api_route( + path="/conformance", + endpoint=self.get_conformance, + methods=["GET"], + tags=["Products"], + summary="Get product conformance", + description=( + "Returns the conformance classes that apply specifically to this product, " + "indicating which features and capabilities are supported." + ), + response_model=Conformance, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "conformsTo": [ + 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", + ] + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + self.add_api_route( + path="/queryables", + endpoint=self.get_queryables, + methods=["GET"], + tags=["Products"], + summary="Get queryables", + description=( + "Returns a JSON Schema definition of the properties that can be used to filter " + "opportunities and orders for this product." + ), + 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, + "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", + }, + }, + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + self.add_api_route( + path="/order-parameters", + endpoint=self.get_order_parameters, + methods=["GET"], + 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." + ), + response_model=OrderParameters, + responses={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/json": { + "example": { + "type": "object", + "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", + }, + }, + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + # Orders endpoints + self.add_api_route( + path="/orders", + endpoint=self.create_order, + methods=["POST"], + response_class=GeoJSONResponse, + status_code=status.HTTP_201_CREATED, + tags=["Orders"], + summary="Create order", + description=( + "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={ + status.HTTP_201_CREATED: { + "description": "Order created successfully", + "content": { + "application/geo+json": { + "example": { + "type": "Feature", + "id": "order-123", + "properties": { + "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", + }, + ], + } + } + }, + }, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid order request"}, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + self.add_api_route( + path="/orders", + endpoint=self.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="List product orders", + description=( + "Returns a collection of orders for this product. The response 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": [ + { + "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", + } + ], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + # Opportunities endpoints + self.add_api_route( + path="/opportunities", + endpoint=self.search_opportunities, + methods=["POST"], + response_class=GeoJSONResponse, + tags=["Opportunities"], + summary="Search opportunities", + description=( + "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={ + status.HTTP_200_OK: { + "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": { + "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", + }, + ], + } + } + }, + }, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid search request"}, + status.HTTP_404_NOT_FOUND: {"description": "Product not found"}, + }, + ) + + self.add_api_route( + path="/opportunities/{opportunityCollectionId}", + endpoint=self.get_opportunity_collection, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Opportunities"], + summary="Get opportunity collection", + description=( + "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={ + status.HTTP_200_OK: { + "description": "Successful response", + "content": { + "application/geo+json": { + "example": { + "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", + }, + ], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Opportunity collection not found"}, + }, + ) + + 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 + + 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( + 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]: + """Get orders for this product.""" + return None # 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]: + """Get opportunity collection for async search.""" + return None # type: ignore + + def search_opportunities( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None = Query( + 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/py.typed b/pystapi-schema-generator/src/pystapi_schema_generator/py.typed new file mode 100644 index 0000000..e69de29 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..9e7f5ad --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/root_router.py @@ -0,0 +1,408 @@ +from typing import Any, ClassVar + +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 pystapi_schema_generator import STAPI_BASE_URL, STAPI_EXAMPLE_URL, STAPI_VERSION + +from .product_router import ProductRouter + + +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._setup_routes() + + def _setup_routes(self) -> None: + """Set up all routes for the root router.""" + # Core endpoints + self.add_api_route( + "/", + self.get_root, + methods=["GET"], + tags=["Core"], + summary="API root", + description=( + "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", + "content": { + "application/json": { + "example": { + "id": "stapi-example", + "title": "STAPI API", + "description": "Implementation of the STAPI specification for remote sensing data", + "conformsTo": [ + 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": 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", + }, + { + "rel": "create-order", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products/{{productId}}/orders", + "title": "Create Order", + }, + ], + } + } + }, + } + }, + ) + + self.add_api_route( + "/conformance", + self.get_conformance, + methods=["GET"], + tags=["Core"], + summary="API conformance", + description=( + "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", + "content": { + "application/json": { + "example": { + "conformsTo": [ + 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", + ] + } + } + }, + } + }, + ) + + # Products endpoints + self.add_api_route( + "/products", + self.get_products, + methods=["GET"], + tags=["Products"], + summary="List products", + description=( + "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", + "content": { + "application/json": { + "example": { + "products": [ + { + "type": "Collection", + "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/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", + "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": [ + { + "rel": "self", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/products", + } + ], + } + } + }, + } + }, + ) + + # Orders endpoints + self.add_api_route( + "/orders", + self.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="List orders", + description=( + "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", + "content": { + "application/geo+json": { + "example": { + "type": "FeatureCollection", + "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", + } + ], + } + } + }, + } + }, + ) + + self.add_api_route( + "/orders/{orderId}", + self.get_order, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="Get order", + description=( + "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", + "content": { + "application/geo+json": { + "example": { + "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}/orders/order-123", + }, + { + "rel": "monitor", + "type": "application/json", + "href": f"{STAPI_EXAMPLE_URL}/orders/order-123/statuses", + }, + ], + } + } + }, + }, + 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 order statuses", + description=( + "Returns the history of status changes for a specific order. The response includes " + "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", + "content": { + "application/json": { + "example": { + "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", + } + ], + } + } + }, + }, + status.HTTP_404_NOT_FOUND: {"description": "Order not found"}, + }, + ) + + 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 + + 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.""" + self.product_routers[product.id] = ProductRouter(product, self, *args, **kwargs) + self.include_router(self.product_routers[product.id], prefix=f"/products/{product.id}") + + 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( + 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]: + """Get the status history of a specific order.""" + return None # type: ignore diff --git a/uv.lock b/uv.lock index d345226..aa30d29 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.11" members = [ "pystapi", "pystapi-client", + "pystapi-schema-generator", "pystapi-validator", "stapi-fastapi", "stapi-pydantic", @@ -1513,6 +1514,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "pystapi-client" }, + { name = "pystapi-schema-generator" }, { name = "pystapi-validator" }, { name = "stapi-fastapi" }, { name = "stapi-pydantic" }, @@ -1539,6 +1541,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" }, @@ -1582,6 +1585,31 @@ requires-dist = [ { name = "stapi-pydantic", editable = "stapi-pydantic" }, ] +[[package]] +name = "pystapi-schema-generator" +version = "0.0.1" +source = { editable = "pystapi-schema-generator" } +dependencies = [ + { name = "fastapi" }, + { name = "geojson-pydantic" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "stapi-pydantic" }, + { name = "types-pyyaml" }, + { name = "uvicorn" }, +] + +[package.metadata] +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" }, +] + [[package]] name = "pystapi-validator" version = "0.1.0" @@ -2284,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"