diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbf3f84..506614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Check template JavaScript run: npm run check:templates + - name: Lint OpenAPI + run: npm run openapi:lint + test: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.redocly.yaml b/.redocly.yaml new file mode 100644 index 0000000..3a9fa49 --- /dev/null +++ b/.redocly.yaml @@ -0,0 +1,7 @@ +# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289). +extends: + - recommended + +apis: + meals@v1: + root: openapi.yaml diff --git a/README.md b/README.md index dbdba00..8417a32 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Publish routes are omitted from CloudFront. The generated form uses relative Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1) - **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev. +- **VPC** — Prod attaches to the After Hours VPC (`existing_vpc_id` / `existing_public_subnet_ids` from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback. +- **HTTP contract** — `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and afterhours-shift-manager). - **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s. - **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`. - **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF) @@ -186,9 +188,11 @@ meal-order-manager/ ├── .github/workflows/ │ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify │ ├── deploy-api.yaml # Image CD to Fargate -│ ├── ci.yml # PR checks +│ ├── ci.yml # PR checks (pytest, template JS, OpenAPI lint) │ └── ci-terraform.yaml # terraform fmt / validate ├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue) +├── openapi.yaml # Employee HTTP contract (Redocly recommended) +├── .redocly.yaml ├── Dockerfile ├── src/ │ ├── scraper/ # Playwright menu scraper diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..a11cced --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,651 @@ +openapi: 3.1.0 +info: + title: Meal Order Manager + version: 0.1.0 + description: > + Flask HTTP API on ECS Fargate behind orders.seahaven.com. The internal + portal SPA calls menu, submit, and admin with a Cognito ID token from + GET /api/auth/meals-token. Weekly-menu GitHub Actions uses HMAC publish + routes that CloudFront does not expose. JSON errors are currently + `{ error: string }`. Health matches the portal BFF `{ stage, sha }`. + Lint with the same Redocly `extends: recommended` config as + internal-portal and afterhours-shift-manager. + contact: + name: Sea Haven Engineering + license: + name: Proprietary + identifier: LicenseRef-SeaHaven + +servers: + - url: / + description: orders.seahaven.com CloudFront / local Flask :5050 + +tags: + - name: Runtime + description: Unauthenticated health + - name: Menu + description: Public weekly menu and form status + - name: Orders + description: Employee submit and own-order lookup + - name: Admin + description: Admin order edit and summary PDF + - name: Publish + description: Weekly-menu HMAC publish (not on CloudFront) + +paths: + /api/health: + get: + operationId: getHealth + tags: [Runtime] + summary: Runtime health + security: [] + responses: + "200": + description: Process is up + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + + /api/menu/{week}: + get: + operationId: getMenu + tags: [Menu] + summary: Published menu for a week + security: [] + parameters: + - $ref: "#/components/parameters/WeekPath" + responses: + "200": + description: Menu payload + content: + application/json: + schema: + $ref: "#/components/schemas/MenuPayload" + "400": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + + /api/form-status/{week}: + get: + operationId: getFormStatus + tags: [Menu] + summary: Open or closed for a week + security: [] + parameters: + - $ref: "#/components/parameters/WeekPath" + responses: + "200": + description: Form status + content: + application/json: + schema: + $ref: "#/components/schemas/FormStatus" + + /api/submit-order: + post: + operationId: submitOrder + tags: [Orders] + summary: Place or replace this week's order + security: + - portalCognito: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitBody" + responses: + "200": + description: Order saved + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitResult" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "410": + $ref: "#/components/responses/StringError" + "429": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + + /api/orders/{week}: + get: + operationId: getMyOrder + tags: [Orders] + summary: Caller's order only + security: + - portalCognito: [] + parameters: + - $ref: "#/components/parameters/WeekPath" + responses: + "200": + description: Empty list when the caller has no order + content: + application/json: + schema: + $ref: "#/components/schemas/MyOrders" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + + /api/admin/orders: + get: + operationId: adminListOrders + tags: [Admin] + summary: List weeks or one week's orders + security: + - portalCognito: [] + parameters: + - name: week + in: query + schema: + type: string + responses: + "200": + description: Weeks list or week detail + content: + application/json: + schema: + $ref: "#/components/schemas/AdminOrdersResponse" + "403": + $ref: "#/components/responses/StringError" + put: + operationId: adminUpdateOrder + tags: [Admin] + summary: Recalculate and replace an order + security: + - portalCognito: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminUpdateBody" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMutation" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + delete: + operationId: adminDeleteOrder + tags: [Admin] + summary: Delete an order + security: + - portalCognito: [] + parameters: + - name: week + in: query + required: true + schema: + type: string + - name: email + in: query + required: true + schema: + type: string + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMutation" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + + /api/admin/summary-pdf: + get: + operationId: adminSummaryPdf + tags: [Admin] + summary: Presigned summary PDF URL + security: + - portalCognito: [] + parameters: + - name: week + in: query + required: true + schema: + type: string + responses: + "200": + description: Short-lived URL + content: + application/json: + schema: + $ref: "#/components/schemas/SummaryPdf" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "500": + $ref: "#/components/responses/StringError" + + /api/roster: + get: + operationId: getRoster + tags: [Orders] + summary: Name and email list used by the static form + security: [] + responses: + "200": + description: Roster + content: + application/json: + schema: + $ref: "#/components/schemas/FormRoster" + + /api/publish/settings: + get: + operationId: publishSettings + tags: [Publish] + summary: Bulk discount and subsidy for scrape + security: + - publishKey: [] + responses: + "200": + description: Pricing fields only + content: + application/json: + schema: + $ref: "#/components/schemas/PublishSettings" + "403": + $ref: "#/components/responses/StringError" + + /api/publish/menu: + post: + operationId: publishMenu + tags: [Publish] + summary: Write this week's scraped menu + security: + - publishKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishMenuBody" + responses: + "200": + description: Published + content: + application/json: + schema: + $ref: "#/components/schemas/PublishMenuResult" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + +components: + securitySchemes: + portalCognito: + type: http + scheme: bearer + bearerFormat: JWT + description: Portal Cognito ID token. GIS google_id_token is also accepted on submit until cutover. + publishKey: + type: apiKey + in: header + name: X-Meals-Publish-Key + + parameters: + WeekPath: + name: week + in: path + required: true + description: "`current`, `YYYY-WNN`, or for orders a calendar date that maps to a week" + schema: + type: string + minLength: 1 + + responses: + StringError: + description: Current meals JSON error + content: + application/json: + schema: + $ref: "#/components/schemas/StringErrorBody" + + schemas: + Health: + type: object + additionalProperties: false + required: [stage, sha] + properties: + stage: + type: string + minLength: 1 + description: Workspace stage (`dev`, `prod`, or `local`) + sha: + type: string + minLength: 1 + description: Git SHA or `unknown` locally + + StringErrorBody: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string + minLength: 1 + + Meal: + type: object + additionalProperties: true + required: [name, price] + properties: + name: + type: string + price: + type: number + calories: + type: [string, number] + protein: + type: [string, number] + description: + type: [string, "null"] + dietary_tags: + type: [array, "null"] + items: + type: string + image_url: + type: string + is_new: + type: boolean + + MenuPayload: + type: object + additionalProperties: false + required: + - week + - form_status + - meal_count + - meals + - bulk_discount_percent + - company_subsidy_percent + properties: + week: + type: string + form_status: + type: string + scraped_at: + type: [string, "null"] + menu_url: + type: string + meal_count: + type: integer + meals: + type: array + items: + $ref: "#/components/schemas/Meal" + bulk_discount_percent: + type: number + company_subsidy_percent: + type: number + order_deadline: + type: string + + FormStatus: + type: object + additionalProperties: false + required: [week, status] + properties: + week: + type: string + status: + type: string + reopen_at: + type: integer + + SubmitItem: + type: object + additionalProperties: true + required: [name, quantity] + properties: + name: + type: string + quantity: + type: number + retail_price: + type: number + + SubmitBody: + type: object + additionalProperties: true + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/SubmitItem" + google_id_token: + type: string + + SubmitResult: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + message: + type: string + total: + type: number + + MyOrders: + type: object + additionalProperties: false + required: [week, orders] + properties: + week: + type: string + orders: + type: array + items: + type: object + additionalProperties: false + required: [employee_email] + properties: + employee_email: + type: string + + AdminWeek: + type: object + additionalProperties: true + required: [week] + properties: + week: + type: string + form_status: + type: string + meal_count: + type: integer + order_count: + type: integer + + AdminOrderItem: + type: object + additionalProperties: false + required: [name, quantity, retail_price, price, subtotal] + properties: + name: + type: string + quantity: + type: integer + retail_price: + type: number + price: + type: number + subtotal: + type: number + + AdminOrder: + type: object + additionalProperties: false + required: [employee_name, employee_email, items, total, submitted_at] + properties: + employee_name: + type: string + employee_email: + type: string + items: + type: array + items: + $ref: "#/components/schemas/AdminOrderItem" + total: + type: number + submitted_at: + type: string + + AdminWeeks: + type: object + additionalProperties: false + required: [weeks] + properties: + weeks: + type: array + items: + $ref: "#/components/schemas/AdminWeek" + + AdminWeekOrders: + type: object + additionalProperties: false + required: [week, orders, total_employees, grand_total] + properties: + week: + type: string + orders: + type: array + items: + $ref: "#/components/schemas/AdminOrder" + total_employees: + type: integer + grand_total: + type: number + + AdminOrdersResponse: + oneOf: + - $ref: "#/components/schemas/AdminWeeks" + - $ref: "#/components/schemas/AdminWeekOrders" + + AdminUpdateBody: + type: object + additionalProperties: false + required: [week, email, items] + properties: + week: + type: string + email: + type: string + items: + type: array + items: + $ref: "#/components/schemas/SubmitItem" + + AdminMutation: + type: object + additionalProperties: false + required: [status, week] + properties: + status: + type: string + week: + type: string + email: + type: string + total: + type: number + + SummaryPdf: + type: object + additionalProperties: false + required: [week, url] + properties: + week: + type: string + url: + type: string + format: uri + + FormRoster: + type: object + additionalProperties: false + required: [employees] + properties: + employees: + type: array + items: + type: object + additionalProperties: false + required: [name, email] + properties: + name: + type: string + email: + type: string + + PublishSettings: + type: object + additionalProperties: false + required: [bulk_discount_percent, company_subsidy_percent] + properties: + bulk_discount_percent: + type: number + company_subsidy_percent: + type: number + + PublishMenuBody: + type: object + additionalProperties: true + required: [meals] + properties: + meals: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Meal" + scraped_at: + type: string + menu_url: + type: string + + PublishMenuResult: + type: object + additionalProperties: false + required: [status, week, meal_count] + properties: + status: + type: string + week: + type: string + meal_count: + type: integer diff --git a/package-lock.json b/package-lock.json index 87536c0..fa60137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "devDependencies": { "@eslint/js": "10.0.1", + "@redocly/cli": "2.52.1", "eslint": "10.10.0", "globals": "17.12.0", "prettier": "3.9.8" @@ -256,6 +257,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@redocly/cli": { + "version": "2.52.1", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.52.1.tgz", + "integrity": "sha512-gwfx1WelVDHU/cw+GkpLV9+xksOI3obJGgL1nUVrAFUyXNDxM+aifXDUZk7BfbOIpF69Sea2ant0cOqbQfg/KQ==", + "dev": true, + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/package.json b/package.json index c5048d8..153dea4 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "check:templates": "npm run lint:templates && npm run format:templates", "format:templates": "prettier --check \"src/server/templates/*.js\"", "format:templates:write": "prettier --write \"src/server/templates/*.js\"", - "lint:templates": "eslint \"src/server/templates/*.js\"" + "lint:templates": "eslint \"src/server/templates/*.js\"", + "openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml" }, "devDependencies": { "@eslint/js": "10.0.1", + "@redocly/cli": "2.52.1", "eslint": "10.10.0", "globals": "17.12.0", "prettier": "3.9.8" diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py new file mode 100644 index 0000000..6e96d44 --- /dev/null +++ b/tests/test_openapi_contract.py @@ -0,0 +1,21 @@ +"""OpenAPI 3.1 + Redocly recommended, matching internal-portal (DEV-289).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_openapi_uses_redocly_recommended(): + redocly = (ROOT / ".redocly.yaml").read_text() + package = (ROOT / "package.json").read_text() + spec = (ROOT / "openapi.yaml").read_text() + ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + assert "extends:" in redocly + assert "- recommended" in redocly + assert "root: openapi.yaml" in redocly + assert '"openapi:lint"' in package + assert '"@redocly/cli": "2.52.1"' in package + assert "npm run openapi:lint" in ci + assert "openapi: 3.1.0" in spec + assert "required: [stage, sha]" in spec + assert "{ error: string }" in spec or "`{ error: string }`" in spec diff --git a/tests/test_terraform_vpc.py b/tests/test_terraform_vpc.py index 2e4bc50..db49885 100644 --- a/tests/test_terraform_vpc.py +++ b/tests/test_terraform_vpc.py @@ -33,3 +33,8 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default(): assert 'check "existing_subnets_in_vpc"' in data assert "from = aws_vpc.this" in vpc assert "to = aws_vpc.this[0]" in vpc + outputs = _read("outputs.tf") + assert "output \"vpc_id\"" in outputs + assert "value = local.vpc_id" in outputs + assert "output \"public_subnet_ids\"" in outputs + assert 'check "prod_reuses_afterhours_vpc"' in data