Skip to content

STAPI v0.2.0 alignment - #129

Open
jkeifer wants to merge 48 commits into
mainfrom
jak/stapi-v0.2.0
Open

STAPI v0.2.0 alignment#129
jkeifer wants to merge 48 commits into
mainfrom
jak/stapi-v0.2.0

Conversation

@jkeifer

@jkeifer jkeifer commented Jul 24, 2026

Copy link
Copy Markdown
Member

Aligns stapi-pydantic and stapi-fastapi with STAPI v0.2.0. Two breaking releases: stapi-pydantic 0.2.0 and stapi-fastapi 0.9.0. pystapi-client and pystapi-validator both get updates because of their dependencies on one or both of the stapi-* libs (these changes were not reviewed closely due to the immaturity of both of these components).

#130 contains a follow-on commit to add pystapi-schema-generator. I don't know that we actually want that, but I had it so I pushed it for consideration.

What I'm changing

Models (stapi-pydantic 0.2.0)

  • Request shapes follow the spec: OpportunityRequest and OrderRequest (was *Payload) compose a shared SearchParameters instead of each declaring datetime/geometry/filter. An opportunity search body is now a valid order request body.
  • Response entities carry stapi_type/stapi_version, a required geometry-derived bbox, and numberMatched on every collection.
  • OrderProperties holds one order_request (a StoredOrderRequest) in place of three loosely-related fields; OpportunitySearchRecord records the search_parameters searched for rather than the request body that carried them.
  • Renames to match the spec: ProductsCollectionProductCollection, OrderStatusesOrderStatusCollection, OpportunitySearchRecordsOpportunitySearchRecordCollection. The pre-0.2.0 compatibility aliases are gone.
  • DatetimeInterval permits a singly-open interval; BoundedDatetimeInterval is the both-ends form. Status codes are extension-tolerant and constrainable (OrderStatus[MyCodes]). Geometry is the six types the spec enumerates.

Routers (stapi-fastapi 0.9.0)

  • Every list backend returns a Page instead of one of four differently-shaped tuples, so every collection response is assembled the same way and can publish numberMatched.
  • A route is a declarative Route with a required summary, tag and errors set, so an operation cannot be published without a title, a heading, or an accurate statement of how it can fail.
  • Path parameters are camelCase as the spec documents them; limit is bounded and published; the search-record-statuses and stored-opportunity-collection endpoints are paginated.
  • Conformance is derived from what is actually served rather than declared, so the landing page can no longer advertise a class whose routes were never registered.
  • Five runtime dependencies the library never imported are dropped.

Bugs fixed along the way — each has its own commit with the reasoning:

  • A withheld get_order_statuses backend was not withheld: the gate tested the router's own handler method, so a server with no backend advertised the class, published the route, emitted a monitor link on every order, and 500'd when a client followed it.
  • conformsTo could not round-trip. It declared a serialization alias but no validation alias, so Conformance could not parse its own output and silently dropped the value. number_matched had the same bug.
  • ?self=x 500'd every collection endpoint — query params were splatted into URL.include_query_params as Python keywords, colliding with its own self. Repeated params collapsed to the last value.
  • Registering two products with the same id left the first product's routes serving while the router reported the second.
  • The conformance gate could never fail: it ran test 0 and then a one-argument [ $result ] that is true for any non-empty string. Underneath it, the validator suite was never collected by pytest at all — the file did not match test_*.py, so it silently ran zero tests.
  • A bare Failure(ValueError()) was read as "no such page", so any incidental ValueError from a backend became a 404 rather than the 500 it was.
  • OrderStatus.new ignored the class it was called on, so OrderStatus[MyCodes].new(...) returned the wrong type and accepted codes outside its enum.
  • A malformed CQL2 filter escaped validation entirely and surfaced as a server error.

How I did it

  • 48 commits, curated. The branch is bisectable end to end. The test suite passed for each. Reviewing commit by commit is possible if desired, instead of via the large diff.
  • Breaking changes are marked. 30 of the 48 are !-marked, and every BREAKING changelog entry says what to do about it.
  • Both changelogs have a ### Migrating section. stapi-fastapi's is split by audience (backend implementers, model users, API callers). It deliberately repeats the stapi-pydantic tasks, because upgrading stapi-fastapi always drags the model changes with it and sending a reader to a second changelog would be bad.

Claude Opus 5 was leveraged heavily to make these changes, but I have attempted to perform a thorough review of the stapi-fastapi and stapi-pydantic source changes, and a slightly-less-stringent-but-still-thorough-review of the tests for both of those components.

