Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
575e500
feat!: bump STAPI_VERSION to 0.2.0 and add shared SearchParameters model
jkeifer Jul 24, 2026
7e9e331
feat!: OrderRequest composes SearchParameters with optional order_par…
jkeifer Jul 24, 2026
a5026e0
feat!: OpportunityRequest composes SearchParameters
jkeifer Jul 24, 2026
395920b
feat!: Order properties nest order_request; require bbox; OrderCollec…
jkeifer Jul 24, 2026
d64d01f
fix: compute 3D bboxes per RFC 7946; strengthen order parameters typi…
jkeifer Jul 24, 2026
619291e
feat!: opportunity entities gain stapi fields; search record uses req…
jkeifer Jul 24, 2026
0a7d510
fix: Opportunity geometry and properties are required and non-nullable
jkeifer Jul 24, 2026
a294eaf
feat!: ProductsCollection stapi fields; add cql2_property_names helper
jkeifer Jul 24, 2026
26ea2b8
feat!: stapi-fastapi v0.2.0 request/response shapes and required-quer…
jkeifer Jul 24, 2026
befc0c6
fix!: match opportunities-async conformance URI; adopt v0.2.0 request…
jkeifer Jul 24, 2026
d64e864
fix: end-anchor conformance URI patterns to prevent suffix false posi…
jkeifer Jul 24, 2026
62cdb48
chore!: bump packages for STAPI v0.2.0; add openapi export script
jkeifer Jul 24, 2026
100ff0b
fix: wire search record statuses into the reference application
jkeifer Jul 24, 2026
2fc8452
feat!: remove pre-0.2.0 compatibility aliases
jkeifer Jul 24, 2026
3490f01
fix: dedicated generic app for openapi export; round-trip and rejecti…
jkeifer Jul 24, 2026
51e91f8
Enrich the exported OpenAPI document metadata
jkeifer Jul 24, 2026
82e7d03
feat: promote OpenAPI export into stapi-fastapi as reference_app
jkeifer Jul 24, 2026
044297d
feat: extract OpenAPI export into pystapi-schema-generator package
jkeifer Jul 24, 2026
2d89f79
style: format stapi-pydantic tests with ruff
jkeifer Jul 24, 2026
5557dfd
feat: support singly-open datetime intervals in SearchParameters
jkeifer Jul 24, 2026
3be379e
feat!: allow provider extension status codes; make code sets constrai…
jkeifer Jul 24, 2026
055a5fb
fix!: align model schemas with spec required-ness and extensibility
jkeifer Jul 24, 2026
ebd8a02
fix!: spec-conformance fixes for capability advertisement and links
jkeifer Jul 24, 2026
2c22041
fix!: correct conformance checking scope and URI matching
jkeifer Jul 24, 2026
66ee785
fix: clean and harden the exported OpenAPI document
jkeifer Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"pystapi-validator",
"stapi-pydantic",
"stapi-fastapi",
"pystapi-schema-generator",
]

