mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +00:00
feat(menu): expose production menu API (#191)
This commit is contained in:
parent
ed98ad0ac9
commit
86bb476e27
6 changed files with 289 additions and 9 deletions
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
58
tests/test_terraform_menu_api.py
Normal file
58
tests/test_terraform_menu_api.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue