Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions infrastructure/aws/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ No modules.
| Name | Type |
|------|------|
| [aws_api_gateway_deployment.branch_deployment](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_deployment) | resource |
| [aws_api_gateway_gateway_response.cors](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_gateway_response) | resource |
| [aws_api_gateway_integration.lambda_integrations](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource |
| [aws_api_gateway_method.lambda_methods](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource |
| [aws_api_gateway_resource.lambda_resources](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_resource) | resource |
Expand Down
31 changes: 30 additions & 1 deletion infrastructure/aws/api_gateway.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,40 @@ resource "aws_api_gateway_rest_api" "branch_api" {
}
}

# Attach CORS headers to API-Gateway-generated error responses (e.g. a lambda
# 502 or a 403 for an unmatched route) so they surface with their real status
# instead of as an opaque browser "CORS error". '*' matches the lambda json()
# helper.
resource "aws_api_gateway_gateway_response" "cors" {
for_each = toset(["DEFAULT_4XX", "DEFAULT_5XX"])
rest_api_id = aws_api_gateway_rest_api.branch_api.id
response_type = each.key

response_parameters = {
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
"gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'"
"gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
}
}

# Define supported HTTP methods per Lambda function based on handlers
# NOTE: Must be kept in sync with actual Lambda handlers in apps/backend/lambdas/*/openapi.yaml
# OPTIONS is appended to every resource so browser CORS preflights are answered.
# The frontend sends Content-Type: application/json on every call (see
# apps/frontend/src/lib/api.ts), which makes even GETs non-simple and triggers a
# preflight; the frontend (CloudFront) and this API are cross-origin. Preflight
# is routed to the proxy lambda, which short-circuits OPTIONS with 200 +
# Access-Control-Allow-Origin: * (see each handler's json() helper).
locals {
lambda_methods = {
base_methods = {
auth = ["GET", "POST"]
donors = ["GET"]
expenditures = ["GET", "POST"]
projects = ["GET", "POST"]
reports = ["GET"]
users = ["GET", "POST", "DELETE", "PATCH"]
}
lambda_methods = { for svc, methods in local.base_methods : svc => concat(methods, ["OPTIONS"]) }
}

# Create a resource for each Lambda function
Expand Down Expand Up @@ -90,6 +113,12 @@ resource "aws_api_gateway_deployment" "branch_deployment" {

rest_api_id = aws_api_gateway_rest_api.branch_api.id

# Force a new deployment when routing changes (e.g. the OPTIONS methods added
# for CORS) — otherwise the stage keeps serving the old method set.
triggers = {
redeploy = sha1(jsonencode(local.lambda_methods))
}

lifecycle {
create_before_destroy = true
}
Expand Down
27 changes: 26 additions & 1 deletion infrastructure/preview/api_gateway.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,41 @@ resource "aws_api_gateway_rest_api" "preview_api" {
}
}

# Attach CORS headers to API-Gateway-generated error responses (e.g. a lambda
# 502 or a 403 for an unmatched route). Without these, such errors reach the
# browser with no Access-Control-Allow-Origin and surface as an opaque "CORS
# error" that hides the real status. '*' matches the lambda json() helper.
resource "aws_api_gateway_gateway_response" "cors" {
for_each = toset(["DEFAULT_4XX", "DEFAULT_5XX"])
rest_api_id = aws_api_gateway_rest_api.preview_api.id
response_type = each.key

response_parameters = {
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
"gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'"
"gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
}
}

# Must be kept in sync with infrastructure/aws/api_gateway.tf.
# OPTIONS is added to every resource so browser CORS preflights are answered:
# the frontend sends Content-Type: application/json on every call (see
# apps/frontend/src/lib/api.ts), which makes even GETs non-simple and triggers a
# preflight. Preview frontend (shared CloudFront) and the preview API are
# cross-origin, so without an OPTIONS method API Gateway rejects the preflight
# with no CORS headers → the browser reports a CORS error. Routing OPTIONS to the
# proxy lambda works because the lambda short-circuits OPTIONS with 200 +
# Access-Control-Allow-Origin: * (see each handler's json() helper).
locals {
lambda_methods = {
base_methods = {
auth = ["GET", "POST"]
donors = ["GET"]
expenditures = ["GET", "POST"]
projects = ["GET", "POST"]
reports = ["GET"]
users = ["GET", "POST", "DELETE", "PATCH"]
}
lambda_methods = { for svc, methods in local.base_methods : svc => concat(methods, ["OPTIONS"]) }
}

resource "aws_api_gateway_resource" "lambda_resources" {
Expand Down
Loading