[dependency-groups]
Expand All @@ -19,6 +20,8 @@ dev = [
"pre-commit>=4.2.0",
"pre-commit-hooks>=5.0.0",
"pygithub>=2.6.1",
"pyyaml>=6.0",
"types-pyyaml>=6.0",
]
docs = [
"mkdocs-material>=9.6.11",
Expand All @@ -29,13 +32,14 @@ docs = [
default-groups = ["dev", "docs"]

[tool.uv.workspace]
members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi"]
members = ["pystapi-validator", "stapi-pydantic", "pystapi-client", "stapi-fastapi", "pystapi-schema-generator"]

[tool.uv.sources]
pystapi-client.workspace = true
pystapi-validator.workspace = true
stapi-pydantic.workspace = true
stapi-fastapi.workspace = true
pystapi-schema-generator.workspace = true

[tool.ruff]
line-length = 120
Expand All @@ -62,7 +66,8 @@ files = [
"pystapi-client/src/pystapi_client/**/*.py",
"pystapi-validator/src/pystapi_validator/**/*.py",
"stapi-pydantic/src/stapi_pydantic/**/*.py",
"stapi-fastapi/src/stapi_fastapi/**/*.py"
"stapi-fastapi/src/stapi_fastapi/**/*.py",
"pystapi-schema-generator/src/pystapi_schema_generator/**/*.py"
]

[[tool.mypy.overrides]]
Expand Down
2 changes: 1 addition & 1 deletion pystapi-client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pystapi-client"
version = "0.0.1"
version = "0.0.2"
description = "Python library for searching Satellite Tasking API (STAPI) APIs."
readme = "README.md"
authors = [
Expand Down
74 changes: 58 additions & 16 deletions pystapi-client/src/pystapi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
Link,
Opportunity,
OpportunityCollection,
OpportunityPayload,
OpportunityRequest,
Order,
OrderCollection,
OrderPayload,
OrderRequest,
Product,
ProductsCollection,
)
Expand Down Expand Up @@ -257,13 +257,53 @@ def has_conformance(self, conformance_class: ConformanceClasses | str) -> bool:

return any(re.match(conformance_class.pattern, uri) for uri in self.get_conforms_to())

def _supports_opportunities(self) -> bool:
"""Check if the API supports opportunities"""
return self.has_conformance(ConformanceClasses.OPPORTUNITIES)
def _product_has_conformance(
self,
product: str | Product,
conformance_class: ConformanceClasses,
) -> bool:
"""Check whether a Product advertises the given conformance class.

Opportunity capability classes are advertised per-Product (in the
Product's own ``conformsTo``, also served at
``/products/{id}/conformance``), not in the root landing page.

Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`. If an ID is given the Product
is fetched from the API.
conformance_class: The conformance class to check for.

Return:
Whether the Product conforms to the given class.
"""
if isinstance(product, str):
product = self.get_product(product)
return any(re.match(conformance_class.pattern, uri) for uri in product.conformsTo)

def product_supports_opportunities(self, product: str | Product) -> bool:
"""Check if a Product supports synchronous opportunity search.

Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`.

Return:
Whether the Product supports synchronous opportunity search.
"""
return self._product_has_conformance(product, ConformanceClasses.OPPORTUNITIES)

def product_supports_async_opportunities(self, product: str | Product) -> bool:
"""Check if a Product supports asynchronous opportunity search.

def _supports_async_opportunities(self) -> bool:
"""Check if the API supports asynchronous opportunities"""
return self.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES)
Args:
product: A Product ID or an already-fetched
:class:`~stapi_pydantic.Product`.

Return:
Whether the Product supports asynchronous opportunity search.
"""
return self._product_has_conformance(product, ConformanceClasses.ASYNC_OPPORTUNITIES)

def get_products(self, limit: int | None = None) -> Iterator[Product]:
"""Get all products from this STAPI API
Expand Down Expand Up @@ -316,14 +356,16 @@ def get_product_opportunities(
"""
product_opportunities_endpoint = self._get_products_href(product_id, subpath="opportunities")

opportunity_parameters = OpportunityPayload.model_validate(
opportunity_parameters = OpportunityRequest.model_validate(
{
"datetime": (
datetime.fromisoformat(date_range[0]),
datetime.fromisoformat(date_range[1]),
),
"geometry": geometry,
"filter": cql2_filter,
"search_parameters": {
"datetime": (
datetime.fromisoformat(date_range[0]),
datetime.fromisoformat(date_range[1]),
),
"geometry": geometry,
"filter": cql2_filter,
},
"limit": limit,
}
)
Expand All @@ -348,7 +390,7 @@ def get_product_opportunities(
for opportunity_collection in product_opportunities_json:
yield from OpportunityCollection.model_validate(opportunity_collection).features

def create_product_order(self, product_id: str, order_parameters: OrderPayload) -> Order: # type: ignore[type-arg]
def create_product_order(self, product_id: str, order_parameters: OrderRequest) -> Order: # type: ignore[type-arg]
# TODO Update return type after the pydantic model generic type is fixed
"""Create an order for a product

Expand Down
9 changes: 7 additions & 2 deletions pystapi-client/src/pystapi_client/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ class ConformanceClasses(Enum):
"""Enumeration class for Conformance Classes"""

# defined conformance classes regexes
# API-level classes (advertised in the root landing page / `/conformance`)
CORE = "/core"
ORDER_STATUSES = "/order-statuses"
SEARCHES_OPPORTUNITY = "/searches-opportunity"
SEARCHES_OPPORTUNITY_STATUSES = "/searches-opportunity-statuses"
# Product-level classes (advertised in a Product's own `conformsTo`)
OPPORTUNITIES = "/opportunities"
ASYNC_OPPORTUNITIES = "/async-opportunities"
ASYNC_OPPORTUNITIES = "/opportunities-async"

@classmethod
def get_by_name(cls, name: str) -> "ConformanceClasses":
Expand All @@ -29,4 +34,4 @@ def valid_uri(self) -> str:

@property
def pattern(self) -> re.Pattern[str]:
return re.compile(rf"{re.escape('https://stapi.example.com/v')}(.*){re.escape(self.value)}")
return re.compile(rf"{re.escape('https://stapi.example.com/v')}[^/]+{re.escape(self.value)}\Z")
3 changes: 3 additions & 0 deletions pystapi-client/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ def mock_products_response(request: Request) -> Response:
respx_mock.get("/products").mock(side_effect=mock_products_response)
respx_mock.get("/products", params={"limit": 1}).mock(side_effect=mock_products_response)

for product in products["products"]:
respx_mock.get(f"/products/{product['id']}").return_value = Response(200, json=product)

yield respx_mock
7 changes: 4 additions & 3 deletions pystapi-client/tests/fixtures/landing_page.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"title": "A simple STAPI Example",
"description": "This API demonstrated the landing page for a SpatioTemporal Asset Tasking API",
"conformsTo": [
"https://stapi.example.com/v0.1.0/core",
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
"https://stapi.example.com/v0.2.0/core",
"https://stapi.example.com/v0.2.0/order-statuses",
"https://stapi.example.com/v0.2.0/searches-opportunity",
"https://stapi.example.com/v0.2.0/searches-opportunity-statuses"
],
"links": [
{
Expand Down
16 changes: 16 additions & 0 deletions pystapi-client/tests/fixtures/products.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
{
"stapi_type": "ProductCollection",
"stapi_version": "0.2.0",
"products": [
{
"type": "Collection",
"stapi_type": "Product",
"stapi_version": "0.2.0",
"id": "multispectral",
"conformsTo": [
"https://stapi.example.com/v0.2.0/opportunities",
"https://stapi.example.com/v0.2.0/opportunities-async",
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
],
"title": "Multispectral",
"description": "Full color EO image",
"keywords": [
Expand Down Expand Up @@ -103,7 +113,13 @@
},
{
"type": "Collection",
"stapi_type": "Product",
"stapi_version": "0.2.0",
"id": "spotlight",
"conformsTo": [
"https://geojson.org/schema/Point.json",
"https://geojson.org/schema/Polygon.json"
],
"title": "Spotlight",
"description": "SAR Spotlight frame",
"keywords": [
Expand Down
88 changes: 88 additions & 0 deletions pystapi-client/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pystapi_client.client import Client
from pystapi_client.conformance import ConformanceClasses
from respx import MockRouter
from stapi_pydantic import Link

Expand All @@ -23,3 +24,90 @@ def test_pagination(api: MockRouter) -> None:
products_link = Link(href="http://stapi.test/products", method="GET", body={"limit": 1}, rel="")
for products_collection in client.stapi_io.get_pages(products_link, "products"):
assert len(products_collection["products"]) == 1


def test_async_opportunities_uri_matches_reference_server() -> None:
server_advertised = "https://stapi.example.com/v0.2.0/opportunities-async"
assert ConformanceClasses.ASYNC_OPPORTUNITIES.pattern.match(server_advertised)


def test_sync_opportunities_uri_does_not_match_async_uri() -> None:
async_uri = "https://stapi.example.com/v0.2.0/opportunities-async"
assert not ConformanceClasses.OPPORTUNITIES.pattern.match(async_uri)
assert ConformanceClasses.OPPORTUNITIES.pattern.match("https://stapi.example.com/v0.2.0/opportunities")


# --- Item 1: version pattern is a single path segment, anchored with \Z ---


def test_version_pattern_matches_single_version_segment() -> None:
pattern = ConformanceClasses.OPPORTUNITIES.pattern
assert pattern.match("https://stapi.example.com/v0.2.0/opportunities")


def test_version_pattern_rejects_extra_path_segments() -> None:
pattern = ConformanceClasses.OPPORTUNITIES.pattern
assert not pattern.match("https://stapi.example.com/v0.2.0/foo/opportunities")


def test_version_pattern_rejects_empty_version() -> None:
pattern = ConformanceClasses.OPPORTUNITIES.pattern
assert not pattern.match("https://stapi.example.com/v/opportunities")


def test_version_pattern_rejects_trailing_newline() -> None:
pattern = ConformanceClasses.OPPORTUNITIES.pattern
assert not pattern.match("https://stapi.example.com/v0.2.0/opportunities\n")


# --- Item 2: API-level extension conformance classes exist in the enum ---


def test_api_level_extension_classes_exist_and_match() -> None:
order_statuses = ConformanceClasses.get_by_name("ORDER_STATUSES")
searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY")
searches_opportunity_statuses = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY_STATUSES")

assert order_statuses.pattern.match("https://stapi.example.com/v0.2.0/order-statuses")
assert searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity")
assert searches_opportunity_statuses.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses")


def test_searches_opportunity_does_not_match_statuses_uri() -> None:
searches_opportunity = ConformanceClasses.get_by_name("SEARCHES_OPPORTUNITY")
assert not searches_opportunity.pattern.match("https://stapi.example.com/v0.2.0/searches-opportunity-statuses")


# --- Item 3 / 4: product-scoped opportunity capability checks ---


def test_supports_opportunities_reads_product_conformance(api: MockRouter) -> None:
client = Client.open(url="http://stapi.test")
assert client.product_supports_opportunities("multispectral") is True


def test_supports_async_opportunities_reads_product_conformance(api: MockRouter) -> None:
client = Client.open(url="http://stapi.test")
assert client.product_supports_async_opportunities("multispectral") is True


def test_product_without_opportunities_returns_false(api: MockRouter) -> None:
client = Client.open(url="http://stapi.test")
assert client.product_supports_opportunities("spotlight") is False
assert client.product_supports_async_opportunities("spotlight") is False


def test_opportunity_support_does_not_depend_on_root_conformance(api: MockRouter) -> None:
client = Client.open(url="http://stapi.test")
# Root conformsTo must not advertise the product-level opportunity classes.
assert not client.has_conformance(ConformanceClasses.OPPORTUNITIES)
assert not client.has_conformance(ConformanceClasses.ASYNC_OPPORTUNITIES)
# Yet the product does support opportunities per its own conformsTo.
assert client.product_supports_opportunities("multispectral") is True


def test_root_advertises_api_level_extension_classes(api: MockRouter) -> None:
client = Client.open(url="http://stapi.test")
assert client.has_conformance(ConformanceClasses.CORE)
assert client.has_conformance("ORDER_STATUSES")
assert client.has_conformance("SEARCHES_OPPORTUNITY")
9 changes: 9 additions & 0 deletions pystapi-schema-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# pystapi-schema-generator

A minimal reference STAPI application and console script for exporting its OpenAPI document as YAML.

## Usage

```bash
pystapi-schema-generator > openapi.yaml
```
30 changes: 30 additions & 0 deletions pystapi-schema-generator/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "pystapi-schema-generator"
version = "0.1.0"
description = "Reference STAPI application and OpenAPI schema export tooling"
readme = "README.md"
license = "MIT"
authors = [
{ name = "Christian Wygoda", email = "christian.wygoda@wygoda.net" },
{ name = "Phil Varner", email = "phil@philvarner.com" },
]
requires-python = ">=3.11"
dependencies = [
"stapi-fastapi>=0.9.0",
"pyyaml>=6.0",
]

[project.scripts]
pystapi-schema-generator = "pystapi_schema_generator.application:main"

[dependency-groups]
dev = [
"pytest>=8.3.5",
]

[tool.uv.sources]
stapi-fastapi = { workspace = true }

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .application import create_reference_app, export_openapi, main

__all__ = [
"create_reference_app",
"export_openapi",
"main",
]
Loading
Loading