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") method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "") path = event.get("rawPath", "")
if "/api/menu/" in path and method == "GET":
return handle_menu(event)
if path == "/api/publish/settings" and method == "GET": if path == "/api/publish/settings" and method == "GET":
return handle_publish_settings() return handle_publish_settings()
@ -214,6 +217,40 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"}) 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(): def handle_publish_settings():
"""Return only the pricing fields needed by the weekly-menu workflow.""" """Return only the pricing fields needed by the weekly-menu workflow."""
settings = get_settings() settings = get_settings()
@ -617,5 +654,5 @@ def response(status_code: int, body: dict) -> dict:
return { return {
"statusCode": status_code, "statusCode": status_code,
"headers": {"Content-Type": "application/json"}, "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. # HTTP API fronting the order form.
# #
# Three authorization modes coexist, matching template.yaml: # 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 # AWS_IAM — the weekly-menu publication routes, called with SigV4 by
# scripts/upload_menu.py from GitHub Actions # scripts/upload_menu.py from GitHub Actions
# CUSTOM — every /api/admin route, behind the Google ID token authorizer # 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. # the route key.
resource "aws_apigatewayv2_api" "order_api" { resource "aws_apigatewayv2_api" "order_api" {
@ -14,13 +14,18 @@ resource "aws_apigatewayv2_api" "order_api" {
protocol_type = "HTTP" protocol_type = "HTTP"
description = "meal-order-manager order form and admin API" 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 { cors_configuration {
allow_origins = [local.form_url] allow_origins = [
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] "https://orders.seahaven.com",
allow_headers = ["Content-Type", "Authorization"] "https://internal.seahaven.com",
max_age = 3600 "https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
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" 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" { resource "aws_cloudfront_distribution" "form" {
enabled = true enabled = true
is_ipv6_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_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 { default_cache_behavior {
target_origin_id = "S3FormOrigin" target_origin_id = "S3FormOrigin"
viewer_protocol_policy = "redirect-to-https" viewer_protocol_policy = "redirect-to-https"

View file

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

View file

@ -9,6 +9,7 @@ import os
import sys import sys
import urllib.error import urllib.error
from datetime import datetime from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -204,6 +205,106 @@ def test_publish_menu_rejects_invalid_json(mock_put):
mock_put.assert_not_called() 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 # 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