diff --git a/changelog.d/apiv2-1e-metadata-catalog-route.added.md b/changelog.d/apiv2-1e-metadata-catalog-route.added.md new file mode 100644 index 000000000..85a9b21dc --- /dev/null +++ b/changelog.d/apiv2-1e-metadata-catalog-route.added.md @@ -0,0 +1 @@ +GET /v2/model-catalog returns every parameter node, parameter (with its current-law values keyed by start date) and variable of a catalog version in one response, for clients that load the whole model at once. Each instance builds the document once per country and catalog version, and the response supports ETag revalidation. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..e407480ef 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | ---: | | route group count | 9 | | workflow count | 16 | -| request count | 89 | +| request count | 90 | | db entity count | 6 | | sim flow count | 3 | @@ -18,7 +18,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | --- | --- | --- | | `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check` | `none` | `none` | | `specification` | `specification` | `none` | `none` | -| `metadata` | `metadata`, `datasets`, `economy-options`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` | +| `metadata` | `metadata`, `datasets`, `economy-options`, `model-catalog`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` | | `policy` | `policy`, `policies`, `user-policy`, `user-policies` | `policy` | `none` | | `household` | `household`, `households`, `user-household`, `user-households`, `calculate`, `calculate-full` | `household` | `household` | | `economy` | `economy` | `simulation` | `economy` | @@ -214,6 +214,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | `GET` | `/v2/regions/{region_id}?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | | `GET` | `/v2/regions/by-code/state/ca?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` | | `GET` | `/v2/tax-benefit-models/by-country/us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.model`, `result.model_version` | +| `GET` | `/v2/model-catalog?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.parameter_nodes`, `result.parameters`, `result.variables` | | `GET` | `/v2/economy-options?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.current_law_id`, `result.region`, `result.time_period`, `result.datasets` | ### `simulation_submit_poll` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7a0f1b67d..9669e79cd 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,7 +1,7 @@ { "metadata": { "db_entity_count": 6, - "request_count": 89, + "request_count": 90, "route_group_count": 9, "sim_flow_count": 3, "workflow_count": 16 @@ -33,6 +33,7 @@ "metadata", "datasets", "economy-options", + "model-catalog", "parameter-values", "parameters", "regions", @@ -1599,6 +1600,21 @@ "result.model_version" ] }, + { + "expected_status": 200, + "headers": [], + "method": "GET", + "path": "/v2/model-catalog?country_id=us", + "route_group": "metadata", + "stable_response_fields": [ + "status", + "message", + "result.policyengine_version", + "result.parameter_nodes", + "result.parameters", + "result.variables" + ] + }, { "expected_status": 200, "headers": [], diff --git a/docs/migration/stage-9-v2-metadata.md b/docs/migration/stage-9-v2-metadata.md index 4ce7d2c8e..a0d5a7842 100644 --- a/docs/migration/stage-9-v2-metadata.md +++ b/docs/migration/stage-9-v2-metadata.md @@ -12,8 +12,10 @@ read-only, resource-specific routes from the Cloud Run ASGI application under `/v2`. The routes separately serve tax-benefit models, model versions, variables, parameters, direct parameter-tree children, canonical parameter values, logical input datasets, regions, and compact economy-selection -options. Collection routes require `country_id`, use bounded `offset` and -`limit` pagination, and never assemble the complete catalog into one response. +options. Collection routes require `country_id` and use bounded `offset` and +`limit` pagination. One later addition, `GET /v2/model-catalog`, does assemble +a whole catalog version into one cached response for clients that load the +complete model at start-up (see Preview verification). The generated OpenAPI document is available at `GET /v2/openapi.json`. The unprefixed Flask/API v1 routes remain mounted within the Cloud Run ASGI @@ -198,6 +200,24 @@ Request `GET /v2/openapi.json` and confirm that the public document contains every documented resource path and explicit component schema references for success and error responses. +`GET /v2/model-catalog` returns a whole catalog version in one response: every +parameter node, every parameter with its current-law values (keyed by start +date), and every variable. It exists for clients such as the app's v3 +workspace that load the whole model at start-up; the paged routes would need +about 2,550 requests of 500 rows for the US catalog (100,276 `gov` parameters +and 1,174,586 values). Each API instance builds the serialized document once +per country and catalog version and keeps the four most recently used. Measured +locally against the 6.7.5 catalog, the US document takes 5.8 seconds to build +and is 60.7 MB (12.1 MB gzipped); the UK document takes 1.0 second and is +1.3 MB. The response carries an ETag naming the country, catalog version and +document format, and `Cache-Control: public, max-age=86400` when the request +names `policyengine_version` (`max-age=300` otherwise, because the running +version changes on deploy). The release workflow checks the UK document, +including a conditional request that must return 304. The route reads its +query through the shared `CatalogQuery` model, so an unknown or repeated +parameter, an unsupported country or an invalid version returns 422 before +any database read (the paged metadata routes return 400 for the last two). + Also request the unprefixed US and UK metadata endpoints and confirm their responses still come from v1. Do not modify route selectors, internal callers, or traffic rules to use `/v2` during Stage 9. diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index a7d0712a3..b1abb9de9 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -31,8 +31,12 @@ AccountPrincipal, UserPrincipal, ) + from policyengine_api.services.v2.metadata.catalog_cache import ( + MetadataCatalogCache, + ) from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, + MetadataCatalogDocument, MetadataDataset, MetadataDetailResult, MetadataEconomyOptionsResult, @@ -309,6 +313,18 @@ def get_economy_options( """ ... + def get_model_catalog( + self, + country_id: str, + policyengine_version: str | None = None, + ) -> MetadataCatalogDocument: + """Return the selected catalog's parameter nodes, parameters and variables. + + Parameters carry their current-law values. The result is the serialized + success envelope, built once per country and catalog version. + """ + ... + class V2PolicyResourceService(Protocol): """Route-independent native policy operations for one request.""" @@ -723,6 +739,16 @@ def _running_policyengine_version() -> str: return importlib_metadata.version("policyengine") +@lru_cache(maxsize=1) +def _metadata_catalog_cache() -> MetadataCatalogCache: + """Return the catalog documents this process keeps for every request.""" + from policyengine_api.services.v2.metadata.catalog_cache import ( + MetadataCatalogCache, + ) + + return MetadataCatalogCache() + + def _default_v2_metadata_reader_factory() -> V2MetadataResourceReader: from policyengine_api.data.v2.database import get_v2_session_factory from policyengine_api.services.v2.metadata.database_session import ( @@ -733,6 +759,7 @@ def _default_v2_metadata_reader_factory() -> V2MetadataResourceReader: return V2MetadataService( MetadataDatabaseSession(get_v2_session_factory()()), running_policyengine_version=_running_policyengine_version(), + catalog_cache=_metadata_catalog_cache(), ) diff --git a/policyengine_api/fastapi_routes/v2/metadata/catalog_routes.py b/policyengine_api/fastapi_routes/v2/metadata/catalog_routes.py new file mode 100644 index 000000000..da5fefe35 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/metadata/catalog_routes.py @@ -0,0 +1,107 @@ +"""API v2 route serving a whole metadata catalog version in one response.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Depends, Header +from starlette.responses import JSONResponse, Response + +from policyengine_api.fastapi_routes.query_parameters import query_dependency +from policyengine_api.fastapi_routes.v2.metadata.common import ( + ERROR_RESPONSES, + run_metadata_read, +) +from policyengine_api.fastapi_routes.v2.metadata.response_models import ( + MetadataCatalogResponse, +) +from policyengine_api.query_parameters import CatalogQuery + +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2MetadataResourceReader, + ) + from policyengine_api.services.v2.metadata.types import MetadataCatalogDocument + +METADATA_CATALOG_FORMAT = 1 +"""Changes whenever the document's shape changes, so validators never match old bodies.""" + +RUNNING_CATALOG_MAX_AGE_SECONDS = 300 +PINNED_CATALOG_MAX_AGE_SECONDS = 86_400 + + +def catalog_entity_tag(document: MetadataCatalogDocument) -> str: + """Return the quoted ETag naming a document's country, version and format.""" + return ( + f'"{document.country_id}:{document.policyengine_version}:' + f'{METADATA_CATALOG_FORMAT}"' + ) + + +def _matches(if_none_match: str | None, entity_tag: str) -> bool: + if if_none_match is None: + return False + candidates = {candidate.strip() for candidate in if_none_match.split(",")} + return "*" in candidates or bool( + {entity_tag, f"W/{entity_tag}"}.intersection(candidates) + ) + + +def catalog_response( + document: MetadataCatalogDocument, + *, + pinned: bool, + if_none_match: str | None, +) -> Response: + """Serve the document, or 304 when the client already holds this version. + + A request that names its catalog version may be cached for a day; one that + takes the running version is revalidated after five minutes, since the + running version changes when the API deploys a new PolicyEngine.py. + """ + max_age = ( + PINNED_CATALOG_MAX_AGE_SECONDS if pinned else RUNNING_CATALOG_MAX_AGE_SECONDS + ) + entity_tag = catalog_entity_tag(document) + headers = {"ETag": entity_tag, "Cache-Control": f"public, max-age={max_age}"} + if _matches(if_none_match, entity_tag): + return Response(status_code=304, headers=headers) + return Response( + content=document.body, media_type="application/json", headers=headers + ) + + +def build_v2_model_catalog_router( + dependencies: NativeRouteDependencies, +) -> APIRouter: + """Build the whole-catalog route; building it touches no database.""" + router = APIRouter(prefix="/v2", responses=ERROR_RESPONSES) + catalog_query = query_dependency(CatalogQuery) + + @router.get( + "/model-catalog", + response_model=MetadataCatalogResponse, + summary="Get every parameter node, parameter and variable of a catalog", + description=( + "Returns the selected PolicyEngine.py catalog's parameter nodes, " + "parameters with their current-law values, and variables in one " + "response, for clients that load the whole model at once. Name the " + "catalog with policyengine_version to make the response cacheable for " + "a day; the ETag supports conditional requests." + ), + ) + def get_model_catalog( + query: CatalogQuery = Depends(catalog_query), + if_none_match: str | None = Header(default=None), + ) -> Response | JSONResponse: + def read(reader: V2MetadataResourceReader) -> Response: + return catalog_response( + reader.get_model_catalog(query.country_id, query.policyengine_version), + pinned=query.policyengine_version is not None, + if_none_match=if_none_match, + ) + + return run_metadata_read(dependencies, read) + + return router diff --git a/policyengine_api/fastapi_routes/v2/metadata/common.py b/policyengine_api/fastapi_routes/v2/metadata/common.py index 23a89466f..f7e653108 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/common.py +++ b/policyengine_api/fastapi_routes/v2/metadata/common.py @@ -62,6 +62,7 @@ ResponseT = TypeVar("ResponseT", bound=BaseModel) +ReadT = TypeVar("ReadT") def read_resource( @@ -75,6 +76,20 @@ def read_resource( (400, 404 or 503, and 500 for anything unexpected), and the reader is closed whatever happens. """ + return run_metadata_read( + dependencies, lambda reader: response_type(result=operation(reader)) + ) + + +def run_metadata_read( + dependencies: NativeRouteDependencies, + operation: Callable[[V2MetadataResourceReader], ReadT], +) -> ReadT | JSONResponse: + """Run one metadata read and return its result unchanged. + + Failures become the typed v2 error responses described by read_resource, + and the reader is closed whatever happens. + """ reader = None try: factory = dependencies.v2_metadata_reader_factory @@ -85,7 +100,7 @@ def read_resource( factory = _default_v2_metadata_reader_factory reader = factory() - return response_type(result=operation(reader)) + return operation(reader) except (InvalidMetadataPageError, InvalidPolicyEngineVersionError) as error: return v2_error_response(400, str(error)) except UnsupportedPreviewCountryError as error: diff --git a/policyengine_api/fastapi_routes/v2/metadata/response_models.py b/policyengine_api/fastapi_routes/v2/metadata/response_models.py index d53660834..4ea46a755 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/response_models.py +++ b/policyengine_api/fastapi_routes/v2/metadata/response_models.py @@ -6,6 +6,7 @@ from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, + MetadataCatalogResult, MetadataDataset, MetadataDetailResult, MetadataEconomyOptionsResult, @@ -139,3 +140,7 @@ class MetadataEconomyOptionsResponse( MetadataResourceSuccessResponse[MetadataEconomyOptionsResult] ): """Success response holding the options for configuring an economy comparison.""" + + +class MetadataCatalogResponse(MetadataResourceSuccessResponse[MetadataCatalogResult]): + """Success response holding a whole catalog version's parameters and variables.""" diff --git a/policyengine_api/fastapi_routes/v2/routes.py b/policyengine_api/fastapi_routes/v2/routes.py index 0521719be..b55b57436 100644 --- a/policyengine_api/fastapi_routes/v2/routes.py +++ b/policyengine_api/fastapi_routes/v2/routes.py @@ -12,6 +12,9 @@ build_v2_household_router, ) from policyengine_api.fastapi_routes.v2.me.routes import build_v2_me_router +from policyengine_api.fastapi_routes.v2.metadata.catalog_routes import ( + build_v2_model_catalog_router, +) from policyengine_api.fastapi_routes.v2.metadata.geography_routes import ( build_v2_metadata_geography_router, ) @@ -56,6 +59,7 @@ def build_v2_router( router.include_router(build_v2_metadata_model_router(dependencies)) router.include_router(build_v2_metadata_parameter_router(dependencies)) router.include_router(build_v2_metadata_geography_router(dependencies)) + router.include_router(build_v2_model_catalog_router(dependencies)) @router.get( "/v2/openapi.json", diff --git a/policyengine_api/migration_registry.py b/policyengine_api/migration_registry.py index e49de6986..25fd541f6 100644 --- a/policyengine_api/migration_registry.py +++ b/policyengine_api/migration_registry.py @@ -39,6 +39,7 @@ class RouteGroupConfig: "metadata", "datasets", "economy-options", + "model-catalog", "parameter-values", "parameters", "regions", diff --git a/policyengine_api/services/v2/metadata/catalog_cache.py b/policyengine_api/services/v2/metadata/catalog_cache.py new file mode 100644 index 000000000..10b5f04ed --- /dev/null +++ b/policyengine_api/services/v2/metadata/catalog_cache.py @@ -0,0 +1,70 @@ +"""A bounded, process-wide store of serialized v2 metadata catalog documents.""" + +from __future__ import annotations + +import threading +from collections import OrderedDict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.services.v2.metadata.types import MetadataCatalogDocument + +CatalogKey = tuple[str, str] +"""A country ID and the exact PolicyEngine.py version of its catalog.""" + +DEFAULT_CATALOG_CAPACITY = 4 + + +class MetadataCatalogCache: + """Keep the most recently used catalog documents, built at most once each. + + A published catalog version never changes, so its document is kept until + newer documents push it out (least recently used first). Requests for a + document that is being built wait for that build instead of starting + another; a failed build keeps nothing, so the next request tries again. + """ + + def __init__(self, *, capacity: int = DEFAULT_CATALOG_CAPACITY) -> None: + """Keep at most ``capacity`` documents (at least one).""" + if capacity < 1: + msg = "capacity must be positive" + raise ValueError(msg) + self._capacity = capacity + self._documents: OrderedDict[CatalogKey, MetadataCatalogDocument] = ( + OrderedDict() + ) + self._lock = threading.Lock() + self._build_locks: dict[CatalogKey, threading.Lock] = {} + + def _stored(self, key: CatalogKey) -> MetadataCatalogDocument | None: + with self._lock: + document = self._documents.get(key) + if document is not None: + self._documents.move_to_end(key) + return document + + def get_or_build( + self, + key: CatalogKey, + build: Callable[[], MetadataCatalogDocument], + ) -> MetadataCatalogDocument: + """Return the document for ``key``, calling ``build`` only when none is kept.""" + document = self._stored(key) + if document is not None: + return document + with self._lock: + build_lock = self._build_locks.setdefault(key, threading.Lock()) + with build_lock: + document = self._stored(key) + if document is not None: + return document + document = build() + with self._lock: + self._documents[key] = document + self._documents.move_to_end(key) + while len(self._documents) > self._capacity: + self._documents.popitem(last=False) + self._build_locks.pop(key, None) + return document diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py b/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py index d29390ef5..0338e4cc0 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any import sqlalchemy as sa -from sqlmodel import Session, col +from sqlmodel import Session, col, select from policyengine_api.data.v2.models import Parameter, ParameterNode from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows @@ -167,3 +167,15 @@ def read_parameter_children( limit=limit, ), ) + + +def read_all_parameter_nodes( + session: Session, *, model_version_id: UUID +) -> list[ParameterNode]: + """Return every parameter node of a model version in name order.""" + return read_rows( + session, + select(ParameterNode) + .where(col(ParameterNode.tax_benefit_model_version_id) == model_version_id) + .order_by(col(ParameterNode.name)), + ) diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py b/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py index 85fa4dc0d..9fd461b5e 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py @@ -2,18 +2,26 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import sqlalchemy as sa +from sqlalchemy.exc import SQLAlchemyError from sqlmodel import Session, col, select +from policyengine_api.data.v2.catalog.catalog_selection import ( + MetadataCatalogUnavailableError, +) from policyengine_api.data.v2.models import Parameter, ParameterValue from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +from policyengine_api.services.v2.metadata.types import MetadataCatalogParameterRow if TYPE_CHECKING: + from collections.abc import Iterator from datetime import datetime from uuid import UUID +CATALOG_VALUE_BATCH_SIZE = 10_000 + def _escape_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") @@ -128,3 +136,65 @@ def read_parameter_value( ), ) return rows[0] if rows else None + + +def read_all_parameter_rows( + session: Session, *, model_version_id: UUID +) -> list[MetadataCatalogParameterRow]: + """Return every parameter of a model version, without values, in name order.""" + rows = read_rows( + session, + sa.select( + col(Parameter.id), + col(Parameter.name), + col(Parameter.label), + col(Parameter.description), + col(Parameter.data_type), + col(Parameter.unit), + ) + .where(col(Parameter.tax_benefit_model_version_id) == model_version_id) + .order_by(col(Parameter.name)), + ) + return [ + MetadataCatalogParameterRow( + id=parameter_id, + name=name, + label=label, + description=description, + data_type=data_type, + unit=unit, + ) + for parameter_id, name, label, description, data_type, unit in rows + ] + + +def iter_canonical_parameter_values( + session: Session, *, model_version_id: UUID +) -> Iterator[tuple[UUID, datetime, Any]]: + """Yield every current-law value of a model version as it is read. + + Each item is (parameter_id, start_date, value). A parameter's values are + consecutive and oldest first. Rows are fetched in batches, so the whole + set is never held at once. Raise MetadataCatalogUnavailableError when the + database query fails. + """ + statement = ( + select( + ParameterValue.parameter_id, + ParameterValue.start_date, + ParameterValue.value_json, + ) + .join(Parameter, col(Parameter.id) == col(ParameterValue.parameter_id)) + .where( + col(Parameter.tax_benefit_model_version_id) == model_version_id, + col(ParameterValue.policy_id).is_(None), + col(ParameterValue.dynamic_id).is_(None), + ) + .order_by(col(ParameterValue.parameter_id), col(ParameterValue.start_date)) + .execution_options(yield_per=CATALOG_VALUE_BATCH_SIZE) + ) + try: + yield from session.exec(statement) + except SQLAlchemyError as error: + msg = "the v2 metadata catalog cannot be queried" + raise MetadataCatalogUnavailableError(msg) from error diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py b/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py index 4d84be396..1378f6528 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py @@ -62,3 +62,13 @@ def read_variable( ), ) return rows[0] if rows else None + + +def read_all_variables(session: Session, *, model_version_id: UUID) -> list[Variable]: + """Return every variable of a model version in name order.""" + return read_rows( + session, + select(Variable) + .where(col(Variable.tax_benefit_model_version_id) == model_version_id) + .order_by(col(Variable.name)), + ) diff --git a/policyengine_api/services/v2/metadata/services.py b/policyengine_api/services/v2/metadata/services.py index 8082c6fad..4686d14cb 100644 --- a/policyengine_api/services/v2/metadata/services.py +++ b/policyengine_api/services/v2/metadata/services.py @@ -9,6 +9,7 @@ SelectedCatalog, validate_policyengine_version, ) +from policyengine_api.services.v2.metadata.catalog_cache import MetadataCatalogCache from policyengine_api.services.v2.metadata.database_connectors.reads import ( read_metadata_catalog, ) @@ -17,9 +18,12 @@ read_datasets, ) from policyengine_api.services.v2.metadata.database_connectors.reads_parameter_tree import ( + read_all_parameter_nodes, read_parameter_children, ) from policyengine_api.services.v2.metadata.database_connectors.reads_parameters import ( + iter_canonical_parameter_values, + read_all_parameter_rows, read_parameter, read_parameter_value, read_parameter_values, @@ -31,10 +35,12 @@ read_regions, ) from policyengine_api.services.v2.metadata.database_connectors.reads_variables import ( + read_all_variables, read_variable, read_variables, ) from policyengine_api.services.v2.metadata.transformations import ( + metadata_catalog_document, metadata_dataset, metadata_economy_options, metadata_model, @@ -50,6 +56,7 @@ ) from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, + MetadataCatalogDocument, MetadataDataset, MetadataDetailResult, MetadataEconomyOptionsResult, @@ -90,12 +97,18 @@ def __init__( database_session: MetadataDatabaseSession, *, running_policyengine_version: str, + catalog_cache: MetadataCatalogCache | None = None, ) -> None: - """Bind to a request's session and the running PolicyEngine.py version.""" + """Bind to a request's session and the running PolicyEngine.py version. + + catalog_cache keeps built catalog documents across requests; without + one, this service keeps its own for its lifetime. + """ self._database_session = database_session self._running_policyengine_version = validate_policyengine_version( running_policyengine_version ) + self._catalog_cache = catalog_cache or MetadataCatalogCache() def close(self) -> None: """Close the request's database session.""" @@ -510,6 +523,42 @@ def get_region_by_code( item=metadata_region(row), ) + def get_model_catalog( + self, + country_id: str, + policyengine_version: str | None = None, + ) -> MetadataCatalogDocument: + """Return every parameter node, parameter and variable of the selected catalog. + + Parameters carry their current-law values. The serialized document is + built once per country and catalog version and then served from the + catalog cache, because a published catalog version never changes. + """ + selected = self._select_catalog(country_id, policyengine_version) + session = self._database_session.session + model_version_id = selected.model_version.id + + def build() -> MetadataCatalogDocument: + return metadata_catalog_document( + selected, + parameter_nodes=read_all_parameter_nodes( + session, model_version_id=model_version_id + ), + parameters=read_all_parameter_rows( + session, model_version_id=model_version_id + ), + values=iter_canonical_parameter_values( + session, model_version_id=model_version_id + ), + variables=read_all_variables( + session, model_version_id=model_version_id + ), + ) + + return self._catalog_cache.get_or_build( + (selected.country_id, selected.policyengine_version), build + ) + def get_economy_options( self, country_id: str, policyengine_version: str | None = None ) -> MetadataEconomyOptionsResult: diff --git a/policyengine_api/services/v2/metadata/transformations.py b/policyengine_api/services/v2/metadata/transformations.py index 588869af4..74f8851e3 100644 --- a/policyengine_api/services/v2/metadata/transformations.py +++ b/policyengine_api/services/v2/metadata/transformations.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, TypeVar from policyengine_api.dataset_display import get_dataset_display_label from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, + MetadataCatalogDocument, MetadataDataset, MetadataDatasetOption, MetadataEconomyOptionsResult, @@ -25,14 +27,21 @@ ) if TYPE_CHECKING: + from collections.abc import Iterable + from uuid import UUID + from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog from policyengine_api.data.v2.models import ( Dataset, Parameter, + ParameterNode, ParameterValue, Region, Variable, ) + from policyengine_api.services.v2.metadata.types import ( + MetadataCatalogParameterRow, + ) ResourceT = TypeVar("ResourceT") @@ -223,3 +232,98 @@ def metadata_economy_options( ) ], ) + + +def _catalog_json(value: object) -> bytes: + return json.dumps( + value, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ).encode("utf-8") + + +def catalog_value_date(start_date: datetime) -> str: + """Return a value's UTC start day as YYYY-MM-DD, treating a naive time as UTC.""" + if start_date.tzinfo is not None: + start_date = start_date.astimezone(UTC) + return start_date.date().isoformat() + + +def _catalog_parameter( + parameter: MetadataCatalogParameterRow, values: dict[str, Any] +) -> bytes: + return _catalog_json( + { + "id": str(parameter.id), + "name": parameter.name, + "label": parameter.label, + "description": parameter.description, + "data_type": parameter.data_type, + "unit": parameter.unit, + "values": values, + } + ) + + +def metadata_catalog_document( + selected: SelectedCatalog, + *, + parameter_nodes: list[ParameterNode], + parameters: list[MetadataCatalogParameterRow], + values: Iterable[tuple[UUID, datetime, Any]], + variables: list[Variable], +) -> MetadataCatalogDocument: + """Serialize a catalog version as the model-catalog success envelope. + + values holds (parameter_id, start_date, value) with each parameter's values + consecutive and oldest first; it is consumed once, as it is read, and each + parameter is serialized as soon as its values are complete. Parameters, + nodes and variables appear in the order given (name order). + """ + parameters_by_id = {parameter.id: parameter for parameter in parameters} + serialized_by_id: dict[UUID, bytes] = {} + current_parameter_id: UUID | None = None + current_values: dict[str, Any] = {} + for parameter_id, start_date, value in values: + if parameter_id != current_parameter_id: + if current_parameter_id in parameters_by_id: + serialized_by_id[current_parameter_id] = _catalog_parameter( + parameters_by_id[current_parameter_id], current_values + ) + current_parameter_id = parameter_id + current_values = {} + current_values[catalog_value_date(start_date)] = value + if current_parameter_id in parameters_by_id: + serialized_by_id[current_parameter_id] = _catalog_parameter( + parameters_by_id[current_parameter_id], current_values + ) + + serialized_parameters = b",".join( + serialized_by_id.get(parameter.id) or _catalog_parameter(parameter, {}) + for parameter in parameters + ) + serialized_nodes = _catalog_json( + [ + {"name": node.name, "label": node.label, "description": node.description} + for node in parameter_nodes + ] + ) + serialized_variables = _catalog_json( + [metadata_variable(variable).model_dump(mode="json") for variable in variables] + ) + body = b"".join( + ( + b'{"status":"ok","message":null,"result":{"policyengine_version":', + _catalog_json(selected.policyengine_version), + b',"parameter_nodes":', + serialized_nodes, + b',"parameters":[', + serialized_parameters, + b'],"variables":', + serialized_variables, + b"}}", + ) + ) + return MetadataCatalogDocument( + country_id=selected.country_id, + policyengine_version=selected.policyengine_version, + body=body, + ) diff --git a/policyengine_api/services/v2/metadata/types.py b/policyengine_api/services/v2/metadata/types.py index b74333291..b7aa1627b 100644 --- a/policyengine_api/services/v2/metadata/types.py +++ b/policyengine_api/services/v2/metadata/types.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime from enum import StrEnum from typing import Generic, Literal, TypeVar @@ -172,6 +173,61 @@ class MetadataParameterChild(StrictResponseModel): parameter: MetadataParameterSummary | None = None +class MetadataParameterNode(StrictResponseModel): + """A node of the parameter tree: a path that groups parameters and has no values.""" + + name: str + label: str | None + description: str | None + + +class MetadataCatalogParameter(MetadataParameterSummary): + """A policy parameter with every current-law value it has held. + + values maps each start date (YYYY-MM-DD) to the value in effect from that + date until the day before the next start date; the latest value has no end. + """ + + values: dict[str, JsonValue] + + +class MetadataCatalogResult(StrictResponseModel): + """Every parameter node, parameter and variable of one catalog version. + + Parameters carry their current-law values. Each list is in name order. + """ + + policyengine_version: str + parameter_nodes: list[MetadataParameterNode] + parameters: list[MetadataCatalogParameter] + variables: list[MetadataVariable] + + +@dataclass(frozen=True) +class MetadataCatalogDocument: + """The serialized catalog response body for one country and catalog version. + + body is the complete JSON success envelope; a published catalog version + never changes, so the same body can be served for as long as it is kept. + """ + + country_id: str + policyengine_version: str + body: bytes + + +@dataclass(frozen=True) +class MetadataCatalogParameterRow: + """The descriptive columns of one stored parameter, read without its values.""" + + id: UUID + name: str + label: str | None + description: str | None + data_type: str | None + unit: str | None + + ResourceT = TypeVar("ResourceT") diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 60ccd9d19..472c98785 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -654,6 +654,20 @@ class WorkflowContract: ), route_group="metadata", ), + ContractRequest( + method="GET", + path="/v2/model-catalog?country_id=us", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.policyengine_version", + "result.parameter_nodes", + "result.parameters", + "result.variables", + ), + route_group="metadata", + ), ContractRequest( method="GET", path="/v2/economy-options?country_id=us", diff --git a/tests/integration/test_live_v2_metadata.py b/tests/integration/test_live_v2_metadata.py index 235304156..46c442056 100644 --- a/tests/integration/test_live_v2_metadata.py +++ b/tests/integration/test_live_v2_metadata.py @@ -44,7 +44,9 @@ def test_live_v2_openapi_describes_every_preview_resource(api_client) -> None: assert response.status_code == 200, response.text[:500] document = response.json() expected_paths = {f"/v2/{resource}" for resource in PAGED_RESOURCES} - expected_paths.update({"/v2/parameters/children", "/v2/economy-options"}) + expected_paths.update( + {"/v2/parameters/children", "/v2/economy-options", "/v2/model-catalog"} + ) assert expected_paths <= document["paths"].keys() assert all("get" in document["paths"][path] for path in expected_paths) assert "V2ErrorResponse" in document["components"]["schemas"] @@ -104,3 +106,22 @@ def test_live_v2_invalid_version_returns_a_typed_error(api_client) -> None: assert payload["status"] == "error" assert payload["message"] assert "result" not in payload or payload["result"] is None + + +def test_live_v2_model_catalog_serves_the_deployed_uk_catalog(api_client) -> None: + expected_version = _installed_policyengine_version() + query = {"country_id": "uk", "policyengine_version": expected_version} + + response = api_client.get("/v2/model-catalog", params=query) + revalidated = api_client.get( + "/v2/model-catalog", + params=query, + headers={"If-None-Match": response.headers.get("etag", "")}, + ) + + result = _assert_ok(response, expected_version) + assert result["parameters"] + assert result["parameter_nodes"] + assert result["variables"] + assert all(isinstance(item["values"], dict) for item in result["parameters"]) + assert revalidated.status_code == 304 diff --git a/tests/integration/test_v2_metadata_routes.py b/tests/integration/test_v2_metadata_routes.py index a3d29d93b..4c7f41ca0 100644 --- a/tests/integration/test_v2_metadata_routes.py +++ b/tests/integration/test_v2_metadata_routes.py @@ -360,3 +360,37 @@ def test_postgres_parameter_collection_remains_available_without_values( assert parameters.status_code == values.status_code == 200 assert parameters.json()["result"]["items"] assert values.json()["result"]["items"] == [] + + +@pytest.mark.parametrize("country_id", ["us", "uk"]) +def test_postgres_model_catalog_holds_the_published_catalog( + published_engine: Engine, + country_id: str, +) -> None: + client = _client(published_engine) + before = _catalog_counts(published_engine) + + catalog = client.get("/v2/model-catalog", params={"country_id": country_id}) + paged_parameters = client.get("/v2/parameters", params={"country_id": country_id}) + revalidated = client.get( + "/v2/model-catalog", + params={"country_id": country_id}, + headers={"If-None-Match": catalog.headers["etag"]}, + ) + + assert catalog.status_code == 200 + result = catalog.json()["result"] + assert result["policyengine_version"] == POLICYENGINE_VERSION + assert [node["name"] for node in result["parameter_nodes"]] == ["gov.example"] + assert [parameter["id"] for parameter in result["parameters"]] == [ + item["id"] for item in paged_parameters.json()["result"]["items"] + ] + assert result["parameters"][0]["values"] == { + "2025-01-01": 0.1, + "2026-01-01": 0.2, + } + assert [variable["name"] for variable in result["variables"]] == [ + "employment_income" + ] + assert revalidated.status_code == 304 + assert _catalog_counts(published_engine) == before diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index ab6b199e7..cbbe2e95c 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -14,7 +14,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["metadata"] == { "route_group_count": 9, "workflow_count": 16, - "request_count": 89, + "request_count": 90, "db_entity_count": 6, "sim_flow_count": 3, } diff --git a/tests/unit/v2/test_metadata_catalog.py b/tests/unit/v2/test_metadata_catalog.py new file mode 100644 index 000000000..2cc17eab3 --- /dev/null +++ b/tests/unit/v2/test_metadata_catalog.py @@ -0,0 +1,301 @@ +"""The bulk v2 metadata catalog: one document per country and catalog version.""" + +from __future__ import annotations + +import json +import threading +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from sqlalchemy import event +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, create_engine, select + +from policyengine_api.data.v2.models import ( + V2_METADATA, + Parameter, + ParameterValue, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.fastapi_routes.v2.metadata.response_models import ( + MetadataCatalogResponse, +) +from policyengine_api.services.v2.metadata.catalog_cache import MetadataCatalogCache +from policyengine_api.services.v2.metadata.database_session import ( + MetadataDatabaseSession, +) +from policyengine_api.services.v2.metadata.services import V2MetadataService +from policyengine_api.services.v2.metadata.types import MetadataCatalogDocument +from tests.fixtures.v2_catalog import POLICYENGINE_VERSION, normalized_catalog +from tests.unit.v2.test_metadata_service import _insert_country + +LATER_POLICYENGINE_VERSION = "5.0.5" + + +@pytest.fixture +def catalog_session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + V2_METADATA.create_all(engine) + with Session(engine) as session: + for country in normalized_catalog().countries: + _insert_country(session, country, include_model=True) + session.commit() + + session = Session(engine) + try: + yield session + finally: + session.close() + engine.dispose() + + +def _service( + session: Session, catalog_cache: MetadataCatalogCache | None = None +) -> V2MetadataService: + return V2MetadataService( + MetadataDatabaseSession(session), + running_policyengine_version=POLICYENGINE_VERSION, + catalog_cache=catalog_cache, + ) + + +def _us_parameter(session: Session, name: str = "gov.example.rate") -> Parameter: + return session.exec( + select(Parameter) + .join( + TaxBenefitModelVersion, + TaxBenefitModelVersion.id == Parameter.tax_benefit_model_version_id, + ) + .join(TaxBenefitModel, TaxBenefitModel.id == TaxBenefitModelVersion.model_id) + .where( + TaxBenefitModel.name == "policyengine-us", + TaxBenefitModelVersion.version == POLICYENGINE_VERSION, + Parameter.name == name, + ) + ).one() + + +def _result(document: MetadataCatalogDocument) -> dict: + envelope = json.loads(document.body) + assert envelope["status"] == "ok" + assert envelope["message"] is None + return envelope["result"] + + +def test_catalog_holds_every_node_parameter_value_and_variable( + catalog_session: Session, +) -> None: + document = _service(catalog_session).get_model_catalog("us") + result = _result(document) + parameter = _us_parameter(catalog_session) + + assert document.country_id == "us" + assert document.policyengine_version == POLICYENGINE_VERSION + assert result["policyengine_version"] == POLICYENGINE_VERSION + assert result["parameter_nodes"] == [ + { + "name": "gov.example", + "label": "Example policy", + "description": "Example policy parameters", + } + ] + assert result["parameters"] == [ + { + "id": str(parameter.id), + "name": "gov.example.rate", + "label": "Example rate", + "description": "An example rate", + "data_type": "float", + "unit": "/1", + "values": {"2025-01-01": 0.1, "2026-01-01": 0.2}, + } + ] + assert [variable["name"] for variable in result["variables"]] == [ + "employment_income" + ] + assert result["variables"][0]["entity"] == "person" + assert result["variables"][0]["label"] == "Employment income" + + +def test_catalog_body_matches_the_published_response_schema( + catalog_session: Session, +) -> None: + document = _service(catalog_session).get_model_catalog("uk") + + response = MetadataCatalogResponse.model_validate_json(document.body) + + assert response.result.policyengine_version == POLICYENGINE_VERSION + assert [item.name for item in response.result.parameters] == ["gov.example.rate"] + + +def test_catalog_values_are_current_law_only_and_ordered_by_start_date( + catalog_session: Session, +) -> None: + parameter = _us_parameter(catalog_session) + catalog_session.add_all( + [ + ParameterValue( + id=uuid4(), + parameter_id=parameter.id, + value_json=0.9, + start_date=datetime(2024, 1, 1, tzinfo=UTC), + end_date=None, + policy_id=uuid4(), + ), + Parameter( + id=uuid4(), + name="gov.example.allowance", + label=None, + description=None, + data_type="bool", + unit=None, + tax_benefit_model_version_id=parameter.tax_benefit_model_version_id, + ), + ] + ) + catalog_session.commit() + + result = _result(_service(catalog_session).get_model_catalog("us")) + + assert [item["name"] for item in result["parameters"]] == [ + "gov.example.allowance", + "gov.example.rate", + ] + assert result["parameters"][0]["values"] == {} + assert list(result["parameters"][1]["values"].items()) == [ + ("2025-01-01", 0.1), + ("2026-01-01", 0.2), + ] + + +def test_catalog_for_an_explicit_version_reads_that_version( + catalog_session: Session, +) -> None: + later = normalized_catalog(policyengine_version=LATER_POLICYENGINE_VERSION) + _insert_country(catalog_session, later.country("us"), include_model=False) + catalog_session.commit() + + running = _service(catalog_session).get_model_catalog("us") + selected = _service(catalog_session).get_model_catalog( + "us", LATER_POLICYENGINE_VERSION + ) + + assert running.policyengine_version == POLICYENGINE_VERSION + assert selected.policyengine_version == LATER_POLICYENGINE_VERSION + assert ( + _result(selected)["parameters"][0]["id"] + != _result(running)["parameters"][0]["id"] + ) + + +def test_catalog_is_built_once_per_country_and_version( + catalog_session: Session, +) -> None: + statements: list[str] = [] + + def record_statement(_connection, _cursor, statement, *_args) -> None: + statements.append(statement.lower()) + + catalog_cache = MetadataCatalogCache() + bind = catalog_session.get_bind() + event.listen(bind, "before_cursor_execute", record_statement) + try: + first = _service(catalog_session, catalog_cache).get_model_catalog("us") + statements_for_first_build = len(statements) + second = _service(catalog_session, catalog_cache).get_model_catalog("us") + finally: + event.remove(bind, "before_cursor_execute", record_statement) + + later_statements = statements[statements_for_first_build:] + assert second is first + assert later_statements + assert all("parameter_values" not in statement for statement in later_statements) + assert all("from variables" not in statement for statement in later_statements) + + +def _document(country_id: str, policyengine_version: str) -> MetadataCatalogDocument: + return MetadataCatalogDocument( + country_id=country_id, + policyengine_version=policyengine_version, + body=f"{country_id}-{policyengine_version}".encode(), + ) + + +def test_cache_evicts_the_least_recently_used_document() -> None: + catalog_cache = MetadataCatalogCache(capacity=2) + builds: list[tuple[str, str]] = [] + + def build(country_id: str, version: str): + def build_document() -> MetadataCatalogDocument: + builds.append((country_id, version)) + return _document(country_id, version) + + return build_document + + catalog_cache.get_or_build(("us", "1.0.0"), build("us", "1.0.0")) + catalog_cache.get_or_build(("uk", "1.0.0"), build("uk", "1.0.0")) + catalog_cache.get_or_build(("us", "1.0.0"), build("us", "1.0.0")) + catalog_cache.get_or_build(("us", "2.0.0"), build("us", "2.0.0")) + catalog_cache.get_or_build(("us", "1.0.0"), build("us", "1.0.0")) + catalog_cache.get_or_build(("uk", "1.0.0"), build("uk", "1.0.0")) + + assert builds == [ + ("us", "1.0.0"), + ("uk", "1.0.0"), + ("us", "2.0.0"), + ("uk", "1.0.0"), + ] + + +def test_cache_builds_once_for_concurrent_requests() -> None: + catalog_cache = MetadataCatalogCache() + build_started = threading.Event() + release_build = threading.Event() + build_count = 0 + results: list[MetadataCatalogDocument] = [] + + def slow_build() -> MetadataCatalogDocument: + nonlocal build_count + build_count += 1 + build_started.set() + release_build.wait(timeout=5) + return _document("us", "1.0.0") + + def request_document() -> None: + results.append(catalog_cache.get_or_build(("us", "1.0.0"), slow_build)) + + first_request = threading.Thread(target=request_document) + first_request.start() + assert build_started.wait(timeout=5) + waiting_requests = [threading.Thread(target=request_document) for _ in range(3)] + for request in waiting_requests: + request.start() + release_build.set() + for request in [first_request, *waiting_requests]: + request.join(timeout=5) + + assert build_count == 1 + assert len(results) == 4 + assert all(result is results[0] for result in results) + + +def test_cache_does_not_keep_a_failed_build() -> None: + catalog_cache = MetadataCatalogCache() + + def failing_build() -> MetadataCatalogDocument: + msg = "database unavailable" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="database unavailable"): + catalog_cache.get_or_build(("us", "1.0.0"), failing_build) + document = catalog_cache.get_or_build( + ("us", "1.0.0"), lambda: _document("us", "1.0.0") + ) + + assert document.body == b"us-1.0.0" diff --git a/tests/unit/v2/test_metadata_routes.py b/tests/unit/v2/test_metadata_routes.py index 79ec7c1ed..cf1237ce4 100644 --- a/tests/unit/v2/test_metadata_routes.py +++ b/tests/unit/v2/test_metadata_routes.py @@ -26,6 +26,7 @@ ) from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, + MetadataCatalogDocument, MetadataDataset, MetadataDatasetOption, MetadataDetailResult, @@ -443,9 +444,12 @@ def test_default_reader_uses_the_installed_policyengine_version( captured = {} reader = object() - def query_service(candidate_session, *, running_policyengine_version): + def query_service( + candidate_session, *, running_policyengine_version, catalog_cache + ): captured["session"] = candidate_session.session captured["version"] = running_policyengine_version + captured.setdefault("catalog_caches", []).append(catalog_cache) return reader monkeypatch.setattr(database, "get_v2_session_factory", lambda: lambda: session) @@ -458,10 +462,15 @@ def query_service(candidate_session, *, running_policyengine_version): route_dependencies._running_policyengine_version.cache_clear() try: result = route_dependencies._default_v2_metadata_reader_factory() + repeated = route_dependencies._default_v2_metadata_reader_factory() finally: route_dependencies._running_policyengine_version.cache_clear() assert result is reader + assert repeated is reader + first_cache, second_cache = captured.pop("catalog_caches") + assert first_cache is second_cache + assert first_cache is route_dependencies._metadata_catalog_cache() assert captured == {"session": session, "version": "5.2.0"} @@ -521,6 +530,7 @@ def test_openapi_references_explicit_resource_response_schemas() -> None: "/v2/datasets", "/v2/datasets/{dataset_id}", "/v2/economy-options", + "/v2/model-catalog", "/v2/parameters", "/v2/parameters/children", "/v2/parameters/{parameter_id}", @@ -583,3 +593,164 @@ def test_openapi_references_explicit_resource_response_schemas() -> None: "application/json" ]["schema"] assert error_schema["$ref"] == "#/components/schemas/V2ErrorResponse" + + +CATALOG_VERSION = "4.20.3" +CATALOG_BODY = ( + b'{"status":"ok","message":null,"result":{"policyengine_version":"4.20.3",' + b'"parameter_nodes":[],"parameters":[],"variables":[]}}' +) + + +def _catalog_reader(**reader_options) -> ResourceReader: + return ResourceReader( + { + "get_model_catalog": MetadataCatalogDocument( + country_id="us", + policyengine_version=CATALOG_VERSION, + body=CATALOG_BODY, + ) + }, + **reader_options, + ) + + +def test_model_catalog_returns_the_document_with_validators() -> None: + reader = _catalog_reader() + client = _client(lambda: reader) + + running = client.get("/v2/model-catalog?country_id=us") + pinned = client.get( + f"/v2/model-catalog?country_id=us&policyengine_version={CATALOG_VERSION}" + ) + + assert running.status_code == 200 + assert running.content == CATALOG_BODY + assert running.headers["content-type"] == "application/json" + assert running.headers["etag"] == pinned.headers["etag"] + assert CATALOG_VERSION in running.headers["etag"] + assert running.headers["cache-control"] == "public, max-age=300" + assert pinned.headers["cache-control"] == "public, max-age=86400" + assert reader.calls == [ + ("get_model_catalog", ("us", None), {}), + ("get_model_catalog", ("us", CATALOG_VERSION), {}), + ] + assert reader.closed is True + + +def test_model_catalog_answers_a_matching_etag_with_not_modified() -> None: + client = _client(_catalog_reader) + etag = client.get("/v2/model-catalog?country_id=us").headers["etag"] + + unchanged = client.get( + "/v2/model-catalog?country_id=us", headers={"If-None-Match": etag} + ) + changed = client.get( + "/v2/model-catalog?country_id=us", + headers={"If-None-Match": '"us:0.0.1:1"'}, + ) + + assert unchanged.status_code == 304 + assert unchanged.content == b"" + assert unchanged.headers["etag"] == etag + assert changed.status_code == 200 + assert changed.content == CATALOG_BODY + + +@pytest.mark.parametrize( + ("error", "status_code"), + [ + (UnsupportedPreviewCountryError("ca"), 400), + (InvalidPolicyEngineVersionError("bad"), 400), + (MetadataCatalogVersionNotFoundError("absent"), 404), + (MetadataCatalogUnavailableError("down"), 503), + (RuntimeError("secret detail"), 500), + ], +) +def test_model_catalog_failures_use_documented_error_statuses( + error: Exception, status_code: int +) -> None: + reader = _catalog_reader(error=error) + response = _client(lambda: reader).get("/v2/model-catalog?country_id=us") + + assert response.status_code == status_code + assert response.json()["status"] == "error" + assert "secret detail" not in response.text + assert reader.closed is True + + +def test_openapi_publishes_the_model_catalog_schema() -> None: + schema = _client(_catalog_reader).get("/v2/openapi.json").json() + + operation = schema["paths"]["/v2/model-catalog"]["get"] + success_schema = operation["responses"]["200"]["content"]["application/json"][ + "schema" + ] + assert success_schema["$ref"] == "#/components/schemas/MetadataCatalogResponse" + result_schema = schema["components"]["schemas"]["MetadataCatalogResult"] + assert set(result_schema["properties"]) == { + "policyengine_version", + "parameter_nodes", + "parameters", + "variables", + } + parameter_schema = schema["components"]["schemas"]["MetadataCatalogParameter"] + assert "values" in parameter_schema["required"] + + +def test_model_catalog_normalizes_the_shared_catalog_query() -> None: + reader = _catalog_reader() + client = _client(lambda: reader) + + response = client.get("/v2/model-catalog?country_id=US") + + assert response.status_code == 200 + assert reader.calls == [("get_model_catalog", ("us", None), {})] + + +@pytest.mark.parametrize( + "query_string", + [ + "", + "country_id=ca", + "country_id=us&policyengine_versoin=4.20.3", + "country_id=us&country_id=uk", + "country_id=us&policyengine_version=4.20.3&policyengine_version=4.20.4", + "country_id=us&policyengine_version=latest", + ], +) +def test_model_catalog_rejects_queries_outside_the_shared_contract( + query_string: str, +) -> None: + reader = _catalog_reader() + client = _client(lambda: reader) + + response = client.get(f"/v2/model-catalog?{query_string}") + + assert response.status_code == 422 + assert response.json() == { + "status": "error", + "message": "Invalid API v2 request", + } + assert reader.calls == [] + + +def test_openapi_declares_the_model_catalog_query_contract() -> None: + schema = _client(_catalog_reader).get("/v2/openapi.json").json() + + parameters = { + parameter["name"]: parameter + for parameter in schema["paths"]["/v2/model-catalog"]["get"]["parameters"] + if parameter["in"] == "query" + } + + assert set(parameters) == {"country_id", "policyengine_version"} + assert parameters["country_id"]["required"] is True + assert parameters["country_id"]["schema"]["enum"] == ["us", "uk"] + assert parameters["policyengine_version"]["required"] is False + version_schemas = parameters["policyengine_version"]["schema"]["anyOf"] + assert [ + version_schema["maxLength"] + for version_schema in version_schemas + if version_schema["type"] == "string" + ] == [128] diff --git a/tests/unit/v2/test_metadata_service.py b/tests/unit/v2/test_metadata_service.py index 9880f265e..b843679ad 100644 --- a/tests/unit/v2/test_metadata_service.py +++ b/tests/unit/v2/test_metadata_service.py @@ -620,6 +620,7 @@ def test_resource_entrypoints_are_defined_in_the_service_module() -> None: "get_region", "get_region_by_code", "get_economy_options", + "get_model_catalog", } for method_name in method_names: