Skip to content

Commit 3cdb146

Browse files
Add API layer with deployment infrastructure and CI/CD (#12)
API: - Projects + workspaces CRUD with dual DB support (SQLite + DynamoDB) - Cognito JWT auth with dev bypass (gated behind debug mode) - Repository pattern with Protocol-based interfaces - Security hardened: input validation, pagination, error handling - 94 tests passing, Pyright strict, Ruff clean Deployment: - Lambda container image (Python 3.14) with Mangum adapter - API Gateway HTTP API v2 with custom domain ({ws}-api.openfactcheck.com) - DynamoDB single-table design matching frontend key scheme - ECR in separate repositories/ deployment (no chicken-and-egg) - Build tooling with hash-based skip logic (task build:lambda) - LIVE alias pattern for safe Lambda deployments CI/CD: - Integration: push to main → deploy infra + build + push + update Lambda - Production: version tag → publish to PyPI + deploy
1 parent 96d1ded commit 3cdb146

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+5204
-43
lines changed

.env.example

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# OpenFactCheck API Configuration
2+
# Copy to .env and update values as needed.
3+
4+
# Server
5+
OPENFACTCHECK_HOST=0.0.0.0
6+
OPENFACTCHECK_PORT=8000
7+
OPENFACTCHECK_DEBUG=true
8+
9+
# CORS
10+
OPENFACTCHECK_CORS_ORIGINS=["http://localhost:3001"]
11+
12+
# Auth (auth_bypass only works when debug=true — both must be set for local dev)
13+
OPENFACTCHECK_AUTH_BYPASS=true
14+
OPENFACTCHECK_COGNITO_REGION=us-east-1
15+
OPENFACTCHECK_COGNITO_USER_POOL_ID=
16+
OPENFACTCHECK_COGNITO_CLIENT_ID=
17+
18+
# Database
19+
OPENFACTCHECK_DATABASE_BACKEND=sqlite
20+
OPENFACTCHECK_SQLITE_PATH=~/.openfactcheck/data.db
21+
OPENFACTCHECK_DYNAMODB_TABLE_NAME=openfactcheck
22+
OPENFACTCHECK_DYNAMODB_REGION=us-east-1
23+
24+
# Execution
25+
OPENFACTCHECK_MAX_EXECUTION_TIMEOUT=600

.github/workflows/ci-integration.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,10 @@ jobs:
6767
aws configure set aws_session_token "$AWS_SESSION_TOKEN" --profile hasaniqbal-dev
6868
aws configure set region us-east-1 --profile hasaniqbal-dev
6969
70-
- name: Deploy to integration
71-
run: task deploy TARGET=integration AUTO_APPROVE=true
70+
- name: Deploy infrastructure
71+
run: |
72+
task deploy TARGET=integration DEP=repositories AUTO_APPROVE=true
73+
task deploy TARGET=integration AUTO_APPROVE=true
74+
75+
- name: Build and deploy API
76+
run: task deploy:api TARGET=integration

.github/workflows/ci-production.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,10 @@ jobs:
103103
aws configure set aws_session_token "$AWS_SESSION_TOKEN" --profile hasaniqbal-dev
104104
aws configure set region us-east-1 --profile hasaniqbal-dev
105105
106-
- name: Deploy to production
107-
run: task deploy TARGET=production AUTO_APPROVE=true
106+
- name: Deploy infrastructure
107+
run: |
108+
task deploy TARGET=production DEP=repositories AUTO_APPROVE=true
109+
task deploy TARGET=production AUTO_APPROVE=true
110+
111+
- name: Build and deploy API
112+
run: task deploy:api TARGET=production

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,9 @@ accounts/
7070
environments/
7171

7272
# Temp files
73-
tmp
73+
tmp
74+
75+
# Build artifacts
76+
deployments/containers/*/build/
77+
deployments/containers/*/.hash
78+
deployments/base/ignore.*.auto.tfvars

deployments/base/aws_apigateway.tf

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# ##############################################################################
2+
# API Gateway HTTP API (v2)
3+
# ##############################################################################
4+
5+
resource "aws_apigatewayv2_api" "openfactcheck" {
6+
name = "openfactcheck-api-${terraform.workspace}-${var.aws_region}"
7+
description = "OpenFactCheck API Gateway for ${terraform.workspace}-${var.aws_region}"
8+
protocol_type = "HTTP"
9+
10+
disable_execute_api_endpoint = true
11+
12+
cors_configuration {
13+
allow_origins = var.cors_origins
14+
allow_methods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
15+
allow_headers = ["Authorization", "Content-Type", "X-Request-ID"]
16+
allow_credentials = true
17+
max_age = 3600
18+
}
19+
20+
tags = {
21+
Name = "OpenFactCheck - API Gateway - ${terraform.workspace} - ${var.aws_region}"
22+
}
23+
}
24+
25+
# ##############################################################################
26+
# Integration — Lambda proxy via LIVE alias
27+
# ##############################################################################
28+
29+
resource "aws_apigatewayv2_integration" "lambda" {
30+
api_id = aws_apigatewayv2_api.openfactcheck.id
31+
description = "OpenFactCheck API Lambda for ${terraform.workspace}-${var.aws_region}"
32+
integration_type = "AWS_PROXY"
33+
connection_type = "INTERNET"
34+
integration_method = "POST"
35+
integration_uri = aws_lambda_alias.api_live.invoke_arn
36+
payload_format_version = "2.0"
37+
}
38+
39+
# ##############################################################################
40+
# Routes
41+
# ##############################################################################
42+
43+
resource "aws_apigatewayv2_route" "default" {
44+
api_id = aws_apigatewayv2_api.openfactcheck.id
45+
route_key = "$default"
46+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
47+
}
48+
49+
resource "aws_apigatewayv2_route" "options" {
50+
api_id = aws_apigatewayv2_api.openfactcheck.id
51+
route_key = "OPTIONS /{proxy+}"
52+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
53+
authorization_type = "NONE"
54+
}
55+
56+
57+
# ##############################################################################
58+
# Stage — auto-deploy with access logging
59+
# ##############################################################################
60+
61+
resource "aws_apigatewayv2_stage" "default" {
62+
api_id = aws_apigatewayv2_api.openfactcheck.id
63+
name = "$default"
64+
auto_deploy = true
65+
66+
access_log_settings {
67+
destination_arn = aws_cloudwatch_log_group.api.arn
68+
format = <<JSON
69+
{ "requestTime": "$context.requestTime", "requestId": "$context.requestId", "httpMethod": "$context.httpMethod", "path": "$context.path", "routeKey": "$context.routeKey", "status": $context.status, "responseLatency": $context.responseLatency, "integrationRequestId": "$context.integration.requestId", "functionResponseStatus": "$context.integration.status", "integrationLatency": "$context.integration.latency", "integrationServiceStatus": "$context.integration.integrationStatus", "ip": "$context.identity.sourceIp", "userAgent": "$context.identity.userAgent", "error": { "message": "$context.error.message", "responseType": "$context.error.responseType" } }
70+
JSON
71+
}
72+
73+
tags = {
74+
Name = "OpenFactCheck - API Gateway Stage - ${terraform.workspace} - ${var.aws_region}"
75+
}
76+
}
77+
78+
# ##############################################################################
79+
# Custom Domain
80+
# ##############################################################################
81+
82+
resource "aws_apigatewayv2_domain_name" "api" {
83+
domain_name = local.api_domain
84+
85+
domain_name_configuration {
86+
certificate_arn = local.certificate_arn
87+
endpoint_type = "REGIONAL"
88+
security_policy = "TLS_1_2"
89+
}
90+
91+
tags = {
92+
Name = "OpenFactCheck - API Domain - ${terraform.workspace} - ${var.aws_region}"
93+
}
94+
}
95+
96+
resource "aws_apigatewayv2_api_mapping" "api" {
97+
api_id = aws_apigatewayv2_api.openfactcheck.id
98+
domain_name = aws_apigatewayv2_domain_name.api.id
99+
stage = aws_apigatewayv2_stage.default.id
100+
}
101+
102+
# ##############################################################################
103+
# API Gateway Logs
104+
# ##############################################################################
105+
106+
resource "aws_cloudwatch_log_group" "api" {
107+
name = "/openfactcheck-${terraform.workspace}-${var.aws_region}/api/"
108+
retention_in_days = 30
109+
110+
tags = {
111+
Name = "OpenFactCheck - CloudWatch - API - ${terraform.workspace} - ${var.aws_region}"
112+
}
113+
}

deployments/base/aws_dynamodb.tf

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# ##############################################################################
2+
# DynamoDB Table — Single-table design
3+
# ##############################################################################
4+
5+
resource "aws_dynamodb_table" "openfactcheck" {
6+
name = "openfactcheck-db-${terraform.workspace}-${var.aws_region}"
7+
billing_mode = "PAY_PER_REQUEST"
8+
hash_key = "PK"
9+
10+
global_secondary_index {
11+
name = "gs1"
12+
hash_key = "GS1PK"
13+
projection_type = "ALL"
14+
}
15+
16+
attribute {
17+
name = "PK"
18+
type = "S"
19+
}
20+
21+
attribute {
22+
name = "GS1PK"
23+
type = "S"
24+
}
25+
26+
deletion_protection_enabled = terraform.workspace == "production" ? true : false
27+
28+
tags = {
29+
Name = "OpenFactCheck - DynamoDB - ${terraform.workspace} - ${var.aws_region}"
30+
}
31+
}

deployments/base/aws_iam_lambda.tf

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# ##############################################################################
2+
# IAM Role — Lambda Execution
3+
# ##############################################################################
4+
5+
resource "aws_iam_role" "lambda_api" {
6+
name = "openfactcheck-lambda-api-${terraform.workspace}-${var.aws_region}"
7+
8+
assume_role_policy = jsonencode({
9+
Version = "2012-10-17"
10+
Statement = [
11+
{
12+
Effect = "Allow"
13+
Principal = {
14+
Service = "lambda.amazonaws.com"
15+
}
16+
Action = "sts:AssumeRole"
17+
}
18+
]
19+
})
20+
21+
tags = {
22+
Name = "OpenFactCheck - Lambda API Role - ${terraform.workspace}"
23+
}
24+
}
25+
26+
# ##############################################################################
27+
# CloudWatch Logs
28+
# ##############################################################################
29+
30+
resource "aws_iam_role_policy" "lambda_api_logs" {
31+
name = "cloudwatch-logs"
32+
role = aws_iam_role.lambda_api.id
33+
34+
policy = jsonencode({
35+
Version = "2012-10-17"
36+
Statement = [
37+
{
38+
Effect = "Allow"
39+
Action = [
40+
"logs:CreateLogGroup",
41+
"logs:CreateLogStream",
42+
"logs:PutLogEvents"
43+
]
44+
Resource = "arn:aws:logs:${var.aws_region}:${var.aws_account}:*"
45+
}
46+
]
47+
})
48+
}
49+
50+
# ##############################################################################
51+
# DynamoDB Access
52+
# ##############################################################################
53+
54+
resource "aws_iam_role_policy" "lambda_api_dynamodb" {
55+
name = "dynamodb-access"
56+
role = aws_iam_role.lambda_api.id
57+
58+
policy = jsonencode({
59+
Version = "2012-10-17"
60+
Statement = [
61+
{
62+
Effect = "Allow"
63+
Action = [
64+
"dynamodb:GetItem",
65+
"dynamodb:PutItem",
66+
"dynamodb:UpdateItem",
67+
"dynamodb:DeleteItem",
68+
"dynamodb:Query"
69+
]
70+
Resource = [
71+
aws_dynamodb_table.openfactcheck.arn,
72+
"${aws_dynamodb_table.openfactcheck.arn}/index/*"
73+
]
74+
}
75+
]
76+
})
77+
}

deployments/base/aws_lambda.tf

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ##############################################################################
2+
# ECR Data Source — repository lives in deployments/repositories
3+
# ##############################################################################
4+
5+
data "aws_ecr_repository" "api" {
6+
name = "openfactcheck-api-${terraform.workspace}"
7+
}
8+
9+
# ##############################################################################
10+
# Lambda Function — API
11+
# ##############################################################################
12+
13+
resource "aws_lambda_function" "api" {
14+
function_name = "openfactcheck-api-${terraform.workspace}-${var.aws_region}"
15+
description = var.build_version
16+
role = aws_iam_role.lambda_api.arn
17+
package_type = "Image"
18+
image_uri = "${data.aws_ecr_repository.api.repository_url}:latest"
19+
publish = true
20+
timeout = 30
21+
memory_size = 256
22+
23+
environment {
24+
variables = {
25+
OPENFACTCHECK_DATABASE_BACKEND = "dynamodb"
26+
OPENFACTCHECK_DYNAMODB_TABLE_NAME = aws_dynamodb_table.openfactcheck.name
27+
OPENFACTCHECK_DYNAMODB_REGION = var.aws_region
28+
OPENFACTCHECK_COGNITO_REGION = var.aws_region
29+
OPENFACTCHECK_COGNITO_USER_POOL_ID = aws_cognito_user_pool.openfactcheck.id
30+
OPENFACTCHECK_COGNITO_CLIENT_ID = aws_cognito_user_pool_client.openfactcheck_client.id
31+
OPENFACTCHECK_CORS_ORIGINS = jsonencode(var.cors_origins)
32+
OPENFACTCHECK_DEBUG = "false"
33+
OPENFACTCHECK_AUTH_BYPASS = "false"
34+
}
35+
}
36+
37+
tags = {
38+
Name = "OpenFactCheck - Lambda API - ${terraform.workspace} - ${var.aws_region}"
39+
}
40+
41+
lifecycle {
42+
ignore_changes = [image_uri]
43+
}
44+
}
45+
46+
# ##############################################################################
47+
# Lambda Alias — LIVE
48+
# ##############################################################################
49+
50+
resource "aws_lambda_alias" "api_live" {
51+
name = "LIVE"
52+
description = "OpenFactCheck API Current Release"
53+
function_name = aws_lambda_function.api.arn
54+
function_version = aws_lambda_function.api.version
55+
}
56+
57+
# ##############################################################################
58+
# Lambda Permissions — Allow API Gateway to invoke via LIVE alias
59+
# ##############################################################################
60+
61+
resource "aws_lambda_permission" "api_gateway_default" {
62+
action = "lambda:InvokeFunction"
63+
function_name = aws_lambda_function.api.function_name
64+
principal = "apigateway.amazonaws.com"
65+
source_arn = "${aws_apigatewayv2_api.openfactcheck.execution_arn}/*/$default"
66+
qualifier = "LIVE"
67+
}
68+
69+
resource "aws_lambda_permission" "api_gateway_proxy" {
70+
action = "lambda:InvokeFunction"
71+
function_name = aws_lambda_function.api.function_name
72+
principal = "apigateway.amazonaws.com"
73+
source_arn = "${aws_apigatewayv2_api.openfactcheck.execution_arn}/*/*/{proxy+}"
74+
qualifier = "LIVE"
75+
}

deployments/base/aws_route53.tf

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# ##############################################################################
2+
# Route 53 — DNS records for the API custom domain
3+
# ##############################################################################
4+
5+
resource "aws_route53_record" "api" {
6+
zone_id = local.route53_zone_id
7+
name = local.api_domain
8+
type = "A"
9+
10+
alias {
11+
name = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].target_domain_name
12+
zone_id = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].hosted_zone_id
13+
evaluate_target_health = false
14+
}
15+
}
16+
17+
resource "aws_route53_record" "api_ipv6" {
18+
zone_id = local.route53_zone_id
19+
name = local.api_domain
20+
type = "AAAA"
21+
22+
alias {
23+
name = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].target_domain_name
24+
zone_id = aws_apigatewayv2_domain_name.api.domain_name_configuration[0].hosted_zone_id
25+
evaluate_target_health = false
26+
}
27+
}

0 commit comments

Comments
 (0)