Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ dist/*
**/*.h5
**/*.csv.gz
.env
.ds_store
.ds_store
uv.lock
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

A version of the PolicyEngine API that runs the `calculate` endpoint over household object. To debug locally, run `make debug`.

## Quick self-hosted run

If you want to try the API without requesting hosted credentials, run the published Docker image:

```
docker run --rm -p 8080:8080 ghcr.io/policyengine/policyengine-household-api:latest
```

The image can take a little time to initialize on first start and is best run on a machine with roughly
4 GB of RAM available.

Then inspect the service metadata:

```
curl http://localhost:8080/
```

and send calculations to:

```
http://localhost:8080/us/calculate
```

Hosted API docs live at https://www.policyengine.org/us/api.

## Local development with Docker Compose

To run this app locally via Docker Compose:
Expand Down
4 changes: 4 additions & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- bump: patch
changes:
changed:
- Return JSON service metadata from the home endpoint and improve self-hosted Docker guidance, including a container healthcheck and non-root runtime user.
13 changes: 7 additions & 6 deletions config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ CONFIG_FILE=config/local.yaml make debug
# Mount a custom config file
docker run -v /path/to/your/config.yaml:/custom/config.yaml \
-e CONFIG_FILE=/custom/config.yaml \
policyengine/household-api
ghcr.io/policyengine/policyengine-household-api:latest
```

#### Docker Compose
```yaml
version: '3.13'
services:
household-api:
image: policyengine/household-api
image: ghcr.io/policyengine/policyengine-household-api:latest
volumes:
- ./my-config.yaml:/app/config/custom.yaml
environment:
Expand Down Expand Up @@ -123,7 +123,7 @@ spec:
spec:
containers:
- name: api
image: policyengine/household-api
image: ghcr.io/policyengine/policyengine-household-api:latest
env:
- name: CONFIG_FILE
value: /config/config.yaml
Expand Down Expand Up @@ -301,11 +301,12 @@ Use environment variables to override specific settings:

```bash
docker run -e FLASK_DEBUG=1 \
-p 8080:8080 \
-e AUTH__ENABLED=false \ # Disable Auth0 for local dev
-e ANALYTICS__ENABLED=false \ # Disable analytics for local dev
-e AI__ENABLED=false \
-e DATABASE__PROVIDER=sqlite \
policyengine/household-api
ghcr.io/policyengine/policyengine-household-api:latest
```

#### Template Variable Substitution
Expand Down Expand Up @@ -359,15 +360,15 @@ docker run -v /path/to/config.yaml:/app/config/custom.yaml \
-v /path/to/values.env:/app/config/values.env \
-e CONFIG_FILE=/app/config/custom.yaml \
-e CONFIG_VALUE_SETTINGS=/app/config/values.env \
policyengine/household-api
ghcr.io/policyengine/policyengine-household-api:latest
```

Or with Docker Compose:
```yaml
version: '3.13'
services:
household-api:
image: policyengine/household-api
image: ghcr.io/policyengine/policyengine-household-api:latest
volumes:
- ./my-config.yaml:/app/config/custom.yaml
- ./my-values.env:/app/config/values.env
Expand Down
18 changes: 14 additions & 4 deletions gcp/policyengine_household_api/Dockerfile.production
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,19 @@ COPY ./config/default.yaml /app/config/default.yaml
# Copy startup script
COPY ./gcp/policyengine_household_api/start.sh /app/start.sh
RUN chmod +x /app/start.sh

# Configure environment (runs as root by default)

# Drop root privileges in the runtime image.
RUN groupadd policyapi && \
useradd --gid policyapi --create-home policyapi && \
chown -R policyapi:policyapi /app /opt/venv

# Configure runtime environment.
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8080

CMD ["/app/start.sh"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:8080/liveness_check || exit 1

USER policyapi

CMD ["/app/start.sh"]
43 changes: 42 additions & 1 deletion policyengine_household_api/decorators/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,43 @@

from typing import Optional, Any, Callable
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.oauth2.rfc6750 import BearerTokenValidator
from ..auth.validation import Auth0JWTBearerTokenValidator
from ..utils.config_loader import get_config, get_config_value


class StaticBearerToken:
"""Minimal token object for test-only bearer token validation."""

def __init__(self, token_string: str, scope: str = ""):
self.token_string = token_string
self.scope = scope

def is_expired(self) -> bool:
return False

def is_revoked(self) -> bool:
return False

def get_scope(self) -> str:
return self.scope


class StaticBearerTokenValidator(BearerTokenValidator):
"""Accept a single configured bearer token for test environments."""

def __init__(self, expected_token: str):
super().__init__()
self.expected_token = expected_token

def authenticate_token(
self, token_string: Optional[str]
) -> Optional[StaticBearerToken]:
if token_string == self.expected_token:
return StaticBearerToken(token_string)
return None


class NoOpDecorator:
"""
No-operation decorator used when authentication is disabled.
Expand Down Expand Up @@ -63,14 +96,22 @@ def _setup_authentication(self) -> None:
"""
# Check if Auth0 is explicitly enabled via configuration
self._auth_enabled = get_config_value("auth.enabled", False)
app_environment = get_config_value("app.environment", "")
auth0_test_token = get_config_value("auth.auth0.test_token", "")

# Get Auth0 configuration values
auth0_address = get_config_value("auth.auth0.address", "")
auth0_audience = get_config_value("auth.auth0.audience", "")

# Initialize the appropriate decorator
if self._auth_enabled:
if auth0_address and auth0_audience:
if app_environment == "test_with_auth" and auth0_test_token:
resource_protector = ResourceProtector()
resource_protector.register_token_validator(
StaticBearerTokenValidator(auth0_test_token)
)
self._decorator = resource_protector
elif auth0_address and auth0_audience:
# Set up real Auth0 authentication
resource_protector = ResourceProtector()
validator = Auth0JWTBearerTokenValidator(
Expand Down
37 changes: 31 additions & 6 deletions policyengine_household_api/endpoints/home.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
def get_home() -> str:
"""Get the home page of the PolicyEngine household API.
import json

Returns:
str: The home page.
"""
return f"<h1>PolicyEngine household API</h1><p>Use this API to compute the impact of public policy upon households.</p>"
from flask import Response


def get_home() -> Response:
"""Return service metadata for self-serve and hosted API users."""

response_body = {
"status": "ok",
"message": "PolicyEngine household API",
"result": {
"docs_url": "https://www.policyengine.org/us/api",
"container_image": "ghcr.io/policyengine/policyengine-household-api",
"hosted_calculate_url_template": (
"https://household.api.policyengine.org/{country_id}/calculate"
),
"local_calculate_url_template": (
"http://localhost:8080/{country_id}/calculate"
),
"health_checks": {
"liveness": "/liveness_check",
"readiness": "/readiness_check",
},
},
}

return Response(
json.dumps(response_body),
status=200,
mimetype="application/json",
)
35 changes: 29 additions & 6 deletions policyengine_household_api/openapi_spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,39 @@ servers:
paths:
/:
get:
summary: Get the home page of the PolicyEngine API
summary: Get service metadata for the PolicyEngine household API
operationId: get_home
description: Returns the home page of the PolicyEngine API as an HTML string.
description: Returns service metadata, documentation links, and self-hosting hints.
responses:
200:
description: The home page.
description: Service metadata.
content:
text/html:
application/json:
schema:
type: string
type: object
properties:
status:
type: string
message:
type: string
result:
type: object
properties:
docs_url:
type: string
container_image:
type: string
hosted_calculate_url:
type: string
local_calculate_url:
type: string
health_checks:
type: object
properties:
liveness:
type: string
readiness:
type: string
/{country_id}/metadata:
get:
summary: Get metadata for a country
Expand Down Expand Up @@ -841,4 +864,4 @@ paths:
paths:
type: object
servers:
type: array
type: array
34 changes: 34 additions & 0 deletions tests/fixtures/decorators/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@
}
}

AUTH_TEST_ENVIRONMENT_CONFIG = {
"app": {
"environment": "test_with_auth",
},
"auth": {
"enabled": True,
"auth0": {
**AUTH0_CONFIG_DATA,
"test_token": "test-jwt-token",
},
},
}

AUTH_DISABLED_CONFIG = {
"auth": {
"enabled": False,
Expand Down Expand Up @@ -99,6 +112,27 @@ def config_side_effect(path: str, default: Any = None) -> Any:
yield mock_config


@pytest.fixture
def auth_test_environment():
"""Set up environment for local bearer-token validation in tests."""
with patch(
"policyengine_household_api.decorators.auth.get_config_value"
) as mock_config:

def config_side_effect(path: str, default: Any = None) -> Any:
config_map = {
"app.environment": "test_with_auth",
"auth.enabled": True,
"auth.auth0.address": AUTH0_CONFIG_DATA["address"],
"auth.auth0.audience": AUTH0_CONFIG_DATA["audience"],
"auth.auth0.test_token": "test-jwt-token",
}
return config_map.get(path, default)

mock_config.side_effect = config_side_effect
yield mock_config


@pytest.fixture
def auth_disabled_environment():
"""Set up environment with authentication disabled."""
Expand Down
6 changes: 4 additions & 2 deletions tests/fixtures/utils/computation_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@


@pytest.fixture
def mock_config_ai_disabled():
def mock_config_ai_disabled(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
with patch(
"policyengine_household_api.utils.computation_tree.get_config_value"
) as mock_config:
Expand All @@ -25,7 +26,8 @@ def config_side_effect(key, default=None):


@pytest.fixture
def mock_config_ai_enabled_no_key():
def mock_config_ai_enabled_no_key(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
with patch(
"policyengine_household_api.utils.computation_tree.get_config_value"
) as mock_config:
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


@pytest.fixture
def client(autouse=True):
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
Expand Down
Loading
Loading