Checklist

  • Tests pass: ./scripts/run-tests.sh — 298 passed
  • Checks pass: uv run pre-commit run --all-files — all 11 hooks
  • CHANGELOG is updated (if necessary) — both, with migration guides
  • Docs build: uv run mkdocs build --strict

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread stapi-fastapi/src/stapi_fastapi/routers/product_router.py Outdated
Comment thread pystapi-schema-generator/tests/test_application.py Outdated
Comment thread stapi-pydantic/src/stapi_pydantic/datetime_interval.py Outdated
Comment thread stapi-pydantic/src/stapi_pydantic/opportunity.py
Comment thread stapi-fastapi/src/stapi_fastapi/routers/product_router.py Outdated
Comment thread stapi-fastapi/src/stapi_fastapi/routers/product_router.py Outdated
Comment thread stapi-fastapi/src/stapi_fastapi/routers/product_router.py Outdated
Comment thread stapi-fastapi/tests/test_opportunity_async.py Outdated
Comment thread stapi-pydantic/src/stapi_pydantic/datetime_interval.py
Comment thread stapi-pydantic/src/stapi_pydantic/geometry.py Outdated
Comment thread stapi-pydantic/src/stapi_pydantic/opportunity.py Outdated
Comment thread stapi-pydantic/src/stapi_pydantic/order.py Outdated
Comment thread .github/workflows/ci.yaml
jkeifer added 23 commits August 6, 2026 17:20
The library targets STAPI v0.2.0. `STAPI_VERSION` is now importable from
the package root rather than only from `stapi_pydantic.constants`, since
callers building conformance URIs need it.

This is breaking beyond the constant itself: stapi-fastapi spells every
conformance URI as an f-string over `STAPI_VERSION`, so all six API and
product conformance classes it advertises change version segment with it.

The distribution version moves to 0.2.0 in step.
`JsonSchemaModel` annotated `type[BaseModel]` with a PlainValidator and a
PlainSerializer so a model class could stand in for its own schema. Two
consequences: every deployment published an orphan `BaseModel` component
that nothing referenced, and the value could not be read back, since
validation demanded a class where the wire carried an object.

`JsonSchema` is an ordinary root model over the schema document, built
with `JsonSchema.from_model`. The product queryables and order-parameters
endpoints now derive the schema at the call site and return it.
`Queryables.required_property_names()` reads the required set straight out
of the published queryables JSON Schema, so a client is held to exactly the
set it can see rather than to a list maintained alongside it.

Cached per subclass rather than computed once on `Queryables`: the cache
keys on `cls`, so no class inherits a parent's answer the way a plain class
attribute would.

Nothing consults it yet; the routers start enforcing it later on this branch.
STAPI defines a geometry conformance class for each of Point, MultiPoint,
LineString, MultiLineString, Polygon and MultiPolygon, and none for
GeometryCollection. Accepting one meant accepting a value no implementation
could declare support for, so `stapi_pydantic.Geometry` is now that
six-member union, discriminated on `type`, in place of
`geojson_pydantic.geometries.Geometry`.

The new module also carries the bbox helpers the models need next:
`compute_geometry_bbox`, `bbox_from_geometry_input` and `union_bboxes`.
Nothing calls them yet; `bbox` becomes geometry-derived later on this branch,
which is where their tests land.

stapi-fastapi's opportunity response model follows the same union.
A STAPI interval expressing a query may be open on one end, written `..`
or an empty string. `DatetimeInterval` rejected both, so a search for
"anything after this instant" could not be spelled.

`DatetimeInterval` is now that general interval and validates as
`tuple[AwareDatetime | None, AwareDatetime | None]`; a doubly-open interval
is still rejected. The both-ends-bounded form moves to the new
`BoundedDatetimeInterval`, which is what a result rather than a query needs:
`OpportunityProperties.datetime` uses it, since a provider returning an
opportunity has already determined both ends.

Callers relying on both tuple members being non-None must switch to
`BoundedDatetimeInterval`. stapi-fastapi's interval tests do exactly that.
`Expr.validate()` raises cql2's own exception types, which pydantic does not
convert into a `ValidationError`. The exception escaped validation entirely,
so a client sending a malformed filter got a server error rather than having
its request rejected. It is now re-raised as a `ValueError`, which pydantic
does convert.

`CQL2Filter` is typed `dict[str, Any]` instead of a bare `dict`, which
retires the `type: ignore[type-arg]` its users carried.

Also adds `cql2_property_names`, which walks a CQL2 JSON expression and
collects the property names it references. The routers use it later on this
branch to check a filter against a product's required queryables.
The Opportunity Request and the Order Request each declared `datetime`,
`geometry` and `filter` themselves, so the two definitions of the same spec
object could drift. `SearchParameters` is that object, declared once.

