From 53be1ecb5af74324dee4c81c1a4b8cf333ef44bf Mon Sep 17 00:00:00 2001 From: hrodmn Date: Thu, 14 May 2026 14:58:43 -0500 Subject: [PATCH 01/13] chore: write spec for adding collections transactions to STAC API --- .gitignore | 2 + .../specs/stac-api-collection-transactions.md | 404 ++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 dev-docs/specs/stac-api-collection-transactions.md diff --git a/.gitignore b/.gitignore index 264593d..a22612d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ cdk.out # potentially installed packages stac-browser/ + +dev-docs/plans/ diff --git a/dev-docs/specs/stac-api-collection-transactions.md b/dev-docs/specs/stac-api-collection-transactions.md new file mode 100644 index 0000000..b774f5d --- /dev/null +++ b/dev-docs/specs/stac-api-collection-transactions.md @@ -0,0 +1,404 @@ +# Spec: STAC API collection transactions runtime + +## Context +MAAP eoAPI currently deploys the stock `eoapi-cdk` pgSTAC STAC API Lambda through `PgStacApiLambda` in `cdk/PgStacInfra.ts`. That runtime exposes the standard read-only STAC API and can enable the upstream STAC transaction extension. + +The problem is that upstream `stac-fastapi` transaction support is all-or-nothing: +- enabling `TransactionExtension` registers both collection and item write routes +- it advertises both collection and item transaction conformance classes +- it exposes those routes in OpenAPI docs + +For MAAP, we only want collection-level transactions for now. We do not want item-management transaction routes exposed, documented, or accidentally usable. We also need an auth layer on the collection write routes, with HTTP Basic acceptable now and JWT expected later. Finally, this must be switchable per deployment so it can be enabled for `userInfrastructure` while other deployments stay read-only on the same runtime. + +## Goals +- Add a custom STAC API runtime under `cdk/runtimes/eoapi/stac/`. +- Support collection transaction routes only: + - `POST /collections` + - `PUT /collections/{collection_id}` + - `PATCH /collections/{collection_id}` + - `DELETE /collections/{collection_id}` +- Do not expose item transaction routes: + - no route registration + - no OpenAPI entries + - no item transaction conformance class +- Add an auth layer for collection transaction routes. +- Make transaction support opt-in per `PgStacInfra` deployment. +- Keep the existing read-only STAC API behavior unchanged when the feature is disabled. +- Add a local `docker-compose.yml` for running the MAAP custom STAC and raster runtimes together during development. +- Leave a clean path to JWT-based auth later. + +## Non-goals +- Implement JWT auth now. +- Add item-level transaction support. +- Build tenant- or collection-specific authorization rules. +- Change ingestion flows outside the STAC API Lambda. +- Upstream a general-purpose fix to `stac-fastapi` as part of this change. + +## Constraints and Assumptions +- Current STAC API deployment uses `eoapi-cdk` `PgStacApiLambda`. +- `eoapi-cdk` already supports overriding Lambda code via `lambdaFunctionOptions.code` and `handler`. +- Upstream `stac-fastapi-pgstac` v6.2 runtime imports `app` from `stac_fastapi.pgstac.app`, not `stac_fastapi.pgstac.main`. +- Upstream transaction wiring currently couples collection and item transaction routes in one extension. +- Secrets should not be stored as plaintext CDK config values when avoidable. +- We should minimize blast radius for the public STAC deployment. + +## Architecture Overview +The change has two layers. + +1. Runtime layer + - Add a custom Python runtime package at `cdk/runtimes/eoapi/stac/`. + - Rebuild the STAC API app locally instead of relying on the upstream all-in-one transaction extension. + - Register normal read-only STAC behavior exactly as today. + - Conditionally register a MAAP-specific collection-transactions extension. + - this will just require a subclass of stac_fastapi.extensions.transaction.TransactionExtension with a `register()` method that omits the item routes + - Apply auth only to the collection write routes. + - use the upstream `TransactionExtension(..., route_dependencies=...)` support added in `stac-fastapi` PR #885 once that release is available + +2. Infrastructure layer + - Add a `transactions` config block under `stacApiConfig` in `PgStacInfra` props. + - All deployments will use the same custom runtime. + - The collection-transactions extension will only be activated (via Lambda env var) for instances where the `transactions` config is enabled. + - Enable this only for `userInfrastructure` initially. + +This keeps route behavior read-only unless transactions are explicitly enabled, while standardizing the runtime shape across deployments. + +## Runtime Design + +### File layout +Proposed layout: + +```text +cdk/ + dockerfiles/ + Dockerfile.stac + runtimes/ + eoapi/ + stac/ + README.md + pyproject.toml + uv.lock + .python-version + eoapi/ + stac/ + __init__.py + main.py + auth.py + transactions.py + handler.py +docker-compose.yml +``` + +### App construction +`eoapi/stac/main.py` will build the FastAPI app. + +Implementation approach: +- make `eoapi/stac/main.py` a near 1:1 copy of `/home/henry/workspace/stac-utils/stac-fastapi-pgstac/stac_fastapi/pgstac/app.py` +- keep upstream app construction, middleware, lifespan wiring, request-model setup, and extension composition aligned as closely as possible +- continue to use upstream pgSTAC clients for core read behavior +- continue to use upstream request/response models where possible +- do not enable the default upstream `TransactionExtension` +- instead, register a local `CollectionTransactionExtension` in the same spot, with the rest of the app structure staying unchanged unless MAAP has a specific reason to diverge + +Why copy the app closely instead of mutating the upstream app after import? +- removing routes after registration is brittle +- conformance classes and docs become easy to miss +- auth attachment is cleaner when routes are created locally +- it avoids depending on upstream internal route order or router structure +- keeping the file near-identical to upstream makes future drift easier to review and reduce + +### CollectionTransactionExtension +Add a local extension in `eoapi/stac/transactions.py`. + +It should: +- subclass `TransactionExtension` +- set `conformance_classes = [TransactionConformanceClasses.COLLECTIONS]` +- implement the same collection transaction route contracts as upstream +- reuse upstream `TransactionsClient` for collection CRUD methods +- use the upstream `route_dependencies` constructor support from `stac-fastapi` PR #885 so auth can be attached at extension construction time rather than by hand on each route +- override `register(app: FastAPI)` to set `self.router.prefix = app.state.router_prefix`, call the four collection registration helpers, and then `include_router(...)` +- register only these routes: + +```text +POST /collections +PUT /collections/{collection_id} +PATCH /collections/{collection_id} +DELETE /collections/{collection_id} +``` + +It should not register any `/collections/{collection_id}/items...` write routes. + +It should advertise only this conformance class: + +```text +https://api.stacspec.org/v1.0.0/collections/extensions/transaction +``` + +It must not advertise: + +```text +https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction +``` + +This should stay intentionally small: reuse upstream route helper methods such as `register_create_collection()` and only narrow the registered surface, rather than forking transaction route implementations. + +### Auth model +Add a small auth abstraction in `eoapi/stac/auth.py`. + +Initial modes: +- `none` +- `basic` + +The route-level dependency contract is: + +```python +async def require_transaction_auth(request: Request) -> None: + ... +``` + +Behavior: +- passed through `TransactionExtension(..., route_dependencies=[...])` when collection transactions are enabled +- applied only to collection transaction routes +- no effect on read-only routes +- returns `401` with `WWW-Authenticate: Basic` when credentials are missing or invalid in basic mode +- should not be wired per-route manually unless the upstream release plan changes + +Basic auth credential source: +- Lambda env contains `MAAP_TRANSACTION_AUTH_MODE=basic` +- Lambda env contains `MAAP_TRANSACTION_AUTH_SECRET_ARN=` +- referenced secret payload format: + +```json +{ + "username": "...", + "password": "..." +} +``` + +Future JWT compatibility: +- auth dispatch should be mode-based, not hardcoded inside route handlers +- adding `jwt` later should require a new verifier implementation, not route rewrites + +### Runtime environment variables +The custom runtime should support these env vars in addition to existing STAC API env vars: + +```text +ENABLED_EXTENSIONS=collection_transaction,collection_search,... +MAAP_TRANSACTION_AUTH_MODE=none|basic +MAAP_TRANSACTION_AUTH_SECRET_ARN=arn:aws:secretsmanager:... +``` + + +Rules: +- if `collection_transaction` is not in the `ENABLED_EXTENSIONS` env var, do not register the transaction endpoints +- if enabled and auth mode is `basic`, secret ARN is required +- if enabled and auth mode is `none`, raise an error +- item transaction routes remain absent in all modes for this runtime version + +### DB connection behavior +The runtime must create a write pool only when collection transactions are enabled. + +Equivalent intent to upstream: +- read-only deployments use read pool only +- transaction-enabled deployments initialize write pool too + +`handler.py` should keep the same Lambda/Mangum and SnapStart lifecycle pattern already used by the upstream `eoapi-cdk` runtime so connection handling stays consistent. + +## API or Interface Design + +### TypeScript props +Add a new optional transactions block under `stacApiConfig`. + +```ts +stacApiConfig: { + customDomainName?: string; + integrationApiArn?: string; + transactions?: { + enabled: boolean; + authMode: "basic"; + authSecretArn?: string; + }; +}; +``` + +Validation rules: +- `transactions` omitted => current behavior +- `transactions.enabled === false` => current behavior +- `transactions.enabled === true` and `authMode === "basic"` => `authSecretArn` required +- `transactions.enabled === true` and `authMode === "none"` => no secret required + +### CDK usage +Initial intended usage in `cdk/app.ts`: + +- `coreInfrastructure`: no transactions block +- `userInfrastructure`: transactions enabled + +Example: + +```ts +stacApiConfig: { + customDomainName: userStacStacApiCustomDomainName, + transactions: { + enabled: true, + authMode: "basic", + authSecretArn: userStacCollectionTransactionsAuthSecretArn, + }, +} +``` + +### Runtime override in PgStacInfra +For all deployments: +- keep using `new PgStacApiLambda(...)` +- pass `lambdaFunctionOptions.code = lambda.Code.fromDockerBuild(...)` +- pass `lambdaFunctionOptions.handler = "handler.handler"` +- pass extension-selection env vars through `apiEnv` +- do not rely on upstream `enabledExtensions` transaction flag + +When transactions are enabled, also pass auth env vars and secret access. + +This preserves existing API Gateway, custom domain, VPC, DB, and SnapStart behavior managed by `eoapi-cdk` while standardizing on one MAAP-owned runtime. + +## Data Model +No database schema change is required. + +New configuration data introduced: + +### CDK deployment config +```ts +interface StacTransactionsConfig { + enabled: boolean; + authMode: "basic"; + authSecretArn?: string; +} +``` + +### Secrets Manager payload for basic auth +```json +{ + "username": "stac-writer", + "password": "" +} +``` + +## Integration Points + +### `cdk/PgStacInfra.ts` +Changes: +- extend `Props.stacApiConfig` +- always use the custom STAC runtime override for the STAC API Lambda +- grant transaction auth secret read access to the STAC API Lambda when needed +- pass auth and transactions env vars into the Lambda +- keep disabled deployments on the read-only path inside the shared custom runtime + +### `cdk/app.ts` +Changes: +- wire transactions config only for `userInfrastructure` +- leave `coreInfrastructure` unchanged + +### `cdk/config.ts` +Add optional config values for the user stack, for example: + +```text +USER_STAC_COLLECTION_TRANSACTIONS_ENABLED +USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE +USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN +``` + +These should default to disabled when unset. + +### Tests +Likely touch points: +- `test/config.test.ts` for config parsing +- new unit or synth-level tests for `PgStacInfra` transaction config behavior +- runtime tests for auth and route exposure + +### Docs +Update at least: +- repo `README.md` if deployment configuration is user-facing +- runtime `README.md` under `cdk/runtimes/eoapi/stac/` +- `docker-compose.yml` usage notes for local runtime development + +## Migration Path +1. Add custom runtime package and Dockerfile. +2. Add a local `docker-compose.yml` for the custom STAC and raster runtimes, following the shape of `/home/henry/workspace/devseed/eoapi-devseed/docker-compose.yml` where it still fits this repo. +3. Copy `stac_fastapi.pgstac.app` into the MAAP runtime as a near 1:1 local app, swapping in `CollectionTransactionExtension` for the default transaction extension. +4. Add transactions config to `PgStacInfra` and config loading in `cdk/config.ts`. +5. Wire `userInfrastructure` to use the new config. +6. When the `stac-fastapi` release that includes PR #885 is available, update any MAAP dependency pins needed to consume it and finish the extension-level auth attachment through `route_dependencies`. +7. Deploy to a non-prod internal environment. +8. Verify: + - collection transaction routes work with auth + - item transaction routes return `404` + - OpenAPI docs show only collection transaction routes + - conformance output includes only the collection transaction class +9. Promote to other user STAC deployments as needed. + +No backfill or data migration is required. + +## Testing Strategy + +### Runtime tests +Add Python tests around the custom app builder: +- transactions disabled => no write routes present +- transactions enabled => collection write routes present +- item write routes absent +- OpenAPI schema excludes item transaction routes +- conformance classes exclude item transaction URI +- basic auth rejects unauthenticated requests with `401` +- basic auth accepts valid credentials + +### Infrastructure tests +Add TypeScript tests for: +- config parsing defaults to disabled +- enabling basic auth without secret ARN throws +- all deployments use the custom Lambda handler/code override +- enabling transactions adds expected Lambda env vars +- disabled mode omits transaction auth env vars and keeps read-only behavior + +### Local development checks +Add local verification for `docker-compose.yml`: +- custom STAC runtime starts against local pgSTAC +- custom raster runtime starts alongside it +- disabled-mode STAC startup works before the auth-hook release lands +- compose configuration remains usable for the final auth-enabled verification pass once PR #885 is available + +### Smoke tests +Post-deploy manual/API checks: +- `GET /` and `GET /conformance` +- `POST /collections` with and without auth +- `PUT/PATCH/DELETE /collections/{collection_id}` with auth +- `POST /collections/{collection_id}/items` should be `404` +- Swagger/OpenAPI should not document item transaction routes + +## Decision Log +| Decision | Options Considered | Rationale | +|----------|--------------------|-----------| +| Use one custom runtime for all STAC deployments | Only use the custom runtime when transactions are enabled; patch routes in place; use stock runtime unchanged | Keeps route behavior switchable per deployment while avoiding two runtime code paths for the same API surface | +| Keep a near 1:1 local copy of `stac_fastapi.pgstac.app` | Mutate the upstream app after import; reassemble a more custom MAAP app from smaller pieces | A near-identical copy keeps behavior aligned with upstream while making the transaction-extension substitution explicit and reviewable | +| Implement a collection-only extension | Monkeypatch the upstream router | A local extension is explicit, testable, and resilient to upstream internal changes | +| Use `TransactionExtension.route_dependencies` for auth attachment when available | API Gateway auth only; middleware on all routes; manually attaching dependencies per route | We only need protection on collection write routes, and the upstream `route_dependencies` hook added in PR #885 gives us a cleaner extension-level attachment point once released, without blocking the rest of the runtime and infrastructure work | +| Store basic auth credentials in Secrets Manager | Plain env vars; SSM parameters | Secrets Manager is the least bad option for credentials and matches existing Lambda secret-read patterns | +| Omit item transaction routes entirely | Expose them but block with auth/authorization | Not registering them is safer and keeps docs/conformance honest | + +## Open Questions +- Should failed item transaction requests return `404` or an explicit `403` from a defensive blocker route? The cleaner default is `404` by not registering them. +- Do we want to add explicit public-stack transaction config now for symmetry, or keep the config surface user-stack-only until there is a real second use case? +- Should basic auth credentials be a single shared writer credential, or do we expect multiple clients soon enough to justify a richer secret format? +- Do we want CloudWatch metrics or structured logs specifically for collection transaction attempts and auth failures? +- Which released `stac-fastapi` version first includes PR #885, and do any downstream MAAP dependencies need version bumps before we can rely on it for the final auth attachment? +- How do we want to keep the local `app.py` copy aligned with future upstream `stac-fastapi-pgstac` changes after this fork lands? +- Is there any existing upstream work toward collection-only transaction registration that we may want to track before maintaining this long term? + +## References +- `cdk/PgStacInfra.ts` +- `cdk/app.ts` +- `cdk/config.ts` +- `cdk/runtimes/eoapi/raster/` +- `node_modules/eoapi-cdk/lib/stac-api/index.d.ts` +- `node_modules/eoapi-cdk/lib/stac-api/runtime/src/stac_api/handler.py` +- `/home/henry/workspace/stac-utils/stac-fastapi-pgstac/stac_fastapi/pgstac/app.py` +- `https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/app.py` +- `https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/transactions.py` +- `https://github.com/stac-utils/stac-fastapi/blob/main/stac_fastapi/extensions/stac_fastapi/extensions/transaction/transaction.py` +- `https://github.com/stac-utils/stac-fastapi/issues/884` +- `https://github.com/stac-utils/stac-fastapi/pull/885` +- `/home/henry/workspace/devseed/eoapi-devseed/docker-compose.yml` From 91d1c9f7136dacce6a5d97ba4e66e401e42357ca Mon Sep 17 00:00:00 2001 From: hrodmn Date: Thu, 14 May 2026 20:43:07 -0500 Subject: [PATCH 02/13] feat: add custom STAC API runtime with collections transactions --- .gitignore | 3 + cdk/dockerfiles/Dockerfile.stac | 25 + cdk/runtimes/eoapi/stac/.python-version | 1 + cdk/runtimes/eoapi/stac/README.md | 36 + .../eoapi/stac/eoapi/stac/__init__.py | 3 + cdk/runtimes/eoapi/stac/eoapi/stac/main.py | 265 ++++++ .../eoapi/stac/eoapi/stac/transactions.py | 40 + cdk/runtimes/eoapi/stac/pyproject.toml | 41 + cdk/runtimes/eoapi/stac/tests/test_app.py | 106 +++ cdk/runtimes/eoapi/stac/uv.lock | 774 ++++++++++++++++++ docker-compose.yml | 98 +++ 11 files changed, 1392 insertions(+) create mode 100644 cdk/dockerfiles/Dockerfile.stac create mode 100644 cdk/runtimes/eoapi/stac/.python-version create mode 100644 cdk/runtimes/eoapi/stac/README.md create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/main.py create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py create mode 100644 cdk/runtimes/eoapi/stac/pyproject.toml create mode 100644 cdk/runtimes/eoapi/stac/tests/test_app.py create mode 100644 cdk/runtimes/eoapi/stac/uv.lock create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index a22612d..6bcd36a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ cdk.out # potentially installed packages stac-browser/ +# local docker compose state +.pgdata/ + dev-docs/plans/ diff --git a/cdk/dockerfiles/Dockerfile.stac b/cdk/dockerfiles/Dockerfile.stac new file mode 100644 index 0000000..ae70511 --- /dev/null +++ b/cdk/dockerfiles/Dockerfile.stac @@ -0,0 +1,25 @@ +ARG PYTHON_VERSION=3.12 + +FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} +COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ + +RUN dnf install -y findutils && dnf clean all && rm -rf /var/cache/dnf + +WORKDIR /tmp + +COPY runtimes/eoapi/stac /tmp/stac +RUN cd /tmp/stac && uv export --locked --no-editable --no-dev --format requirements.txt -o requirements.txt && \ + uv pip install \ + --compile-bytecode \ + --no-binary pydantic \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + -r /tmp/stac/requirements.txt + +RUN cd /asset && find . -type f -name '*.pyc' | while read f; do n=$(echo $f | sed 's/__pycache__\///' | sed 's/.cpython-[0-9]*//'); cp $f $n; done; +RUN cd /asset && find . -type d -a -name '__pycache__' -print0 | xargs -0 rm -rf +RUN cd /asset && find . -type f -a -name '*.py' -print0 | xargs -0 rm -f +RUN find /asset -type d -a -name 'tests' -print0 | xargs -0 rm -rf + +CMD ["echo", "hello world"] diff --git a/cdk/runtimes/eoapi/stac/.python-version b/cdk/runtimes/eoapi/stac/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md new file mode 100644 index 0000000..bc3c52e --- /dev/null +++ b/cdk/runtimes/eoapi/stac/README.md @@ -0,0 +1,36 @@ +## eoapi.stac + +MAAP-owned STAC API runtime scaffolding. + +This package is the local home for the custom STAC runtime used by MAAP deployments. In this first slice it provides the packaging, Docker build path, and local development wiring needed to iterate on the runtime in-repo. + +### Local development + +Start the local pgSTAC + STAC + raster stack from the repository root: + +```bash +docker compose up --build stac raster database +``` + +The local compose setup keeps STAC transaction behavior disabled by default. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. + +### Environment shape + +The local STAC service uses the same pgSTAC-style environment variables already used elsewhere in eoapi development: + +- `POSTGRES_USER` +- `POSTGRES_PASS` +- `POSTGRES_DBNAME` +- `POSTGRES_HOST_READER` +- `POSTGRES_HOST_WRITER` +- `POSTGRES_PORT` +- `DB_MIN_CONN_SIZE` +- `DB_MAX_CONN_SIZE` +- `ENABLED_EXTENSIONS` +- `TITILER_ENDPOINT` + +### Packaging notes + +- `cdk/dockerfiles/Dockerfile.stac` builds the Lambda asset from this package. +- The Docker build context for local and CDK builds is `cdk/`. +- Runtime behavior, auth, and collection transaction wiring will land in this package in follow-up units. diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py b/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py new file mode 100644 index 0000000..77dc59f --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py @@ -0,0 +1,3 @@ +"""eoapi.stac.""" + +__version__ = "0.1.0" diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py new file mode 100644 index 0000000..4c261bf --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py @@ -0,0 +1,265 @@ +"""MAAP-owned STAC API application assembly.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import cast + +from brotli_asgi import BrotliMiddleware +from fastapi import APIRouter, FastAPI +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.middleware import ProxyHeaderMiddleware +from stac_fastapi.api.models import ( + EmptyRequest, + ItemCollectionUri, + JSONResponse, + create_get_request_model, + create_post_request_model, + create_request_model, +) +from stac_fastapi.extensions.core import ( + CollectionSearchExtension, + CollectionSearchFilterExtension, + FieldsExtension, + ItemCollectionFilterExtension, + OffsetPaginationExtension, + SearchFilterExtension, + SortExtension, + TokenPaginationExtension, +) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses +from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses +from stac_fastapi.extensions.core.query import QueryConformanceClasses +from stac_fastapi.extensions.core.sort import SortConformanceClasses +from stac_fastapi.pgstac.config import Settings +from stac_fastapi.pgstac.core import CoreCrudClient, health_check +from stac_fastapi.pgstac.db import close_db_connection, connect_to_db +from stac_fastapi.pgstac.extensions import FreeTextExtension, QueryExtension +from stac_fastapi.pgstac.extensions.filter import FiltersClient +from stac_fastapi.pgstac.transactions import TransactionsClient +from stac_fastapi.pgstac.types.search import PgstacSearch +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import APIRequest +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware + +from eoapi.stac.transactions import CollectionTransactionExtension + +settings = Settings() + +COLLECTION_TRANSACTION_EXTENSION = "collection_transaction" + +SEARCH_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(), + "sort": SortExtension(), + "fields": FieldsExtension(), + "filter": SearchFilterExtension(client=FiltersClient()), + "pagination": TokenPaginationExtension(), +} + +COLLECTION_SEARCH_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]), + "sort": SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]), + "fields": FieldsExtension( + conformance_classes=[FieldsConformanceClasses.COLLECTIONS] + ), + "filter": CollectionSearchFilterExtension(client=FiltersClient()), + "free_text": FreeTextExtension( + conformance_classes=[FreeTextConformanceClasses.COLLECTIONS] + ), + "pagination": OffsetPaginationExtension(), +} + +ITEM_COLLECTION_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(conformance_classes=[QueryConformanceClasses.ITEMS]), + "sort": SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]), + "fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), + "filter": ItemCollectionFilterExtension(client=FiltersClient()), + "pagination": TokenPaginationExtension(), +} + +DEFAULT_ENABLED_EXTENSIONS: set[str] = { + *SEARCH_EXTENSIONS_MAP.keys(), + *COLLECTION_SEARCH_EXTENSIONS_MAP.keys(), + *ITEM_COLLECTION_EXTENSIONS_MAP.keys(), + "collection_search", +} + +KNOWN_EXTENSIONS: set[str] = { + *DEFAULT_ENABLED_EXTENSIONS, + COLLECTION_TRANSACTION_EXTENSION, +} + + +def parse_enabled_extensions(raw_value: str | None) -> set[str]: + """Parse and validate the ENABLED_EXTENSIONS environment value.""" + if raw_value is None: + return set(DEFAULT_ENABLED_EXTENSIONS) + + enabled_extensions = {part.strip() for part in raw_value.split(",")} + if "" in enabled_extensions: + raise ValueError("Invalid ENABLED_EXTENSIONS: empty extension name") + + unknown_extensions = enabled_extensions - KNOWN_EXTENSIONS + if unknown_extensions: + joined_unknown_extensions = ", ".join(sorted(unknown_extensions)) + raise ValueError( + f"Invalid ENABLED_EXTENSIONS: unsupported extensions: {joined_unknown_extensions}" + ) + + return enabled_extensions + + +def _build_middlewares() -> list[Middleware]: + """Build the middleware stack used by the upstream pgSTAC app.""" + return [ + Middleware(BrotliMiddleware), + Middleware(ProxyHeaderMiddleware), + Middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_origin_regex=settings.cors_origin_regex, + allow_methods=settings.cors_methods, + allow_credentials=settings.cors_credentials, + allow_headers=settings.cors_headers, + max_age=600, + ), + ] + + +def _build_lifespan(with_collection_transactions: bool): + """Build the FastAPI lifespan for local app execution.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + await connect_to_db( + app, + add_write_connection_pool=with_collection_transactions, + ) + yield + await close_db_connection(app) + + return lifespan + + +def create_app( + *, + enabled_extensions: set[str] | None = None, + connect_to_database: bool = True, +) -> FastAPI: + """Create the MAAP STAC app with optional collection transactions.""" + resolved_extensions = ( + enabled_extensions + if enabled_extensions is not None + else parse_enabled_extensions(os.environ.get("ENABLED_EXTENSIONS")) + ) + application_extensions: list[ApiExtension] = [] + with_collection_transactions = ( + COLLECTION_TRANSACTION_EXTENSION in resolved_extensions + ) + + if with_collection_transactions: + application_extensions.append( + CollectionTransactionExtension( + client=TransactionsClient(), + settings=settings, + response_class=JSONResponse, + ) + ) + + search_extensions = [ + extension + for key, extension in SEARCH_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + post_request_model = create_post_request_model( + search_extensions, + base_model=PgstacSearch, + ) + get_request_model = create_get_request_model(search_extensions) + application_extensions.extend(search_extensions) + + items_get_request_model: type[APIRequest] = ItemCollectionUri + item_collection_extensions = [ + extension + for key, extension in ITEM_COLLECTION_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + if item_collection_extensions: + items_get_request_model = cast( + type[APIRequest], + create_request_model( + model_name="ItemCollectionUri", + base_model=ItemCollectionUri, + extensions=item_collection_extensions, + request_type="GET", + ), + ) + application_extensions.extend(item_collection_extensions) + + collections_get_request_model: type[APIRequest] = EmptyRequest + if "collection_search" in resolved_extensions: + collection_search_extensions = [ + extension + for key, extension in COLLECTION_SEARCH_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + collection_search_extension = CollectionSearchExtension.from_extensions( + collection_search_extensions + ) + collections_get_request_model = collection_search_extension.GET + application_extensions.append(collection_search_extension) + + api = StacApi( + app=FastAPI( + openapi_url=settings.openapi_url, + docs_url=settings.docs_url, + redoc_url=None, + root_path=settings.root_path, + title=settings.stac_fastapi_title, + version=settings.stac_fastapi_version, + description=settings.stac_fastapi_description, + lifespan=( + _build_lifespan(with_collection_transactions) + if connect_to_database + else None + ), + ), + router=APIRouter(prefix=settings.prefix_path), + settings=settings, + extensions=application_extensions, + client=CoreCrudClient(pgstac_search_model=post_request_model), # type: ignore[arg-type] + response_class=JSONResponse, + items_get_request_model=items_get_request_model, + search_get_request_model=get_request_model, + search_post_request_model=post_request_model, + collections_get_request_model=collections_get_request_model, + middlewares=_build_middlewares(), + health_check=health_check, # type: ignore[arg-type] + ) + return api.app + + +def run() -> None: + """Run the app locally with uvicorn if it is installed.""" + try: + import uvicorn + except ImportError as error: + raise RuntimeError("Uvicorn must be installed in order to use command") from error + + uvicorn.run( + "eoapi.stac.main:app", + host=settings.app_host, + port=settings.app_port, + log_level="info", + reload=settings.reload, + root_path=os.getenv("UVICORN_ROOT_PATH", ""), + ) + + +app = create_app() + + +if __name__ == "__main__": + run() diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py new file mode 100644 index 0000000..2290ab5 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py @@ -0,0 +1,40 @@ +"""Collection-only transaction extension for the MAAP STAC runtime.""" + +from typing import Any + +import attr +from fastapi import APIRouter, FastAPI +from starlette.responses import Response + +from stac_fastapi.api.models import JSONResponse +from stac_fastapi.extensions.core.transaction import ( + AsyncBaseTransactionsClient, + TransactionConformanceClasses, + TransactionExtension, +) +from stac_fastapi.types.config import ApiSettings + + +@attr.s +class CollectionTransactionExtension(TransactionExtension): + """Register only collection transaction routes and conformance classes.""" + + client: AsyncBaseTransactionsClient = attr.ib() + settings: ApiSettings = attr.ib() + conformance_classes: list[str] = attr.ib( + factory=lambda: [TransactionConformanceClasses.COLLECTIONS] + ) + schema_href: str | None = attr.ib(default=None) + router: APIRouter = attr.ib(factory=APIRouter) + response_class: type[Response] = attr.ib(default=JSONResponse) + route_dependencies: list[Any] = attr.ib(factory=list) + + def register(self, app: FastAPI) -> None: + """Register collection transaction routes with the target app.""" + self.router.prefix = app.state.router_prefix + self.router.dependencies = list(self.route_dependencies) + self.register_create_collection() + self.register_update_collection() + self.register_patch_collection() + self.register_delete_collection() + app.include_router(self.router, tags=["Collection Transaction Extension"]) diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml new file mode 100644 index 0000000..35dd2d7 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "eoapi.stac" +description = "Custom STAC API runtime for MAAP" +readme = "README.md" +requires-python = ">=3.12" +authors = [ + {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, + {name = "Henry Rodman", email = "henry@developmentseed.com"}, +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: GIS", +] +dynamic = ["version"] +dependencies = [ + "mangum==0.19", + "stac-fastapi-pgstac>=6.2,<6.3", + "starlette-cramjam>=0.4,<0.5", +] + +[project.optional-dependencies] +test = [ + "httpx>=0.28.1", + "pytest>=8.3.5", +] + +[build-system] +requires = ["pdm-pep517"] +build-backend = "pdm.pep517.api" + +[tool.pdm.version] +source = "file" +path = "eoapi/stac/__init__.py" + +[tool.pdm.build] +includes = ["eoapi/stac"] +excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] diff --git a/cdk/runtimes/eoapi/stac/tests/test_app.py b/cdk/runtimes/eoapi/stac/tests/test_app.py new file mode 100644 index 0000000..4de8feb --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_app.py @@ -0,0 +1,106 @@ +"""Application tests for the MAAP STAC runtime.""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app, parse_enabled_extensions + + +@pytest.fixture +def collection_transaction_app() -> Iterator[TestClient]: + """Build a test client with collection transactions enabled.""" + app = create_app( + enabled_extensions={"query", "sort", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + with TestClient(app) as client: + yield client + + +def test_read_only_app_omits_collection_transaction_routes() -> None: + """The default app should stay read-only when collection transactions are disabled.""" + app = create_app( + enabled_extensions={"query", "sort", "collection_search"}, + connect_to_database=False, + ) + openapi = app.openapi() + + assert "/collections" in openapi["paths"] + assert "get" in openapi["paths"]["/collections"] + assert "post" not in openapi["paths"]["/collections"] + assert "/collections/{collection_id}" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}"].keys()) == {"get"} + assert "/collections/{collection_id}/items" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}/items"].keys()) == {"get"} + assert "/collections/{collection_id}/items/{item_id}" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}/items/{item_id}"].keys()) == {"get"} + + +def test_collection_transaction_app_registers_collection_only_routes( + collection_transaction_app: TestClient, +) -> None: + """Enabling collection transactions should expose only collection write routes.""" + openapi = collection_transaction_app.app.openapi() + + assert set(openapi["paths"]["/collections"].keys()) == {"get", "post"} + assert set(openapi["paths"]["/collections/{collection_id}"].keys()) == { + "get", + "put", + "patch", + "delete", + } + assert set(openapi["paths"]["/collections/{collection_id}/items"].keys()) == {"get"} + assert set(openapi["paths"]["/collections/{collection_id}/items/{item_id}"].keys()) == {"get"} + assert {parameter["name"] for parameter in openapi["paths"]["/collections"]["get"]["parameters"]} >= { + "query", + "sortby", + } + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("post", "/collections/test/items"), + ("put", "/collections/test/items/item-1"), + ("patch", "/collections/test/items/item-1"), + ("delete", "/collections/test/items/item-1"), + ], +) +def test_item_transaction_write_methods_are_not_registered( + collection_transaction_app: TestClient, + method: str, + path: str, +) -> None: + """Item transaction write methods should stay unregistered.""" + request_kwargs = {"json": {}} if method != "delete" else {} + response = getattr(collection_transaction_app, method)(path, **request_kwargs) + + assert response.status_code == 405 + + +def test_openapi_and_conformance_advertise_collection_transactions_only( + collection_transaction_app: TestClient, +) -> None: + """OpenAPI and conformance output should match the collection-only contract.""" + openapi = collection_transaction_app.app.openapi() + + assert "/collections/test/items" not in openapi["paths"] + assert "/collections/{collection_id}/items/{item_id}" in openapi["paths"] + assert "put" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert "patch" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert "delete" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + + response = collection_transaction_app.get("/conformance") + + assert response.status_code == 200 + conformance_classes = response.json()["conformsTo"] + assert "https://api.stacspec.org/v1.0.0/collections/extensions/transaction" in conformance_classes + assert "https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction" not in conformance_classes + + +def test_parse_enabled_extensions_rejects_malformed_values() -> None: + """Malformed extension configuration should fail clearly.""" + with pytest.raises(ValueError, match="Invalid ENABLED_EXTENSIONS"): + parse_enabled_extensions("query,,collection_transaction") diff --git a/cdk/runtimes/eoapi/stac/uv.lock b/cdk/runtimes/eoapi/stac/uv.lock new file mode 100644 index 0000000..40b5a6a --- /dev/null +++ b/cdk/runtimes/eoapi/stac/uv.lock @@ -0,0 +1,774 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotli-asgi" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/df/b1fee43d30ac579f1faa5ff3773765927f2671794d647cc8f80aae96130b/brotli_asgi-1.6.0.tar.gz", hash = "sha256:f9985d99ecb082cf5e67486a58c27b7f39b2d3be8d9d13c38abc12328cedce9a", size = 5900, upload-time = "2026-01-02T08:00:53.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/8a/067e8546ea69e6999c2e7e6655acea039e9353ace0b8bd205a87991fb5c4/brotli_asgi-1.6.0-py3-none-any.whl", hash = "sha256:09d956bdc3cdfc495758fe6485f644731a9523a5f85696ea7a9227783ab363ef", size = 4847, upload-time = "2026-01-02T08:00:52.232Z" }, +] + +[[package]] +name = "buildpg" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/f2/ff0e51a3c2390538da6eb4f85e30d87aafbcc6d057c6c9bb9fa222c8f2fc/buildpg-0.4.tar.gz", hash = "sha256:3a6c1f40fb6c826caa819d84727e36a1372f7013ba696637b492e5935916d479", size = 12493, upload-time = "2022-03-01T17:00:53.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/5a/c5ecd08a0c9b4dfece3b41aeefc3770968b4a2da1784941c9c8dd1c65347/buildpg-0.4-py3-none-any.whl", hash = "sha256:20d539976c81ea6f5529d3930016b0482ed0ff06def3d6da79d0fc0a3bbaeeb1", size = 11746, upload-time = "2022-03-01T17:00:52.19Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cql2" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4e/7fe4e5e8ead84e4adda9af4ea7b20773b47f357d66549d792803bd498b7b/cql2-0.5.6.tar.gz", hash = "sha256:90fd10cf222d15999899dc67b2310814574d7471fef6beb05ed25c2a9820ef09", size = 176636, upload-time = "2026-05-08T13:22:30.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/3c/ee4d7b6b5d2f2bae527d23bc8098e2b43279d3b4f382c83ac5b6f4b3c50a/cql2-0.5.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:714d597bf307372ba68284c895ec79c32ee81d3d6430ef041b3fb8a89ebcaf29", size = 3097993, upload-time = "2026-05-08T13:22:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fb/33ca4ad5abc13df26339543c3d2833e5cce5006316edea539a45ddd7722c/cql2-0.5.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7c1fc266664a3f68ce9fc94fb63ccb43ca41f031eda5c0aa6f12571b988153a2", size = 3056885, upload-time = "2026-05-08T13:22:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/36/687ddea044b238130b88742a8f2657d161f747b1664f58c40bb48413a547/cql2-0.5.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:362ca54af3794748fb5904aa183bdcdfb077a536dec55c29723bc0f0bfeb97c2", size = 3333320, upload-time = "2026-05-08T13:22:09.198Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/bf3c2080bcfb09de01d550e4dc792fa590aeb7939d63bdceb0a8ea1eedad/cql2-0.5.6-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:914a8eaa6609808d629ccc2a3825609bd72a7f2e4c4e2a8ed69af42943435be6", size = 3201076, upload-time = "2026-05-08T13:22:11.045Z" }, + { url = "https://files.pythonhosted.org/packages/ee/26/11eb534576b02e0844d76b6598ff33a34b1a02e93198e2950f43a21b46bb/cql2-0.5.6-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d360a9ab787af7110f63982ffe80a2d82b27a52fe7ce40af814b28bf5158c250", size = 3595586, upload-time = "2026-05-08T13:22:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a2/1c824016b329171b62b99ae9e7d9f9680a7bcaefca850c69ad063d3c8495/cql2-0.5.6-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:662476f269fab592ceb0d9e4876499d72a0e3a132f7a764385dce62ba3d79fe1", size = 4301284, upload-time = "2026-05-08T13:22:12.607Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/7ac2eda713052f2a207e0981ebd942d4aa4ee508f8c179fc1cecb88a1393/cql2-0.5.6-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:240392a707d3a76db19b91c1185f56285d71b5aef3655a36a44709f83e2e74d3", size = 3278639, upload-time = "2026-05-08T13:22:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/ae/82/d05aa9f07babfb73292719770188369b4c456349da6d8a30361b50815dc5/cql2-0.5.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a480bf1261b93c049f251c741c7da33c21821799c85619188764e4a0f0d62b5", size = 3394476, upload-time = "2026-05-08T13:22:18.435Z" }, + { url = "https://files.pythonhosted.org/packages/ed/31/a75c7a8c634eb5509a90a8a5ad29936be483bdb11a765c5895eb056066be/cql2-0.5.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:370baf8fd0a54973531fbdb2cb78f279085d24d94454d199e2397f19d61b4abc", size = 3506307, upload-time = "2026-05-08T13:22:24.145Z" }, + { url = "https://files.pythonhosted.org/packages/9e/45/00353b1516a1a8c0326f11d9e442b5d88025523bef6ea5a6a14e2f490fe0/cql2-0.5.6-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:526e2e371469e775d3affed20a033e3924dcded8d022ca7e4f28dd73dce77e27", size = 3474116, upload-time = "2026-05-08T13:22:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1d/99da637a52fcfa46e5f871ea2edc5b93158fadc0c556b9ab4cf565f226ed/cql2-0.5.6-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:4f0e3e333be08430ef68d1db9731726c9ec5714b53ba989fb639720dff99d24d", size = 3661512, upload-time = "2026-05-08T13:22:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ea/c13b0c3a80e71f51602459510c05592d1a67cb2b780d1d5ed6e6cfe54fe0/cql2-0.5.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d657b1a571c482bbd2c8f124b9f1730ffd433a57ef61caa7855c56af998b4b78", size = 3637784, upload-time = "2026-05-08T13:22:29.248Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/411b8da44d983e140721327ecf54b48db523f0345cd060135ebbf40e0eff/cql2-0.5.6-cp310-abi3-win32.whl", hash = "sha256:7d9cbb92d372d5f5516f35ab34ea6c62ca3f88f184f7a16968fc373844374443", size = 2400943, upload-time = "2026-05-08T13:22:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/3c/93/154e095f3d9af711c62e659dffaf3681464b566ec9cff375d8565e6e512d/cql2-0.5.6-cp310-abi3-win_amd64.whl", hash = "sha256:074672d50812b7c73110e70175d3c575a096540bdd947925f43a4b8a6a5fd10f", size = 2601041, upload-time = "2026-05-08T13:22:31.908Z" }, +] + +[[package]] +name = "cramjam" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/68/09b6b5603d21a0c7d4362d513217a5079c47b1b7a88967c52dbef13db183/cramjam-2.9.1.tar.gz", hash = "sha256:336cc591d86cbd225d256813779f46624f857bc9c779db126271eff9ddc524ae", size = 47892, upload-time = "2024-12-12T13:40:44.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/66/69a1c17331e38b02c78c923262fc315272de7c2618ef7eac8b3358969d90/cramjam-2.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79417957972553502b217a0093532e48893c8b4ca30ccc941cefe9c72379df7c", size = 2132273, upload-time = "2024-12-12T13:38:05.648Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/23d0b1d3301480e924545cdd27f2b949c50438949f64c74e800a09c12c37/cramjam-2.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce2b94117f373defc876f88e74e44049a9969223dbca3240415b71752d0422fb", size = 1926919, upload-time = "2024-12-12T13:38:08.928Z" }, + { url = "https://files.pythonhosted.org/packages/8e/da/e9565f4abbbaa14645ccd7ce83f9631e90955454b87dc3ef9208aebc72e6/cramjam-2.9.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:67040e0fd84404885ec716a806bee6110f9960c3647e0ef1670aab3b7375a70a", size = 2271776, upload-time = "2024-12-12T13:38:11.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/ac/e6e0794ac01deb52e7a6a3e59720699abdee08d9b9c63a8d8874201d8155/cramjam-2.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bedb84e068b53c944bd08dcb501fd00d67daa8a917922356dd559b484ce7eab", size = 2109248, upload-time = "2024-12-12T13:38:14.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/0f/c3724b2dcdfbe7e07917803cf7a6db4a874818a6f8d2b95ca1ceaf177170/cramjam-2.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06e3f97a379386d97debf08638a78b3d3850fdf6124755eb270b54905a169930", size = 2088611, upload-time = "2024-12-12T13:38:17.464Z" }, + { url = "https://files.pythonhosted.org/packages/ce/16/929a5ae899ad6298f58e66622dc223476fe8e1d4e8dae608f4e1a34bfd09/cramjam-2.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11118675e9c7952ececabc62f023290ee4f8ecf0bee0d2c7eb8d1c402ee9769d", size = 2438373, upload-time = "2024-12-12T13:38:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/ad473f1ca65d3285e8c1d99fc0289f5856224c0d452dabcf856fd4dcdd77/cramjam-2.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b7de6b61b11545570e4d6033713f3599525efc615ee353a822be8f6b0c65b77", size = 2836669, upload-time = "2024-12-12T13:38:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5a/e9b4868ee27099a2a21646cf5ea5cf08c660eae90b55a395ada974dcf3fb/cramjam-2.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57ca8f3775324a9de3ee6f05ca172687ba258c0dea79f7e3a6b4112834982f2a", size = 2343995, upload-time = "2024-12-12T13:38:24.266Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/870a9b4524107bf85a207b82a42613318881238b20f2d237e62815af646a/cramjam-2.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9847dd6f288f1c56359f52acb48ff2df848ff3e3bff34d23855bbcf7016427cc", size = 2374270, upload-time = "2024-12-12T13:38:29.136Z" }, + { url = "https://files.pythonhosted.org/packages/70/4b/b69e8e3951b7cec5e7da2539b7573bb396bed66af07d760b1878b00fd120/cramjam-2.9.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d1248dfa7f151e893ce819670f00879e4b7650b8d4c01279ce4f12140d68dd2", size = 2388789, upload-time = "2024-12-12T13:38:31.194Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/af02f6192060413314735c0db61259d7279b0d8d99eee29eff2af09c5892/cramjam-2.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9da6d970281083bae91b914362de325414aa03c01fc806f6bb2cc006322ec834", size = 2402459, upload-time = "2024-12-12T13:38:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/20/9a/a4ab3e90d72eb4f2c1b983fa32b4050ba676f533ba15bd78158f0632295a/cramjam-2.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1c33bc095db5733c841a102b8693062be5db8cdac17b9782ebc00577c6a94480", size = 2518440, upload-time = "2024-12-12T13:38:37.385Z" }, + { url = "https://files.pythonhosted.org/packages/35/3b/e632dd7e2c5c8a2af2d83144b00d6840f1afcf9c6959ed59ec5b0f925288/cramjam-2.9.1-cp312-cp312-win32.whl", hash = "sha256:9e9193cd4bb57e7acd3af24891526299244bfed88168945efdaa09af4e50720f", size = 1822630, upload-time = "2024-12-12T13:38:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a2/d1c46618b81b83578d58a62f3709046c4f3b4ddba10df4b9797cfe096b98/cramjam-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:15955dd75e80f66c1ea271167a5347661d9bdc365f894a57698c383c9b7d465c", size = 2094684, upload-time = "2024-12-12T13:38:41.345Z" }, + { url = "https://files.pythonhosted.org/packages/85/45/f1d1e6ffdceb3b0c18511df2f8e779e03972459fb71d7c1ab0f6a5c063a3/cramjam-2.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5a7797a2fff994fc5e323f7a967a35a3e37e3006ed21d64dcded086502f482af", size = 2131814, upload-time = "2024-12-12T13:38:43.484Z" }, + { url = "https://files.pythonhosted.org/packages/3a/96/36bbd431fbf0fa2ff51fd2db4c3bead66e9e373693a8455d411d45125a68/cramjam-2.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d51b9b140b1df39a44bff7896d98a10da345b7d5f5ce92368d328c1c2c829167", size = 1926380, upload-time = "2024-12-12T13:38:46.749Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/99b6507ec697d5f56d32c9c04614775004b05b7fa870725a492dc6b639eb/cramjam-2.9.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:07ac76b7f992556e7aa910244be11ece578cdf84f4d5d5297461f9a895e18312", size = 2271581, upload-time = "2024-12-12T13:38:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/6d55dff244fb22c0b686dd5a96a754c0638f8a94056beb27c457c6035cc5/cramjam-2.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d90a72608c7550cd7eba914668f6277bfb0b24f074d1f1bd9d061fcb6f2adbd6", size = 2109255, upload-time = "2024-12-12T13:38:50.436Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/b9fcf492a21a8d978c6f999025fce2c6656399448c017ed2fc859425f37f/cramjam-2.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56495975401b1821dbe1f29cf222e23556232209a2fdb809fe8156d120ca9c7f", size = 2088323, upload-time = "2024-12-12T13:38:52.254Z" }, + { url = "https://files.pythonhosted.org/packages/88/1f/69b523395aeaa201dbd53d203453288205a0c651e7c910161892d694eb4d/cramjam-2.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b695259e71fde6d5be66b77a4474523ced9ffe9fe8a34cb9b520ec1241a14d3", size = 2437930, upload-time = "2024-12-12T13:38:55.081Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/d07e802f1786c4082e8286db1087563e4fab31cd6534ed31523f1f9584d1/cramjam-2.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab1e69dc4831bbb79b6d547077aae89074c83e8ad94eba1a3d80e94d2424fd02", size = 2836655, upload-time = "2024-12-12T13:38:58.323Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/6b425e82395c078bc95a7437b685e6bdba39d28c2b2986d79374fc1681aa/cramjam-2.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:440b489902bfb7a26d3fec1ca888007615336ff763d2a32a2fc40586548a0dbf", size = 2387107, upload-time = "2024-12-12T13:39:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/33/65/7bf97d89ba7607aaea5464af6f249e3d94c291acf73d72768367a3e361c0/cramjam-2.9.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:217fe22b41f8c3dce03852f828b059abfad11d1344a1df2f43d3eb8634b18d75", size = 2374006, upload-time = "2024-12-12T13:39:03.993Z" }, + { url = "https://files.pythonhosted.org/packages/29/11/8b6c82eda6d0affbc15d7ab4dc758856eb4308e8ddae73300c1648f5aa0f/cramjam-2.9.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:95f3646ddc98af25af25d5692ae65966488a283813336ea9cf41b22e542e7c0d", size = 2388731, upload-time = "2024-12-12T13:39:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/48/25/6cdd57c0b1a83c98aec9029310d09a6c1a31e9e9fb8efd9001bd0cbea992/cramjam-2.9.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:6b19fc60ead1cae9795a5b359599da3a1c95d38f869bdfb51c441fd76b04e926", size = 2402131, upload-time = "2024-12-12T13:39:08Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/cbf80c9647fa582432aa833c4bdd20cf437917c8066ce653e3b78deff658/cramjam-2.9.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:8dc5207567459d049696f62a1fdfb220f3fe6aa0d722285d44753e12504dac6c", size = 2555296, upload-time = "2024-12-12T13:39:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/18/a6/fabe1959a980f5d2783a6c138311509dd168bd76e62018624a91cd1cbb41/cramjam-2.9.1-cp313-cp313-win32.whl", hash = "sha256:fbfe35929a61b914de9e5dbacde0cfbba86cbf5122f9285a24c14ed0b645490b", size = 1822484, upload-time = "2024-12-12T13:39:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/24e4562771711711c466768c92097640ed97b0283abe9043ffb6c6d4cf04/cramjam-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:06068bd191a82ad4fc1ac23d6f8627fb5e37ec4be0431711b9a2dbacaccfeddb", size = 2094445, upload-time = "2024-12-12T13:39:15.421Z" }, +] + +[[package]] +name = "eoapi-stac" +source = { editable = "." } +dependencies = [ + { name = "mangum" }, + { name = "stac-fastapi-pgstac" }, + { name = "starlette-cramjam" }, +] + +[package.optional-dependencies] +test = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.1" }, + { name = "mangum", specifier = "==0.19" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.5" }, + { name = "stac-fastapi-pgstac", specifier = ">=6.2,<6.3" }, + { name = "starlette-cramjam", specifier = ">=0.4,<0.5" }, +] +provides-extras = ["test"] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "geojson-pydantic" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/76/77c6f2e608028a2885e4d9d9a28cb6801879972d4bb3106c1dca02201df6/geojson_pydantic-2.1.1.tar.gz", hash = "sha256:3b64fa2dcd98108ff8a19bfb01eee3dab41bc230be2481804aefd7c1659d1c23", size = 8156, upload-time = "2026-04-07T09:48:07.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/97/d59d999f00aec90fc7a4efb742fc11e6c160552aa1b313438d3497998644/geojson_pydantic-2.1.1-py3-none-any.whl", hash = "sha256:55354f22ededc3c070e3210fec6f518d784b65c4368c1763f2c6dc4bab79b898", size = 9456, upload-time = "2026-04-07T09:48:05.772Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hydraters" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ca/dadb0e01677b47ba9a71bfebbcb47c3236d5f8cb7abccfe969278f59cedb/hydraters-0.1.3.tar.gz", hash = "sha256:d5b60a535cc911e7f42875e26ed2a39014b6d22dd0c4f3f441cae3a3b0e6baf3", size = 95732, upload-time = "2025-10-27T19:32:12.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/c065a89387259e8f284a85f417e22e1bdda45dc081a54da4cf24b95e1213/hydraters-0.1.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:36e12d7d425076be040914f40b69109e451473fd809f827eee6c4122adb26640", size = 220772, upload-time = "2025-10-27T19:31:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/16/07/13e6361f61a24eae6e7531264312ca1bb1008cb4a4e077fcb3bc62c97817/hydraters-0.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:97743c9102f522441489fe50ab91b986508d6d08c782adcdb48b755fa6fb85bd", size = 213779, upload-time = "2025-10-27T19:31:21.642Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7f/a153ca7847072ad68631715d853c3726d4ddbd51f1b6c8593abadcf5b420/hydraters-0.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08207aabf3023f6be2709f8cdc6c13e2597c38c4116acca1dbf5fb49898da45d", size = 239934, upload-time = "2025-10-27T19:30:20.867Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/c153ddac7d4682f64c85fa19b233819730870753ee40016d4ec8f9f30157/hydraters-0.1.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16b97157193236232b620ddbfae8cd1f01c85b3162792c879d79b3e38124606b", size = 249126, upload-time = "2025-10-27T19:30:32.473Z" }, + { url = "https://files.pythonhosted.org/packages/53/56/62f79e65e84a49d5d46f1d858f01e33e13352151e6eeb610cb5d22442382/hydraters-0.1.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f92f661ed47e9e26ecfb94f52afc7712e3f62705f36ce6e68c4d2acc8586c08", size = 384590, upload-time = "2025-10-27T19:30:42.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/93/1c669ced9ad0cf9b9df30d1a73fa542cb7fcaf9d66e08467a97944d98a65/hydraters-0.1.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fa47f4be90d4712ecdf3dfed8161d9e874f8ea8dcf56dae40dca458d1ffd972", size = 269407, upload-time = "2025-10-27T19:30:53.028Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b2/dc5d3d66c94129b8cbbb53bbc210090ef721eecd7f67f861286204a29ede/hydraters-0.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908e390c4c9acf197993c403836d66e27c23baf6cdd7ec8ac1334e657b0144bc", size = 247803, upload-time = "2025-10-27T19:31:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/dd/19/97574bf14990e9ae3775ee50f29c53d869a0d3e7986df4461938c1530bb4/hydraters-0.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd7dc42df7ac0875acfa852900f74fa1a28d22e413d4c62f9f22a2055f8edd9", size = 258436, upload-time = "2025-10-27T19:31:03.133Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9f/9ee6ac36e71f4b323ed3f9b3cb47d687b34fdfb97b54fab98111dc985d91/hydraters-0.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f84e967631033102bfabd4d36c75eeba145b667a1593f11fcdd0e042c45075c2", size = 420283, upload-time = "2025-10-27T19:31:32.294Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/63069d1f431a8df0dedff63d55ff0c67dc7157982dbee05b5faac2696996/hydraters-0.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d28daf0c040ac8a9d03cf04b5ef2f9f65d286340dcecc1c9c9e1f7cdb0ef63e0", size = 512864, upload-time = "2025-10-27T19:31:41.861Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/376061358062a607b97111fdf87774b42d87025785f971b219707c849845/hydraters-0.1.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5beb9d7639b873f7f2afe562fdd2934d864f99a56f007d31b11bad9c32bae9f", size = 438142, upload-time = "2025-10-27T19:31:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/323c7a0d2e4b79dcd6a0bd5a1df06ad7d4a1dcacad17a96c6ef38675a9ff/hydraters-0.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9dbedfd0ef43811c49c7fe530e74a6c3e16116bd233ce35ee4243835fb807f4c", size = 409586, upload-time = "2025-10-27T19:32:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/05/e2/b915dcf4b27fdcd04c4bf7214fd40f0fee113f8ba6390a6009f68d9e369b/hydraters-0.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:44614e67823ddf40f5599e3b83f863c535dbc817c4d7dd125f2d7e0cbb104178", size = 109393, upload-time = "2025-10-27T19:32:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/de/13/2e1bac20baea51a9cf570d834ec212617bc90e146a05d6410d56239d5261/hydraters-0.1.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:210fb5095e4a43473c46095f4e56e847f6829b1aabf57bea4656f16030b4815b", size = 220426, upload-time = "2025-10-27T19:31:28.247Z" }, + { url = "https://files.pythonhosted.org/packages/59/da/d25b72c7982e796a444e15fa55c1ebe8c2821eb0207658ae28c4109c6c04/hydraters-0.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:397d9a9e41d81012a85ae0325b1ad52c66bf6099e231ad63ce1db2c304b0d8a1", size = 213516, upload-time = "2025-10-27T19:31:22.909Z" }, + { url = "https://files.pythonhosted.org/packages/a8/60/526afd05c96cb91ac90c6603a64e3461e38cb3edf5c7768a7b61ac38d3c1/hydraters-0.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e8c5c366c3aac6c412f3ff081cfe2f791f84c2d0a60bdc6fc7c786b3cd07c3", size = 239739, upload-time = "2025-10-27T19:30:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/eb/48af59c0771a7552e87f4b583fa7e21afdcb17d903527b5f53e374bbebc0/hydraters-0.1.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dee4b03c4586dc735279bbeb6410dbfd75e61b650f40afb6edd80cd5c6d375e8", size = 248905, upload-time = "2025-10-27T19:30:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/494deced877176c77f9155539f1826a1c1b785a1610072fb5dc0cb8c14ff/hydraters-0.1.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dbba2b794a071670a46a5ab69c812eabaddc058fc7ea23530a2c88a22dba3a9", size = 383697, upload-time = "2025-10-27T19:30:44.03Z" }, + { url = "https://files.pythonhosted.org/packages/ed/43/5c0aabb8e5ec3a53cc723853aef484a5a8e8e8992f65d80725598839759c/hydraters-0.1.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f3ecb740a638f4ac80524e59cc53e07aac6251665faefb47345c93a47c0717", size = 269271, upload-time = "2025-10-27T19:30:54.222Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/6c38922814f31ba24a904cc52acbc98f200803f817e9a00dc8557d282908/hydraters-0.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256b51fcffe2473ee0a16117e5e8660278d38cfa4a607276241bfcccb6510cd6", size = 247518, upload-time = "2025-10-27T19:31:13.845Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d6/b296999fc6f9b4bbe55c9bb3dfb614ae53291ca0327a174a9c898f27c629/hydraters-0.1.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:01af0bec141c86bed5662c57c2029912411b17d0608390e36b8c1f6991662842", size = 258390, upload-time = "2025-10-27T19:31:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a6/3f9e8e5c4459cfcb2fd949030df3b119345d544e45aa4a24ffba0e0823d5/hydraters-0.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea025d64fa30b9e8b716d8b810f5eed8056f136d34016677da085da3d62835ec", size = 420114, upload-time = "2025-10-27T19:31:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6a/6b91766e312f17ab9b2bc0d353f9fd272e7e9cd489c9f86027275a195caf/hydraters-0.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8620a3493dfafb9317cda7f6c8ce9eb80d53bcd59feef79228c2e1590f5b3bd6", size = 512653, upload-time = "2025-10-27T19:31:43.368Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/39899df4da19ed2c01bd4aa0f2f58ecc107335430654c13a9910efbd8692/hydraters-0.1.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3b4c12c7cd8a582decf6a6f57fe85e75eb1046e28a48075995dcefcca11cf009", size = 437930, upload-time = "2025-10-27T19:31:54.13Z" }, + { url = "https://files.pythonhosted.org/packages/bb/24/250943a5013e064d7fc3f9939c6b25281abfe2cfddbdfb7121ff394524b2/hydraters-0.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4d3ab310279af941831a50efad346b5cffcb688aa0644477eea92a724c830ee", size = 409535, upload-time = "2025-10-27T19:32:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/f5/58/d69abce2ecc0547297f0b6952c16a587ef26be8a00fa7ea7638e283dc4f8/hydraters-0.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:a718e570a390cc1fd625be039c4fb1979c8236e5996ccb1272c95659f0eca246", size = 109110, upload-time = "2025-10-27T19:32:16.494Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/ae2465a2b3043eef9ca306c9542d858ef74c3124f79060edf21343eac922/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3d1b0cbea0d935a55d139617956feaf433e68d229239b0e2cdc22e6566d891", size = 238086, upload-time = "2025-10-27T19:30:23.655Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/6fa3f1a62efd9dbb75faa1ba621945573e5a48bfff35294a8ccea91913a0/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:88dbaf32a311ae1b6249f67e6c56f7d9078cb1c9ffdb3e4e4b5aec04dfb0bc22", size = 247478, upload-time = "2025-10-27T19:30:35.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/7d4fbea5d9a299ee88b5e708ba5d6fa0fa949093249996f8ee7d8bc0242f/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff1d32b5bf5cf60e52bd72a74410745d79b2acde43394dbcc707084171bd6ba8", size = 384876, upload-time = "2025-10-27T19:30:45.413Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/1c4877c9b15c9a9f9ba8242af1b1dc3fb644711b8a58e03e63872eecbee5/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2072dbf3165ef6d6a5ecdb7161207358926679b74d8aa285f151974879f0ad5a", size = 268190, upload-time = "2025-10-27T19:30:55.794Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/65f95fdf6c6939f131f7e903606792ecefc64700476819cf0c23dcc48e86/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8bfd5ee925e525861c4513afd4aaa56455ca5f9f3daf458846de4888860bfe8c", size = 418617, upload-time = "2025-10-27T19:31:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cc/87033e41f13fd4bdc003248a6fe65e880c406581605de7d2234c15a0317b/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0249bcfbdfca7bd41103bb162c3f51af200d430ff3203f9dced35c62ec5ecc72", size = 511151, upload-time = "2025-10-27T19:31:44.772Z" }, + { url = "https://files.pythonhosted.org/packages/ca/27/5c9bcebf946842d8289f1591407b3a1387541eb220cf27c9b32378037f03/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3a96e9b0a3be6ec2064f8556406fc4f225237fcaed1c118837a0ac9be2e62692", size = 437101, upload-time = "2025-10-27T19:31:55.632Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/558519cd9b2888ef2859bef96d7338930377f3f629ec0f3b4df242083ec7/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e4add7c611aae45e17eebda71f5d5d918dbb5515f4cbf33a3e8635a915f9b94", size = 408715, upload-time = "2025-10-27T19:32:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/f5df44b3883295c342f8ba8bb7478ce36bd8de0513bc5c9d2aafa517dd88/hydraters-0.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:015770cba7cee9899f15fafc9486b77293c65dfd091da167d529546c8f2a4eee", size = 212845, upload-time = "2025-10-27T19:31:24.147Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/6f775dd489fb85d5c2dbb9f50f7570130753b6a66b0263e9bc5dc0e42246/hydraters-0.1.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab670257e094d42e7cee1886109ea5af8ccb40eb4a3e8fc574550cad864276c2", size = 246841, upload-time = "2025-10-27T19:31:15.101Z" }, + { url = "https://files.pythonhosted.org/packages/73/c1/0f2adcb91f0066b2ddcb59922adb2423a6b54d99d8c9169fea3f246e3082/hydraters-0.1.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ed6a9760a7c5dd9323b354d26fc93b7819257f072300897efd37b518babbb9b", size = 257948, upload-time = "2025-10-27T19:31:05.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/37/c894a1e3cc2c19ac92d401957d703a620f726d2bab32c376c05c5ce3c75a/hydraters-0.1.3-cp314-cp314-win32.whl", hash = "sha256:be4492c6ed1d96cfa2cbc76b8e63163b878c51165c53712033906fb4539fd63a", size = 104209, upload-time = "2025-10-27T19:32:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/0ab4d10a51f512fa607501b67b1db53389465457b65cd275891537aebb7c/hydraters-0.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0475e847adad810228f544da7b5e3ae9ab5c49e91f6fe37d39e47ac5e847a426", size = 109215, upload-time = "2025-10-27T19:32:17.724Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "json-merge-patch" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/eb/776e896abba05e810022a2ddd907f0b761164330f259279079ee81fe1898/json_merge_patch-0.3.0.tar.gz", hash = "sha256:4a022d78fc2f09cb49d96c646efc380d3b5ead2b5c7dabe22c3928c2c2e9c4e0", size = 5026, upload-time = "2025-03-25T09:48:41.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl", hash = "sha256:e0a593719b293ff63858ecaae3afbcb4ff0b57f785453c6783d7b0c3e2708b76", size = 5482, upload-time = "2025-03-25T09:48:40.4Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "mangum" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/e0/6ee9bfa27226252a449cba12fc57d3f1c3ce661813377ab33e29245389a4/mangum-0.19.0.tar.gz", hash = "sha256:e388e7c491b7b67970f8234e46fd4a7b21ff87785848f418de08148f71cf0bd6", size = 85792, upload-time = "2024-09-26T20:44:49.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/ec/dd1cae5f6b1b4a08c01de587b45e889036b2f8c06408621e0cb273909965/mangum-0.19.0-py3-none-any.whl", hash = "sha256:e500b35f495d5e68ac98bc97334896d6101523f2ee2c57ba6a61893b65266e59", size = 17083, upload-time = "2024-09-26T20:44:48.357Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "stac-fastapi-api" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli-asgi" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/13/5cb1ecd4ccec6f4dd9e1bf8ffe7dda257d31da73603e43e4c2805c33f631/stac_fastapi_api-6.2.1.tar.gz", hash = "sha256:049b52530d56c6f1ab4da6249a112f0031b3cf19ee95af9c212a011f137b9e17", size = 11816, upload-time = "2026-02-10T15:34:46.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/7a/89ed5747787473c2904ad1177fcaff049ee3bd796b788e492d54c4ff69da/stac_fastapi_api-6.2.1-py3-none-any.whl", hash = "sha256:c2b9ea71d0f0eb97510eb00a2fb6fdb9b03838c6fee940d8ea5602923e664a63", size = 14153, upload-time = "2026-02-10T15:34:46.803Z" }, +] + +[[package]] +name = "stac-fastapi-extensions" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "stac-fastapi-api" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/62/c71166b1f959ada3ea72e12ed5270e6b59b4e18f1b2f1825f4f8e58896c4/stac_fastapi_extensions-6.2.1.tar.gz", hash = "sha256:355c0c5f0d2c9b87d0238fff031963b2878d66a67faec93ff6f47d530aa370c0", size = 16673, upload-time = "2026-02-10T15:34:43.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0e/f6306c9a984934eb17ff27f61b57c37942adda86302833f3dd9ec64eede8/stac_fastapi_extensions-6.2.1-py3-none-any.whl", hash = "sha256:d72f5c9323df3f54a9731f3813f9b036b81235e8728a17a6c99ca202751e4308", size = 34011, upload-time = "2026-02-10T15:34:44.239Z" }, +] + +[[package]] +name = "stac-fastapi-pgstac" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asyncpg" }, + { name = "attrs" }, + { name = "brotli-asgi" }, + { name = "buildpg" }, + { name = "cql2" }, + { name = "hydraters" }, + { name = "json-merge-patch" }, + { name = "jsonpatch" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "stac-fastapi-api" }, + { name = "stac-fastapi-extensions" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/4d/8c2c19d7516c877d0ea511b666dcfaccdd0a29e3dc16eae05d6b5919eba8/stac_fastapi_pgstac-6.2.2.tar.gz", hash = "sha256:bdefccbcadb5c1247c545ac92095bae6c06fc6a307b73849c7848eaa368cae69", size = 22300, upload-time = "2026-02-10T11:46:10.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/83/aedffbd2ee320e2fe4c24c60b2d5cc5071aba29c27c8253b2990e7a783f2/stac_fastapi_pgstac-6.2.2-py3-none-any.whl", hash = "sha256:1f2d044beefe6b2fd8882607c50951c57400d8f77e7d0fbb37118bffcad08feb", size = 26723, upload-time = "2026-02-10T11:46:12.145Z" }, +] + +[[package]] +name = "stac-fastapi-types" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "fastapi" }, + { name = "iso8601" }, + { name = "pydantic-settings" }, + { name = "stac-pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/c6/34035e9db29f441d461c72df7d97b2f2ff322cd39fcac4057c5c8d070f9e/stac_fastapi_types-6.2.1.tar.gz", hash = "sha256:08e0a2f5304afcc65820861946b21f77b4511810d5e877f41736cdc51a471489", size = 10620, upload-time = "2026-02-10T15:34:41.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5c/e8826215add172cdea041390be03eb58eeb8da3bf37eec2ad1589ebbeca2/stac_fastapi_types-6.2.1-py3-none-any.whl", hash = "sha256:0cd0b697431c13a335df5ff99b6d84303187f1612979021912d9a952fa38e3bc", size = 13555, upload-time = "2026-02-10T15:34:40.699Z" }, +] + +[[package]] +name = "stac-pydantic" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "geojson-pydantic" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/4cf716bf9a7fc2cd288b3bebb7e720f4fb214076352a5a4e6336ee63f0d4/stac_pydantic-3.5.1.tar.gz", hash = "sha256:1332136b5b4b80a8fbb0b40b02d1ea6ff220b9f7f33f72886d4cfb4966fca3b9", size = 16303, upload-time = "2026-05-05T07:57:35.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ae/3804a96068df879cce3d28c95b1be5d0186e44b40e6c52348b60082a05b9/stac_pydantic-3.5.1-py3-none-any.whl", hash = "sha256:3dd981ea150f5ee99836a7bbb43bc14ad2b12cba95f28839d6a969a9a6d89b9b", size = 25649, upload-time = "2026-05-05T07:57:36.64Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "starlette-cramjam" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cramjam" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/05/4a478b9d43b73496b2f22b9af8239902065f1e1cd39d7f98cf269f776041/starlette_cramjam-0.4.0.tar.gz", hash = "sha256:bd36e68109b13c29d1e7aa0ddb7eaf614bfd144be99d8dcb5ece95c96dbcec17", size = 7946, upload-time = "2024-10-17T16:10:53.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/46/de94ca00de3505c2d7b802ac5e581eef1fb0b3039966d7655aa7b53f6821/starlette_cramjam-0.4.0-py3-none-any.whl", hash = "sha256:c1943087641c8ed5a08fc166664875a1f44c6f1de4301ed21f23a261df821c1b", size = 7201, upload-time = "2024-10-17T16:10:51.203Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..924306b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,98 @@ +services: + stac: + platform: linux/amd64 + build: + context: ./cdk + dockerfile: dockerfiles/Dockerfile.stac + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" + environment: + PYTHONUNBUFFERED: "1" + PYTHONPATH: /asset + POSTGRES_USER: username + POSTGRES_PASS: password + POSTGRES_DBNAME: postgis + POSTGRES_HOST_READER: database + POSTGRES_HOST_WRITER: database + POSTGRES_PORT: "5432" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "1" + ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search + TITILER_ENDPOINT: http://raster:8082 + env_file: + - path: .env + required: false + - path: .stac.env + required: false + depends_on: + - database + - raster + command: + - /bin/bash + - -lc + - | + until python -c 'import socket; socket.create_connection(("database", 5432), 2).close()'; do sleep 1; done + uvicorn stac_fastapi.pgstac.app:app --host 0.0.0.0 --port 8081 --workers 1 + + raster: + platform: linux/amd64 + build: + context: ./cdk + dockerfile: dockerfiles/Dockerfile.raster + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:8082:8082" + environment: + PYTHONUNBUFFERED: "1" + PYTHONPATH: /asset + POSTGRES_USER: username + POSTGRES_PASS: password + POSTGRES_DBNAME: postgis + POSTGRES_HOST: database + POSTGRES_PORT: "5432" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "10" + CPL_TMPDIR: /tmp + GDAL_CACHEMAX: 75% + GDAL_INGESTED_BYTES_AT_OPEN: "32768" + GDAL_DISABLE_READDIR_ON_OPEN: EMPTY_DIR + GDAL_HTTP_MERGE_CONSECUTIVE_RANGES: "YES" + GDAL_HTTP_MULTIPLEX: "YES" + GDAL_HTTP_VERSION: "2" + VSI_CACHE: "TRUE" + VSI_CACHE_SIZE: "536870912" + MOSAIC_CONCURRENCY: "1" + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} + env_file: + - path: .env + required: false + - path: .raster.env + required: false + depends_on: + - database + command: + - /bin/bash + - -lc + - | + until python -c 'import socket; socket.create_connection(("database", 5432), 2).close()'; do sleep 1; done + uvicorn eoapi.raster.main:app --host 0.0.0.0 --port 8082 --workers 1 + + database: + image: ghcr.io/stac-utils/pgstac:v0.9.9 + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:5439:5432" + environment: + POSTGRES_USER: username + POSTGRES_PASSWORD: password + POSTGRES_DB: postgis + PGUSER: username + PGPASSWORD: password + PGDATABASE: postgis + command: postgres -N 500 + volumes: + - ./.pgdata:/var/lib/postgresql/data + +networks: + default: + name: maap-eoapi From 104c76f327a871cac04065fb69496039d3d8e333 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Thu, 14 May 2026 20:43:44 -0500 Subject: [PATCH 03/13] chore: incorporate stac-auth-proxy into spec --- .../specs/stac-api-collection-transactions.md | 90 ++++++++++++++----- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/dev-docs/specs/stac-api-collection-transactions.md b/dev-docs/specs/stac-api-collection-transactions.md index b774f5d..85150ee 100644 --- a/dev-docs/specs/stac-api-collection-transactions.md +++ b/dev-docs/specs/stac-api-collection-transactions.md @@ -10,6 +10,8 @@ The problem is that upstream `stac-fastapi` transaction support is all-or-nothin For MAAP, we only want collection-level transactions for now. We do not want item-management transaction routes exposed, documented, or accidentally usable. We also need an auth layer on the collection write routes, with HTTP Basic acceptable now and JWT expected later. Finally, this must be switchable per deployment so it can be enabled for `userInfrastructure` while other deployments stay read-only on the same runtime. +We also want to preserve a clean path to using `developmentseed/stac-auth-proxy` inside the runtime as in-process middleware rather than only as a separate reverse proxy. That project already provides OIDC/JWT enforcement, OpenAPI security augmentation, STAC Authentication Extension responses, and policy-driven filtering. Its docs explicitly support applying the middleware stack to an existing FastAPI app via `configure_app(...)`, which makes it relevant to the long-term auth design here. + ## Goals - Add a custom STAC API runtime under `cdk/runtimes/eoapi/stac/`. - Support collection transaction routes only: @@ -22,6 +24,7 @@ For MAAP, we only want collection-level transactions for now. We do not want ite - no OpenAPI entries - no item transaction conformance class - Add an auth layer for collection transaction routes. +- Preserve a clean path to optional in-process `stac-auth-proxy` middleware for future OIDC/JWT auth. - Make transaction support opt-in per `PgStacInfra` deployment. - Keep the existing read-only STAC API behavior unchanged when the feature is disabled. - Add a local `docker-compose.yml` for running the MAAP custom STAC and raster runtimes together during development. @@ -29,6 +32,7 @@ For MAAP, we only want collection-level transactions for now. We do not want ite ## Non-goals - Implement JWT auth now. +- Deploy `stac-auth-proxy` as a separate standalone reverse proxy in front of the Lambda as part of this first change. - Add item-level transaction support. - Build tenant- or collection-specific authorization rules. - Change ingestion flows outside the STAC API Lambda. @@ -39,6 +43,9 @@ For MAAP, we only want collection-level transactions for now. We do not want ite - `eoapi-cdk` already supports overriding Lambda code via `lambdaFunctionOptions.code` and `handler`. - Upstream `stac-fastapi-pgstac` v6.2 runtime imports `app` from `stac_fastapi.pgstac.app`, not `stac_fastapi.pgstac.main`. - Upstream transaction wiring currently couples collection and item transaction routes in one extension. +- `stac-auth-proxy` is primarily packaged as a reverse proxy, but its middleware stack can also be applied directly to an existing FastAPI app via `configure_app(...)`. +- Current `stac-auth-proxy` auth enforcement is OIDC/JWT-oriented. It does not replace the need for a simple first-pass Basic auth path. +- Running the full proxy app in front of the Lambda would add an extra hop and duplicate some request/response shaping that we already control in the runtime. - Secrets should not be stored as plaintext CDK config values when avoidable. - We should minimize blast radius for the public STAC deployment. @@ -50,9 +57,10 @@ The change has two layers. - Rebuild the STAC API app locally instead of relying on the upstream all-in-one transaction extension. - Register normal read-only STAC behavior exactly as today. - Conditionally register a MAAP-specific collection-transactions extension. - - this will just require a subclass of stac_fastapi.extensions.transaction.TransactionExtension with a `register()` method that omits the item routes + - this will just require a subclass of `stac_fastapi.extensions.transaction.TransactionExtension` with a `register()` method that omits the item routes - Apply auth only to the collection write routes. - - use the upstream `TransactionExtension(..., route_dependencies=...)` support added in `stac-fastapi` PR #885 once that release is available + - for initial Basic auth, use the upstream `TransactionExtension(..., route_dependencies=...)` support added in `stac-fastapi` PR #885 once that release is available + - Keep an auth-provider seam so future OIDC/JWT mode can install `stac-auth-proxy` middleware in-process on the same FastAPI app instead of introducing a separate proxy tier. 2. Infrastructure layer - Add a `transactions` config block under `stacApiConfig` in `PgStacInfra` props. @@ -144,22 +152,22 @@ This should stay intentionally small: reuse upstream route helper methods such a ### Auth model Add a small auth abstraction in `eoapi/stac/auth.py`. -Initial modes: -- `none` -- `basic` +Initial and planned modes: +- `basic` for the first implementation +- `oidc` as the future mode backed by `stac-auth-proxy` middleware -The route-level dependency contract is: +The route-level dependency contract for the initial Basic path is: ```python async def require_transaction_auth(request: Request) -> None: ... ``` -Behavior: +Behavior in `basic` mode: - passed through `TransactionExtension(..., route_dependencies=[...])` when collection transactions are enabled - applied only to collection transaction routes - no effect on read-only routes -- returns `401` with `WWW-Authenticate: Basic` when credentials are missing or invalid in basic mode +- returns `401` with `WWW-Authenticate: Basic` when credentials are missing or invalid - should not be wired per-route manually unless the upstream release plan changes Basic auth credential source: @@ -174,25 +182,48 @@ Basic auth credential source: } ``` +Planned `oidc` mode using `stac-auth-proxy` middleware: +- do not run the full reverse proxy app in front of the Lambda +- instead, apply the middleware stack to the in-process FastAPI app, using `stac_auth_proxy.configure_app(app, settings=...)` or selected middleware classes directly if we need tighter control +- configure path/method protection so only collection transaction routes are private: + +```json +{ + "^/collections$": ["POST"], + "^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"] +} +``` + +- do not mark item transaction routes as private because they should not exist in this runtime variant +- optionally use its OpenAPI and Authentication Extension middleware so docs and STAC responses advertise OIDC requirements consistently +- leave filtering middleware disabled unless and until we explicitly adopt record-level authorization + +Why this is a future path rather than the first implementation: +- `stac-auth-proxy` currently assumes OIDC/JWT, not Basic auth +- our immediate need is a minimal collection-write guard +- the in-process middleware route is still valuable because it gives us a ready-made JWT/OIDC layer later without adding a separate network hop + Future JWT compatibility: - auth dispatch should be mode-based, not hardcoded inside route handlers -- adding `jwt` later should require a new verifier implementation, not route rewrites +- adding `oidc` later should require selecting a different auth provider, not rewriting collection transaction routes ### Runtime environment variables The custom runtime should support these env vars in addition to existing STAC API env vars: ```text ENABLED_EXTENSIONS=collection_transaction,collection_search,... -MAAP_TRANSACTION_AUTH_MODE=none|basic +MAAP_TRANSACTION_AUTH_MODE=basic|oidc MAAP_TRANSACTION_AUTH_SECRET_ARN=arn:aws:secretsmanager:... +MAAP_OIDC_DISCOVERY_URL=https://issuer/.well-known/openid-configuration +MAAP_ALLOWED_JWT_AUDIENCES=aud1,aud2 ``` - Rules: - if `collection_transaction` is not in the `ENABLED_EXTENSIONS` env var, do not register the transaction endpoints - if enabled and auth mode is `basic`, secret ARN is required -- if enabled and auth mode is `none`, raise an error +- if enabled and auth mode is `oidc`, OIDC discovery URL is required - item transaction routes remain absent in all modes for this runtime version +- the runtime may internally map MAAP env vars into `stac-auth-proxy` settings rather than exposing the proxy's full env surface directly ### DB connection behavior The runtime must create a write pool only when collection transactions are enabled. @@ -214,8 +245,10 @@ stacApiConfig: { integrationApiArn?: string; transactions?: { enabled: boolean; - authMode: "basic"; + authMode: "basic" | "oidc"; authSecretArn?: string; + oidcDiscoveryUrl?: string; + allowedJwtAudiences?: string[]; }; }; ``` @@ -224,7 +257,8 @@ Validation rules: - `transactions` omitted => current behavior - `transactions.enabled === false` => current behavior - `transactions.enabled === true` and `authMode === "basic"` => `authSecretArn` required -- `transactions.enabled === true` and `authMode === "none"` => no secret required +- `transactions.enabled === true` and `authMode === "oidc"` => `oidcDiscoveryUrl` required +- `transactions.enabled === true` and `authMode === "oidc"` with audience enforcement => `allowedJwtAudiences` optional but recommended ### CDK usage Initial intended usage in `cdk/app.ts`: @@ -253,7 +287,7 @@ For all deployments: - pass extension-selection env vars through `apiEnv` - do not rely on upstream `enabledExtensions` transaction flag -When transactions are enabled, also pass auth env vars and secret access. +When transactions are enabled, also pass auth env vars and, for `basic` mode, secret access. This preserves existing API Gateway, custom domain, VPC, DB, and SnapStart behavior managed by `eoapi-cdk` while standardizing on one MAAP-owned runtime. @@ -266,8 +300,10 @@ New configuration data introduced: ```ts interface StacTransactionsConfig { enabled: boolean; - authMode: "basic"; + authMode: "basic" | "oidc"; authSecretArn?: string; + oidcDiscoveryUrl?: string; + allowedJwtAudiences?: string[]; } ``` @@ -301,6 +337,8 @@ Add optional config values for the user stack, for example: USER_STAC_COLLECTION_TRANSACTIONS_ENABLED USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN +USER_STAC_COLLECTION_TRANSACTIONS_OIDC_DISCOVERY_URL +USER_STAC_COLLECTION_TRANSACTIONS_ALLOWED_JWT_AUDIENCES ``` These should default to disabled when unset. @@ -310,6 +348,7 @@ Likely touch points: - `test/config.test.ts` for config parsing - new unit or synth-level tests for `PgStacInfra` transaction config behavior - runtime tests for auth and route exposure +- if we add `oidc` mode later, runtime tests that `stac-auth-proxy` middleware protects only collection transaction routes and leaves read routes untouched ### Docs Update at least: @@ -324,13 +363,14 @@ Update at least: 4. Add transactions config to `PgStacInfra` and config loading in `cdk/config.ts`. 5. Wire `userInfrastructure` to use the new config. 6. When the `stac-fastapi` release that includes PR #885 is available, update any MAAP dependency pins needed to consume it and finish the extension-level auth attachment through `route_dependencies`. -7. Deploy to a non-prod internal environment. -8. Verify: +7. Keep the runtime auth abstraction narrow so a later `oidc` mode can install `stac-auth-proxy` middleware without changing the collection transaction extension. +8. Deploy to a non-prod internal environment. +9. Verify: - collection transaction routes work with auth - item transaction routes return `404` - OpenAPI docs show only collection transaction routes - conformance output includes only the collection transaction class -9. Promote to other user STAC deployments as needed. +10. Promote to other user STAC deployments as needed. No backfill or data migration is required. @@ -345,6 +385,8 @@ Add Python tests around the custom app builder: - conformance classes exclude item transaction URI - basic auth rejects unauthenticated requests with `401` - basic auth accepts valid credentials +- future `oidc` mode can be enabled without reintroducing item transaction docs or routes +- future `oidc` mode protects only the collection transaction endpoints when configured with collection-only private endpoint regexes ### Infrastructure tests Add TypeScript tests for: @@ -375,7 +417,8 @@ Post-deploy manual/API checks: | Use one custom runtime for all STAC deployments | Only use the custom runtime when transactions are enabled; patch routes in place; use stock runtime unchanged | Keeps route behavior switchable per deployment while avoiding two runtime code paths for the same API surface | | Keep a near 1:1 local copy of `stac_fastapi.pgstac.app` | Mutate the upstream app after import; reassemble a more custom MAAP app from smaller pieces | A near-identical copy keeps behavior aligned with upstream while making the transaction-extension substitution explicit and reviewable | | Implement a collection-only extension | Monkeypatch the upstream router | A local extension is explicit, testable, and resilient to upstream internal changes | -| Use `TransactionExtension.route_dependencies` for auth attachment when available | API Gateway auth only; middleware on all routes; manually attaching dependencies per route | We only need protection on collection write routes, and the upstream `route_dependencies` hook added in PR #885 gives us a cleaner extension-level attachment point once released, without blocking the rest of the runtime and infrastructure work | +| Use `TransactionExtension.route_dependencies` for initial Basic auth attachment | API Gateway auth only; middleware on all routes; manually attaching dependencies per route | We only need protection on collection write routes right now, and the upstream `route_dependencies` hook added in PR #885 gives us a clean extension-level attachment point for the minimal Basic-auth first step | +| Preserve `stac-auth-proxy` as an in-process middleware option for future OIDC/JWT | Run a standalone reverse proxy in front of the Lambda; build our own JWT middleware from scratch; ignore the project for now | `stac-auth-proxy` already solves OIDC enforcement, OpenAPI security augmentation, and STAC Authentication Extension responses. Using it in-process keeps that path open without committing this first iteration to a separate proxy hop or to OIDC immediately | | Store basic auth credentials in Secrets Manager | Plain env vars; SSM parameters | Secrets Manager is the least bad option for credentials and matches existing Lambda secret-read patterns | | Omit item transaction routes entirely | Expose them but block with auth/authorization | Not registering them is safer and keeps docs/conformance honest | @@ -385,6 +428,8 @@ Post-deploy manual/API checks: - Should basic auth credentials be a single shared writer credential, or do we expect multiple clients soon enough to justify a richer secret format? - Do we want CloudWatch metrics or structured logs specifically for collection transaction attempts and auth failures? - Which released `stac-fastapi` version first includes PR #885, and do any downstream MAAP dependencies need version bumps before we can rely on it for the final auth attachment? +- If we adopt `stac-auth-proxy` in-process later, should we call `configure_app(...)` wholesale or add only `EnforceAuthMiddleware`, `OpenApiMiddleware`, and `AuthenticationExtensionMiddleware` directly for tighter control? +- Do we want the future `oidc` mode to expose the STAC Authentication Extension immediately, or should that remain separately configurable? - How do we want to keep the local `app.py` copy aligned with future upstream `stac-fastapi-pgstac` changes after this fork lands? - Is there any existing upstream work toward collection-only transaction registration that we may want to track before maintaining this long term? @@ -401,4 +446,9 @@ Post-deploy manual/API checks: - `https://github.com/stac-utils/stac-fastapi/blob/main/stac_fastapi/extensions/stac_fastapi/extensions/transaction/transaction.py` - `https://github.com/stac-utils/stac-fastapi/issues/884` - `https://github.com/stac-utils/stac-fastapi/pull/885` +- `https://github.com/developmentseed/stac-auth-proxy` +- `https://developmentseed.org/stac-auth-proxy/user-guide/getting-started/` +- `https://developmentseed.org/stac-auth-proxy/user-guide/configuration/` +- `https://developmentseed.org/stac-auth-proxy/user-guide/route-level-auth/` +- `https://developmentseed.org/stac-auth-proxy/architecture/middleware-stack/` - `/home/henry/workspace/devseed/eoapi-devseed/docker-compose.yml` From 9b2a6c05c43c4c49eca219f7e6c41fb5f061dd1b Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 09:44:37 -0500 Subject: [PATCH 04/13] chore: get local docker network working --- cdk/PgStacInfra.ts | 1 + cdk/dockerfiles/Dockerfile.raster | 10 ++++++++- cdk/dockerfiles/Dockerfile.stac | 10 ++++++++- cdk/runtimes/eoapi/raster/uv.lock | 2 +- cdk/runtimes/eoapi/stac/README.md | 12 +++++++++-- cdk/runtimes/eoapi/stac/pyproject.toml | 12 +++++------ cdk/runtimes/eoapi/stac/uv.lock | 13 ++++++----- docker-compose.yml | 30 ++++++++++++++++---------- 8 files changed, 63 insertions(+), 27 deletions(-) diff --git a/cdk/PgStacInfra.ts b/cdk/PgStacInfra.ts index 87f09e9..f045541 100644 --- a/cdk/PgStacInfra.ts +++ b/cdk/PgStacInfra.ts @@ -141,6 +141,7 @@ export class PgStacInfra extends Stack { const titilerPgstacLambdaOptions: CustomLambdaFunctionProps = { code: lambda.Code.fromDockerBuild(__dirname, { file: "dockerfiles/Dockerfile.raster", + targetStage: "lambda", buildArgs: { PYTHON_VERSION: "3.12" }, }), handler: "handler.handler", diff --git a/cdk/dockerfiles/Dockerfile.raster b/cdk/dockerfiles/Dockerfile.raster index a07facc..35922c2 100644 --- a/cdk/dockerfiles/Dockerfile.raster +++ b/cdk/dockerfiles/Dockerfile.raster @@ -1,6 +1,6 @@ ARG PYTHON_VERSION=3.12 -FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} +FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} AS lambda COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ # Install system dependencies to compile (numexpr) @@ -32,4 +32,12 @@ RUN rm -rdf /asset/numpy/doc/ /asset/boto3* /asset/botocore* /asset/bin /asset/g COPY handlers/raster_handler.py /asset/handler.py +FROM lambda AS local +RUN uv pip install \ + --python /var/lang/bin/python \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + uvicorn + CMD ["echo", "hello world"] diff --git a/cdk/dockerfiles/Dockerfile.stac b/cdk/dockerfiles/Dockerfile.stac index ae70511..4ec3e16 100644 --- a/cdk/dockerfiles/Dockerfile.stac +++ b/cdk/dockerfiles/Dockerfile.stac @@ -1,6 +1,6 @@ ARG PYTHON_VERSION=3.12 -FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} +FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} AS lambda COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ RUN dnf install -y findutils && dnf clean all && rm -rf /var/cache/dnf @@ -22,4 +22,12 @@ RUN cd /asset && find . -type d -a -name '__pycache__' -print0 | xargs -0 rm -rf RUN cd /asset && find . -type f -a -name '*.py' -print0 | xargs -0 rm -f RUN find /asset -type d -a -name 'tests' -print0 | xargs -0 rm -rf +FROM lambda AS local +RUN uv pip install \ + --python /var/lang/bin/python \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + uvicorn + CMD ["echo", "hello world"] diff --git a/cdk/runtimes/eoapi/raster/uv.lock b/cdk/runtimes/eoapi/raster/uv.lock index 188cda1..71a4297 100644 --- a/cdk/runtimes/eoapi/raster/uv.lock +++ b/cdk/runtimes/eoapi/raster/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md index bc3c52e..ecc0711 100644 --- a/cdk/runtimes/eoapi/stac/README.md +++ b/cdk/runtimes/eoapi/stac/README.md @@ -12,7 +12,7 @@ Start the local pgSTAC + STAC + raster stack from the repository root: docker compose up --build stac raster database ``` -The local compose setup keeps STAC transaction behavior disabled by default. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. +The local compose setup keeps STAC collection transactions disabled by default. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. ### Environment shape @@ -29,8 +29,16 @@ The local STAC service uses the same pgSTAC-style environment variables already - `ENABLED_EXTENSIONS` - `TITILER_ENDPOINT` +The local raster service also expects mosaic settings, so the compose file provides development defaults for: + +- `MOSAIC_BACKEND` +- `MOSAIC_HOST` + ### Packaging notes -- `cdk/dockerfiles/Dockerfile.stac` builds the Lambda asset from this package. +- `cdk/dockerfiles/Dockerfile.stac` has separate `lambda` and `local` targets. - The Docker build context for local and CDK builds is `cdk/`. +- `docker-compose.yml` builds the `local` target, which layers `uvicorn` on top of the runtime asset for local development only. +- Lambda builds should continue using the default `lambda` target without `uvicorn`. +- The local compose stack runs the MAAP app via `uvicorn eoapi.stac.main:app`. - Runtime behavior, auth, and collection transaction wiring will land in this package in follow-up units. diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml index 35dd2d7..72712a4 100644 --- a/cdk/runtimes/eoapi/stac/pyproject.toml +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -22,12 +22,6 @@ dependencies = [ "starlette-cramjam>=0.4,<0.5", ] -[project.optional-dependencies] -test = [ - "httpx>=0.28.1", - "pytest>=8.3.5", -] - [build-system] requires = ["pdm-pep517"] build-backend = "pdm.pep517.api" @@ -39,3 +33,9 @@ path = "eoapi/stac/__init__.py" [tool.pdm.build] includes = ["eoapi/stac"] excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.3", +] diff --git a/cdk/runtimes/eoapi/stac/uv.lock b/cdk/runtimes/eoapi/stac/uv.lock index 40b5a6a..ad11adb 100644 --- a/cdk/runtimes/eoapi/stac/uv.lock +++ b/cdk/runtimes/eoapi/stac/uv.lock @@ -239,21 +239,24 @@ dependencies = [ { name = "starlette-cramjam" }, ] -[package.optional-dependencies] -test = [ +[package.dev-dependencies] +dev = [ { name = "httpx" }, { name = "pytest" }, ] [package.metadata] requires-dist = [ - { name = "httpx", marker = "extra == 'test'", specifier = ">=0.28.1" }, { name = "mangum", specifier = "==0.19" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.5" }, { name = "stac-fastapi-pgstac", specifier = ">=6.2,<6.3" }, { name = "starlette-cramjam", specifier = ">=0.4,<0.5" }, ] -provides-extras = ["test"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, +] [[package]] name = "fastapi" diff --git a/docker-compose.yml b/docker-compose.yml index 924306b..855bc22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,10 @@ services: build: context: ./cdk dockerfile: dockerfiles/Dockerfile.stac + target: local + entrypoint: + - /bin/bash + - -lc ports: - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" environment: @@ -17,7 +21,7 @@ services: POSTGRES_PORT: "5432" DB_MIN_CONN_SIZE: "1" DB_MAX_CONN_SIZE: "1" - ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search + ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction TITILER_ENDPOINT: http://raster:8082 env_file: - path: .env @@ -28,17 +32,20 @@ services: - database - raster command: - - /bin/bash - - -lc - - | - until python -c 'import socket; socket.create_connection(("database", 5432), 2).close()'; do sleep 1; done - uvicorn stac_fastapi.pgstac.app:app --host 0.0.0.0 --port 8081 --workers 1 + - >- + until (echo > /dev/tcp/database/5432) >/dev/null 2>&1; do sleep 1; done + && python -m uvicorn eoapi.stac.main:app --host 0.0.0.0 --port 8081 + --workers 1 raster: platform: linux/amd64 build: context: ./cdk dockerfile: dockerfiles/Dockerfile.raster + target: local + entrypoint: + - /bin/bash + - -lc ports: - "${MY_DOCKER_IP:-127.0.0.1}:8082:8082" environment: @@ -60,6 +67,8 @@ services: GDAL_HTTP_VERSION: "2" VSI_CACHE: "TRUE" VSI_CACHE_SIZE: "536870912" + MOSAIC_BACKEND: dynamodb:// + MOSAIC_HOST: ${MOSAIC_HOST:-localhost} MOSAIC_CONCURRENCY: "1" AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} @@ -72,11 +81,10 @@ services: depends_on: - database command: - - /bin/bash - - -lc - - | - until python -c 'import socket; socket.create_connection(("database", 5432), 2).close()'; do sleep 1; done - uvicorn eoapi.raster.main:app --host 0.0.0.0 --port 8082 --workers 1 + - >- + until (echo > /dev/tcp/database/5432) >/dev/null 2>&1; do sleep 1; done + && python -m uvicorn eoapi.raster.main:app --host 0.0.0.0 --port 8082 + --workers 1 database: image: ghcr.io/stac-utils/pgstac:v0.9.9 From 6a73b110c7e0756b2effc84866aa380dcd6ac125 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 13:28:56 -0500 Subject: [PATCH 05/13] feat: add basic auth to the collections transactions endpoints --- cdk/runtimes/eoapi/stac/README.md | 23 ++- cdk/runtimes/eoapi/stac/eoapi/stac/auth.py | 127 +++++++++++++ cdk/runtimes/eoapi/stac/eoapi/stac/handler.py | 150 +++++++++++++++ cdk/runtimes/eoapi/stac/eoapi/stac/main.py | 23 ++- .../eoapi/stac/eoapi/stac/settings.py | 26 +++ cdk/runtimes/eoapi/stac/pyproject.toml | 1 + cdk/runtimes/eoapi/stac/tests/test_app.py | 33 +++- cdk/runtimes/eoapi/stac/tests/test_auth.py | 176 ++++++++++++++++++ docker-compose.yml | 14 +- pyproject.toml | 1 + 10 files changed, 565 insertions(+), 9 deletions(-) create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/auth.py create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/handler.py create mode 100644 cdk/runtimes/eoapi/stac/eoapi/stac/settings.py create mode 100644 cdk/runtimes/eoapi/stac/tests/test_auth.py diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md index ecc0711..6bbb8be 100644 --- a/cdk/runtimes/eoapi/stac/README.md +++ b/cdk/runtimes/eoapi/stac/README.md @@ -12,7 +12,18 @@ Start the local pgSTAC + STAC + raster stack from the repository root: docker compose up --build stac raster database ``` -The local compose setup keeps STAC collection transactions disabled by default. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. +The local compose setup bind-mounts `cdk/runtimes/eoapi/stac/` into the container and runs `uvicorn --reload`, so changes under `cdk/runtimes/eoapi/stac/eoapi/stac/` are picked up without rebuilding the image. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. + +When you enable collection transactions, the runtime now fails closed unless these env vars are present: + +- `MAAP_TRANSACTION_AUTH_MODE=basic` +- one of: + - `MAAP_TRANSACTION_AUTH_SECRET_ARN`, or + - both `MAAP_TRANSACTION_AUTH_USERNAME` and `MAAP_TRANSACTION_AUTH_PASSWORD` + +The secret form is intended for Lambda deployments. The username/password env-var form is intended for local docker-compose development. If a secret ARN is present, it takes precedence. + +The secret must be a JSON object with `username` and `password` string fields. ### Environment shape @@ -28,6 +39,10 @@ The local STAC service uses the same pgSTAC-style environment variables already - `DB_MAX_CONN_SIZE` - `ENABLED_EXTENSIONS` - `TITILER_ENDPOINT` +- `MAAP_TRANSACTION_AUTH_MODE` +- `MAAP_TRANSACTION_AUTH_USERNAME` +- `MAAP_TRANSACTION_AUTH_PASSWORD` +- `MAAP_TRANSACTION_AUTH_SECRET_ARN` The local raster service also expects mosaic settings, so the compose file provides development defaults for: @@ -40,5 +55,7 @@ The local raster service also expects mosaic settings, so the compose file provi - The Docker build context for local and CDK builds is `cdk/`. - `docker-compose.yml` builds the `local` target, which layers `uvicorn` on top of the runtime asset for local development only. - Lambda builds should continue using the default `lambda` target without `uvicorn`. -- The local compose stack runs the MAAP app via `uvicorn eoapi.stac.main:app`. -- Runtime behavior, auth, and collection transaction wiring will land in this package in follow-up units. +- The local compose stack runs the MAAP app via `uvicorn eoapi.stac.main:app --reload --reload-dir /workspace/eoapi/stac`. +- The Lambda runtime entrypoint is `eoapi.stac.handler.handler` and preserves the upstream SnapStart-aware connection lifecycle. +- Collection write-route auth is attached with FastAPI security dependencies on `POST /collections` plus `PUT`, `PATCH`, and `DELETE /collections/{collection_id}`. +- Those dependencies are declared as HTTP Basic auth in OpenAPI, so Swagger UI shows the protected routes with the built-in auth flow instead of relying only on the browser challenge popup. diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py new file mode 100644 index 0000000..e2f9fd9 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -0,0 +1,127 @@ +"""Authentication helpers for collection transaction routes.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from hmac import compare_digest +from typing import Annotated, Any + +from fastapi import HTTPException, Security, status +from fastapi.params import Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from eoapi.stac.settings import ( + MAAP_TRANSACTION_AUTH_MODE_ENV, + MAAP_TRANSACTION_AUTH_PASSWORD_ENV, + MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, + MAAP_TRANSACTION_AUTH_USERNAME_ENV, + TransactionAuthSettings, +) + +_BASIC_AUTH_CHALLENGE_HEADERS = {"WWW-Authenticate": "Basic"} +basic_auth_scheme = HTTPBasic( + auto_error=False, + description="HTTP Basic authentication for collection transaction routes.", +) +transaction_auth_settings = TransactionAuthSettings() + + +@lru_cache(maxsize=None) +def load_secret_dict(secret_arn: str) -> dict[str, Any]: + """Load and parse a JSON secret from AWS Secrets Manager.""" + try: + import boto3 + except ImportError as error: + raise RuntimeError( + "boto3 is required to load Secrets Manager credentials" + ) from error + + response = boto3.client("secretsmanager").get_secret_value(SecretId=secret_arn) + secret_string = response.get("SecretString") + if not secret_string: + raise RuntimeError(f"Secret {secret_arn} did not contain SecretString") + + try: + secret_dict = json.loads(secret_string) + except json.JSONDecodeError as error: + raise RuntimeError(f"Secret {secret_arn} did not contain valid JSON") from error + + if not isinstance(secret_dict, dict): + raise RuntimeError(f"Secret {secret_arn} must decode to a JSON object") + + return secret_dict + + +def _unauthorized_basic_auth() -> HTTPException: + """Build the standard HTTP Basic auth challenge response.""" + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing basic authentication credentials", + headers=_BASIC_AUTH_CHALLENGE_HEADERS, + ) + + +def get_basic_auth_credentials() -> tuple[str, str]: + """Load basic-auth credentials from Secrets Manager or local env vars.""" + if transaction_auth_settings.secret_arn: + secret = load_secret_dict(transaction_auth_settings.secret_arn) + if not isinstance(secret.get("username"), str) or not isinstance( + secret.get("password"), str + ): + raise RuntimeError( + "Transaction auth secret must contain string fields 'username' and " + "'password'" + ) + return secret["username"], secret["password"] + + if transaction_auth_settings.username and transaction_auth_settings.password: + return transaction_auth_settings.username, transaction_auth_settings.password + + raise RuntimeError( + "Collection transactions require either MAAP_TRANSACTION_AUTH_SECRET_ARN or " + "both MAAP_TRANSACTION_AUTH_USERNAME and MAAP_TRANSACTION_AUTH_PASSWORD when " + "MAAP_TRANSACTION_AUTH_MODE=basic" + ) + + +def validate_transaction_auth_config() -> str: + """Validate transaction auth configuration and return the selected mode.""" + auth_mode = transaction_auth_settings.mode + if auth_mode != "basic": + raise RuntimeError( + "Collection transactions require MAAP_TRANSACTION_AUTH_MODE=basic for " + "this runtime version" + ) + + get_basic_auth_credentials() + + return auth_mode + + +async def require_transaction_auth( + credentials: Annotated[ + HTTPBasicCredentials | None, + Security(basic_auth_scheme), + ], +) -> None: + """Require valid transaction auth for collection write routes.""" + auth_mode = validate_transaction_auth_config() + if auth_mode != "basic": + raise RuntimeError(f"Unsupported transaction auth mode: {auth_mode}") + + if credentials is None: + raise _unauthorized_basic_auth() + + expected_username, expected_password = get_basic_auth_credentials() + if not compare_digest(credentials.username, expected_username) or not compare_digest( + credentials.password, + expected_password, + ): + raise _unauthorized_basic_auth() + + +def build_transaction_route_dependencies() -> list[Depends]: + """Build validated route dependencies for transaction routes.""" + validate_transaction_auth_config() + return [Depends(require_transaction_auth)] diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py new file mode 100644 index 0000000..9f5b86f --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py @@ -0,0 +1,150 @@ +"""AWS Lambda handler for the MAAP STAC runtime.""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from mangum import Mangum +from stac_fastapi.pgstac.config import PostgresSettings +from stac_fastapi.pgstac.db import close_db_connection, connect_to_db + +from eoapi.stac.auth import load_secret_dict +from eoapi.stac.main import ( + COLLECTION_TRANSACTION_EXTENSION, + app, + parse_enabled_extensions, +) + +try: + from snapshot_restore_py import register_after_restore, register_before_snapshot +except ImportError: + + def register_before_snapshot(func): + """Fallback decorator when snapshot_restore_py is unavailable.""" + return func + + def register_after_restore(func): + """Fallback decorator when snapshot_restore_py is unavailable.""" + return func + + +logger = logging.getLogger(__name__) + + +_CONNECTIONS_INITIALIZED = False +WITH_COLLECTION_TRANSACTIONS = ( + COLLECTION_TRANSACTION_EXTENSION + in parse_enabled_extensions(os.environ.get("ENABLED_EXTENSIONS")) +) + + +def _build_postgres_settings() -> PostgresSettings: + """Fetch pgSTAC credentials from Secrets Manager.""" + secret_arn = os.getenv("PGSTAC_SECRET_ARN") + if not secret_arn: + raise RuntimeError("PGSTAC_SECRET_ARN must be set for the STAC Lambda runtime") + + logger.info("Loading pgSTAC connection secret") + secret = load_secret_dict(secret_arn) + return PostgresSettings( + pghost=secret["host"], + pgdatabase=secret["dbname"], + pguser=secret["username"], + pgpassword=secret["password"], + pgport=int(secret["port"]), + ) + + +def _close_pool(pool_name: str) -> None: + """Close a database pool on the global app state if it exists.""" + pool = getattr(app.state, pool_name, None) + if not pool: + return + + try: + pool.close() + except Exception: + logger.exception("SnapStart: error closing %s", pool_name) + finally: + setattr(app.state, pool_name, None) + + +@register_before_snapshot +def on_snapshot(): + """Close DB pools before the Lambda snapshot is taken.""" + _close_pool("readpool") + _close_pool("writepool") + return {"statusCode": 200} + + +@register_after_restore +def on_snap_restore(): + """Recreate DB pools after a Lambda snapshot restore.""" + global _CONNECTIONS_INITIALIZED + + try: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + _close_pool("readpool") + _close_pool("writepool") + loop.run_until_complete( + connect_to_db( + app, + postgres_settings=_build_postgres_settings(), + add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, + ) + ) + _CONNECTIONS_INITIALIZED = True + except Exception: + logger.exception("SnapStart: failed to initialize database connection") + raise + + return {"statusCode": 200} + + +@app.on_event("startup") +async def startup_event() -> None: + """Connect to the database when the app starts.""" + logger.info("Setting up DB connection") + await connect_to_db( + app, + postgres_settings=_build_postgres_settings(), + add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, + ) + logger.info("DB connection setup complete") + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + """Close database pools during shutdown.""" + logger.info("Closing DB connection") + await close_db_connection(app) + logger.info("DB connection closed") + + +handler = Mangum( + app, + lifespan="off", + text_mime_types=["text/", "application/"], +) + + +if "AWS_EXECUTION_ENV" in os.environ and not _CONNECTIONS_INITIALIZED: + logger.info("Cold start: initializing database connection") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete( + connect_to_db( + app, + postgres_settings=_build_postgres_settings(), + add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, + ) + ) + _CONNECTIONS_INITIALIZED = True + logger.info("Database connection initialized") diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py index 4c261bf..5f34f72 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py @@ -7,6 +7,8 @@ from typing import cast from brotli_asgi import BrotliMiddleware +from eoapi.stac.auth import build_transaction_route_dependencies +from eoapi.stac.transactions import CollectionTransactionExtension from fastapi import APIRouter, FastAPI from stac_fastapi.api.app import StacApi from stac_fastapi.api.middleware import ProxyHeaderMiddleware @@ -18,6 +20,7 @@ create_post_request_model, create_request_model, ) +from stac_fastapi.api.routes import Scope from stac_fastapi.extensions.core import ( CollectionSearchExtension, CollectionSearchFilterExtension, @@ -44,8 +47,6 @@ from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware -from eoapi.stac.transactions import CollectionTransactionExtension - settings = Settings() COLLECTION_TRANSACTION_EXTENSION = "collection_transaction" @@ -91,6 +92,13 @@ COLLECTION_TRANSACTION_EXTENSION, } +TRANSACTION_ROUTE_SCOPES: list[Scope] = [ + {"path": "/collections", "method": "POST"}, + {"path": "/collections/{collection_id}", "method": "PUT"}, + {"path": "/collections/{collection_id}", "method": "PATCH"}, + {"path": "/collections/{collection_id}", "method": "DELETE"}, +] + def parse_enabled_extensions(raw_value: str | None) -> set[str]: """Parse and validate the ENABLED_EXTENSIONS environment value.""" @@ -158,8 +166,10 @@ def create_app( with_collection_transactions = ( COLLECTION_TRANSACTION_EXTENSION in resolved_extensions ) + transaction_route_dependencies = [] if with_collection_transactions: + transaction_route_dependencies = build_transaction_route_dependencies() application_extensions.append( CollectionTransactionExtension( client=TransactionsClient(), @@ -238,6 +248,11 @@ def create_app( middlewares=_build_middlewares(), health_check=health_check, # type: ignore[arg-type] ) + if transaction_route_dependencies: + api.add_route_dependencies( + scopes=TRANSACTION_ROUTE_SCOPES, + dependencies=transaction_route_dependencies, + ) return api.app @@ -246,7 +261,9 @@ def run() -> None: try: import uvicorn except ImportError as error: - raise RuntimeError("Uvicorn must be installed in order to use command") from error + raise RuntimeError( + "Uvicorn must be installed in order to use command" + ) from error uvicorn.run( "eoapi.stac.main:app", diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py new file mode 100644 index 0000000..f6e8603 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py @@ -0,0 +1,26 @@ +"""Settings for the MAAP STAC runtime.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + +MAAP_TRANSACTION_AUTH_MODE_ENV = "MAAP_TRANSACTION_AUTH_MODE" +MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV = "MAAP_TRANSACTION_AUTH_SECRET_ARN" +MAAP_TRANSACTION_AUTH_USERNAME_ENV = "MAAP_TRANSACTION_AUTH_USERNAME" +MAAP_TRANSACTION_AUTH_PASSWORD_ENV = "MAAP_TRANSACTION_AUTH_PASSWORD" + + +class TransactionAuthSettings(BaseSettings): + """Configuration for collection transaction authentication.""" + + mode: Literal["basic"] | None = None + secret_arn: str | None = None + username: str | None = None + password: str | None = None + + model_config = SettingsConfigDict( + env_prefix="MAAP_TRANSACTION_AUTH_", + extra="ignore", + ) diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml index 72712a4..df4b27c 100644 --- a/cdk/runtimes/eoapi/stac/pyproject.toml +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "mangum==0.19", + "pydantic-settings>=2,<3", "stac-fastapi-pgstac>=6.2,<6.3", "starlette-cramjam>=0.4,<0.5", ] diff --git a/cdk/runtimes/eoapi/stac/tests/test_app.py b/cdk/runtimes/eoapi/stac/tests/test_app.py index 4de8feb..3c68a1a 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_app.py +++ b/cdk/runtimes/eoapi/stac/tests/test_app.py @@ -5,12 +5,27 @@ import pytest from fastapi.testclient import TestClient +from eoapi.stac import auth from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app, parse_enabled_extensions +from eoapi.stac.settings import TransactionAuthSettings + + +@pytest.fixture(autouse=True) +def reload_transaction_auth_settings() -> None: + """Refresh auth settings after env changes in each test.""" + auth.transaction_auth_settings = TransactionAuthSettings() + yield + auth.transaction_auth_settings = TransactionAuthSettings() @pytest.fixture -def collection_transaction_app() -> Iterator[TestClient]: +def collection_transaction_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: """Build a test client with collection transactions enabled.""" + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, "bob") + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, "builder") + auth.transaction_auth_settings = TransactionAuthSettings() app = create_app( enabled_extensions={"query", "sort", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, connect_to_database=False, @@ -91,6 +106,22 @@ def test_openapi_and_conformance_advertise_collection_transactions_only( assert "put" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] assert "patch" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] assert "delete" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert openapi["components"]["securitySchemes"]["HTTPBasic"] == { + "type": "http", + "scheme": "basic", + "description": "HTTP Basic authentication for collection transaction routes.", + } + assert openapi["paths"]["/collections"]["post"]["security"] == [{"HTTPBasic": []}] + assert openapi["paths"]["/collections/{collection_id}"]["put"]["security"] == [ + {"HTTPBasic": []} + ] + assert openapi["paths"]["/collections/{collection_id}"]["patch"]["security"] == [ + {"HTTPBasic": []} + ] + assert openapi["paths"]["/collections/{collection_id}"]["delete"]["security"] == [ + {"HTTPBasic": []} + ] + assert "security" not in openapi["paths"]["/collections"]["get"] response = collection_transaction_app.get("/conformance") diff --git a/cdk/runtimes/eoapi/stac/tests/test_auth.py b/cdk/runtimes/eoapi/stac/tests/test_auth.py new file mode 100644 index 0000000..92e2eaa --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_auth.py @@ -0,0 +1,176 @@ +"""Authentication tests for collection transaction routes.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator + +import pytest +from fastapi.security import HTTPBasicCredentials +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from eoapi.stac import auth +from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app +from eoapi.stac.settings import TransactionAuthSettings + + +@pytest.fixture(autouse=True) +def reload_transaction_auth_settings() -> None: + """Refresh auth settings after env changes in each test.""" + auth.transaction_auth_settings = TransactionAuthSettings() + yield + auth.transaction_auth_settings = TransactionAuthSettings() + + +@pytest.fixture +def basic_auth_secret_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure basic auth to read credentials from Secrets Manager.""" + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, "test-secret-arn") + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, raising=False) + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, raising=False) + auth.transaction_auth_settings = TransactionAuthSettings() + + +@pytest.fixture +def basic_auth_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure basic auth to read credentials directly from env vars.""" + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, "bob") + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, "builder") + auth.transaction_auth_settings = TransactionAuthSettings() + + +@pytest.fixture +def collection_transaction_app( + monkeypatch: pytest.MonkeyPatch, + basic_auth_env_credentials: None, +) -> Iterator[TestClient]: + """Build a transaction-enabled app using env-provided credentials.""" + app = create_app( + enabled_extensions={"query", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + with TestClient(app) as client: + yield client + + +def test_require_transaction_auth_accepts_valid_basic_credentials( + monkeypatch: pytest.MonkeyPatch, + basic_auth_env_credentials: None, +) -> None: + """Valid basic auth credentials should satisfy the dependency.""" + + credentials = HTTPBasicCredentials(username="bob", password="builder") + + asyncio.run(auth.require_transaction_auth(credentials)) + + +def test_collection_transaction_routes_require_auth( + collection_transaction_app: TestClient, +) -> None: + """Transaction routes should challenge unauthenticated requests.""" + response = collection_transaction_app.post("/collections", json={}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_invalid_basic_auth_is_rejected( + collection_transaction_app: TestClient, +) -> None: + """Invalid basic auth credentials should be rejected.""" + response = collection_transaction_app.post( + "/collections", + json={}, + auth=("alice", "wonderland"), + ) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_read_routes_do_not_require_transaction_auth( + collection_transaction_app: TestClient, +) -> None: + """Read routes should not inherit the transaction auth dependency.""" + collections_get_route = next( + route + for route in collection_transaction_app.app.routes + if getattr(route, "path", None) == "/collections" + and "GET" in getattr(route, "methods", set()) + ) + + assert collections_get_route.dependencies == [] + + +def test_collection_write_routes_receive_transaction_auth_dependency( + collection_transaction_app: TestClient, +) -> None: + """Collection write routes should receive the auth dependency.""" + protected_routes = { + (route.path, next(iter(route.methods))): route + for route in collection_transaction_app.app.routes + if getattr(route, "path", None) in {"/collections", "/collections/{collection_id}"} + and getattr(route, "methods", None) + and next(iter(route.methods)) in {"POST", "PUT", "PATCH", "DELETE"} + } + + assert set(protected_routes) == { + ("/collections", "POST"), + ("/collections/{collection_id}", "PUT"), + ("/collections/{collection_id}", "PATCH"), + ("/collections/{collection_id}", "DELETE"), + } + for route in protected_routes.values(): + assert len(route.dependencies) == 1 + assert route.dependencies[0].dependency == auth.require_transaction_auth + + +def test_transaction_enabled_app_accepts_secret_manager_credentials( + monkeypatch: pytest.MonkeyPatch, + basic_auth_secret_env: None, +) -> None: + """Secrets Manager credentials should still be supported.""" + monkeypatch.setattr( + auth, + "load_secret_dict", + lambda secret_arn: {"username": "bob", "password": "builder"}, + ) + + app = create_app( + enabled_extensions={COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + + assert app is not None + + +def test_transaction_enabled_app_fails_closed_without_any_basic_auth_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing basic-auth config should fail app creation.""" + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, raising=False) + monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, raising=False) + auth.transaction_auth_settings = TransactionAuthSettings() + + with pytest.raises(RuntimeError, match="MAAP_TRANSACTION_AUTH_USERNAME"): + create_app( + enabled_extensions={COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + + +def test_transaction_enabled_app_fails_closed_for_unsupported_auth_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unsupported auth modes should fail app creation.""" + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "none") + monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, "test-secret-arn") + + with pytest.raises(ValidationError, match="Input should be 'basic'"): + auth.transaction_auth_settings = TransactionAuthSettings() diff --git a/docker-compose.yml b/docker-compose.yml index 855bc22..0c49ac1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,11 @@ services: - -lc ports: - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" + volumes: + - ./cdk/runtimes/eoapi/stac:/workspace environment: PYTHONUNBUFFERED: "1" - PYTHONPATH: /asset + PYTHONPATH: /workspace:/asset POSTGRES_USER: username POSTGRES_PASS: password POSTGRES_DBNAME: postgis @@ -23,6 +25,14 @@ services: DB_MAX_CONN_SIZE: "1" ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction TITILER_ENDPOINT: http://raster:8082 + STAC_FASTAPI_TITLE: MAAP Local STAC API + STAC_FASTAPI_LANDING_ID: maap-stac-api-local + STAC_FASTAPI_DESCRIPTION: Local deployment of the MAAP STAC API + STAC_FASTAPI_VERSION: 0.1.0 + MAAP_TRANSACTION_AUTH_MODE: ${MAAP_TRANSACTION_AUTH_MODE:-basic} + MAAP_TRANSACTION_AUTH_USERNAME: ${MAAP_TRANSACTION_AUTH_USERNAME:-username} + MAAP_TRANSACTION_AUTH_PASSWORD: ${MAAP_TRANSACTION_AUTH_PASSWORD:-password} + MAAP_TRANSACTION_AUTH_SECRET_ARN: ${MAAP_TRANSACTION_AUTH_SECRET_ARN:-} env_file: - path: .env required: false @@ -35,7 +45,7 @@ services: - >- until (echo > /dev/tcp/database/5432) >/dev/null 2>&1; do sleep 1; done && python -m uvicorn eoapi.stac.main:app --host 0.0.0.0 --port 8081 - --workers 1 + --workers 1 --reload --reload-dir /workspace/eoapi/stac raster: platform: linux/amd64 diff --git a/pyproject.toml b/pyproject.toml index e346b6f..fe5bbd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" [tool.uv.sources] dps-stac-item-generator = { path = "cdk/constructs/DpsStacItemGenerator/runtime" } eoapi-raster = { path = "cdk/runtimes/eoapi/raster" } +eoapi-stac = { path = "cdk/runtimes/eoapi/stac" } [dependency-groups] From 8c32f442c9267fa6ba03d1aa075b9a49c6f3c813 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 13:43:59 -0500 Subject: [PATCH 06/13] feat: add auth and new collection transaction logic to the infra --- README.md | 15 +++ cdk/PgStacInfra.ts | 94 +++++++++++++++-- cdk/app.ts | 2 + cdk/config.ts | 77 ++++++++++++-- cdk/runtimes/eoapi/stac/uv.lock | 2 + test/config.test.ts | 46 ++++++++- test/pgstac-infra.test.ts | 178 ++++++++++++++++++++++++++++++++ 7 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 test/pgstac-infra.test.ts diff --git a/README.md b/README.md index b0ef3f6..487a1d6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,21 @@ This repository contains the AWS CDK code (written in typescript) used to deploy Deployment happens through a github workflow manually triggered and defined in `.github/workflows/deploy.yaml`. +## User STAC collection transactions + +The internal `userSTAC` deployment can now opt into collection-only STAC transactions. + +Enable them with: + +- `USER_STAC_COLLECTION_TRANSACTIONS_ENABLED=true` +- `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE=basic` + +When enabled, this CDK stack creates and manages the Secrets Manager secret used for STAC basic auth by default, grants the STAC Lambda read access to it, and publishes the secret ARN to SSM at: + +- `/maap-eoapi//internal/stac-collection-transaction-auth-secret-arn` + +You can still override the secret with `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN` if you need to point at an existing secret instead. + ## Networking and accessibility of the database. diff --git a/cdk/PgStacInfra.ts b/cdk/PgStacInfra.ts index f045541..98cb71e 100644 --- a/cdk/PgStacInfra.ts +++ b/cdk/PgStacInfra.ts @@ -6,6 +6,7 @@ import { aws_lambda as lambda, aws_rds as rds, aws_s3 as s3, + aws_secretsmanager as secretsmanager, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_cloudwatch as cloudwatch, @@ -89,14 +90,75 @@ export class PgStacInfra extends Stack { : ec2.SubnetType.PRIVATE_WITH_EGRESS, }; + const transactionsConfig = stacApiConfig.transactions; + if (transactionsConfig && transactionsConfig.authMode !== "basic") { + throw new Error( + `Unsupported STAC collection transaction auth mode: ${transactionsConfig.authMode}`, + ); + } + + const transactionAuthSecret = transactionsConfig + ? transactionsConfig.authSecretArn + ? secretsmanager.Secret.fromSecretCompleteArn( + this, + "stac-collection-transaction-auth-secret", + transactionsConfig.authSecretArn, + ) + : new secretsmanager.Secret( + this, + "stac-collection-transaction-auth-secret", + { + description: `Basic auth secret for MAAP ${type} STAC collection transactions (${stage})`, + secretName: `/maap-eoapi/${stage}/${type}/stac-collection-transaction-basic-auth`, + generateSecretString: { + secretStringTemplate: JSON.stringify({ + username: `maap-${type}-stac-writer`, + }), + generateStringKey: "password", + excludePunctuation: true, + }, + }, + ) + : undefined; + + const stacEnabledExtensions = [ + "query", + "sort", + "fields", + "filter", + "free_text", + "pagination", + "collection_search", + ...(transactionsConfig ? ["collection_transaction"] : []), + ]; + + const stacApiEnv: Record = { + STAC_FASTAPI_TITLE: `MAAP ${type} STAC API (${stage})`, + STAC_FASTAPI_LANDING_ID: `maap-${type}-stac-api-${stage}`, + STAC_FASTAPI_DESCRIPTION: `The ${type} STAC API for the [MAAP project](https://maap-project.org)`, + STAC_FASTAPI_VERSION: version, + ENABLED_EXTENSIONS: stacEnabledExtensions.join(","), + ...(transactionsConfig + ? { + MAAP_TRANSACTION_AUTH_MODE: transactionsConfig.authMode, + MAAP_TRANSACTION_AUTH_SECRET_ARN: + transactionAuthSecret!.secretArn, + } + : {}), + }; + + const stacApiLambdaOptions: CustomLambdaFunctionProps = { + code: lambda.Code.fromDockerBuild(__dirname, { + file: "dockerfiles/Dockerfile.stac", + targetStage: "lambda", + buildArgs: { PYTHON_VERSION: "3.12" }, + }), + handler: "handler.handler", + }; + // STAC API const stacApiLambda = new PgStacApiLambda(this, "pgstac-api", { - apiEnv: { - STAC_FASTAPI_TITLE: `MAAP ${type} STAC API (${stage})`, - STAC_FASTAPI_LANDING_ID: `maap-${type}-stac-api-${stage}`, - STAC_FASTAPI_DESCRIPTION: `The ${type} STAC API for the [MAAP project](https://maap-project.org)`, - STAC_FASTAPI_VERSION: version, - }, + apiEnv: stacApiEnv, vpc, db: pgstacDb.connectionTarget, dbSecret: pgstacDb.pgstacSecret, @@ -113,6 +175,7 @@ export class PgStacInfra extends Stack { }) : undefined, enableSnapStart: true, + lambdaFunctionOptions: stacApiLambdaOptions, }); stacApiLambda.lambdaFunction.connections.allowTo( @@ -128,6 +191,16 @@ export class PgStacInfra extends Stack { }); } + if (transactionAuthSecret) { + transactionAuthSecret.grantRead(stacApiLambda.lambdaFunction); + + new ssm.StringParameter(this, "stac-collection-transaction-auth-secret-param", { + parameterName: `/maap-eoapi/${stage}/${type}/stac-collection-transaction-auth-secret-arn`, + stringValue: transactionAuthSecret.secretArn, + description: `Secrets Manager ARN for MAAP ${type} STAC collection transaction auth (${stage})`, + }); + } + // titiler-pgstac const titilerDataAccessRole = iam.Role.fromRoleArn( this, @@ -616,6 +689,15 @@ export interface Props extends StackProps { * STAC API api gateway source ARN to be granted STAC API lambda invoke permission. */ integrationApiArn?: string; + + /** + * Optional collection transaction support for the STAC API. + * When omitted, the API stays read-only. + */ + transactions?: { + authMode: "basic" | "jwt"; + authSecretArn?: string; + }; }; /** diff --git a/cdk/app.ts b/cdk/app.ts index 3a3e133..49ea236 100644 --- a/cdk/app.ts +++ b/cdk/app.ts @@ -30,6 +30,7 @@ const { userStacCollectionIdRegistry, userStacInboundTopicArns, userStacItemGenRoleArn, + userStacCollectionTransactions, userStacStacApiCustomDomainName, userStacTitilerPgStacApiCustomDomainName, version, @@ -108,6 +109,7 @@ const userInfrastructure = new PgStacInfra(app, buildStackName("userSTAC"), { }, stacApiConfig: { customDomainName: userStacStacApiCustomDomainName, + transactions: userStacCollectionTransactions, }, titilerPgstacConfig: { mosaicHost, diff --git a/cdk/config.ts b/cdk/config.ts index 995e791..8afb6cd 100644 --- a/cdk/config.ts +++ b/cdk/config.ts @@ -1,5 +1,27 @@ import * as aws_ec2 from "aws-cdk-lib/aws-ec2"; +export interface CollectionTransactionsConfig { + authMode: "basic" | "jwt"; + authSecretArn?: string; +} + +function parseOptionalBooleanEnv(name: string): boolean | undefined { + const value = process.env[name]; + if (value === undefined || value === "") { + return undefined; + } + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + throw new Error(`Invalid ${name}: ${value}. Expected "true" or "false".`); +} + export class Config { readonly stage: string; readonly version: string; @@ -25,9 +47,11 @@ export class Config { readonly userStacCollectionIdRegistry: Record | undefined; readonly userStacStacApiCustomDomainName: string | undefined; readonly userStacTitilerPgStacApiCustomDomainName: string | undefined; + readonly userStacCollectionTransactions: + | CollectionTransactionsConfig + | undefined; constructor() { - // These are required environment variables and cannot be undefined const requiredVariables = [ { name: "STAGE", value: process.env.STAGE }, { name: "DB_INSTANCE_TYPE", value: process.env.DB_INSTANCE_TYPE }, @@ -103,7 +127,7 @@ export class Config { this.stacBrowserCertificateArn = process.env.STAC_BROWSER_CERTIFICATE_ARN!; this.stacApiCustomDomainName = process.env.STAC_API_CUSTOM_DOMAIN_NAME!; - this.version = process.env.npm_package_version!; // Set by node.js + this.version = process.env.npm_package_version!; this.tags = { project: "MAAP", version: this.version, @@ -117,8 +141,12 @@ export class Config { this.pgstacVersion = process.env.PGSTAC_VERSION!; this.webAclArn = process.env.WEB_ACL_ARN!; this.userStacItemGenRoleArn = process.env.USER_STAC_ITEM_GEN_ROLE_ARN!; - this.userStacStacApiCustomDomainName = process.env.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME; - this.userStacTitilerPgStacApiCustomDomainName = process.env.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME; + this.userStacStacApiCustomDomainName = + process.env.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME; + this.userStacTitilerPgStacApiCustomDomainName = + process.env.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME; + this.userStacCollectionTransactions = + this.parseUserStacCollectionTransactions(); if (process.env.USER_STAC_INBOUND_TOPIC_ARNS) { try { @@ -128,7 +156,7 @@ export class Config { } catch (error) { throw new Error( `Invalid JSON format for USER_STAC_INBOUND_TOPIC_ARNS: ${error}. ` + - `Expected format: ["arn:aws:sns:us-west-2:123456789012:topic-name", ...]` + `Expected format: ["arn:aws:sns:us-west-2:123456789012:topic-name", ...]`, ); } } else { @@ -143,7 +171,7 @@ export class Config { } catch (error) { throw new Error( `Invalid JSON format for USER_STAC_COLLECTION_ID_REGISTRY: ${error}. ` + - `Expected format: {"collection-id": ["user1", "user2"]}` + `Expected format: {"collection-id": ["user1", "user2"]}`, ); } } else { @@ -158,4 +186,41 @@ export class Config { */ buildStackName = (serviceId: string): string => `MAAP-STAC-${this.stage}-${serviceId}`; + + private parseUserStacCollectionTransactions(): + | CollectionTransactionsConfig + | undefined { + const enabled = parseOptionalBooleanEnv( + "USER_STAC_COLLECTION_TRANSACTIONS_ENABLED", + ); + + if (!enabled) { + return undefined; + } + + const authMode = process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE; + if (!authMode) { + throw new Error( + "Must provide USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE when USER_STAC_COLLECTION_TRANSACTIONS_ENABLED=true", + ); + } + + if (authMode !== "basic") { + throw new Error( + `Unsupported USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE: ${authMode}. Expected \"basic\".`, + ); + } + + const authSecretArn = + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN; + + return authSecretArn + ? { + authMode, + authSecretArn, + } + : { + authMode, + }; + } } diff --git a/cdk/runtimes/eoapi/stac/uv.lock b/cdk/runtimes/eoapi/stac/uv.lock index ad11adb..4d96eb5 100644 --- a/cdk/runtimes/eoapi/stac/uv.lock +++ b/cdk/runtimes/eoapi/stac/uv.lock @@ -235,6 +235,7 @@ name = "eoapi-stac" source = { editable = "." } dependencies = [ { name = "mangum" }, + { name = "pydantic-settings" }, { name = "stac-fastapi-pgstac" }, { name = "starlette-cramjam" }, ] @@ -248,6 +249,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "mangum", specifier = "==0.19" }, + { name = "pydantic-settings", specifier = ">=2,<3" }, { name = "stac-fastapi-pgstac", specifier = ">=6.2,<6.3" }, { name = "starlette-cramjam", specifier = ">=0.4,<0.5" }, ] diff --git a/test/config.test.ts b/test/config.test.ts index 0919547..171ac2a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -69,6 +69,7 @@ describe("Config", () => { expect(config.webAclArn).toBe( "arn:aws:wafv2:us-east-1:123456789012:global/webacl/test-acl", ); + expect(config.userStacCollectionTransactions).toBeUndefined(); // Test number properties expect(config.dbAllocatedStorage).toBe(20); @@ -91,7 +92,6 @@ describe("Config", () => { }); test("handles optional environment variables correctly", () => { - // Set optional environment variables process.env.CERTIFICATE_ARN = "arn:aws:acm:us-east-1:123456789012:certificate/optional-cert"; process.env.INGESTOR_DOMAIN_NAME = "ingestor.example.com"; @@ -99,7 +99,6 @@ describe("Config", () => { const config = new Config(); - // Optional values should be set expect(config.certificateArn).toBe( "arn:aws:acm:us-east-1:123456789012:certificate/optional-cert", ); @@ -107,6 +106,49 @@ describe("Config", () => { expect(config.titilerPgStacApiCustomDomainName).toBe("titiler.example.com"); }); + test("enables user STAC collection transactions with stack-managed auth by default", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "basic"; + + const config = new Config(); + + expect(config.userStacCollectionTransactions).toEqual({ + authMode: "basic", + }); + }); + + test("accepts an explicit user STAC collection transaction secret ARN override", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "basic"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth"; + + const config = new Config(); + + expect(config.userStacCollectionTransactions).toEqual({ + authMode: "basic", + authSecretArn: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth", + }); + }); + + test("rejects unsupported user STAC collection transaction auth modes", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "jwt"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth"; + + expect(() => new Config()).toThrow(/Expected \"basic\"/); + }); + + test("rejects invalid user STAC collection transaction boolean values", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "yes"; + + expect(() => new Config()).toThrow( + /USER_STAC_COLLECTION_TRANSACTIONS_ENABLED/, + ); + }); + test("buildStackName formats properly", () => { const config = new Config(); diff --git a/test/pgstac-infra.test.ts b/test/pgstac-infra.test.ts new file mode 100644 index 0000000..1d77e55 --- /dev/null +++ b/test/pgstac-infra.test.ts @@ -0,0 +1,178 @@ +import * as cdk from "aws-cdk-lib"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Match, Template } from "aws-cdk-lib/assertions"; +import { PgStacInfra, Props } from "../cdk/PgStacInfra"; + +function buildTemplate(overrides: Partial = {}): Template { + const app = new cdk.App(); + const networkStack = new cdk.Stack(app, "NetworkStack"); + const vpc = new ec2.Vpc(networkStack, "Vpc", { + maxAzs: 2, + natGateways: 1, + subnetConfiguration: [ + { + name: "public", + subnetType: ec2.SubnetType.PUBLIC, + }, + { + name: "private", + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + { + name: "isolated", + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + }, + ], + }); + + const stack = new PgStacInfra(app, "TestPgStacInfra", { + vpc, + stage: "test", + type: "internal", + version: "1.0.0", + webAclArn: + "arn:aws:wafv2:us-east-1:123456789012:global/webacl/test-acl", + loggingBucketArn: "arn:aws:s3:::test-logging-bucket", + pgstacDbConfig: { + instanceType: new ec2.InstanceType("t3.micro"), + subnetPublic: false, + allocatedStorage: 20, + pgstacVersion: "0.9.5", + }, + stacApiConfig: { + customDomainName: "stac-api.example.com", + }, + titilerPgstacConfig: { + mosaicHost: "example.com/table-name", + bucketsPath: "./titiler_buckets.yaml", + dataAccessRoleArn: "arn:aws:iam::123456789012:role/test-role", + customDomainName: "titiler.example.com", + }, + ...overrides, + }); + + return Template.fromStack(stack); +} + +describe("PgStacInfra STAC runtime wiring", () => { + beforeAll(() => { + jest + .spyOn(lambda.Code, "fromDockerBuild") + .mockImplementation(() => lambda.Code.fromAsset("test")); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("uses the custom STAC handler and keeps transactions disabled by default", () => { + const template = buildTemplate({ + type: "public", + stacApiConfig: { + customDomainName: "public-stac.example.com", + integrationApiArn: + "arn:aws:execute-api:us-west-2:123456789012:api-id/stage/GET/", + }, + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "handler.handler", + Environment: { + Variables: Match.objectLike({ + STAC_FASTAPI_TITLE: "MAAP public STAC API (test)", + STAC_FASTAPI_LANDING_ID: "maap-public-stac-api-test", + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search", + }), + }, + }); + + expect( + Object.keys( + template.findResources("AWS::SecretsManager::Secret", { + Properties: { + Name: + "/maap-eoapi/test/public/stac-collection-transaction-basic-auth", + }, + }), + ), + ).toHaveLength(0); + template.resourceCountIs("AWS::SSM::Parameter", 1); + }); + + test("enables collection transactions with a stack-managed secret by default", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + transactions: { + authMode: "basic", + }, + }, + }); + + template.hasResourceProperties("AWS::SecretsManager::Secret", { + Description: + "Basic auth secret for MAAP internal STAC collection transactions (test)", + Name: "/maap-eoapi/test/internal/stac-collection-transaction-basic-auth", + GenerateSecretString: Match.objectLike({ + GenerateStringKey: "password", + SecretStringTemplate: '{"username":"maap-internal-stac-writer"}', + }), + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "handler.handler", + Environment: { + Variables: Match.objectLike({ + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction", + MAAP_TRANSACTION_AUTH_MODE: "basic", + MAAP_TRANSACTION_AUTH_SECRET_ARN: { + Ref: Match.stringLikeRegexp( + "staccollectiontransactionauthsecret", + ), + }, + }), + }, + }); + + template.hasResourceProperties("AWS::SSM::Parameter", { + Name: + "/maap-eoapi/test/internal/stac-collection-transaction-auth-secret-arn", + }); + }); + + test("uses an explicit transaction auth secret ARN override when provided", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + transactions: { + authMode: "basic", + authSecretArn: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:existing-auth-abcdef", + }, + }, + }); + + expect( + Object.keys( + template.findResources("AWS::SecretsManager::Secret", { + Properties: { + Name: + "/maap-eoapi/test/internal/stac-collection-transaction-basic-auth", + }, + }), + ), + ).toHaveLength(0); + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "handler.handler", + Environment: { + Variables: Match.objectLike({ + MAAP_TRANSACTION_AUTH_SECRET_ARN: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:existing-auth-abcdef", + }), + }, + }); + }); +}); From 5948098d6fe5d481b4f5b70553a5967d1b7acc3c Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 13:56:26 -0500 Subject: [PATCH 07/13] ci: combine the test workflows into one, update docs --- .github/workflows/tests.yml | 114 +++++------ .github/workflows/unit-tests.yml | 30 --- README.md | 18 +- cdk/runtimes/eoapi/stac/README.md | 20 ++ cdk/runtimes/eoapi/stac/tests/test_handler.py | 186 ++++++++++++++++++ 5 files changed, 276 insertions(+), 92 deletions(-) delete mode 100644 .github/workflows/unit-tests.yml create mode 100644 cdk/runtimes/eoapi/stac/tests/test_handler.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4daec8d..5ed0cae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,75 +1,67 @@ -name: tests +name: Unit and runtime tests permissions: - id-token: write # required for requesting the JWT - contents: read # required for actions/checkout + contents: read on: - # Uncomment below for running it manually on the github UI - workflow_dispatch: + pull_request: + push: + branches: [main] + workflow_dispatch: - # Uncomment below for running it on a push in a specific branch - # push: - # branches: - # - "change-stac-api-url-stage" +jobs: + node-tests: + name: node-tests + runs-on: ubuntu-latest - # Uncomment below for running it as a cron job - # schedule: - # - cron: '15 16 * * 5' + steps: + - name: Checkout repository + uses: actions/checkout@v4 -jobs: - python-job: - name: "PyTest tests" - runs-on: ubuntu-latest - strategy: - matrix: - include: - - environment: test - - environment: dev - environment: ${{ matrix.environment }} + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run Jest + run: npm test - steps: - - name: Checkout repository - uses: actions/checkout@v3 + python-runtime-tests: + name: pytest (${{ matrix.runtime.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: + - name: eoapi-stac + path: cdk/runtimes/eoapi/stac + - name: dps-stac-item-generator + path: cdk/constructs/DpsStacItemGenerator/runtime - - name: Setup Python - uses: actions/setup-python@v3 - with: - python-version: '3.12' + defaults: + run: + working-directory: ${{ matrix.runtime.path }} - - name: Assume Github OIDC role - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-region: us-west-2 - role-to-assume: ${{ vars.MAAP_EOAPI_TEST_ROLE }} - role-session-name: maap-eoapi-tests-${{ matrix.environment }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r tests/requirements.txt + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" - - name: Run pytest - env: - INGESTOR_DOMAIN_NAME: ${{ vars.INGESTOR_DOMAIN_NAME }} - STAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.STAC_API_CUSTOM_DOMAIN_NAME }} - TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} - SECRET_ID: ${{ vars.SECRET_ID }} - run: | - pytest tests + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true - - name: slack - if: always() - id: slack - uses: slackapi/slack-github-action@v1.24.0 - with: - # Slack channel id, channel name, or user id to post message. - # See also: https://api.slack.com/methods/chat.postMessage#channels - # You can pass in multiple channels to post to by providing a comma-delimited list of channel IDs. - channel-id: ${{ vars.SLACK_CHANNEL_ID }} - # For posting a simple plain text message - slack-message: "GitHub build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}" - env: - SLACK_BOT_TOKEN: ${{ vars.SLACK_BOT_TOKEN }} + - name: Sync dependencies + run: uv sync --locked --dev - \ No newline at end of file + - name: Run pytest + run: uv run pytest diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index a35627f..0000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Unit Tests - -permissions: - contents: read # required for actions/checkout - -on: - pull_request: - branches: [ main ] - workflow_dispatch: # Allow manual triggering - -jobs: - unit-test: - name: "Run Node.js Unit Tests" - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test diff --git a/README.md b/README.md index 487a1d6..0ae3287 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Deployment happens through a github workflow manually triggered and defined in ` ## User STAC collection transactions -The internal `userSTAC` deployment can now opt into collection-only STAC transactions. +The internal `userSTAC` deployment can now opt into collection-only STAC transactions. The public-facing stack stays on the same MAAP-owned runtime, but remains read-only unless transaction support is explicitly enabled. Enable them with: @@ -27,6 +27,22 @@ When enabled, this CDK stack creates and manages the Secrets Manager secret used You can still override the secret with `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN` if you need to point at an existing secret instead. +The transaction auth secret must be a JSON object with string `username` and `password` fields. + +### What to verify after deployment + +For a transaction-enabled internal deployment, verify: + +- `GET /conformance` includes `https://api.stacspec.org/v1.0.0/collections/extensions/transaction` +- OpenAPI advertises collection write routes only: + - `POST /collections` + - `PUT /collections/{collection_id}` + - `PATCH /collections/{collection_id}` + - `DELETE /collections/{collection_id}` +- unauthenticated collection writes return `401` +- authenticated collection writes succeed +- item write routes are absent from the contract and return `404` or `405` rather than exposing item transaction behavior + ## Networking and accessibility of the database. diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md index 6bbb8be..4677793 100644 --- a/cdk/runtimes/eoapi/stac/README.md +++ b/cdk/runtimes/eoapi/stac/README.md @@ -25,6 +25,16 @@ The secret form is intended for Lambda deployments. The username/password env-va The secret must be a JSON object with `username` and `password` string fields. +### Running tests + +From this directory, run: + +```bash +uv run pytest +``` + +These tests cover app construction, OpenAPI and conformance output, auth behavior, and the custom Lambda handler lifecycle. + ### Environment shape The local STAC service uses the same pgSTAC-style environment variables already used elsewhere in eoapi development: @@ -59,3 +69,13 @@ The local raster service also expects mosaic settings, so the compose file provi - The Lambda runtime entrypoint is `eoapi.stac.handler.handler` and preserves the upstream SnapStart-aware connection lifecycle. - Collection write-route auth is attached with FastAPI security dependencies on `POST /collections` plus `PUT`, `PATCH`, and `DELETE /collections/{collection_id}`. - Those dependencies are declared as HTTP Basic auth in OpenAPI, so Swagger UI shows the protected routes with the built-in auth flow instead of relying only on the browser challenge popup. + +### Post-deploy smoke checks + +For a transaction-enabled deployment, verify: + +- `GET /conformance` advertises only the collection transaction conformance class. +- OpenAPI includes collection write routes and does not advertise item transaction write routes. +- `POST /collections` without auth returns `401` with `WWW-Authenticate: Basic`. +- Authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests against `/collections` succeed when the backing pgSTAC deployment is healthy. +- Item write routes such as `POST /collections/{collection_id}/items` remain unavailable. diff --git a/cdk/runtimes/eoapi/stac/tests/test_handler.py b/cdk/runtimes/eoapi/stac/tests/test_handler.py new file mode 100644 index 0000000..d50e3e3 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_handler.py @@ -0,0 +1,186 @@ +"""Handler tests for the MAAP STAC runtime.""" + +from __future__ import annotations + +import asyncio + +import pytest +from stac_fastapi.pgstac.config import PostgresSettings + +from eoapi.stac import handler + + +class FakePool: + """Simple pool stub that records close calls.""" + + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + """Mark the pool as closed.""" + self.closed = True + + +@pytest.fixture(autouse=True) +def clear_handler_state() -> None: + """Reset handler globals and app state between tests.""" + original_readpool = getattr(handler.app.state, "readpool", None) + original_writepool = getattr(handler.app.state, "writepool", None) + original_initialized = handler._CONNECTIONS_INITIALIZED + original_with_transactions = handler.WITH_COLLECTION_TRANSACTIONS + handler.app.state.readpool = None + handler.app.state.writepool = None + handler._CONNECTIONS_INITIALIZED = False + yield + handler.app.state.readpool = original_readpool + handler.app.state.writepool = original_writepool + handler._CONNECTIONS_INITIALIZED = original_initialized + handler.WITH_COLLECTION_TRANSACTIONS = original_with_transactions + + +def test_build_postgres_settings_requires_secret_arn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The handler should fail clearly without PGSTAC_SECRET_ARN.""" + monkeypatch.delenv("PGSTAC_SECRET_ARN", raising=False) + + with pytest.raises(RuntimeError, match="PGSTAC_SECRET_ARN"): + handler._build_postgres_settings() + + +def test_build_postgres_settings_loads_secret_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The handler should map secret fields into Postgres settings.""" + monkeypatch.setenv("PGSTAC_SECRET_ARN", "pg-secret") + monkeypatch.setattr( + handler, + "load_secret_dict", + lambda secret_arn: { + "host": "db.internal", + "dbname": "pgstac", + "username": "reader", + "password": "secret", + "port": "5432", + }, + ) + + settings = handler._build_postgres_settings() + + assert settings == PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + + +def test_on_snapshot_closes_existing_pools() -> None: + """Snapshot preparation should close and clear both pools.""" + readpool = FakePool() + writepool = FakePool() + handler.app.state.readpool = readpool + handler.app.state.writepool = writepool + + response = handler.on_snapshot() + + assert response == {"statusCode": 200} + assert readpool.closed is True + assert writepool.closed is True + assert handler.app.state.readpool is None + assert handler.app.state.writepool is None + + +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_on_snap_restore_reconnects_with_expected_write_pool_setting( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Snapshot restore should reconnect with the correct write-pool flag.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + response = handler.on_snap_restore() + + assert response == {"statusCode": 200} + assert handler._CONNECTIONS_INITIALIZED is True + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_startup_event_connects_with_expected_write_pool_setting( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Startup should reuse the same write-pool gate as restore.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + asyncio.run(handler.startup_event()) + + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + +def test_shutdown_event_closes_db_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shutdown should delegate to the shared close helper.""" + captured: dict[str, object] = {} + + async def fake_close_db_connection(app: object) -> None: + captured["app"] = app + + monkeypatch.setattr(handler, "close_db_connection", fake_close_db_connection) + + asyncio.run(handler.shutdown_event()) + + assert captured == {"app": handler.app} From 4e291b99f3b198ecc2f489486237778cbb51ff91 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 14:02:18 -0500 Subject: [PATCH 08/13] chore: drop unused constants --- cdk/runtimes/eoapi/stac/eoapi/stac/auth.py | 15 ++++++--------- cdk/runtimes/eoapi/stac/eoapi/stac/settings.py | 5 ----- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py index e2f9fd9..80b7394 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -7,17 +7,12 @@ from hmac import compare_digest from typing import Annotated, Any -from fastapi import HTTPException, Security, status -from fastapi.params import Depends -from fastapi.security import HTTPBasic, HTTPBasicCredentials - from eoapi.stac.settings import ( - MAAP_TRANSACTION_AUTH_MODE_ENV, - MAAP_TRANSACTION_AUTH_PASSWORD_ENV, - MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, - MAAP_TRANSACTION_AUTH_USERNAME_ENV, TransactionAuthSettings, ) +from fastapi import HTTPException, Security, status +from fastapi.params import Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials _BASIC_AUTH_CHALLENGE_HEADERS = {"WWW-Authenticate": "Basic"} basic_auth_scheme = HTTPBasic( @@ -114,7 +109,9 @@ async def require_transaction_auth( raise _unauthorized_basic_auth() expected_username, expected_password = get_basic_auth_credentials() - if not compare_digest(credentials.username, expected_username) or not compare_digest( + if not compare_digest( + credentials.username, expected_username + ) or not compare_digest( credentials.password, expected_password, ): diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py index f6e8603..aaa2df5 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py @@ -6,11 +6,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -MAAP_TRANSACTION_AUTH_MODE_ENV = "MAAP_TRANSACTION_AUTH_MODE" -MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV = "MAAP_TRANSACTION_AUTH_SECRET_ARN" -MAAP_TRANSACTION_AUTH_USERNAME_ENV = "MAAP_TRANSACTION_AUTH_USERNAME" -MAAP_TRANSACTION_AUTH_PASSWORD_ENV = "MAAP_TRANSACTION_AUTH_PASSWORD" - class TransactionAuthSettings(BaseSettings): """Configuration for collection transaction authentication.""" From a198d546425f074ec7c1e606245f6ed0edcf0334 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 14:30:10 -0500 Subject: [PATCH 09/13] chore: clean up and fix a few things --- cdk/PgStacInfra.ts | 2 +- cdk/config.ts | 7 +- cdk/runtimes/eoapi/raster/pyproject.toml | 1 - cdk/runtimes/eoapi/stac/eoapi/stac/auth.py | 26 ++++++-- cdk/runtimes/eoapi/stac/eoapi/stac/handler.py | 65 +++++++++---------- cdk/runtimes/eoapi/stac/pyproject.toml | 1 - cdk/runtimes/eoapi/stac/tests/test_app.py | 15 ++--- cdk/runtimes/eoapi/stac/tests/test_auth.py | 41 ++++++------ test/pgstac-infra.test.ts | 6 +- 9 files changed, 85 insertions(+), 79 deletions(-) diff --git a/cdk/PgStacInfra.ts b/cdk/PgStacInfra.ts index 98cb71e..94afb19 100644 --- a/cdk/PgStacInfra.ts +++ b/cdk/PgStacInfra.ts @@ -153,7 +153,7 @@ export class PgStacInfra extends Stack { targetStage: "lambda", buildArgs: { PYTHON_VERSION: "3.12" }, }), - handler: "handler.handler", + handler: "eoapi.stac.handler.handler", }; // STAC API diff --git a/cdk/config.ts b/cdk/config.ts index 8afb6cd..7c278bc 100644 --- a/cdk/config.ts +++ b/cdk/config.ts @@ -11,11 +11,12 @@ function parseOptionalBooleanEnv(name: string): boolean | undefined { return undefined; } - if (value === "true") { + const normalizedValue = value.trim().toLowerCase(); + if (normalizedValue === "true") { return true; } - if (value === "false") { + if (normalizedValue === "false") { return false; } @@ -194,7 +195,7 @@ export class Config { "USER_STAC_COLLECTION_TRANSACTIONS_ENABLED", ); - if (!enabled) { + if (enabled !== true) { return undefined; } diff --git a/cdk/runtimes/eoapi/raster/pyproject.toml b/cdk/runtimes/eoapi/raster/pyproject.toml index 18776ad..1c3aa4c 100644 --- a/cdk/runtimes/eoapi/raster/pyproject.toml +++ b/cdk/runtimes/eoapi/raster/pyproject.toml @@ -4,7 +4,6 @@ description = "Custom raster tiling service for MAAP" readme = "README.md" requires-python = ">=3.12" authors = [ - {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, {name = "Henry Rodman", email = "henry@developmentseed.com"}, ] license = {text = "MIT"} diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py index 80b7394..06c44bc 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -22,7 +22,7 @@ transaction_auth_settings = TransactionAuthSettings() -@lru_cache(maxsize=None) +@lru_cache(maxsize=8) def load_secret_dict(secret_arn: str) -> dict[str, Any]: """Load and parse a JSON secret from AWS Secrets Manager.""" try: @@ -57,18 +57,19 @@ def _unauthorized_basic_auth() -> HTTPException: ) +@lru_cache(maxsize=1) def get_basic_auth_credentials() -> tuple[str, str]: """Load basic-auth credentials from Secrets Manager or local env vars.""" if transaction_auth_settings.secret_arn: secret = load_secret_dict(transaction_auth_settings.secret_arn) - if not isinstance(secret.get("username"), str) or not isinstance( - secret.get("password"), str - ): + username = secret.get("username") + password = secret.get("password") + if not isinstance(username, str) or not isinstance(password, str): raise RuntimeError( "Transaction auth secret must contain string fields 'username' and " "'password'" ) - return secret["username"], secret["password"] + return username, password if transaction_auth_settings.username and transaction_auth_settings.password: return transaction_auth_settings.username, transaction_auth_settings.password @@ -90,7 +91,6 @@ def validate_transaction_auth_config() -> str: ) get_basic_auth_credentials() - return auth_mode @@ -118,6 +118,20 @@ async def require_transaction_auth( raise _unauthorized_basic_auth() +def reset_transaction_auth_state() -> None: + """Reset cached auth state after configuration changes.""" + cache_clear = getattr(load_secret_dict, "cache_clear", None) + if cache_clear is not None: + cache_clear() + + cache_clear = getattr(get_basic_auth_credentials, "cache_clear", None) + if cache_clear is not None: + cache_clear() + + global transaction_auth_settings + transaction_auth_settings = TransactionAuthSettings() + + def build_transaction_route_dependencies() -> list[Depends]: """Build validated route dependencies for transaction routes.""" validate_transaction_auth_config() diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py index 9f5b86f..d1f7144 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py @@ -71,36 +71,44 @@ def _close_pool(pool_name: str) -> None: setattr(app.state, pool_name, None) +def _close_pools() -> None: + """Close both read and write pools if they exist.""" + _close_pool("readpool") + _close_pool("writepool") + + +async def _initialize_db_connections() -> None: + """Initialize database pools for the Lambda runtime.""" + await connect_to_db( + app, + postgres_settings=_build_postgres_settings(), + add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, + ) + + +def _initialize_db_connections_sync(*, close_existing_pools: bool = False) -> None: + """Initialize database pools from synchronous Lambda hooks.""" + global _CONNECTIONS_INITIALIZED + + if close_existing_pools: + _close_pools() + + asyncio.run(_initialize_db_connections()) + _CONNECTIONS_INITIALIZED = True + + @register_before_snapshot def on_snapshot(): """Close DB pools before the Lambda snapshot is taken.""" - _close_pool("readpool") - _close_pool("writepool") + _close_pools() return {"statusCode": 200} @register_after_restore def on_snap_restore(): """Recreate DB pools after a Lambda snapshot restore.""" - global _CONNECTIONS_INITIALIZED - try: - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - _close_pool("readpool") - _close_pool("writepool") - loop.run_until_complete( - connect_to_db( - app, - postgres_settings=_build_postgres_settings(), - add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, - ) - ) - _CONNECTIONS_INITIALIZED = True + _initialize_db_connections_sync(close_existing_pools=True) except Exception: logger.exception("SnapStart: failed to initialize database connection") raise @@ -112,11 +120,7 @@ def on_snap_restore(): async def startup_event() -> None: """Connect to the database when the app starts.""" logger.info("Setting up DB connection") - await connect_to_db( - app, - postgres_settings=_build_postgres_settings(), - add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, - ) + await _initialize_db_connections() logger.info("DB connection setup complete") @@ -137,14 +141,5 @@ async def shutdown_event() -> None: if "AWS_EXECUTION_ENV" in os.environ and not _CONNECTIONS_INITIALIZED: logger.info("Cold start: initializing database connection") - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete( - connect_to_db( - app, - postgres_settings=_build_postgres_settings(), - add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, - ) - ) - _CONNECTIONS_INITIALIZED = True + _initialize_db_connections_sync() logger.info("Database connection initialized") diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml index df4b27c..f3da87b 100644 --- a/cdk/runtimes/eoapi/stac/pyproject.toml +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -4,7 +4,6 @@ description = "Custom STAC API runtime for MAAP" readme = "README.md" requires-python = ">=3.12" authors = [ - {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, {name = "Henry Rodman", email = "henry@developmentseed.com"}, ] license = {text = "MIT"} diff --git a/cdk/runtimes/eoapi/stac/tests/test_app.py b/cdk/runtimes/eoapi/stac/tests/test_app.py index 3c68a1a..d278b50 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_app.py +++ b/cdk/runtimes/eoapi/stac/tests/test_app.py @@ -7,25 +7,24 @@ from eoapi.stac import auth from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app, parse_enabled_extensions -from eoapi.stac.settings import TransactionAuthSettings @pytest.fixture(autouse=True) def reload_transaction_auth_settings() -> None: """Refresh auth settings after env changes in each test.""" - auth.transaction_auth_settings = TransactionAuthSettings() + auth.reset_transaction_auth_state() yield - auth.transaction_auth_settings = TransactionAuthSettings() + auth.reset_transaction_auth_state() @pytest.fixture def collection_transaction_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: """Build a test client with collection transactions enabled.""" - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, "bob") - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, "builder") - auth.transaction_auth_settings = TransactionAuthSettings() + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_USERNAME", "bob") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") + auth.reset_transaction_auth_state() app = create_app( enabled_extensions={"query", "sort", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, connect_to_database=False, diff --git a/cdk/runtimes/eoapi/stac/tests/test_auth.py b/cdk/runtimes/eoapi/stac/tests/test_auth.py index 92e2eaa..7b316d8 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_auth.py +++ b/cdk/runtimes/eoapi/stac/tests/test_auth.py @@ -12,35 +12,34 @@ from eoapi.stac import auth from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app -from eoapi.stac.settings import TransactionAuthSettings @pytest.fixture(autouse=True) def reload_transaction_auth_settings() -> None: """Refresh auth settings after env changes in each test.""" - auth.transaction_auth_settings = TransactionAuthSettings() + auth.reset_transaction_auth_state() yield - auth.transaction_auth_settings = TransactionAuthSettings() + auth.reset_transaction_auth_state() @pytest.fixture def basic_auth_secret_env(monkeypatch: pytest.MonkeyPatch) -> None: """Configure basic auth to read credentials from Secrets Manager.""" - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, "test-secret-arn") - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, raising=False) - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, raising=False) - auth.transaction_auth_settings = TransactionAuthSettings() + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", "test-secret-arn") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_USERNAME", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_PASSWORD", raising=False) + auth.reset_transaction_auth_state() @pytest.fixture def basic_auth_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: """Configure basic auth to read credentials directly from env vars.""" - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, "bob") - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, "builder") - auth.transaction_auth_settings = TransactionAuthSettings() + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_USERNAME", "bob") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") + auth.reset_transaction_auth_state() @pytest.fixture @@ -152,11 +151,11 @@ def test_transaction_enabled_app_fails_closed_without_any_basic_auth_credentials monkeypatch: pytest.MonkeyPatch, ) -> None: """Missing basic-auth config should fail app creation.""" - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "basic") - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, raising=False) - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_USERNAME_ENV, raising=False) - monkeypatch.delenv(auth.MAAP_TRANSACTION_AUTH_PASSWORD_ENV, raising=False) - auth.transaction_auth_settings = TransactionAuthSettings() + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_USERNAME", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_PASSWORD", raising=False) + auth.reset_transaction_auth_state() with pytest.raises(RuntimeError, match="MAAP_TRANSACTION_AUTH_USERNAME"): create_app( @@ -169,8 +168,8 @@ def test_transaction_enabled_app_fails_closed_for_unsupported_auth_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Unsupported auth modes should fail app creation.""" - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_MODE_ENV, "none") - monkeypatch.setenv(auth.MAAP_TRANSACTION_AUTH_SECRET_ARN_ENV, "test-secret-arn") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "none") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", "test-secret-arn") with pytest.raises(ValidationError, match="Input should be 'basic'"): - auth.transaction_auth_settings = TransactionAuthSettings() + auth.reset_transaction_auth_state() diff --git a/test/pgstac-infra.test.ts b/test/pgstac-infra.test.ts index 1d77e55..fd3a751 100644 --- a/test/pgstac-infra.test.ts +++ b/test/pgstac-infra.test.ts @@ -77,7 +77,7 @@ describe("PgStacInfra STAC runtime wiring", () => { }); template.hasResourceProperties("AWS::Lambda::Function", { - Handler: "handler.handler", + Handler: "eoapi.stac.handler.handler", Environment: { Variables: Match.objectLike({ STAC_FASTAPI_TITLE: "MAAP public STAC API (test)", @@ -122,7 +122,7 @@ describe("PgStacInfra STAC runtime wiring", () => { }); template.hasResourceProperties("AWS::Lambda::Function", { - Handler: "handler.handler", + Handler: "eoapi.stac.handler.handler", Environment: { Variables: Match.objectLike({ ENABLED_EXTENSIONS: @@ -166,7 +166,7 @@ describe("PgStacInfra STAC runtime wiring", () => { ), ).toHaveLength(0); template.hasResourceProperties("AWS::Lambda::Function", { - Handler: "handler.handler", + Handler: "eoapi.stac.handler.handler", Environment: { Variables: Match.objectLike({ MAAP_TRANSACTION_AUTH_SECRET_ARN: From 4790d4d4c383362856aafb59a5fa88c2cede680b Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 14:38:23 -0500 Subject: [PATCH 10/13] fix: lambda event loops --- .gitignore | 1 + cdk/runtimes/eoapi/stac/eoapi/stac/handler.py | 13 +++++- cdk/runtimes/eoapi/stac/tests/test_handler.py | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6bcd36a..248242c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__ .venv .env .envrc +.env-test .test-env .DS_Store diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py index d1f7144..564f9a4 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py @@ -86,6 +86,16 @@ async def _initialize_db_connections() -> None: ) +def _get_or_create_event_loop() -> asyncio.AbstractEventLoop: + """Return the current event loop, or create one if none exists.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + def _initialize_db_connections_sync(*, close_existing_pools: bool = False) -> None: """Initialize database pools from synchronous Lambda hooks.""" global _CONNECTIONS_INITIALIZED @@ -93,7 +103,8 @@ def _initialize_db_connections_sync(*, close_existing_pools: bool = False) -> No if close_existing_pools: _close_pools() - asyncio.run(_initialize_db_connections()) + loop = _get_or_create_event_loop() + loop.run_until_complete(_initialize_db_connections()) _CONNECTIONS_INITIALIZED = True diff --git a/cdk/runtimes/eoapi/stac/tests/test_handler.py b/cdk/runtimes/eoapi/stac/tests/test_handler.py index d50e3e3..32b094f 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_handler.py +++ b/cdk/runtimes/eoapi/stac/tests/test_handler.py @@ -132,6 +132,46 @@ async def fake_connect_to_db( } +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_initialize_db_connections_sync_leaves_current_event_loop_available( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Sync init should preserve a current event loop for Mangum.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + handler._initialize_db_connections_sync() + + assert handler._CONNECTIONS_INITIALIZED is True + assert asyncio.get_event_loop() is not None + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + @pytest.mark.parametrize("with_transactions", [False, True]) def test_startup_event_connects_with_expected_write_pool_setting( monkeypatch: pytest.MonkeyPatch, From 71e4bc12aa44e2e8f0c30f934f8e4dd5889ed419 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 15 May 2026 15:06:45 -0500 Subject: [PATCH 11/13] fix: read new env vars in deploy.yml --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b75f71..cdabe2c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,9 @@ jobs: USER_STAC_ITEM_GEN_ROLE_ARN: ${{ vars.USER_STAC_ITEM_GEN_ROLE_ARN }} USER_STAC_INBOUND_TOPIC_ARNS: ${{ vars.USER_STAC_INBOUND_TOPIC_ARNS }} USER_STAC_COLLECTION_ID_REGISTRY: ${{ vars.USER_STAC_COLLECTION_ID_REGISTRY }} + USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE }} + USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN }} + USER_STAC_COLLECTION_TRANSACTIONS_ENABLED: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED }} USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME }} USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} WEB_ACL_ARN: ${{ vars.WEB_ACL_ARN }} From 56ecab0595f0ee7df2160d4ce07430ec13ee4c27 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 22 May 2026 09:44:34 -0500 Subject: [PATCH 12/13] chore: update action versions, pin to shas --- .github/workflows/tests.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ed0cae..01deae2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,10 +16,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20" cache: npm @@ -27,7 +29,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Run Jest + - name: Run tests run: npm test python-runtime-tests: @@ -48,15 +50,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true From 11acbb7800ca6c8ce4a06415af99923da91de76f Mon Sep 17 00:00:00 2001 From: hrodmn Date: Wed, 27 May 2026 15:11:59 -0500 Subject: [PATCH 13/13] fix: tighten up list of accepted text_mime_types --- cdk/runtimes/eoapi/stac/eoapi/stac/handler.py | 11 ++++++++++- cdk/runtimes/eoapi/stac/tests/test_handler.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py index 564f9a4..56a50f5 100644 --- a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py @@ -33,6 +33,15 @@ def register_after_restore(func): logger = logging.getLogger(__name__) +TEXT_MIME_TYPES = [ + "text/", + "application/json", + "application/geo+json", + "application/xml", + "application/vnd.api+json", + "application/vnd.oai.openapi", +] + _CONNECTIONS_INITIALIZED = False WITH_COLLECTION_TRANSACTIONS = ( COLLECTION_TRANSACTION_EXTENSION @@ -146,7 +155,7 @@ async def shutdown_event() -> None: handler = Mangum( app, lifespan="off", - text_mime_types=["text/", "application/"], + text_mime_types=TEXT_MIME_TYPES, ) diff --git a/cdk/runtimes/eoapi/stac/tests/test_handler.py b/cdk/runtimes/eoapi/stac/tests/test_handler.py index 32b094f..851114a 100644 --- a/cdk/runtimes/eoapi/stac/tests/test_handler.py +++ b/cdk/runtimes/eoapi/stac/tests/test_handler.py @@ -210,6 +210,18 @@ async def fake_connect_to_db( } +def test_handler_uses_specific_text_mime_types() -> None: + """Mangum should treat expected text-based API types as non-binary.""" + assert handler.handler.config["text_mime_types"] == [ + "text/", + "application/json", + "application/geo+json", + "application/xml", + "application/vnd.api+json", + "application/vnd.oai.openapi", + ] + + def test_shutdown_event_closes_db_connection( monkeypatch: pytest.MonkeyPatch, ) -> None: