feat(menu): expose production menu API (#191)

This commit is contained in:
Adam Moussa 2026-09-15 21:41:10 +00:00 • committed by GitHub
parent ed98ad0ac9
commit 86bb476e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 289 additions and 9 deletions

View file

@ -186,6 +186,9 @@ def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "")
if "/api/menu/" in path and method == "GET":
return handle_menu(event)
if path == "/api/publish/settings" and method == "GET":
return handle_publish_settings()
@ -214,6 +217,40 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"})
def handle_menu(event):
"""Return the published menu in the portal's public MenuPayload shape."""
requested_week = (event.get("pathParameters") or {}).get("week", "")
if requested_week == "current":
week = current_week()
elif re.fullmatch(r"\d{4}-W\d{2}", requested_week):
week = requested_week
else:
return response(400, {"error": "week path param must be current or YYYY-WNN"})
menu = get_menu(week)
if menu is None:
return response(404, {"error": "No menu available for this week"})
meals = menu.get("meals") or []
settings = get_settings()
return response(
200,
{
"week": week,
"form_status": menu.get("form_status", "closed"),
"scraped_at": menu.get("scraped_at"),
"menu_url": menu.get("menu_url"),
"meal_count": int(menu.get("meal_count", len(meals))),
"meals": meals,
"bulk_discount_percent": float(settings.get("bulk_discount_percent", 0)),
"company_subsidy_percent": float(
settings.get("company_subsidy_percent", 0)
),
"order_deadline": settings.get("order_deadline") or "Thursday at 11:59 PM",
},
)
def handle_publish_settings():
"""Return only the pricing fields needed by the weekly-menu workflow."""
settings = get_settings()
@ -617,5 +654,5 @@ def response(status_code: int, body: dict) -> dict:
return {
"statusCode": status_code,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
"body": json.dumps(body, default=float),
}

View file