It permits extra fields: a provider extension parameter sent by a client has
to survive being stored on an order and read back, which a strict model would
silently drop.

Nothing composes it yet -- the requests are reshaped onto it next.

Its tests are also where the open-ended interval behaviour from the previous
commit is exercised, since `SearchParameters.datetime` is the first field to
use the general `DatetimeInterval`.
`Link` carried a wrap serializer registered `when_used="json"`, so
`model_dump()` still emitted `"title": null` and friends while
`model_dump(mode="json")` did not. The two disagreed about the same model.

Each optional field now declares `exclude_if`, so both dump modes omit it.
That also keeps the field out of the serialization schema's required set,
which matters once the response models start marking their defaulted fields
required.

`href` gains a default on the redefined `__init__`. pydantic routes
validation through it, so a payload missing `href` died in argument binding
with a TypeError instead of yielding a ValidationError.
`Conformance` declared a serialization alias but no validation alias, so it
could not parse its own output: a `conformsTo` on the way in bound nothing
and the value was silently dropped. `RootResponse` had the mirror of the
same bug by spelling the field `conformsTo` in Python and never aliasing it.

`ConformsTo` is now one annotated definition -- validating from either
spelling, serializing as `conformsTo` -- shared by `Conformance`,
`RootResponse` and `Product`, so the wire name cannot drift between them.
The Python attribute is `conforms_to` on all three.

Response models opt into `STAPI_RESPONSE_CONFIG`, which turns on
`serialize_by_alias` so a bare `model_dump()` emits wire names, and
`json_schema_serialization_defaults_required` so a defaulted-but-always-present
field is marked required in the serialization schema. It has to be set per
model, since pydantic does not inherit config into nested models.

`Provider.roles` and `Provider.url` become optional and omitted when unset;
both were required, which left a provider publishing neither unrepresentable.

Also deletes stapi-fastapi's `models/root.py`: a duplicate `RootResponse`
carrying the same conformsTo bug, already unreferenced -- the router has been
importing the stapi-pydantic one.
The class disagreed with its own payload: it was named `ProductsCollection`
while its `type` field said `ProductCollection`, which is what the spec calls
it. The name is corrected and the old one dropped rather than aliased.

The aliased `type` field is replaced by `stapi_type`, so a response carries
`"stapi_type": "ProductCollection"`, and `stapi_version` joins it. This is
the first of the collections to gain the pair; the rest follow.

`Product.description` is required, per the spec. It defaulted to the empty
string, so a product could be published with no description at all and still
validate.

stapi-fastapi and pystapi-client follow the rename.
…lass

Order parameters play two roles. At the boundary a product's own model
should reject anything it does not declare, so a typo in a request is an
error rather than a silently ignored field. At rest, inside a stored order,
parameters from any product have to round-trip whatever they carry.

One strict model cannot do both. `BaseOrderParameters` is the permissive
form for stored parameters; `OrderParameters` is now a strict subclass of it
and stays the base products derive from. Nothing stores the base form yet;
`StoredOrderRequest` uses it shortly.

Also drops `Props`, `Geom` and `OPP`, three type variables that were declared
and never used, along with the `OpportunityProperties` import only `OPP` needed.
`OpportunityPayload` and `OrderPayload` each declared `datetime`, `geometry`
and `filter` at the top level, so the same spec object was defined twice and
an opportunity search body was not a valid order request body. Both now carry
a single `search_parameters`, and both are renamed to match the spec's names
for them: `OpportunityRequest` and `OrderRequest`.

`OrderRequest.order_parameters` becomes optional, defaulting to an empty
object. A product whose `OrderParameters` declares required fields still
rejects an omitted value, so it stays effectively required where it matters.

`OpportunityRequest.limit` is `int | None` with the spec's lower bound of 1
and no default. It defaulted to 10, which asserted a page size the client
never named. Callers passing `limit=0` now get a validation error, so the
pagination tests drop that case.

stapi-fastapi's backends, routers and fixtures follow, as does the search
body pystapi-client builds.
`OrderProperties` carried the request in three separate fields:
`search_parameters` (its own near-copy of the search object),
`opportunity_properties`, and an untyped `order_parameters` dict. Nothing
tied them together, and `opportunity_properties` had no counterpart in a
request at all.

They collapse into one `order_request`, a `StoredOrderRequest` holding the
`search_parameters` the client sent alongside its `order_parameters`.

