belt 0.0.7 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +29 -1
- data/README.md +150 -51
- data/exe/belt +6 -0
- data/lib/belt/action_router.rb +7 -1
- data/lib/belt/cli/app_detection.rb +16 -0
- data/lib/belt/cli/bucket_security.rb +122 -0
- data/lib/belt/cli/env_resolver.rb +15 -0
- data/lib/belt/cli/environment_command.rb +77 -0
- data/lib/belt/cli/frontend_command.rb +85 -0
- data/lib/belt/cli/frontend_deploy_command.rb +125 -0
- data/lib/belt/cli/frontend_setup_command.rb +64 -0
- data/lib/belt/cli/generate_command.rb +206 -0
- data/lib/belt/cli/new_command.rb +126 -0
- data/lib/belt/cli/routes_command/route_inference.rb +100 -0
- data/lib/belt/cli/routes_command/schema_loader.rb +71 -0
- data/lib/belt/cli/routes_command.rb +307 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/tasks_command.rb +110 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +117 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/root.rb +26 -0
- data/lib/belt/route_dsl.rb +605 -0
- data/lib/belt/table_inference.rb +71 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/environment/backend.tf.erb +8 -0
- data/lib/templates/environment/main.tf.erb +42 -0
- data/lib/templates/environment/terraform.tfvars.erb +1 -0
- data/lib/templates/environment/variables.tf.erb +16 -0
- data/lib/templates/frontend/react/index.html.erb +12 -0
- data/lib/templates/frontend/react/package.json.erb +20 -0
- data/lib/templates/frontend/react/src/App.jsx +14 -0
- data/lib/templates/frontend/react/src/index.css +10 -0
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
- data/lib/templates/frontend/react/src/main.jsx +10 -0
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
- data/lib/templates/frontend/react/vite.config.js +8 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
- data/lib/templates/generate/controller.rb.erb +59 -0
- data/lib/templates/generate/model.rb.erb +20 -0
- data/lib/templates/new_app/AGENTS.md.erb +130 -0
- data/lib/templates/new_app/Gemfile.erb +5 -0
- data/lib/templates/new_app/README.md.erb +25 -0
- data/lib/templates/new_app/Rakefile.erb +12 -0
- data/lib/templates/new_app/gitignore.erb +14 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
- data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
- data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
- data/lib/templates/new_app/lambda/api.rb.erb +22 -0
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
- data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
- data/lib/templates/views/Edit.jsx.erb +38 -0
- data/lib/templates/views/Form.jsx.erb +34 -0
- data/lib/templates/views/Index.jsx.erb +39 -0
- data/lib/templates/views/New.jsx.erb +26 -0
- data/lib/templates/views/Show.jsx.erb +46 -0
- data.tar.gz.sig +0 -0
- metadata +73 -3
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_providers {
|
|
3
|
+
aws = {
|
|
4
|
+
source = "hashicorp/aws"
|
|
5
|
+
version = "~> 5.0"
|
|
6
|
+
}
|
|
7
|
+
random = {
|
|
8
|
+
source = "hashicorp/random"
|
|
9
|
+
version = "~> 3.0"
|
|
10
|
+
}
|
|
11
|
+
dispatcher = {
|
|
12
|
+
source = "terraform.local/stowzilla/dispatcher"
|
|
13
|
+
version = "99.0.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
provider "aws" {
|
|
19
|
+
region = var.aws_region
|
|
20
|
+
|
|
21
|
+
default_tags {
|
|
22
|
+
tags = {
|
|
23
|
+
Project = "<%= @app_name.capitalize %>"
|
|
24
|
+
Environment = var.environment
|
|
25
|
+
ManagedBy = "Terraform"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
provider "dispatcher" {
|
|
31
|
+
environment = var.environment
|
|
32
|
+
aws_region = var.aws_region
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resource "dispatcher" "main" {
|
|
36
|
+
source = "${path.module}/../routes.tf.rb"
|
|
37
|
+
app_name = var.app_name
|
|
38
|
+
lambda_source_dir = "${path.module}/../../lambda"
|
|
39
|
+
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "templates"]
|
|
40
|
+
frontend_urls = ["http://localhost:3000"]
|
|
41
|
+
friendly_errors = true
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
environment = "<%= @env_name %>"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
variable "app_name" {
|
|
2
|
+
description = "Name of the application"
|
|
3
|
+
type = string
|
|
4
|
+
default = "<%= @app_name %>"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
variable "environment" {
|
|
8
|
+
description = "Environment name"
|
|
9
|
+
type = string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
variable "aws_region" {
|
|
13
|
+
description = "AWS region"
|
|
14
|
+
type = string
|
|
15
|
+
default = "us-east-1"
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title><%= @module_name %></title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= @app_name %>",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1",
|
|
14
|
+
"react-router-dom": "^6.28.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
18
|
+
"vite": "^6.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
|
2
|
+
import Home from './pages/Home'
|
|
3
|
+
|
|
4
|
+
function App() {
|
|
5
|
+
return (
|
|
6
|
+
<BrowserRouter>
|
|
7
|
+
<Routes>
|
|
8
|
+
<Route path="/" element={<Home />} />
|
|
9
|
+
</Routes>
|
|
10
|
+
</BrowserRouter>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default App
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
|
2
|
+
|
|
3
|
+
export async function apiClient(path, options = {}) {
|
|
4
|
+
const { method = 'GET', body, headers = {} } = options
|
|
5
|
+
|
|
6
|
+
const config = {
|
|
7
|
+
method,
|
|
8
|
+
headers: { 'Content-Type': 'application/json', ...headers }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (body) config.body = JSON.stringify(body)
|
|
12
|
+
|
|
13
|
+
const response = await fetch(`${API_URL}${path}`, config)
|
|
14
|
+
const data = await response.json()
|
|
15
|
+
|
|
16
|
+
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
|
|
17
|
+
|
|
18
|
+
return data
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function Home() {
|
|
2
|
+
return (
|
|
3
|
+
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
4
|
+
<h1><%= @module_name %></h1>
|
|
5
|
+
<p>Your Belt app is running. Edit <code>src/pages/Home.jsx</code> to get started.</p>
|
|
6
|
+
</main>
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default Home
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Auto-generated by Belt — frontend hosting infrastructure
|
|
2
|
+
# Re-run `belt setup frontend <%= @env %>` to regenerate
|
|
3
|
+
|
|
4
|
+
resource "random_string" "frontend_suffix" {
|
|
5
|
+
length = 8
|
|
6
|
+
special = false
|
|
7
|
+
upper = false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# S3 bucket for frontend static assets
|
|
11
|
+
resource "aws_s3_bucket" "frontend" {
|
|
12
|
+
bucket = "<%= @app_name %>-frontend-${var.environment}-${random_string.frontend_suffix.result}"
|
|
13
|
+
|
|
14
|
+
lifecycle {
|
|
15
|
+
ignore_changes = [bucket]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resource "aws_s3_bucket_public_access_block" "frontend" {
|
|
20
|
+
bucket = aws_s3_bucket.frontend.id
|
|
21
|
+
|
|
22
|
+
block_public_acls = true
|
|
23
|
+
block_public_policy = true
|
|
24
|
+
ignore_public_acls = true
|
|
25
|
+
restrict_public_buckets = true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "frontend" {
|
|
29
|
+
bucket = aws_s3_bucket.frontend.id
|
|
30
|
+
|
|
31
|
+
rule {
|
|
32
|
+
apply_server_side_encryption_by_default {
|
|
33
|
+
sse_algorithm = "AES256"
|
|
34
|
+
}
|
|
35
|
+
bucket_key_enabled = true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resource "aws_s3_bucket_website_configuration" "frontend" {
|
|
40
|
+
bucket = aws_s3_bucket.frontend.id
|
|
41
|
+
|
|
42
|
+
index_document {
|
|
43
|
+
suffix = "index.html"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
error_document {
|
|
47
|
+
key = "index.html"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# CloudFront OAC — restricts S3 access to CloudFront only
|
|
52
|
+
resource "aws_cloudfront_origin_access_control" "frontend" {
|
|
53
|
+
name = "<%= @app_name %>-${var.environment}-frontend-oac"
|
|
54
|
+
description = "OAC for frontend bucket"
|
|
55
|
+
origin_access_control_origin_type = "s3"
|
|
56
|
+
signing_behavior = "always"
|
|
57
|
+
signing_protocol = "sigv4"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# CloudFront distribution
|
|
61
|
+
resource "aws_cloudfront_distribution" "frontend" {
|
|
62
|
+
origin {
|
|
63
|
+
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
|
|
64
|
+
origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
|
65
|
+
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
enabled = true
|
|
69
|
+
is_ipv6_enabled = true
|
|
70
|
+
default_root_object = "index.html"
|
|
71
|
+
|
|
72
|
+
default_cache_behavior {
|
|
73
|
+
allowed_methods = ["GET", "HEAD", "OPTIONS"]
|
|
74
|
+
cached_methods = ["GET", "HEAD"]
|
|
75
|
+
target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
|
|
76
|
+
compress = true
|
|
77
|
+
viewer_protocol_policy = "redirect-to-https"
|
|
78
|
+
|
|
79
|
+
forwarded_values {
|
|
80
|
+
query_string = false
|
|
81
|
+
cookies {
|
|
82
|
+
forward = "none"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# SPA routing — serve index.html for all 404/403 paths
|
|
88
|
+
custom_error_response {
|
|
89
|
+
error_code = 404
|
|
90
|
+
response_code = 200
|
|
91
|
+
response_page_path = "/index.html"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
custom_error_response {
|
|
95
|
+
error_code = 403
|
|
96
|
+
response_code = 200
|
|
97
|
+
response_page_path = "/index.html"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
restrictions {
|
|
101
|
+
geo_restriction {
|
|
102
|
+
restriction_type = "none"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
viewer_certificate {
|
|
107
|
+
cloudfront_default_certificate = true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
tags = {
|
|
111
|
+
Name = "<%= @app_name %>-${var.environment}-frontend"
|
|
112
|
+
Environment = var.environment
|
|
113
|
+
ManagedBy = "Terraform"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# S3 bucket policy — allow CloudFront OAC access only
|
|
118
|
+
resource "aws_s3_bucket_policy" "frontend" {
|
|
119
|
+
bucket = aws_s3_bucket.frontend.id
|
|
120
|
+
|
|
121
|
+
policy = jsonencode({
|
|
122
|
+
Version = "2012-10-17"
|
|
123
|
+
Statement = [
|
|
124
|
+
{
|
|
125
|
+
Sid = "AllowCloudFrontServicePrincipal"
|
|
126
|
+
Effect = "Allow"
|
|
127
|
+
Principal = {
|
|
128
|
+
Service = "cloudfront.amazonaws.com"
|
|
129
|
+
}
|
|
130
|
+
Action = "s3:GetObject"
|
|
131
|
+
Resource = "${aws_s3_bucket.frontend.arn}/*"
|
|
132
|
+
Condition = {
|
|
133
|
+
StringEquals = {
|
|
134
|
+
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
depends_on = [aws_s3_bucket_public_access_block.frontend]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Outputs for belt deploy
|
|
145
|
+
output "frontend_bucket_name" {
|
|
146
|
+
value = aws_s3_bucket.frontend.id
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
output "frontend_distribution_id" {
|
|
150
|
+
value = aws_cloudfront_distribution.frontend.id
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
output "frontend_url" {
|
|
154
|
+
value = "https://${aws_cloudfront_distribution.frontend.domain_name}"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
output "api_url" {
|
|
158
|
+
value = values(dispatcher.main.api_gateway_urls)[0]
|
|
159
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'application_controller'
|
|
4
|
+
require_relative '../../models/<%= @singular_name %>'
|
|
5
|
+
|
|
6
|
+
module <%= @module_name %>Controllers
|
|
7
|
+
class <%= @class_name %>sController < ApplicationController
|
|
8
|
+
# GET /<%= @resource_name %>
|
|
9
|
+
def index
|
|
10
|
+
<%= @resource_name %> = <%= @class_name %>.all
|
|
11
|
+
success_response(<%= @resource_name %>: <%= @resource_name %>.map(&:to_h))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# POST /<%= @resource_name %>
|
|
15
|
+
def create
|
|
16
|
+
<%= @singular_name %> = <%= @class_name %>.new(<%= @fields.map { |f| "#{f[:name]}: params[:#{f[:name]}]" }.join(', ') %>)
|
|
17
|
+
|
|
18
|
+
if <%= @singular_name %>.save
|
|
19
|
+
success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
|
|
20
|
+
else
|
|
21
|
+
error_response(<%= @singular_name %>.errors.full_messages.join(', '), 422)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /<%= @resource_name %>/:<%= @singular_name %>_id
|
|
26
|
+
def show
|
|
27
|
+
<%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
|
|
28
|
+
return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
|
|
29
|
+
|
|
30
|
+
success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# PUT /<%= @resource_name %>/:<%= @singular_name %>_id
|
|
34
|
+
def update
|
|
35
|
+
<%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
|
|
36
|
+
return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
|
|
37
|
+
|
|
38
|
+
attrs = {}
|
|
39
|
+
<% @fields.each do |field| -%>
|
|
40
|
+
attrs[:<%= field[:name] %>] = params[:<%= field[:name] %>] if params.key?(:<%= field[:name] %>)
|
|
41
|
+
<% end -%>
|
|
42
|
+
|
|
43
|
+
if <%= @singular_name %>.update(attrs)
|
|
44
|
+
success_response(<%= @singular_name %>: <%= @singular_name %>.to_h)
|
|
45
|
+
else
|
|
46
|
+
error_response(<%= @singular_name %>.errors.full_messages.join(', '), 422)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# DELETE /<%= @resource_name %>/:<%= @singular_name %>_id
|
|
51
|
+
def destroy
|
|
52
|
+
<%= @singular_name %> = <%= @class_name %>.find(params[:<%= @singular_name %>_id])
|
|
53
|
+
return error_response('<%= @class_name %> not found', 404) unless <%= @singular_name %>
|
|
54
|
+
|
|
55
|
+
<%= @singular_name %>.destroy
|
|
56
|
+
success_response(message: '<%= @class_name %> deleted')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= @class_name %> < ApplicationRecord
|
|
4
|
+
include Concerns::Timestampable
|
|
5
|
+
|
|
6
|
+
<% @fields.each do |field| -%>
|
|
7
|
+
attr_accessor :<%= field[:name] %>
|
|
8
|
+
<% end -%>
|
|
9
|
+
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
id: id,
|
|
13
|
+
<% @fields.each do |field| -%>
|
|
14
|
+
<%= field[:name] %>: <%= field[:name] %>,
|
|
15
|
+
<% end -%>
|
|
16
|
+
created_at: created_at,
|
|
17
|
+
updated_at: updated_at
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# AGENTS.md — <%= @module_name %>
|
|
2
|
+
|
|
3
|
+
This file explains the project structure, tooling, and conventions for AI agents working in this codebase.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- **Belt** — CLI and runtime framework (like Rails for serverless). Provides Lambda handler, action router, controller base class, and CLI tooling.
|
|
8
|
+
- **ActiveItem** — ActiveRecord-like ORM for DynamoDB. Models inherit from `ActiveItem::Base`.
|
|
9
|
+
- **Dispatcher** — Terraform provider that reads a Ruby DSL (`routes.tf.rb`) and creates API Gateway + Lambda + IAM infrastructure.
|
|
10
|
+
- **Lambda Loadout** — Lambda cold-start optimizer (auto-required by Belt).
|
|
11
|
+
|
|
12
|
+
## Project Structure
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
<%= @app_name %>/
|
|
16
|
+
├── lambda/
|
|
17
|
+
│ ├── <%= @app_name %>.rb # Lambda entry point
|
|
18
|
+
│ ├── controllers/<%= @app_name %>/ # Controllers (namespaced)
|
|
19
|
+
│ │ └── application_controller.rb # Base controller
|
|
20
|
+
│ ├── models/ # ActiveItem models
|
|
21
|
+
│ │ ├── application_record.rb # Base model
|
|
22
|
+
│ │ └── concerns/ # Shared model concerns
|
|
23
|
+
│ ├── lib/routes/ # Route manifests (auto-generated)
|
|
24
|
+
│ └── Gemfile # Lambda-specific dependencies
|
|
25
|
+
├── infrastructure/
|
|
26
|
+
│ ├── routes.tf.rb # API routes (Dispatcher DSL)
|
|
27
|
+
│ ├── schema.tf.rb # Model schema definitions
|
|
28
|
+
│ └── <env>/ # Per-environment Terraform configs
|
|
29
|
+
├── Gemfile # Project-level dependencies
|
|
30
|
+
└── AGENTS.md # This file
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Belt CLI Commands
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
belt new <app_name> # Scaffold a new project
|
|
37
|
+
belt generate resource <name> <field:type ...> # Generate model + controller + routes
|
|
38
|
+
belt generate model <name> <field:type ...> # Generate model only
|
|
39
|
+
belt generate controller <name> # Generate controller only
|
|
40
|
+
belt generate environment <env_name> # Generate Terraform environment directory
|
|
41
|
+
belt setup state # Create/select S3 state bucket
|
|
42
|
+
belt setup tables <env> # Generate DynamoDB table definitions from schema
|
|
43
|
+
belt init <env> # terraform init
|
|
44
|
+
belt plan <env> # terraform plan
|
|
45
|
+
belt apply <env> # terraform apply
|
|
46
|
+
belt destroy <env> # terraform destroy
|
|
47
|
+
belt output <env> # terraform output
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How Routing Works
|
|
51
|
+
|
|
52
|
+
1. `infrastructure/routes.tf.rb` defines routes using a DSL:
|
|
53
|
+
```ruby
|
|
54
|
+
Belt.application.routes.draw do
|
|
55
|
+
namespace :<%= @app_name %> do
|
|
56
|
+
resources :things, tables: [:things]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
2. Dispatcher creates an API Gateway where the namespace becomes a base path mapping. URLs look like:
|
|
62
|
+
```
|
|
63
|
+
https://api.<env>.example.com/<%= @app_name %>/things
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
3. The Lambda entry point (`lambda/<%= @app_name %>.rb`) uses `Belt::ActionRouter` to dispatch requests to controllers based on the route manifest in `lambda/lib/routes/`.
|
|
67
|
+
|
|
68
|
+
4. `resources :things` generates: `GET /things`, `POST /things`, `GET /things/:id`, `PUT /things/:id`, `DELETE /things/:id`. **PUT, not PATCH.**
|
|
69
|
+
|
|
70
|
+
## How Models Work
|
|
71
|
+
|
|
72
|
+
Models use ActiveItem (DynamoDB ORM):
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Post < ApplicationRecord
|
|
76
|
+
attr_accessor :title, :content, :status
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Table names resolve as `{APP_NAME}-{ENVIRONMENT}-{pluralized_model}` (e.g., `<%= @app_name %>-wups-posts`).
|
|
81
|
+
|
|
82
|
+
Key ActiveItem methods: `create!`, `find`, `where`, `update`, `destroy`, `all`, `count`, `exists?`.
|
|
83
|
+
|
|
84
|
+
## How Controllers Work
|
|
85
|
+
|
|
86
|
+
Controllers inherit from `BeltController::Base`:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
module <%= @module_name %>Controllers
|
|
90
|
+
class ThingsController < ApplicationController
|
|
91
|
+
def index
|
|
92
|
+
items = Thing.all
|
|
93
|
+
success_response(things: items.map(&:to_h))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def show
|
|
97
|
+
item = Thing.find(params[:id])
|
|
98
|
+
success_response(thing: item.to_h)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create
|
|
102
|
+
item = Thing.create!(params.slice(:title, :content))
|
|
103
|
+
success_response(thing: item.to_h, status: 201)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`params` contains merged path parameters and parsed JSON body. Use `success_response` and `error_response` helpers.
|
|
110
|
+
|
|
111
|
+
## Deployment Flow
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export AWS_PROFILE=<your_profile>
|
|
115
|
+
belt setup state # One-time: create S3 state bucket
|
|
116
|
+
belt generate environment <env> # One-time: scaffold Terraform configs
|
|
117
|
+
belt setup tables <env> # Generate DynamoDB table resources
|
|
118
|
+
belt init <env> # Initialize Terraform
|
|
119
|
+
belt apply <env> # Deploy everything
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Adding a New Resource
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
belt generate resource comment body:text author:string post_id:string
|
|
126
|
+
belt setup tables <env>
|
|
127
|
+
belt apply <env>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This creates the model, controller, updates routes + schema, generates the DynamoDB table definition, and deploys.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# <%= @module_name %>
|
|
2
|
+
|
|
3
|
+
A serverless application built with [Belt](https://github.com/stowzilla/belt) and [ActiveItem](https://github.com/stowzilla/activeitem).
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
├── infrastructure/
|
|
9
|
+
│ ├── routes.tf.rb # API route definitions
|
|
10
|
+
│ └── schema.tf.rb # Model schema definitions
|
|
11
|
+
└── lambda/
|
|
12
|
+
├── api.rb # Lambda entry point
|
|
13
|
+
├── controllers/<%= @app_name %>/ # Controllers
|
|
14
|
+
├── models/ # ActiveItem models
|
|
15
|
+
└── lib/routes/ # Route manifest
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle install
|
|
22
|
+
|
|
23
|
+
# Deploy
|
|
24
|
+
cd infrastructure && terraform apply
|
|
25
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
# Load rake tasks from all gems that ship them
|
|
6
|
+
Gem.loaded_specs.each_value do |spec|
|
|
7
|
+
tasks_dir = File.join(spec.full_gem_path, 'lib', 'tasks')
|
|
8
|
+
Dir.glob("#{tasks_dir}/**/*.rake").each { |r| load r } if Dir.exist?(tasks_dir)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Load project-local tasks
|
|
12
|
+
Dir.glob('lib/tasks/**/*.rake').each { |r| load r }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Belt.application.schema.draw do
|
|
2
|
+
# model :post do
|
|
3
|
+
# field :title, type: :string, required: true
|
|
4
|
+
# field :slug, type: :string, required: true
|
|
5
|
+
# field :content, type: :string
|
|
6
|
+
# field :status, type: :string, enum: %w[draft published]
|
|
7
|
+
# field :published_at, type: :string
|
|
8
|
+
# end
|
|
9
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'belt'
|
|
4
|
+
require 'activeitem'
|
|
5
|
+
|
|
6
|
+
include Belt::LambdaHandler
|
|
7
|
+
|
|
8
|
+
ActiveItem.configure do |config|
|
|
9
|
+
config.table_prefix = ENV['APP_NAME']
|
|
10
|
+
config.environment = ENV['ENVIRONMENT']
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
require_relative 'lib/routes/<%= @app_name %>_routes'
|
|
14
|
+
<% @resources&.each do |r| -%>
|
|
15
|
+
require_relative 'controllers/<%= @app_name %>/<%= r %>_controller'
|
|
16
|
+
<% end -%>
|
|
17
|
+
|
|
18
|
+
ROUTER = Belt::ActionRouter.new(routes: Routes::<%= @module_name.upcase %>, namespace: '<%= @app_name %>')
|
|
19
|
+
|
|
20
|
+
def execute(path:, body:, event:)
|
|
21
|
+
ROUTER.route(event: event, body: body)
|
|
22
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Routes
|
|
4
|
+
<%= @module_name.upcase %> = [
|
|
5
|
+
# { verb: 'GET', path: '/posts', controller: 'posts', action: 'index' },
|
|
6
|
+
# { verb: 'POST', path: '/posts', controller: 'posts', action: 'create' },
|
|
7
|
+
# { verb: 'GET', path: '/posts/{id}', controller: 'posts', action: 'show' },
|
|
8
|
+
# { verb: 'PUT', path: '/posts/{id}', controller: 'posts', action: 'update' },
|
|
9
|
+
# { verb: 'DELETE', path: '/posts/{id}', controller: 'posts', action: 'destroy' }
|
|
10
|
+
].freeze
|
|
11
|
+
end
|