@ -1,12 +1,12 @@
# HTTP API fronting the order form.
#
# Three authorization modes coexist, matching template.yaml:
# NONE — the public form routes (submit-order, form-status, roster)
# NONE — the public form routes (submit-order, menu, form-status, roster)
# AWS_IAM — the weekly-menu publication routes, called with SigV4 by
# scripts/upload_menu.py from GitHub Actions
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
#
# All nine routes integrate with submit-order, which dispatches internally on
# All ten routes integrate with submit-order, which dispatches internally on
# the route key.
resource "aws_apigatewayv2_api" "order_api" {
@ -14,12 +14,17 @@ resource "aws_apigatewayv2_api" "order_api" {
protocol_type = "HTTP"
description = "meal-order-manager order form and admin API"
# Only the production origin. Local development uses the Flask dev server in
# app.py, which proxies API calls and does not enforce CORS.
cors_configuration {
allow_origins = [local.form_url]
allow_origins = [
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization"]
allow_headers = ["Authorization", "Content-Type"]
allow_credentials = false
max_age = 3600
}
}

View file

@ -6,6 +6,57 @@ resource "aws_cloudfront_origin_access_control" "form" {
signing_protocol = "sigv4"
}
resource "aws_cloudfront_cache_policy" "menu_api" {
name = "${local.project}-menu-api"
comment = "Cache public menu responses for 60 seconds per request origin"
default_ttl = 60
max_ttl = 60
min_ttl = 60
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "whitelist"
headers {
items = ["Origin"]
}
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
}
}
resource "aws_cloudfront_origin_request_policy" "menu_api" {
name = "${local.project}-menu-api"
comment = "Forward CORS preflight headers to the HTTP API"
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "whitelist"
headers {
items = [
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
]
}
}
query_strings_config {
query_string_behavior = "none"
}
}
resource "aws_cloudfront_distribution" "form" {
enabled = true
is_ipv6_enabled = true
@ -26,6 +77,29 @@ resource "aws_cloudfront_distribution" "form" {
origin_access_control_id = aws_cloudfront_origin_access_control.form.id
}
origin {
origin_id = "OrderApiOrigin"
domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
ordered_cache_behavior {
path_pattern = "/api/menu/*"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.menu_api.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id
}
default_cache_behavior {
target_origin_id = "S3FormOrigin"
viewer_protocol_policy = "redirect-to-https"

View file

@ -57,6 +57,11 @@ locals {
authorizer = "NONE"
permission_source = "POST/api/submit-order"
}
menu = {
route_key = "GET /api/menu/{week}"
authorizer = "NONE"
permission_source = "GET/api/menu/*"
}
form_status = {
route_key = "GET /api/form-status/{week}"
authorizer = "NONE"

View file

@ -9,6 +9,7 @@ import os
import sys
import urllib.error
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch
from zoneinfo import ZoneInfo
@ -204,6 +205,106 @@ def test_publish_menu_rejects_invalid_json(mock_put):
mock_put.assert_not_called()
# ===========================================================================
# PUBLIC MENU
# ===========================================================================
@patch(
"submit_order_handler.get_settings",
return_value={
"bulk_discount_percent": Decimal("10"),
"company_subsidy_percent": Decimal("50"),
"order_deadline": "Thursday at 11:59 PM",
},
)
@patch("submit_order_handler.get_menu")
@patch("submit_order_handler.current_week", return_value="2026-W37")
def test_menu_current_returns_portal_payload(mock_week, mock_get_menu, mock_settings):
mock_get_menu.return_value = {
"form_status": "open",
"scraped_at": "2026-09-14T07:30:00-04:00",
"menu_url": "https://example.com/menu",
"meal_count": Decimal("1"),
"meals": [{"name": "Chicken Bowl", "price": Decimal("12.50")}],
}
event = _make_event(
method="GET",
path="/api/menu/current",
path_parameters={"week": "current"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 200
assert body == {
"week": "2026-W37",
"form_status": "open",
"scraped_at": "2026-09-14T07:30:00-04:00",
"menu_url": "https://example.com/menu",
"meal_count": 1,
"meals": [{"name": "Chicken Bowl", "price": 12.5}],
"bulk_discount_percent": 10.0,
"company_subsidy_percent": 50.0,
"order_deadline": "Thursday at 11:59 PM",
}
mock_get_menu.assert_called_once_with("2026-W37")
@patch(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch("submit_order_handler.get_menu")
def test_menu_explicit_week_uses_safe_defaults(mock_get_menu, mock_settings):
mock_get_menu.return_value = {"meals": [{"name": "Soup", "price": 8}]}
event = _make_event(
method="GET",
path="/api/menu/2026-W36",
path_parameters={"week": "2026-W36"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 200
assert body["week"] == "2026-W36"
assert body["form_status"] == "closed"
assert body["meal_count"] == 1
assert body["order_deadline"] == "Thursday at 11:59 PM"
mock_get_menu.assert_called_once_with("2026-W36")
@patch("submit_order_handler.get_settings")
@patch("submit_order_handler.get_menu", return_value=None)
def test_menu_missing_returns_404(mock_get_menu, mock_settings):
event = _make_event(
method="GET",
path="/api/menu/2026-W35",
path_parameters={"week": "2026-W35"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 404
assert body == {"error": "No menu available for this week"}
mock_settings.assert_not_called()
@patch("submit_order_handler.get_menu")
def test_menu_rejects_malformed_week_without_lookup(mock_get_menu):
event = _make_event(
method="GET",
path="/api/menu/not-a-week",
path_parameters={"week": "not-a-week"},
)
status, body = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 400
assert "current or YYYY-WNN" in body["error"]
mock_get_menu.assert_not_called()
# ---------------------------------------------------------------------------
# Module-level patches that must be active before the handler is imported
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,58 @@
"""Structural checks for the public menu route, portal CORS, and edge cache."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_public_menu_route_and_scoped_lambda_permission():
locals_tf = _read("locals.tf")
assert 'route_key = "GET /api/menu/{week}"' in locals_tf
assert 'authorizer = "NONE"' in locals_tf
assert 'permission_source = "GET/api/menu/*"' in locals_tf
def test_cors_allows_form_portal_and_local_origins():
api = _read("apigateway.tf")
for origin in (
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
):
assert f'"{origin}"' in api
assert 'allow_headers = ["Authorization", "Content-Type"]' in api
assert 'allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]' in api
assert "allow_credentials = false" in api
def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
cloudfront = _read("cloudfront.tf")
assert 'origin_id = "OrderApiOrigin"' in cloudfront
assert (
'domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")'
in cloudfront
)
assert 'path_pattern = "/api/menu/*"' in cloudfront
assert 'target_origin_id = "OrderApiOrigin"' in cloudfront
assert (
"cache_policy_id = aws_cloudfront_cache_policy.menu_api.id"
in cloudfront
)
assert (
"origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id"
in cloudfront
)
assert "default_ttl = 60" in cloudfront
assert "max_ttl = 60" in cloudfront
assert "min_ttl = 60" in cloudfront
assert 'items = ["Origin"]' in cloudfront