The stored parameters are typed `BaseOrderParameters`, not `OrderParameters`:
a persisted order can no longer be validated against the product's strict
model, and permissiveness is what lets provider extension fields survive the
round trip. Both the stored request and its search parameters preserve unknown
fields, which the old untyped dict did only by accident.

`OrderSearchParameters` is gone; `SearchParameters` is the one definition.
…t carried it

`OpportunitySearchRecord.opportunity_request` held the whole request object,
so every record echoed back whatever `next` and `limit` the client happened
to page with. Two records describing an identical search compared unequal if
the clients paged differently, and the pagination state was meaningless once
the search was done.

It becomes `search_parameters`, the `SearchParameters` that were actually
searched for. `OpportunitySearchRecords` is renamed
`OpportunitySearchRecordCollection` to match its members, and holds them in
`records` rather than `search_records`.

Both models gain `stapi_type` and `stapi_version`.
The spec lets providers add statuses through extensions, so a closed enum
rejected values a conforming server may legitimately send. `status_code` now
accepts any string by default, while still validating a known code to the
enum, and both status models are generic over their code set so an
implementation can narrow them: `OrderStatus[MyCodes]`.

`OrderStatus.new` constructed a bare `OrderStatus` no matter what class it
was called on, so `OrderStatus[MyCodes].new(...)` returned the wrong type and
accepted codes outside the enum it was parameterized with. It builds `cls` now.

`T` gains a default of `OrderStatus` rather than resolving to the bound
`OrderStatus[Any]`, which is what made `OrderStatusCollection` publish a
second, unconstrained `OrderStatus-2` component whose `status_code` carried no
schema at all.

`reason_code` and `reason_text` are omitted when unset rather than published
as null. Requires typing-extensions >= 4.12, for TypeVar defaults.
`OrderStatuses` was named for its contents rather than for what it is, and
had no counterpart on the opportunity side: the search-record statuses
endpoint returned a bare JSON list, which cannot carry links or a count and
does not match any other collection response in the API.

It becomes `OrderStatusCollection`, and `OpportunitySearchStatusCollection`
is added alongside it. Both carry `stapi_type`, `stapi_version`, `statuses`
and `links`.

The search-record statuses endpoint keeps returning a list for now; it moves
onto the new collection when that endpoint is paginated.
`Order` and `OrderCollection` re-implemented `geojson_pydantic`'s `Feature`
and `FeatureCollection` on top of `_GeoJsonBase`, duplicating the geometry
coercion and the exclude-if-none handling. They derive from the real base
classes again. Field order in a dump follows those bases, so `id` now trails
`geometry` and `properties`.

`OrderCollection`'s `__iter__`, `__len__` and `__getitem__` go with them.
`__iter__` in particular shadowed the one pydantic reserves, which broke
`dict(collection)`. Use `collection.iter()` and `collection.length`, as
`OpportunityCollection` already offered.

On the opportunity side, `Feature` types `geometry` and `properties` as
nullable, so `model_validate({"geometry": None, ...})` was accepted and
produced a dump that violates the spec. Both are now required and
non-nullable, and `id` is string-only rather than accepting any scalar.
`Order` and `Product` carried the pair; the collections and `Opportunity`
did not, so a consumer could not tell an `OrderCollection` from an
`OpportunityCollection` by its payload -- both said only
`"type": "FeatureCollection"`.

`Opportunity`, `OpportunityCollection` and `OrderCollection` gain them, which
completes the set alongside the ones added with the earlier renames.
…ections

An item's bbox is spec-REQUIRED but was optional and excluded from output
when unset, so a conforming server had to compute it itself or publish a
response that violated the spec.

`Order.bbox` and `Opportunity.bbox` are now required and non-nullable, and a
before-validator derives them from the geometry when the caller omits one --
so declaring them required costs callers nothing. The validation and
serialization schemas differ deliberately: a request may omit bbox, a
response always carries it.

A collection's bbox is spec-OPTIONAL and is unioned from its members. An
empty collection has no extent, so it is omitted rather than published as
null. The union is 3D only when every member is; a mix degrades to 2D, since
elevation is unknown for the 2D members.

Two bugs fall out of doing this properly: `union_bboxes` returns None for an
empty sequence, and assigning that back re-triggered the validator without
bound under `validate_assignment`; and a geometry with no coordinates now
raises a clear error rather than an IndexError from `min()`.
Only `OrderCollection` carried `number_matched`, so no other paginated
response could tell a client how many items existed in total.

