mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 05:23:13 +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")
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
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