agentcode 0.9.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 +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 156884253ac38e8338ed1a4dd25f0d6b07b490159981cf527a82767dd753a78c
|
|
4
|
+
data.tar.gz: 38425ac745bee34c114409082f47823d0bee5f0024b42ebc67e86f8c97f4cbd1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4826283c4e629c65742f910b2d53a5714e816830c7bcaefcfb305a88c75d825c03981d71390e2ee10290deea83de55244d4e6ec94e0f23d4dd3e1ee563661573
|
|
7
|
+
data.tar.gz: ee02e159498fc5e3c21467dea7e83ea3c20aa2b85ffea5b23b3ce061f212582d042a22fa9c0952bc3099f87631c045905e5c15e557a2b00e32be6bfdb29b3c94
|
data/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# AgentCode — Rails
|
|
2
|
+
|
|
3
|
+
> Automatic REST API generation for Rails models with built-in security, validation, and advanced querying.
|
|
4
|
+
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](https://rubyonrails.org/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
Register a model, get a full REST API instantly.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
| # | Feature | Description |
|
|
14
|
+
|---|---------|-------------|
|
|
15
|
+
| 1 | **Automatic CRUD Endpoints** | Generates `index`, `show`, `create`, `update`, `destroy` for every registered model. |
|
|
16
|
+
| 2 | **Authentication** | Login, logout, password recovery/reset, invitation-based registration. |
|
|
17
|
+
| 3 | **Authorization & Policies** | Pundit-based permission checks (`{slug}.{action}`), wildcard support. |
|
|
18
|
+
| 4 | **Role-Based Access Control** | Per-org roles via `user_roles` join table. |
|
|
19
|
+
| 5 | **Attribute-Level Permissions** | Control which fields each role can read and write. |
|
|
20
|
+
| 6 | **Validation** | Dual-layer: format rules + field presence. Supports role-keyed rules. |
|
|
21
|
+
| 7 | **Cross-Tenant FK Validation** | `exists:` rules auto-scoped to current org, even through indirect FK relationships. |
|
|
22
|
+
| 8 | **Filtering** | `?filter[field]=value` with AND/OR logic. |
|
|
23
|
+
| 9 | **Sorting** | `?sort=-created_at,title` — ascending and descending. |
|
|
24
|
+
| 10 | **Full-Text Search** | `?search=term` across configured fields, supports relationship dot notation. |
|
|
25
|
+
| 11 | **Pagination** | Header-based metadata (`X-Current-Page`, `X-Last-Page`, `X-Per-Page`, `X-Total`). |
|
|
26
|
+
| 12 | **Field Selection** | `?fields[posts]=id,title,status` to reduce payload. |
|
|
27
|
+
| 13 | **Eager Loading** | `?include=user,comments` with nested, Count/Exists suffixes, and auth per include. |
|
|
28
|
+
| 14 | **Multi-Tenancy** | Organization-based data isolation, auto-set `organization_id`, global scope. |
|
|
29
|
+
| 15 | **Nested Ownership** | Auto-detects org by walking `belongs_to` chains. |
|
|
30
|
+
| 16 | **Route Groups** | Multiple URL prefixes with different middleware/auth (`tenant`, `public`, custom). |
|
|
31
|
+
| 17 | **Soft Deletes** | Discard gem — trash, restore, force-delete endpoints with individual permissions. |
|
|
32
|
+
| 18 | **Audit Trail** | Logs all CRUD events with old/new values, user, IP, and org context. |
|
|
33
|
+
| 19 | **Nested Operations** | `POST /nested` for atomic multi-model transactions with `$N.field` references. |
|
|
34
|
+
| 20 | **Invitations** | Token-based invite system with create, resend, cancel, accept, and role assignment. |
|
|
35
|
+
| 21 | **Hidden Columns** | Base + model-level + policy-level dynamic column hiding per role. |
|
|
36
|
+
| 22 | **Auto-Scope Discovery** | Auto-registers scopes by naming convention. |
|
|
37
|
+
| 23 | **UUID Primary Keys** | `HasUuid` concern for auto-generated UUIDs. |
|
|
38
|
+
| 24 | **Middleware Support** | Global per model + per action middleware. |
|
|
39
|
+
| 25 | **Action Exclusion** | `except_actions` to disable specific CRUD routes. |
|
|
40
|
+
| 26 | **Generator CLI** | `agentcode:install`, `agentcode:generate`, `agentcode:blueprint`, `agentcode:export_postman`. |
|
|
41
|
+
| 27 | **Postman Export** | Auto-generated Postman Collection v2.1 with all endpoints. |
|
|
42
|
+
| 28 | **Blueprint System** | YAML-to-code generation for models, migrations, factories, policies, tests, and seeders. |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle add agentcode
|
|
48
|
+
rails agentcode:install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
For full documentation, guides, and API reference visit:
|
|
54
|
+
|
|
55
|
+
**[https://startsoft-dev.github.io/agentcode-docs/docs/getting-started](https://startsoft-dev.github.io/agentcode-docs/docs/getting-started)**
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module AgentCode
|
|
7
|
+
module Blueprint
|
|
8
|
+
# Parses YAML blueprint files into normalized data structures.
|
|
9
|
+
# Port of agentcode-server BlueprintParser.php / agentcode-adonis-server blueprint_parser.ts.
|
|
10
|
+
class BlueprintParser
|
|
11
|
+
# Parse _roles.yaml file into normalized role definitions.
|
|
12
|
+
#
|
|
13
|
+
# @param file_path [String]
|
|
14
|
+
# @return [Hash<String, Hash>] e.g. { 'owner' => { name: 'Owner', description: '...' } }
|
|
15
|
+
def parse_roles(file_path)
|
|
16
|
+
content = read_file(file_path)
|
|
17
|
+
|
|
18
|
+
raise "Blueprint roles file is empty: #{file_path}" if content.strip.empty?
|
|
19
|
+
|
|
20
|
+
parsed = begin
|
|
21
|
+
YAML.safe_load(content, permitted_classes: [Symbol])
|
|
22
|
+
rescue Psych::SyntaxError
|
|
23
|
+
raise "Invalid YAML syntax in: #{file_path}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise "Invalid YAML structure in: #{file_path}" unless parsed.is_a?(Hash)
|
|
27
|
+
raise "Missing 'roles' key in: #{file_path}" unless parsed["roles"]
|
|
28
|
+
|
|
29
|
+
roles = {}
|
|
30
|
+
|
|
31
|
+
parsed["roles"].each do |slug, value|
|
|
32
|
+
raise "Invalid role definition for '#{slug}' — expected a hash" unless value.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
roles[slug] = {
|
|
35
|
+
name: value["name"] || slug_to_name(slug),
|
|
36
|
+
description: value["description"] || ""
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
roles
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parse a model blueprint YAML file into normalized structure.
|
|
44
|
+
#
|
|
45
|
+
# @param file_path [String]
|
|
46
|
+
# @return [Hash] ParsedBlueprint hash
|
|
47
|
+
def parse_model(file_path)
|
|
48
|
+
content = read_file(file_path)
|
|
49
|
+
|
|
50
|
+
raise "Blueprint file is empty: #{file_path}" if content.strip.empty?
|
|
51
|
+
|
|
52
|
+
parsed = begin
|
|
53
|
+
YAML.safe_load(content, permitted_classes: [Symbol])
|
|
54
|
+
rescue Psych::SyntaxError
|
|
55
|
+
raise "Invalid YAML syntax in: #{file_path}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise "Invalid YAML structure in: #{file_path}" unless parsed.is_a?(Hash)
|
|
59
|
+
raise "Missing 'model' key in: #{file_path}" unless parsed["model"]
|
|
60
|
+
|
|
61
|
+
model_name = parsed["model"]
|
|
62
|
+
slug = parsed["slug"] || model_to_slug(model_name)
|
|
63
|
+
table = parsed["table"] || slug
|
|
64
|
+
|
|
65
|
+
source_file = File.basename(file_path)
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
model: model_name,
|
|
69
|
+
slug: slug,
|
|
70
|
+
table: table,
|
|
71
|
+
options: normalize_options(parsed["options"] || {}),
|
|
72
|
+
columns: normalize_columns(parsed["columns"] || {}),
|
|
73
|
+
relationships: parsed["relationships"] || [],
|
|
74
|
+
permissions: normalize_permissions(parsed["permissions"] || {}),
|
|
75
|
+
source_file: source_file
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Compute SHA-256 hash of a file's contents.
|
|
80
|
+
#
|
|
81
|
+
# @param file_path [String]
|
|
82
|
+
# @return [String] 64-char hex hash
|
|
83
|
+
def compute_file_hash(file_path)
|
|
84
|
+
Digest::SHA256.hexdigest(File.read(file_path))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ──────────────────────────────────────────────
|
|
88
|
+
# Normalization helpers
|
|
89
|
+
# ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def normalize_options(options)
|
|
92
|
+
{
|
|
93
|
+
belongs_to_organization: options.fetch("belongs_to_organization", false),
|
|
94
|
+
soft_deletes: options.fetch("soft_deletes", true),
|
|
95
|
+
audit_trail: options.fetch("audit_trail", false),
|
|
96
|
+
owner: options.fetch("owner", nil),
|
|
97
|
+
except_actions: options.fetch("except_actions", []),
|
|
98
|
+
pagination: options.fetch("pagination", false),
|
|
99
|
+
per_page: options.fetch("per_page", 25)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_columns(columns)
|
|
104
|
+
result = []
|
|
105
|
+
|
|
106
|
+
columns.each do |name, value|
|
|
107
|
+
if value.is_a?(String)
|
|
108
|
+
# Short syntax: field_name: type
|
|
109
|
+
result << {
|
|
110
|
+
name: name,
|
|
111
|
+
type: value,
|
|
112
|
+
nullable: false,
|
|
113
|
+
unique: false,
|
|
114
|
+
index: false,
|
|
115
|
+
default: nil,
|
|
116
|
+
filterable: false,
|
|
117
|
+
sortable: false,
|
|
118
|
+
searchable: false,
|
|
119
|
+
precision: nil,
|
|
120
|
+
scale: nil,
|
|
121
|
+
foreign_model: nil
|
|
122
|
+
}
|
|
123
|
+
elsif value.is_a?(Hash)
|
|
124
|
+
result << {
|
|
125
|
+
name: name,
|
|
126
|
+
type: value.fetch("type", "string"),
|
|
127
|
+
nullable: value.fetch("nullable", false),
|
|
128
|
+
unique: value.fetch("unique", false),
|
|
129
|
+
index: value.fetch("index", false),
|
|
130
|
+
default: value.fetch("default", nil),
|
|
131
|
+
filterable: value.fetch("filterable", false),
|
|
132
|
+
sortable: value.fetch("sortable", false),
|
|
133
|
+
searchable: value.fetch("searchable", false),
|
|
134
|
+
precision: value.fetch("precision", nil),
|
|
135
|
+
scale: value.fetch("scale", nil),
|
|
136
|
+
foreign_model: value.fetch("foreign_model", nil)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize_permissions(permissions)
|
|
145
|
+
result = {}
|
|
146
|
+
|
|
147
|
+
permissions.each do |role, value|
|
|
148
|
+
next unless value.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
result[role] = {
|
|
151
|
+
actions: value["actions"] || [],
|
|
152
|
+
show_fields: normalize_field_list(value["show_fields"]),
|
|
153
|
+
create_fields: normalize_field_list(value["create_fields"]),
|
|
154
|
+
update_fields: normalize_field_list(value["update_fields"]),
|
|
155
|
+
hidden_fields: normalize_field_list(value["hidden_fields"])
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_field_list(value)
|
|
163
|
+
return [] if value.nil?
|
|
164
|
+
return ["*"] if value == "*"
|
|
165
|
+
return [value] if value.is_a?(String)
|
|
166
|
+
return value if value.is_a?(Array)
|
|
167
|
+
|
|
168
|
+
[]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def read_file(file_path)
|
|
174
|
+
File.read(file_path)
|
|
175
|
+
rescue Errno::ENOENT
|
|
176
|
+
raise "File not found or unreadable: #{file_path}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def slug_to_name(slug)
|
|
180
|
+
slug.split("_").map(&:capitalize).join(" ")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def model_to_slug(model_name)
|
|
184
|
+
# PascalCase → snake_case plural
|
|
185
|
+
snake = model_name.gsub(/([A-Z])/, '_\1').downcase.sub(/\A_/, "")
|
|
186
|
+
|
|
187
|
+
# Simple pluralization
|
|
188
|
+
if snake.end_with?("y") && !snake.match?(/[aeiou]y\z/)
|
|
189
|
+
snake[0..-2] + "ies"
|
|
190
|
+
elsif snake.match?(/(s|x|z|ch|sh)\z/)
|
|
191
|
+
snake + "es"
|
|
192
|
+
else
|
|
193
|
+
snake + "s"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
module Blueprint
|
|
5
|
+
# Validates parsed blueprint data structures.
|
|
6
|
+
# Port of agentcode-server BlueprintValidator.php / agentcode-adonis-server blueprint_validator.ts.
|
|
7
|
+
class BlueprintValidator
|
|
8
|
+
VALID_COLUMN_TYPES = %w[
|
|
9
|
+
string text integer bigInteger boolean date datetime
|
|
10
|
+
timestamp decimal float json uuid foreignId
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
VALID_ACTIONS = %w[
|
|
14
|
+
index show store update destroy trashed restore forceDelete
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Validate role definitions.
|
|
18
|
+
#
|
|
19
|
+
# @param roles [Hash<String, Hash>]
|
|
20
|
+
# @return [Hash] { valid:, errors: }
|
|
21
|
+
def validate_roles(roles)
|
|
22
|
+
errors = []
|
|
23
|
+
|
|
24
|
+
if roles.empty?
|
|
25
|
+
errors << "At least one role is required"
|
|
26
|
+
return { valid: false, errors: errors }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
roles.each do |slug, role|
|
|
30
|
+
unless slug.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
31
|
+
errors << "Invalid role slug '#{slug}' — must match /^[a-z][a-z0-9_]*$/"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if role[:name].nil? || role[:name].strip.empty?
|
|
35
|
+
errors << "Role '#{slug}' must have a non-empty name"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
{ valid: errors.empty?, errors: errors }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Validate a full model blueprint.
|
|
43
|
+
#
|
|
44
|
+
# @param blueprint [Hash]
|
|
45
|
+
# @param valid_roles [Hash] optional role definitions for cross-reference
|
|
46
|
+
# @return [Hash] { valid:, errors:, warnings: }
|
|
47
|
+
def validate_model(blueprint, valid_roles = {})
|
|
48
|
+
errors = []
|
|
49
|
+
warnings = []
|
|
50
|
+
|
|
51
|
+
# Model name
|
|
52
|
+
if blueprint[:model].nil? || blueprint[:model].strip.empty?
|
|
53
|
+
errors << "Model name is required"
|
|
54
|
+
elsif !blueprint[:model].match?(/\A[A-Z][a-zA-Z0-9]*\z/)
|
|
55
|
+
errors << "Invalid model name '#{blueprint[:model]}' — must be PascalCase (match /^[A-Z][a-zA-Z0-9]*$/)"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Columns
|
|
59
|
+
errors.concat(validate_columns(blueprint[:columns]))
|
|
60
|
+
|
|
61
|
+
# Permissions
|
|
62
|
+
column_names = blueprint[:columns].map { |c| c[:name] }
|
|
63
|
+
perm_result = validate_permissions(blueprint[:permissions], valid_roles, column_names)
|
|
64
|
+
errors.concat(perm_result[:errors])
|
|
65
|
+
warnings.concat(perm_result[:warnings])
|
|
66
|
+
|
|
67
|
+
# Options
|
|
68
|
+
errors.concat(validate_options(blueprint[:options]))
|
|
69
|
+
|
|
70
|
+
# Relationships
|
|
71
|
+
errors.concat(validate_relationships(blueprint[:relationships]))
|
|
72
|
+
|
|
73
|
+
{ valid: errors.empty?, errors: errors, warnings: warnings }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Validate columns.
|
|
77
|
+
#
|
|
78
|
+
# @param columns [Array<Hash>]
|
|
79
|
+
# @return [Array<String>] errors
|
|
80
|
+
def validate_columns(columns)
|
|
81
|
+
errors = []
|
|
82
|
+
seen = Set.new
|
|
83
|
+
|
|
84
|
+
columns.each do |col|
|
|
85
|
+
if col[:name].nil? || col[:name].strip.empty?
|
|
86
|
+
errors << "Column name is required"
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if seen.include?(col[:name])
|
|
91
|
+
errors << "Duplicate column name '#{col[:name]}'"
|
|
92
|
+
end
|
|
93
|
+
seen.add(col[:name])
|
|
94
|
+
|
|
95
|
+
unless VALID_COLUMN_TYPES.include?(col[:type])
|
|
96
|
+
errors << "Invalid column type '#{col[:type]}' for column '#{col[:name]}'"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if col[:type] == "foreignId" && col[:foreign_model].nil?
|
|
100
|
+
errors << "Column '#{col[:name]}' is foreignId but missing 'foreign_model'"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
errors
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validate permissions.
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash] { errors:, warnings: }
|
|
110
|
+
def validate_permissions(permissions, valid_roles, column_names)
|
|
111
|
+
errors = []
|
|
112
|
+
warnings = []
|
|
113
|
+
has_roles = !valid_roles.empty?
|
|
114
|
+
|
|
115
|
+
permissions.each do |role, perm|
|
|
116
|
+
# Check role exists
|
|
117
|
+
if has_roles && !valid_roles.key?(role)
|
|
118
|
+
errors << "Unknown role '#{role}' in permissions"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check actions
|
|
122
|
+
perm[:actions].each do |action|
|
|
123
|
+
unless VALID_ACTIONS.include?(action)
|
|
124
|
+
errors << "Invalid action '#{action}' for role '#{role}'"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Check field references
|
|
129
|
+
all_column_names = ["id"] + column_names
|
|
130
|
+
check_field_references(perm[:show_fields], all_column_names, role, "show_fields", warnings)
|
|
131
|
+
check_field_references(perm[:create_fields], all_column_names, role, "create_fields", warnings)
|
|
132
|
+
check_field_references(perm[:update_fields], all_column_names, role, "update_fields", warnings)
|
|
133
|
+
|
|
134
|
+
# Warn on conflicts
|
|
135
|
+
if perm[:hidden_fields].any? && perm[:show_fields].any?
|
|
136
|
+
perm[:hidden_fields].each do |field|
|
|
137
|
+
if perm[:show_fields].include?(field)
|
|
138
|
+
warnings << "Role '#{role}': field '#{field}' is in both show_fields and hidden_fields"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Warn on create_fields without store action
|
|
144
|
+
if perm[:create_fields].any? && !perm[:create_fields].include?("*") &&
|
|
145
|
+
perm[:create_fields].any? { |f| f != "*" } && !perm[:actions].include?("store")
|
|
146
|
+
warnings << "Role '#{role}': has create_fields but no 'store' action"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Warn on update_fields without update action
|
|
150
|
+
if perm[:update_fields].any? && !perm[:update_fields].include?("*") &&
|
|
151
|
+
perm[:update_fields].any? { |f| f != "*" } && !perm[:actions].include?("update")
|
|
152
|
+
warnings << "Role '#{role}': has update_fields but no 'update' action"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
{ errors: errors, warnings: warnings }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Validate options.
|
|
160
|
+
def validate_options(options)
|
|
161
|
+
errors = []
|
|
162
|
+
|
|
163
|
+
if options[:except_actions]
|
|
164
|
+
options[:except_actions].each do |action|
|
|
165
|
+
unless VALID_ACTIONS.include?(action)
|
|
166
|
+
errors << "Invalid action '#{action}' in except_actions"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
errors
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Validate relationships.
|
|
175
|
+
def validate_relationships(relationships)
|
|
176
|
+
errors = []
|
|
177
|
+
valid_types = %w[belongsTo hasMany hasOne belongsToMany]
|
|
178
|
+
|
|
179
|
+
relationships.each do |rel|
|
|
180
|
+
rel = rel.transform_keys(&:to_s) if rel.is_a?(Hash)
|
|
181
|
+
|
|
182
|
+
if rel["type"].nil?
|
|
183
|
+
errors << "Relationship is missing type"
|
|
184
|
+
elsif !valid_types.include?(rel["type"])
|
|
185
|
+
errors << "Invalid relationship type '#{rel["type"]}'"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if rel["model"].nil?
|
|
189
|
+
errors << "Relationship is missing model"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
errors
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def check_field_references(fields, column_names, role, field_key, warnings)
|
|
199
|
+
return if fields.empty? || (fields.length == 1 && fields[0] == "*")
|
|
200
|
+
|
|
201
|
+
fields.each do |field|
|
|
202
|
+
if field != "*" && !column_names.include?(field)
|
|
203
|
+
warnings << "Role '#{role}': unknown field '#{field}' in #{field_key}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
module Blueprint
|
|
5
|
+
module Generators
|
|
6
|
+
# Generates FactoryBot factory files with smart Faker detection.
|
|
7
|
+
# Reuses faker mapping logic from GenerateCommand.
|
|
8
|
+
class FactoryGenerator
|
|
9
|
+
# Generate a FactoryBot factory file.
|
|
10
|
+
#
|
|
11
|
+
# @param blueprint [Hash] ParsedBlueprint
|
|
12
|
+
# @return [String] Ruby source code
|
|
13
|
+
def generate(blueprint)
|
|
14
|
+
model_name = blueprint[:model]
|
|
15
|
+
factory_name = model_name.underscore
|
|
16
|
+
columns = blueprint[:columns]
|
|
17
|
+
|
|
18
|
+
field_lines = columns.map do |col|
|
|
19
|
+
next if col[:name] == "organization_id" && blueprint[:options][:belongs_to_organization]
|
|
20
|
+
|
|
21
|
+
if col[:type] == "foreignId" || col[:type] == "references"
|
|
22
|
+
if col[:foreign_model]
|
|
23
|
+
relation = col[:name].sub(/_id\z/, "")
|
|
24
|
+
" association :#{relation}, factory: :#{col[:foreign_model].underscore}"
|
|
25
|
+
else
|
|
26
|
+
" #{col[:name]} { Faker::Number.between(from: 1, to: 10) }"
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
" #{col[:name]} { #{column_to_faker(col)} }"
|
|
30
|
+
end
|
|
31
|
+
end.compact
|
|
32
|
+
|
|
33
|
+
<<~RUBY
|
|
34
|
+
# frozen_string_literal: true
|
|
35
|
+
|
|
36
|
+
FactoryBot.define do
|
|
37
|
+
factory :#{factory_name} do
|
|
38
|
+
#{field_lines.join("\n")}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
RUBY
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def column_to_faker(column)
|
|
47
|
+
case column[:name]
|
|
48
|
+
when "name", "full_name" then "Faker::Name.name"
|
|
49
|
+
when "email" then "Faker::Internet.email"
|
|
50
|
+
when "title" then "Faker::Lorem.sentence(word_count: 3)"
|
|
51
|
+
when "description", "content", "body" then "Faker::Lorem.paragraph"
|
|
52
|
+
when "slug" then "Faker::Internet.slug"
|
|
53
|
+
when "phone", "phone_number" then "Faker::PhoneNumber.phone_number"
|
|
54
|
+
when "url", "website" then "Faker::Internet.url"
|
|
55
|
+
when /\Ais_/ then "[true, false].sample"
|
|
56
|
+
else
|
|
57
|
+
case column[:type]
|
|
58
|
+
when "string" then "Faker::Lorem.sentence(word_count: 3)"
|
|
59
|
+
when "text" then "Faker::Lorem.paragraph"
|
|
60
|
+
when "integer", "bigInteger" then "Faker::Number.between(from: 1, to: 100)"
|
|
61
|
+
when "boolean" then "[true, false].sample"
|
|
62
|
+
when "date" then "Faker::Date.between(from: 1.year.ago, to: Date.today)"
|
|
63
|
+
when "datetime", "timestamp" then "Faker::Time.between(from: 1.year.ago, to: Time.current)"
|
|
64
|
+
when "decimal", "float" then "Faker::Number.decimal(l_digits: 3, r_digits: 2)"
|
|
65
|
+
when "json" then "{}"
|
|
66
|
+
when "uuid" then "SecureRandom.uuid"
|
|
67
|
+
else "Faker::Lorem.word"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|