It is hoisted into one shared annotated field and applied to all six
collections. That fixes a bug the single definition carried: it declared a
serialization alias but no validation alias, so a `numberMatched` in incoming
JSON bound nothing, parsed to None, and was dropped on the way back out. Both
spellings are accepted on input now, and the field is omitted rather than
published as null when unset.
pydantic builds a parameterized model's schema name from each parameter's
repr. For the `Geometry` union that repr is 150 characters of
`Annotated_Union_Point__MultiPoint__LineString__...`, so
`OpportunityCollection[Geometry, MyProperties]` was published as a
200-character component name and repeated in full at every `$ref` to it.

`StapiGenericModel` overrides `model_parametrized_name` to spell each
parameter with a short name -- a class supplies its own, and the one alias
that does not is named explicitly. Anything unnameable falls back to
pydantic's own behaviour rather than guessing.

Only the parameter spelling changes, so the result is
`OpportunityCollection[Geometry, MyProperties]`. This does change component
names in the generated OpenAPI document, which the changelog now records:
a client generated against an earlier build needs regenerating.
`httpx`, `pygeofilter`, `nox`, `pydantic-settings` and `uvicorn` were
declared as runtime dependencies of the library, and none of them is imported
by anything under `src/`. Every application installing stapi-fastapi was
pulling in a test client, a filter parser, a task runner, a settings library
and a server it may not use.

`httpx` moves to the dev group, where the test client actually needs it.

The floor on stapi-pydantic rises to 0.2.0, which is where the models this
release targets live, and the package version moves to 0.9.0.
`LIST_PRODUCTS` was defined twice, identically, on consecutive lines.

`GET_OPPORTUNITY_SEARCH_RECORD_STATUSES` is renamed
`LIST_OPPORTUNITY_SEARCH_RECORD_STATUSES`: it names a collection endpoint,
and every sibling list route is spelled `LIST_`. The registered route name
changes with the constant, so anything calling `url_for` with the old name
must be updated.
jkeifer added 25 commits August 6, 2026 18:42
Every registration and every `url_for` built the route's name as an f-string,
`f"{self.name}:{ROUTE}"` on the root router and
`f"{self.root_router.name}:{self.product.id}:{ROUTE}"` on a product router --
38 sites in all, each of which had to agree on the prefixing convention.

The prefix moves onto the router as `route_name_prefix`, and `route_name()`
joins it. Nothing about the resulting names changes; there is now one place
that decides them, which is what lets `register_route` take a plain route
name in the next commit.
Fifteen hand-assembled `add_api_route` calls each repeated the same
bookkeeping, and each could omit any part of it without complaint. Six routes
had no `summary`, so FastAPI derived titles like `Root:List-Orders` from the
route name; tags were assigned per owning router, so creating an order for a
product was filed under Products rather than Orders.

A route is now a frozen `Route` declaring its name, path, endpoint, summary,
tag and errors, handed to `register_route`. `summary` and `tag` are required,
so an operation cannot be published without a title or a heading.

`errors` is required and deliberately not defaulted. A shared set merged into
every route cannot be narrowed, which is why `GET /` and `GET /conformance`
advertised a 404 despite taking no input and calling no backend. Each route
composes only what it can produce, from `BAD_REQUEST`, `NOT_FOUND` and
`SERVER_ERROR`. That also declares 500, which was returned deliberately
whenever a backend reported failure and documented nowhere.

Every operation gets a stable `operationId`, derived from the prefixed route
name so it stays unique across a deployment mounting several products.
The spec documents the path parameters as `{orderId}`, `{searchRecordId}`
and `{opportunityCollectionId}`; the routes declared them snake_case, so the
published document disagreed with the spec everywhere except the already
camelCase `{productId}`.

The annotations in the new `stapi_fastapi.path_params` alias each parameter,
so the published name is camelCase while the Python parameter stays
snake_case. Titles are set explicitly, since FastAPI would otherwise derive
"Orderid" from the alias.

Request URLs are unchanged -- a path parameter's name never appears in one --
but a generated client binds by parameter name and needs regenerating, and
`url_for` callers must pass the camelCase keyword.
Each paginated endpoint declared `limit: int = 10` and validated nothing.
`GET /products` capped the value at 100 by hand; nowhere else did. A
`limit=0` dead-ended paging, since the next token pointed back at the page
just served, and a negative limit silently truncated the result set with no
`next` link at all.

`Limit` and `NextToken` are declared once and used by every paginated
endpoint, so they validate identically and publish the same bounds. Below the
spec's minimum of 1 is a 422.

Above the maximum is not. The spec makes `limit` what the client asks for
rather than what the server owes, and publishes no ceiling, so an over-large
ask is clamped and answered with a smaller page -- what the client wanted, and
one round trip cheaper than making it ask again. The document advertises no
maximum for the same reason: doing so would invite a 422 that never comes.

