From 86bb476e27c978bae189c692f46d205b8ce59fb7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:41:10 +0000 Subject: [PATCH] feat(menu): expose production menu API (#191) --- functions/submit_order/handler.py | 39 +++++++++++- terraform/apigateway.tf | 21 ++++--- terraform/cloudfront.tf | 74 ++++++++++++++++++++++ terraform/locals.tf | 5 ++ tests/test_submit_order.py | 101 ++++++++++++++++++++++++++++++ tests/test_terraform_menu_api.py | 58 +++++++++++++++++ 6 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 tests/test_terraform_menu_api.py diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 5f3efb2..f988a4d 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -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), } diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf index c182974..06d5652 100644 --- a/terraform/apigateway.tf +++ b/terraform/apigateway.tf @@ -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,13 +14,18 @@ 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_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] - allow_headers = ["Content-Type", "Authorization"] - max_age = 3600 + 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 = ["Authorization", "Content-Type"] + allow_credentials = false + max_age = 3600 } } diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf index 5e634b1..518b091 100644 --- a/terraform/cloudfront.tf +++ b/terraform/cloudfront.tf @@ -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" diff --git a/terraform/locals.tf b/terraform/locals.tf index 6c431fc..d1da791 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -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" diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index cc578e3..f3b7b8f 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_terraform_menu_api.py b/tests/test_terraform_menu_api.py new file mode 100644 index 0000000..89eeb29 --- /dev/null +++ b/tests/test_terraform_menu_api.py @@ -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