Skip to content

Commit d087efe

Browse files
authored
Merge pull request #1497 from PolicyEngine/codex/validate-household-variables
Validate household variables and publish OpenAPI spec
2 parents 4fbca83 + c6465d0 commit d087efe

11 files changed

Lines changed: 782 additions & 65 deletions

File tree

changelog.d/1496.fixed

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Validate household calculate payload variables before simulation, returning
2+
HTTP 400 with structured `errors` for variables not available in the API's
3+
PolicyEngine model version while preserving allowlisted deprecated-variable
4+
warnings. Also expose the OpenAPI specification and document calculate request
5+
and response schemas.

policyengine_household_api/api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
"""
44

55
# Python imports
6+
from functools import lru_cache
7+
from importlib.metadata import PackageNotFoundError
8+
from importlib.metadata import version as package_version
69
import os
10+
from pathlib import Path
11+
import tomllib
712

813
# External imports
914
from flask_cors import CORS
1015
import flask
1116
from flask_limiter import Limiter
1217
from flask_limiter.util import get_remote_address
18+
import yaml
1319
from policyengine_household_api.data.analytics_setup import (
1420
initialize_analytics_db_if_enabled,
1521
)
@@ -34,6 +40,9 @@
3440
print("Initialising API...")
3541

3642
app = application = flask.Flask(__name__)
43+
OPENAPI_SPEC_PATH = Path(__file__).with_name("openapi_spec.yaml")
44+
PACKAGE_NAME = "policyengine-household-api"
45+
PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml"
3746

3847
# Reject absurdly large request bodies before any view runs. 10 MiB is
3948
# well above the largest legitimate household payload we have seen
@@ -89,6 +98,28 @@ def readiness_check():
8998
)
9099

91100

101+
@app.route("/specification", methods=["GET"])
102+
def specification():
103+
spec = load_openapi_spec()
104+
return flask.jsonify(spec)
105+
106+
107+
def load_openapi_spec() -> dict:
108+
with OPENAPI_SPEC_PATH.open() as spec_file:
109+
spec = yaml.safe_load(spec_file)
110+
spec.setdefault("info", {})["version"] = get_api_version()
111+
return spec
112+
113+
114+
@lru_cache
115+
def get_api_version() -> str:
116+
try:
117+
return package_version(PACKAGE_NAME)
118+
except PackageNotFoundError:
119+
with PYPROJECT_PATH.open("rb") as pyproject_file:
120+
return tomllib.load(pyproject_file)["project"]["version"]
121+
122+
92123
# Note: `/calculate_demo` is intentionally public (documented in
93124
# config/README.md). It is guarded by a conservative rate limit rather
94125
# than JWT authentication.

policyengine_household_api/endpoints/home.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ def get_home() -> Response:
1212
"result": {
1313
"docs_url": "https://www.policyengine.org/us/api",
1414
"container_image": "ghcr.io/policyengine/policyengine-household-api",
15+
"openapi_spec_url": (
16+
"https://household.api.policyengine.org/specification"
17+
),
1518
"hosted_calculate_url_template": (
1619
"https://household.api.policyengine.org/{country_id}/calculate"
1720
),

policyengine_household_api/endpoints/household.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
from policyengine_household_api.utils.deprecated_inputs import (
1818
drop_deprecated_inputs,
1919
)
20+
from policyengine_household_api.utils.variable_validation import (
21+
validate_household_variables,
22+
)
2023
from policyengine_household_api.utils.validate_country import validate_country
2124

2225

@@ -127,15 +130,44 @@ def get_calculate(country_id: str, add_missing: bool = False) -> Response:
127130

128131
country = COUNTRIES.get(country_id)
129132

130-
# Strip deprecated inputs before validation so partners who still pass
131-
# removed/renamed variables get a warning + working response instead
132-
# of a `VariableNotFoundError` HTTP 500.
133-
deprecation_warnings = drop_deprecated_inputs(household_json)
134-
135133
# Validate inbound payload shape before reaching the compute layer.
136134
try:
137135
_validate_household_payload(country_id, household_json)
138136
_validate_axes(household_json)
137+
except ValueError as e:
138+
return Response(
139+
json.dumps({"status": "error", "message": str(e)}),
140+
status=400,
141+
mimetype="application/json",
142+
)
143+
144+
variable_errors = validate_household_variables(
145+
household=household_json,
146+
system=country.tax_benefit_system,
147+
model_version=country.policyengine_bundle["model_version"],
148+
)
149+
if variable_errors:
150+
return Response(
151+
json.dumps(
152+
{
153+
"status": "error",
154+
"message": "Invalid household variables.",
155+
"errors": [error.message for error in variable_errors],
156+
}
157+
),
158+
status=400,
159+
mimetype="application/json",
160+
)
161+
162+
# Strip deprecated inputs from a copy before period validation so
163+
# partners who still pass removed/renamed variables get a warning +
164+
# working response instead of a `VariableNotFoundError` HTTP 500.
165+
deprecated_inputs = drop_deprecated_inputs(household_json)
166+
household_json = deprecated_inputs.household
167+
deprecation_warnings = deprecated_inputs.warnings
168+
169+
# Validate inbound period data before reaching the compute layer.
170+
try:
139171
validate_period_keys(household_json, country.tax_benefit_system)
140172
validate_period_budgets(household_json, country.tax_benefit_system)
141173
except ValueError as e:

0 commit comments

Comments
 (0)