The opportunity search's POST body carries its own `limit` and is held to the
same bound.
Two different things were called `pagination_link`. The root router's built a
`next` URL by appending query parameters; the product router's built a `next`
that re-POSTs the search body, because an opportunity search is parameterized
by a body rather than a URL. A reader of one had no way to know the other
existed and behaved differently.

The query-parameter one moves to `StapiFastapiBaseRouter`, where every router
that paginates can reach it. The POST-bodied one is renamed
`search_pagination_link` and says in its docstring what makes it different.
Four list backends returned four differently-shaped tuples: orders a
three-tuple, statuses a `Maybe` of a two-tuple, search records and
opportunities a plain two-tuple. Only the orders one could carry a total, so
only `GET /orders` could publish `numberMatched`, and each handler unpacked
its own shape with its own nested `match`.

`Page` is that shape, once: `items`, `next_token`, `number_matched`, and any
collection-level `links` only the backend can know. Every list backend
returns one, and every collection response is assembled the same way through
`self_link` and `page_links`.

Two things follow. `numberMatched` is now populated on every collection the
backend can count, including `GET /products`. And every paginated response
carries a `self` link, which several did not, so `RootRouter.order_statuses_link`
-- a hand-built `self` link for one endpoint -- is gone.
Two bugs in one place, both about a link describing the thing it points at.

A paginated `self` link was built from the route alone, so every page of a
collection published the same `self` -- pointing at the first page rather than
the one just returned. It now carries the request's query string.

That query string is copied wholesale rather than splatted into
`include_query_params` as Python keywords. Those are user-controlled *names*:
a request for `?self=x` collided with the method's own `self` parameter and
answered 500, and repeated parameters collapsed to their last value.

`next` was hard-coded to `application/json`, so every geo+json collection
published a next link that contradicted its own response. Both `self` and
`next` now take the media type their target actually serves.
Every paginated handler matched `Failure(ValueError())` and answered 404.
That is right for a token identifying no page, and wrong for everything else:
a backend raises `ValueError` incidentally all the time -- an `int()` on
unparseable input, a `list.index` miss on something other than the token --
and each one was reported to the client as a page that does not exist rather
than as the server error it actually was.

`PaginationTokenError` says which is which. It subclasses `ValueError` so
existing backends keep working, but it is not an `HTTPException`: a backend
reports it inside a `Failure` rather than raising it. Handlers match that type
and answer 404; anything else falls through to a 500, as it should.
`GET /searches/opportunities/{searchRecordId}/statuses` returned a bare JSON
array. It published neither `next` nor `limit`, because its backend returned
a plain list with nothing to page, and a bare array cannot carry links or a
total the way every other collection response does. It also meant the
operation had no named response model, so FastAPI inlined the schema and
auto-titled it after the operation.

The backend returns `Maybe[Page[OpportunitySearchStatus]]` and takes `next`
and `limit`, and the endpoint answers with an
`OpportunitySearchStatusCollection`.

The test harness now keeps each search record's status history rather than
reporting only its current status, since a single status cannot exercise
paging.
`GET /products/{productId}/opportunities/{opportunityCollectionId}` returned
whatever collection the backend had stored, whole. It published no `next` and
no `limit`, so a search that produced thousands of opportunities had no way to
serve them a page at a time.

The backend returns `Maybe[Page[Opportunity]]` and takes `next` and `limit`.
Assembling the collection moves out of the backend and into the handler,
which knows the id from the path and can build the `self` and `next` links
itself; a backend now returns only the opportunities plus any collection-level
links it alone knows, such as `create-order`.
A product publishes a queryables schema, and that schema can mark properties
required. Nothing enforced it: a search or order whose filter named none of
them was accepted, and the product's own contract went unchecked.

`Product.validate_required_queryables` reads the required set from the
published schema -- so a client is held to exactly what it can see -- and
compares it against the property names the CQL2 filter actually references.
The opportunity search, the async search and order creation all call it.

`QueryablesError` becomes a 400 rather than a 422. A filter missing a
predicate the product requires is a malformed request, not a well-formed one
the server cannot process.
Conformance was a declaration rather than a fact. A product listed whatever
classes it wanted and `build_conformances` added to them; the root router
published whatever `conformances=` it was handed. Either could name a class
whose routes were never registered, sending a client that trusted the landing
page straight to a 404.

Both now derive the optional classes from what is actually served. The two
opportunity classes are subtracted from a product's declaration and re-added
only if that product supports the search and the router it is mounted on
serves it -- so an async-only product on a sync-only root router advertises
neither. The root router does the same for its three optional API classes,
adding each alongside the route it gates.

