belt 0.0.6 → 0.1.0
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 +4 -0
- 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 +125 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +98 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -1
- 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 +6 -0
- data/lib/templates/new_app/README.md.erb +25 -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 +51 -3
- metadata.gz.sig +0 -0
|
@@ -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
|
+
TerraDispatch.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,9 @@
|
|
|
1
|
+
TerraDispatch.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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Concerns
|
|
4
|
+
module Timestampable
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.attr_accessor :created_at, :updated_at
|
|
7
|
+
base.set_callback :create, :before, :set_timestamps
|
|
8
|
+
base.set_callback :update, :before, :set_updated_at
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def set_timestamps
|
|
14
|
+
now = Time.now.utc.iso8601
|
|
15
|
+
self.created_at ||= now
|
|
16
|
+
self.updated_at = now
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_updated_at
|
|
20
|
+
self.updated_at = Time.now.utc.iso8601
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { apiClient } from '../../lib/apiClient'
|
|
4
|
+
import <%= @class_name %>Form from './<%= @class_name %>Form'
|
|
5
|
+
|
|
6
|
+
export default function <%= @class_name %>Edit() {
|
|
7
|
+
const { id } = useParams()
|
|
8
|
+
const navigate = useNavigate()
|
|
9
|
+
const [<%= @singular_name %>, set<%= @class_name %>] = useState(null)
|
|
10
|
+
const [loading, setLoading] = useState(true)
|
|
11
|
+
const [error, setError] = useState(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
apiClient(`/<%= @resource_name %>/${id}`)
|
|
15
|
+
.then(data => set<%= @class_name %>(data.<%= @singular_name %>))
|
|
16
|
+
.finally(() => setLoading(false))
|
|
17
|
+
}, [id])
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(attrs) {
|
|
20
|
+
try {
|
|
21
|
+
await apiClient(`/<%= @resource_name %>/${id}`, { method: 'PUT', body: attrs })
|
|
22
|
+
navigate(`/<%= @resource_name %>/${id}`)
|
|
23
|
+
} catch (e) {
|
|
24
|
+
setError(e.message)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (loading) return <p>Loading...</p>
|
|
29
|
+
if (!<%= @singular_name %>) return <p><%= @class_name %> not found.</p>
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
33
|
+
<h1>Edit <%= @class_name %></h1>
|
|
34
|
+
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
35
|
+
<<%= @class_name %>Form initialValues={<%= @singular_name %>} onSubmit={handleSubmit} submitLabel="Update" />
|
|
36
|
+
</main>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
|
|
4
|
+
export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, submitLabel = 'Create' }) {
|
|
5
|
+
<% @fields.each do |field| -%>
|
|
6
|
+
const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || '')
|
|
7
|
+
<% end -%>
|
|
8
|
+
|
|
9
|
+
function handleSubmit(e) {
|
|
10
|
+
e.preventDefault()
|
|
11
|
+
onSubmit({ <%= @fields.map { |f| f[:name] }.join(', ') %> })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<form onSubmit={handleSubmit}>
|
|
16
|
+
<% @fields.each do |field| -%>
|
|
17
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
18
|
+
<label style={{ display: 'block', marginBottom: '0.25rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
|
|
19
|
+
<% if field[:type] == 'text' -%>
|
|
20
|
+
<textarea value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
21
|
+
rows={6} style={{ width: '100%' }} />
|
|
22
|
+
<% else -%>
|
|
23
|
+
<input type="text" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
|
|
24
|
+
style={{ width: '100%', padding: '0.5rem' }} />
|
|
25
|
+
<% end -%>
|
|
26
|
+
</div>
|
|
27
|
+
<% end -%>
|
|
28
|
+
|
|
29
|
+
<button type="submit">{submitLabel}</button>
|
|
30
|
+
{' '}
|
|
31
|
+
<Link to="/<%= @resource_name %>">Cancel</Link>
|
|
32
|
+
</form>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
import { apiClient } from '../../lib/apiClient'
|
|
4
|
+
|
|
5
|
+
export default function <%= @class_name %>sIndex() {
|
|
6
|
+
const [<%= @resource_name %>, set<%= @class_name %>s] = useState([])
|
|
7
|
+
const [loading, setLoading] = useState(true)
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
apiClient('/<%= @resource_name %>')
|
|
11
|
+
.then(data => set<%= @class_name %>s(data.<%= @resource_name %> || []))
|
|
12
|
+
.finally(() => setLoading(false))
|
|
13
|
+
}, [])
|
|
14
|
+
|
|
15
|
+
if (loading) return <p>Loading...</p>
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
19
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
20
|
+
<h1><%= @class_name %>s</h1>
|
|
21
|
+
<Link to="/<%= @resource_name %>/new">New <%= @class_name %></Link>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
{<%= @resource_name %>.length === 0 ? (
|
|
25
|
+
<p>No <%= @resource_name %> yet.</p>
|
|
26
|
+
) : (
|
|
27
|
+
<ul style={{ listStyle: 'none', padding: 0 }}>
|
|
28
|
+
{<%= @resource_name %>.map(<%= @singular_name %> => (
|
|
29
|
+
<li key={<%= @singular_name %>.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid #eee' }}>
|
|
30
|
+
<Link to={`/<%= @resource_name %>/${<%= @singular_name %>.id}`}>
|
|
31
|
+
{<%= @singular_name %>.<%= @fields.first&.dig(:name) || 'id' %>}
|
|
32
|
+
</Link>
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
)}
|
|
37
|
+
</main>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { apiClient } from '../../lib/apiClient'
|
|
4
|
+
import <%= @class_name %>Form from './<%= @class_name %>Form'
|
|
5
|
+
|
|
6
|
+
export default function <%= @class_name %>New() {
|
|
7
|
+
const navigate = useNavigate()
|
|
8
|
+
const [error, setError] = useState(null)
|
|
9
|
+
|
|
10
|
+
async function handleSubmit(attrs) {
|
|
11
|
+
try {
|
|
12
|
+
const data = await apiClient('/<%= @resource_name %>', { method: 'POST', body: attrs })
|
|
13
|
+
navigate(`/<%= @resource_name %>/${data.<%= @singular_name %>.id}`)
|
|
14
|
+
} catch (e) {
|
|
15
|
+
setError(e.message)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
21
|
+
<h1>New <%= @class_name %></h1>
|
|
22
|
+
{error && <p style={{ color: 'red' }}>{error}</p>}
|
|
23
|
+
<<%= @class_name %>Form onSubmit={handleSubmit} />
|
|
24
|
+
</main>
|
|
25
|
+
)
|
|
26
|
+
}
|