Jak/stapi v0.2.0 - #129
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhnGNojViYvYDevhtz999a
…tion stapi fields
Feature[G, P] from geojson_pydantic types geometry and properties as
optional/nullable. Opportunity never narrowed these, so
model_validate accepted {"geometry": None, ...}, which then skipped
bbox computation in compute_bbox and produced a dump violating the
v0.2.0 spec (geometry and bbox are both REQUIRED, non-null).
Override geometry and properties on Opportunity to use the class's
own bound TypeVars (G, P) as required fields, and drop the
"geometry is not None" guard in compute_bbox now that geometry can
no longer be None.
…yables enforcement
…tives
ConformanceClasses.pattern built a regex without an end anchor, and
callers used re.match (start-anchored only). This let OPPORTUNITIES
("/opportunities") falsely match URIs like ".../opportunities-async",
so a client could report sync-opportunities support against an
async-only server. Adding a trailing "$" to the pattern fixes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reference app's RootRouter construction omitted
get_opportunity_search_record_statuses, so the exported OpenAPI spec
was missing the /searches/opportunities/{search_record_id}/statuses
endpoint and OpportunitySearchStatusCollection schema. The mock
backend already existed and was used by the conftest.py fixture
router; this wires the same mock into the application used for
OpenAPI export so the reference app exercises the full conformance
surface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the OrderPayload, OpportunityPayload, OrderSearchParameters, OrderStatuses, and OpportunitySearchRecords aliases that scaffolded the v0.2.0 migration. Only the spec-aligned names remain: OrderRequest, OpportunityRequest, SearchParameters, OrderStatusCollection, and OpportunitySearchRecordCollection. Breaking change, accepted for this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RhnGNojViYvYDevhtz999a
Harvested from unmerged PR #68 (pystapi-schema-generator): the spec intro as the info description, a contact block, per-tag descriptions with externalDocs links into the spec documents, and top-level externalDocs pointing at the rendered documentation site. Links are updated for the current spec repo layout and tag names match the actual router tags. Note openapi_extra is a path-operation parameter, not an application one, so top-level externalDocs is injected via an openapi wrapper. Also drop an inert conformsTo kwarg mistakenly passed to Provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the generic reference FastAPI app and OpenAPI post-processing logic from scripts/openapi_app.py + scripts/export-openapi into a new stapi_fastapi.reference_app module, exposing create_reference_app(), export_openapi(), and main(). Add an optional "export" extra (pyyaml) and a stapi-fastapi-export-openapi console script so the tool ships with the package. scripts/export-openapi is now a thin shim delegating to the packaged main(). Add tests/test_reference_app.py asserting export invariants (path inventory, no leaked example-product paths, productId param injection, info/externalDocs, key component schemas, determinism) so these run under the normal test/type gates. types-pyyaml added to the root dev group to keep mypy clean on the new module. Verified the export is byte-identical to the committed spec/openapi.yaml before and after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The framework wheel no longer ships the reference app or a console script. Move the generic reference FastAPI app and OpenAPI export CLI out of stapi-fastapi into a new thin workspace member, pystapi-schema-generator, which realizes the package structure proposed in unmerged PR #68 but wires it against the real stapi_fastapi routers (no forked routers). The package imports pyyaml unconditionally at module scope, since export is its entire purpose, dropping the lazy-import/extra dance stapi-fastapi needed as a general-purpose framework. scripts/export-openapi is now a shim delegating to pystapi_schema_generator.application.main. Wired the new member into the root workspace, dependencies, and mypy file list, and into scripts/run-tests.sh's per-package test loop. Verified the exported openapi.yaml is still byte-identical to the committed stapi-spec artifact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spec's Search Parameters Object datetime allows open ends via '..' or an empty string, with only singly-open intervals permitted. Adds OpenDatetimeInterval; OpportunityProperties keeps the closed DatetimeInterval since an opportunity window is concrete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nable Spec frames statuses as extensible: providers may support additional statuses through extensions. status_code is now enum-or-string (known codes still validate to the enum), and OrderStatus / OpportunitySearchStatus are generic so implementations can constrain the accepted set with their own StrEnum, e.g. OrderStatus[MyCodes]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- bbox non-nullable and serialization-required on Order/Opportunity - spec-REQUIRED defaulted fields (type, stapi_type, stapi_version, links, conformsTo, ...) marked required in serialization schemas - Link schema no longer degrades to a bare object in serialization - stored order requests and search parameters round-trip unknown fields - Opportunity id is string-only; collections omit null id - Product.description required per spec - numberMatched available on all collections - clear error for bbox computation on empty geometries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- products advertise sync/async opportunity classes per actual capability - search records carry rel=monitor links when statuses endpoint exists - landing page uses spec rel search-records - Preference-Applied always sent when a preference was specified - statuses endpoint and conformance gated on async search support - Location headers and correct media types documented in OpenAPI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- opportunity capability checks consult the product's conformsTo per spec, not the root document - conformance URI patterns anchor the version as a single path segment - enum gains API-level extension classes (order-statuses, searches-opportunity, searches-opportunity-statuses) - fixtures model a spec-compliant server Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- generic deterministic operationIds; no concrete-product leakage - readable component schema names; orphan BaseModel eliminated - conformance URI example on GET /conformance - exported doc verified to carry required-field, Location-header, media-type, and extensible-status fixes from the model/router work - full schema-inventory and subprocess determinism invariant tests - bound stapi-fastapi>=0.9.0; license/authors metadata Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the workspace to STAPI v0.2.0 across the Pydantic models, FastAPI server, and Python client, and adds a new pystapi-schema-generator package to export a clean, generic OpenAPI document.
Changes:
- Bump STAPI versions and align server/client behavior and conformance URI handling for v0.2.0.
- Restructure request/response models (notably
SearchParameters,OrderRequest, async opportunity search records/status collections) and tighten JSON-schema serialization requirements. - Add a standalone schema generator package + script to export a deterministic OpenAPI YAML with cleaned schema names/operationIds.
Reviewed changes
Copilot reviewed 47 out of 49 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds new workspace package and dependency updates (incl. pyyaml/types). |
| stapi-pydantic/tests/test_shared.py | Adds schema/serialization tests for shared models (Link/RootResponse/Conformance). |
| stapi-pydantic/tests/test_search_parameters.py | Adds tests for open-ended datetime intervals and SearchParameters behavior. |
| stapi-pydantic/tests/test_product.py | Adds/updates tests for v0.2.0 product models and required fields. |
| stapi-pydantic/tests/test_order.py | Expands tests for order models (status code extensibility, bbox computation, stored requests). |
| stapi-pydantic/tests/test_opportunity.py | Expands tests for opportunity request/record/status models and bbox/schema requirements. |
| stapi-pydantic/tests/test_filter.py | Adds tests for CQL2 property-name extraction helper. |
| stapi-pydantic/src/stapi_pydantic/shared.py | Introduces reusable NumberMatched annotated field and updates Link serialization behavior. |
| stapi-pydantic/src/stapi_pydantic/search_parameters.py | Adds SearchParameters model shared by Order/Opportunity requests. |
| stapi-pydantic/src/stapi_pydantic/root.py | Forces defaults to be required in serialization schema for RootResponse. |
| stapi-pydantic/src/stapi_pydantic/product.py | Updates Product/ProductsCollection fields to v0.2.0 shape (stapi_type/version, required description, numberMatched). |
| stapi-pydantic/src/stapi_pydantic/order.py | Refactors order request/storage shapes, adds bbox computation, introduces status collections and generic status code handling. |
| stapi-pydantic/src/stapi_pydantic/opportunity.py | Refactors opportunity request/search record/status models; adds bbox computation and collections. |
| stapi-pydantic/src/stapi_pydantic/geometry.py | Adds bbox computation helper used by Order/Opportunity models. |
| stapi-pydantic/src/stapi_pydantic/filter.py | Adds cql2_property_names traversal utility. |
| stapi-pydantic/src/stapi_pydantic/datetime_interval.py | Adds singly-open datetime interval parsing/serialization for SearchParameters. |
| stapi-pydantic/src/stapi_pydantic/constants.py | Bumps STAPI_VERSION to 0.2.0. |
| stapi-pydantic/src/stapi_pydantic/conformance.py | Adjusts conformance model schema requirements for serialization. |
| stapi-pydantic/src/stapi_pydantic/init.py | Re-exports new/renamed models and utilities for v0.2.0. |
| stapi-pydantic/pyproject.toml | Bumps package version and adds typing-extensions dependency. |
| stapi-fastapi/tests/test_product.py | Updates server response assertions to new product collection shape. |
| stapi-fastapi/tests/test_order.py | Updates tests for new order request shape, required-filter enforcement, and status collection responses. |
| stapi-fastapi/tests/test_opportunity.py | Adds test ensuring required queryable predicates are enforced for opportunity search. |
| stapi-fastapi/tests/test_opportunity_async.py | Adds/updates async opportunity search tests (monitor links, statuses collection, conformance behavior, Prefer handling). |
| stapi-fastapi/tests/shared.py | Updates test products’ conformsTo behavior to rely on router-derived conformances. |
| stapi-fastapi/tests/conftest.py | Adjusts fixtures to new opportunity search body shape and stops force-overriding product conformsTo. |
| stapi-fastapi/tests/backends.py | Updates mock backends for new request/record shapes and fixes opportunity geometry reflection. |
| stapi-fastapi/tests/application.py | Wires the async search-record-statuses backend in the test application. |
| stapi-fastapi/src/stapi_fastapi/routers/root_router.py | Renames/extends async-search links, returns collections for statuses/records, and gates statuses endpoint correctly. |
| stapi-fastapi/src/stapi_fastapi/routers/product_router.py | Updates payload models, adds required-queryables validation, and improves OpenAPI response metadata. |
| stapi-fastapi/src/stapi_fastapi/errors.py | Changes QueryablesError to HTTP 400. |
| stapi-fastapi/src/stapi_fastapi/backends/product_backend.py | Updates backend type aliases for new payload/request models. |
| stapi-fastapi/pyproject.toml | Bumps stapi-fastapi version to 0.9.0. |
| scripts/run-tests.sh | Includes pystapi-schema-generator in the test runner loop. |
| scripts/export-openapi | Adds shim script to export OpenAPI YAML via the new generator package. |
| pystapi-schema-generator/tests/test_application.py | Adds snapshot/invariant tests for exported OpenAPI (paths, schemas, determinism, cleanliness). |
| pystapi-schema-generator/src/pystapi_schema_generator/py.typed | Marks package as typed. |
| pystapi-schema-generator/src/pystapi_schema_generator/application.py | Implements reference app + OpenAPI post-processing (clean ids/names, dedup, templated product paths, examples). |
| pystapi-schema-generator/src/pystapi_schema_generator/init.py | Exposes generator entrypoints. |
| pystapi-schema-generator/README.md | Documents CLI usage. |
| pystapi-schema-generator/pyproject.toml | Defines the new package, dependencies, and console script. |
| pystapi-client/tests/test_client.py | Adds tests for updated conformance URI patterns and product-scoped opportunity capability checks. |
| pystapi-client/tests/fixtures/products.json | Updates fixtures for v0.2.0 product shapes and per-product conformsTo inventory. |
| pystapi-client/tests/fixtures/landing_page.json | Updates root conformance URIs to v0.2.0 API-level classes only. |
| pystapi-client/tests/conftest.py | Mocks per-product GET endpoints for product conformance capability checks. |
| pystapi-client/src/pystapi_client/conformance.py | Updates conformance class inventory and tightens URI regex patterns. |
| pystapi-client/src/pystapi_client/client.py | Switches opportunity support checks to product-scoped conformsTo and updates request models. |
| pystapi-client/pyproject.toml | Bumps pystapi-client version to 0.0.2. |
| pyproject.toml | Adds new workspace member/package and updates dev dependencies and mypy file set. |
Comments suppressed due to low confidence (1)
stapi-pydantic/src/stapi_pydantic/order.py:97
- OrderStatus.new currently instantiates OrderStatus directly, which bypasses the calling class's generic parameterization/subclassing (e.g. OrderStatus[NarrowCodes]) and can allow values that should be rejected. Use cls(...) so constraints are applied consistently.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore | ||
| def validate_required_queryables(self, search_parameters: SearchParameters) -> None: | ||
| required = set(self.product.queryables.model_json_schema().get("required", [])) |
There was a problem hiding this comment.
Seems the required fields list should be cached? And if no fields are required this method should short circuit, no?
There was a problem hiding this comment.
Maybe this should be a method on the product or its queryables rather than this router?
| [sys.executable, "-m", "pystapi_schema_generator.application"], | ||
| capture_output=True, | ||
| check=True, | ||
| env={"PYTHONHASHSEED": seed, "PATH": os.environ.get("PATH", "")}, |
| DatetimeInterval = Annotated[ | ||
| tuple[AwareDatetime, AwareDatetime], | ||
| BeforeValidator(validate_before), | ||
| AfterValidator(validate_after), | ||
| WrapSerializer(serialize, return_type=str), | ||
| WithJsonSchema({"type": "string"}), | ||
| ] | ||
|
|
||
| # Interval that may be open (via ``..`` or an empty string) on at most one | ||
| # end, per the Search Parameters Object datetime definition. | ||
| OpenDatetimeInterval = Annotated[ | ||
| tuple[AwareDatetime | None, AwareDatetime | None], | ||
| BeforeValidator(validate_open_before), | ||
| AfterValidator(validate_open_after), | ||
| WrapSerializer(serialize_open, return_type=str), | ||
| WithJsonSchema({"type": "string"}), | ||
| ] |
There was a problem hiding this comment.
Why two types here? Shouldn't this just be DatetimeInterval, which should support open intervals?
There was a problem hiding this comment.
Seems like collapsing these types would also allow collapsing the validation functions to some degree.
| def search_body(self) -> dict[str, Any]: | ||
| return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) | ||
| return self.model_dump(mode="json", include={"search_parameters"}) | ||
|
|
||
| def body(self) -> dict[str, Any]: | ||
| return self.model_dump(mode="json") |
There was a problem hiding this comment.
What is the difference between these two? Where are they used?
| # Per spec, whenever the client sent a Prefer header the server must | ||
| # respond with Preference-Applied indicating the mode actually applied. | ||
| # This branch always applies the sync ("wait") mode. |
|
|
||
| async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore | ||
| def validate_required_queryables(self, search_parameters: SearchParameters) -> None: | ||
| required = set(self.product.queryables.model_json_schema().get("required", [])) |
There was a problem hiding this comment.
Seems the required fields list should be cached? And if no fields are required this method should short circuit, no?
|
|
||
| async def create_order(self, payload: OrderPayload, request: Request, response: Response) -> Order: # type: ignore | ||
| def validate_required_queryables(self, search_parameters: SearchParameters) -> None: | ||
| required = set(self.product.queryables.model_json_schema().get("required", [])) |
There was a problem hiding this comment.
Maybe this should be a method on the product or its queryables rather than this router?
| def _build_client(base_url: str = "http://stapiserver", **root_router_kwargs: Any) -> TestClient: | ||
| """Build a test client with an app whose root router is configured explicitly.""" | ||
|
|
||
| @asynccontextmanager | ||
| async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: | ||
| yield { | ||
| "_orders_db": InMemoryOrderDB(), | ||
| "_opportunities_db": InMemoryOpportunityDB(), | ||
| "_opportunities": [create_mock_opportunity()], | ||
| } | ||
|
|
||
| root_router = RootRouter( | ||
| get_orders=mock_get_orders, | ||
| get_order=mock_get_order, | ||
| get_order_statuses=mock_get_order_statuses, | ||
| conformances=[API.core], | ||
| **root_router_kwargs, | ||
| ) | ||
| root_router.add_product(product_test_spotlight_async_opportunity) | ||
|
|
||
| app = FastAPI(lifespan=lifespan) | ||
| app.include_router(root_router, prefix="") | ||
| return TestClient(app, base_url=base_url) | ||
|
|
||
|
|
||
| def _build_async_client(with_statuses: bool, base_url: str = "http://stapiserver") -> TestClient: | ||
| """Async-capable client, optionally with the statuses backend wired.""" | ||
|
|
||
| @asynccontextmanager | ||
| async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: | ||
| yield { | ||
| "_orders_db": InMemoryOrderDB(), | ||
| "_opportunities_db": InMemoryOpportunityDB(), | ||
| "_opportunities": [create_mock_opportunity()], | ||
| } | ||
|
|
||
| kwargs: dict[str, Any] = {} | ||
| if with_statuses: | ||
| kwargs["get_opportunity_search_record_statuses"] = mock_get_opportunity_search_record_statuses | ||
|
|
||
| root_router = RootRouter( | ||
| get_orders=mock_get_orders, | ||
| get_order=mock_get_order, | ||
| get_order_statuses=mock_get_order_statuses, | ||
| get_opportunity_search_records=mock_get_opportunity_search_records, | ||
| get_opportunity_search_record=mock_get_opportunity_search_record, | ||
| conformances=[API.core], | ||
| **kwargs, | ||
| ) | ||
| root_router.add_product(product_test_spotlight_async_opportunity) | ||
|
|
||
| app = FastAPI(lifespan=lifespan) | ||
| app.include_router(root_router, prefix="") | ||
| return TestClient(app, base_url=base_url) |
There was a problem hiding this comment.
Don't these duplicate functionality already present elsewhere? Couldn't the config of the app be configured using some pytest marks or something, providing products/root router behaviors in a more composible/reusable manner?
| value: tuple[datetime | None, datetime | None], | ||
| ) -> tuple[datetime | None, datetime | None]: | ||
| if value[0] is None and value[1] is None: | ||
| raise ValueError("only singly-open intervals are allowed") |
There was a problem hiding this comment.
Why? Where did this requirement come from? Why can't we support ../..?
| raise ValueError("cannot compute bbox: geometry has no coordinates") | ||
| lons = [c[0] for c in coords] | ||
| lats = [c[1] for c in coords] | ||
| if all(len(c) >= 3 for c in coords): |
There was a problem hiding this comment.
Does this work with M coordinates, or could it accidentally conflate M coords as Z?
| stapi_version: str = STAPI_VERSION | ||
| # geojson-pydantic excludes bbox-when-None via a custom serializer schema | ||
| # gen can't see; override with a schema-visible exclude_if. | ||
| bbox: BBox | None = Field(default=None, exclude_if=lambda v: v is None) |
There was a problem hiding this comment.
Should this be computed from the opportunity bboxes? Seems like this would also apply to other collection models (OrderCollection? SearchRecordCollection?)
| # bbox is spec-REQUIRED; non-nullable annotation makes the schema | ||
| # non-nullable in both modes, the config makes it serialization-required, | ||
| # and the compute_bbox after-validator fills the computed default. | ||
| bbox: BBox = cast(BBox, None) |
There was a problem hiding this comment.
I'm not sure this makes sense, and it leads to a type error (Variable is mutable so its type is invariant). Perhaps the spec should simply require bbox, and here we just go with the BBox | None annotation anyway? That might make the openapi docs subtly misaligned, however...hmm, I'm not sure about this one.
What I'm changing
How I did it
Checklist
./scripts/run-tests.shuv run pre-commit run --all-files