Supporting async search also no longer implies the sync class, which it did
by adding both.

Lists are sorted, so a deployment's conformance output no longer varies
between processes with set iteration order. The core URI comes from
stapi-pydantic, so the models and the server cannot disagree about it.
The gate read `self.get_order_statuses`, which is the router's own handler
method, not the `get_order_statuses` backend argument. A bound method is
always truthy, so the gate never closed.

A server that supplied no order-statuses backend therefore advertised the
conformance class, published `GET /orders/{orderId}/statuses`, and emitted a
`monitor` link on every order -- and returned a 500 when a client followed it,
because the backend it needed was never there.

`supports_order_statuses` reads the backend, and both the route registration
and the `monitor` link go through it. Every sibling gate was checked against
the same mistake; this was the only one making it.
Three related corrections to how search records are advertised.

The landing page published the search records link under
`opportunity-search-records`. The spec calls the relation `search-records`.

The statuses route was registered whenever its backend was supplied, even on
a router with no async opportunity search. The search-record endpoints it
hangs off only exist when async search is supported, so it registered a route
whose parent resources were absent; it now sits inside that gate, as does its
conformance class.

A search record carried only a `self` link, so a client had no way to reach
its statuses. `opportunity_search_record_links` adds a `monitor` link when
the statuses endpoint is actually registered, and every place that returned a
search record -- the async search response, the single-record fetch, the list
-- now builds its links through it rather than appending a `self` link
apiece.
`add_product` overwrote `product_routers[product.id]` but `include_router`
only ever appends. Registering two products with the same id left the first
router's routes mounted and matching every request, while `product_routers`
-- which the handlers read -- pointed at the second. The server served one
product and reported the other.

Nothing can be un-included, so the second registration is rejected rather
than replacing the first.
The header was set only when the preference was `wait` *and* the root router
supported async search. A client that asked for `respond-async` on a
sync-only product, or for `wait` on a router that could not have done
otherwise, was told nothing about what the server actually did -- which is
the one case where the client most needs to be told.

It is now sent whenever the request carried a `Prefer` header, naming the
preference the server actually applied, as the spec requires.
The search route declared one shape for every product: a GeoJSON response
class and a 200 `OpportunityCollection`, plus a 201 `OpportunitySearchRecord`
merged in unconditionally. An async-only product cannot return a 200
collection at all, and documenting the 201 on a sync-only product `$ref`d a
component that deployment never registers.

Each outcome is now declared only when the product can produce it: the
response class, status code and model are chosen from what it supports, and
the 200 and 201 blocks are added independently.

Both also declare `Preference-Applied`, with the values it can take, on
whichever responses can carry it -- it was sent but documented nowhere. The
`Location` headers on order creation and async search are documented for the
same reason.
The client's public signatures were suppressed rather than typed:
`Iterator[Order]`, `Iterator[Opportunity]` and `OrderRequest` each carried a
`type: ignore[type-arg]` and a TODO saying the annotation would be fixed once
the pydantic generics were. They are, so the parameters are spelled out --
`Opportunity[Geometry, OpportunityProperties]`, `OrderRequest[OrderParameters]`
-- and the suppressions and TODOs go with them. `CQL2Filter` no longer needs
one either, now that it is `dict[str, Any]`.

The package requires stapi-pydantic 0.2.0, since that is where these models
live, and its version moves to 0.0.2.
The conformance pattern was `(.*)` for the version segment and unanchored, so
it matched far more than it should: `/opportunities` matched
`/opportunities-async`, a URI with extra path segments after the class
matched, and a trailing newline matched. The segment is now `[^/]+` and the
pattern is anchored with `\Z`.

`ASYNC_OPPORTUNITIES` also pointed at `/async-opportunities`, which no server
publishes -- the class is `/opportunities-async`.

The opportunity capability checks read the root landing page, but those
classes are advertised per Product, not at the root. `_supports_opportunities`
and `_supports_async_opportunities` are replaced by public
`product_supports_opportunities` and `product_supports_async_opportunities`,
which take a Product or its id and read that Product's own `conformsTo`.

The fixtures move to v0.2.0 conformance URIs and carry per-product
`conformsTo`, with one product deliberately declaring neither opportunity
class so the negative case is exercised. A `has_next_page` off-by-one in the
pagination mock -- comparing against the already-sliced page rather than the
full fixture -- is fixed in passing.
schemathesis 4 moved the OpenAPI checks to `schemathesis.specs.openapi.checks`,
dropped `experimental` and `from_uri`, and gave every check a `CheckContext`
first argument. The validator suite uses all four, so it cannot run against
v4 unported.

