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..ae02a6f --- /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.10" +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..e69de29 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..88cf4a7 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/application.py @@ -0,0 +1,60 @@ +from fastapi import FastAPI +from stapi_pydantic import Product + +from pystapi_schema_generator.router import RootRouter + +router = RootRouter() +router.add_product( + Product( + id="{productId}", + description="An example product", + license="CC-BY-4.0", + 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="") + + +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/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..b9b346a --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/product_router.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from fastapi import ( + APIRouter, + Request, + Response, + status, +) +from stapi_fastapi.responses import GeoJSONResponse +from stapi_pydantic import ( + Conformance, + OpportunityCollection, + OpportunityPayload, + Order, + OrderCollection, + OrderParameters, + OrderPayload, + OrderStatus, + Prefer, + Product, + Queryables, +) + +if TYPE_CHECKING: + from .router import RootRouter + + +class ProductRouter(APIRouter): + 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 + + # Product endpoints + 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="/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_queryables, + methods=["GET"], + tags=["Products"], + summary="describe the queryables for a product", + description="...", + ) + + self.add_api_route( + path="/order-parameters", + endpoint=self.get_order_parameters, + methods=["GET"], + tags=["Products"], + summary="describe the order parameters for a product", + description="...", + ) + + # 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 a new order for product with id `productId`", + description="...", + ) + + self.add_api_route( + path="/orders", + endpoint=self.get_orders, + methods=["GET"], + response_class=GeoJSONResponse, + tags=["Orders"], + summary="get a list of orders for the specific product", + description="...", + ) + + # 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="...", + ) + + # Product endpoints + def get_product(self) -> Product: + return None # type: ignore + + def get_conformance(self) -> Conformance: + return None # type: ignore + + def get_queryables(self) -> Queryables: + return None # type: ignore + + 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 + + def get_orders(self, request: Request, next: str | None = None, limit: int = 10) -> OrderCollection[OrderStatus]: + return None # type: ignore + + def get_opportunity_collection(self, opportunity_collection_id: str, request: Request) -> OpportunityCollection: # type: ignore + return None # type: ignore + + def search_opportunities( + self, + search: OpportunityPayload, + request: Request, + response: Response, + prefer: Prefer | None, + ) -> OpportunityCollection: # type: ignore + 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/router.py b/pystapi-schema-generator/src/pystapi_schema_generator/router.py new file mode 100644 index 0000000..0891ff7 --- /dev/null +++ b/pystapi-schema-generator/src/pystapi_schema_generator/router.py @@ -0,0 +1,121 @@ +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 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"