The cap is explicit, with the reason in the manifest, rather than left to
whatever resolves. It pulls schemathesis back to 3.39.16 and pytest to 8.4.2,
which is what that line supports.
The gate could not fail. `scripts/validate-stapi-fastapi` ran `test 0`, which
always succeeds, and then checked `if [ $result ]` -- a one-argument test that
is true for any non-empty string, so an exit code of 1 still printed
"Validated OK!". Underneath it, `tests/validate_api.py` was never collected by
pytest at all, because the name does not match `test_*.py`: the validation
suite silently ran zero tests.

The file is renamed `test_validate_api.py`, which is the substantive fix. With
it running, three things in it turn out to be broken: `assert schema.validate()`
asserts on a method that returns `None`, the checks were invoked directly with
the wrong signature instead of being passed to `call_and_validate`, and
hypothesis filters out every generated POST body -- now suppressed explicitly,
with a note that it is a generator limitation and must not mask the contract
checks.

The document to validate against is now supplied by the caller rather than
fetched from a hard-coded GitHub URL, so this checks conformance to the spec
rather than that an export matches the app it came from. The script takes it
as a mandatory argument and exits 2 without one; the console script grows a
real CLI and resolves the suite from `__file__` rather than the cwd. Server
cleanup moves to a trap, since a bare `kill` with no PIDs fails under `set -e`
and would fail a run that passed.

CI drops the step: it has no document to hand the script now that one is
required.
`PaginationTokenError` is the one failure a backend returns that the handlers
translate into something other than a 500, and none of the six protocol
docstrings said so. Each paginated backend now documents it alongside the
`Success` and generic-`Failure` cases, so an implementer can see the whole
contract without reading the router.

Also settles helper placement -- `register_route` after the link builders,
`opportunity_search_record_self_link` beside the links helper that calls it,
the `supports_*` properties together -- and drops two stale docstrings naming
the Python parameter where the published camelCase name belongs. No behaviour
change.
mypy ran over `src/` only, so the test suites -- which are where the models
and routers are actually exercised -- were unchecked. Pointing it at whole
packages does not work in one invocation: each package carries its own
top-level `tests` package, and mypy collides on the module name.

`scripts/run-mypy.sh` runs one package at a time, mirroring `run-tests.sh`,
and collects failures so it reports every package rather than bailing on the
first. pre-commit calls it in place of the bare `mypy`.

The fallout is annotations across the suites: return types, an `AssertLink`
protocol for the `assert_link` fixture's call signature, `cast` where a
fixture types something more loosely than its use needs. The mypy override
for `pygeofilter` is replaced by one for `respx` -- pygeofilter is no longer
a dependency, and respx ships no stubs and is imported only from the client's
tests, which are now checked.
The entries accumulated under `## [Unreleased]` as each change landed. They
become `## [0.2.0]` and `## [0.9.0]`, each with an opening paragraph saying
who the release breaks and how the **BREAKING** markers work.

Each gets a `### Migrating` section: an ordered list of what a reader has to
change, rather than a restatement of what happened. stapi-fastapi's is split
by audience -- backend implementers, model users, and API callers -- because
those three sets of tasks barely overlap.

The stapi-pydantic migration tasks are repeated in stapi-fastapi's guide.
They are duplication, deliberately: upgrading stapi-fastapi always drags the
model changes with it, and sending a reader to a second changelog to find the
other half of their upgrade is worse than saying it twice. stapi-pydantic's
own changelog stays the detailed reference and is linked as such.
Five comments described a prior state of the code rather than the code as it
is, which is only legible to someone who remembers the bug.

Three test docstrings narrated the defect they were written for -- `?self=`
"used to 500", a repeated param "used to collapse to the last", a product's
declaration that "must still depend" on the routes registered. Each now
states the invariant it holds. A comment in the client's pagination mock was
a sentence fragment describing the off-by-one it replaced; it now names the
two values being compared.

The same starlette note appeared in two files with different wordings, one of
them temporal. They agree now.

`pystapi-validator/README.md` was wrong in three places: the spec is supplied
by the caller rather than fetched from a GitHub URL, there is no `BASE_URL` to
edit, and `tests/validate_api.py` no longer exists under that name. It now
documents the console script and the two environment variables the suite
actually reads.
@jkeifer jkeifer changed the title Jak/stapi v0.2.0 STAPI v0.2.0 alignment Aug 7, 2026
@jkeifer
jkeifer marked this pull request as ready for review August 7, 2026 03:48
@jkeifer
jkeifer requested a review from gadomski as a code owner August 7, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants