plutonium 0.42.0 → 0.43.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
- data/.claude/skills/plutonium-controller/SKILL.md +38 -1
- data/.claude/skills/plutonium-definition/SKILL.md +14 -0
- data/.claude/skills/plutonium-forms/SKILL.md +16 -1
- data/.claude/skills/plutonium-profile/SKILL.md +276 -0
- data/.claude/skills/plutonium-views/SKILL.md +23 -1
- data/CHANGELOG.md +36 -0
- data/app/assets/plutonium.css +1 -1
- data/app/views/plutonium/_resource_header.html.erb +6 -27
- data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
- data/app/views/resource/_resource_details.rabl +3 -2
- data/app/views/resource/index.rabl +3 -2
- data/app/views/resource/show.rabl +3 -2
- data/docs/guides/user-profile.md +322 -0
- data/docs/reference/controller/index.md +38 -1
- data/docs/reference/definition/index.md +16 -0
- data/docs/reference/views/forms.md +15 -0
- data/docs/reference/views/index.md +23 -1
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
- data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
- data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
- data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
- data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
- data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
- data/lib/generators/pu/invites/USAGE +0 -1
- data/lib/generators/pu/invites/install_generator.rb +62 -15
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
- data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
- data/lib/generators/pu/profile/USAGE +59 -0
- data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
- data/lib/generators/pu/profile/conn/USAGE +33 -0
- data/lib/generators/pu/profile/conn_generator.rb +167 -0
- data/lib/generators/pu/profile/install_generator.rb +119 -0
- data/lib/generators/pu/profile/setup/USAGE +42 -0
- data/lib/generators/pu/profile/setup_generator.rb +73 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -4
- data/lib/generators/pu/rodauth/install_generator.rb +2 -2
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
- data/lib/generators/pu/saas/api_client_generator.rb +0 -2
- data/lib/generators/pu/saas/membership_generator.rb +68 -19
- data/lib/generators/pu/saas/setup_generator.rb +7 -2
- data/lib/generators/pu/saas/user_generator.rb +0 -2
- data/lib/plutonium/auth/rodauth.rb +8 -0
- data/lib/plutonium/core/controller.rb +7 -4
- data/lib/plutonium/core/controllers/authorizable.rb +5 -1
- data/lib/plutonium/definition/base.rb +7 -0
- data/lib/plutonium/helpers/display_helper.rb +6 -0
- data/lib/plutonium/profile/security_section.rb +118 -0
- data/lib/plutonium/resource/controller.rb +17 -7
- data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
- data/lib/plutonium/resource/controllers/presentable.rb +46 -3
- data/lib/plutonium/resource/record/associated_with.rb +7 -1
- data/lib/plutonium/routing/mapper_extensions.rb +18 -18
- data/lib/plutonium/routing/route_set_extensions.rb +23 -2
- data/lib/plutonium/ui/breadcrumbs.rb +111 -131
- data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
- data/lib/plutonium/ui/form/resource.rb +26 -19
- data/lib/plutonium/ui/page/base.rb +14 -14
- data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
- data/lib/plutonium/ui/table/resource.rb +3 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +17 -3
- data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<%-
|
|
2
|
+
# Helper methods for template
|
|
3
|
+
def optional_marker(column)
|
|
4
|
+
column[:null] ? '?' : ''
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
resource = @current_resource
|
|
8
|
+
model_name = resource[:typespec_name]
|
|
9
|
+
all_columns = resource[:columns]
|
|
10
|
+
all_column_names = all_columns.map { |c| c[:name] }
|
|
11
|
+
|
|
12
|
+
# Extract special columns if they exist
|
|
13
|
+
primary_key_column = all_columns.find { |c| c[:name] == resource[:primary_key] }
|
|
14
|
+
created_at_column = all_columns.find { |c| c[:name] == 'created_at' }
|
|
15
|
+
updated_at_column = all_columns.find { |c| c[:name] == 'updated_at' }
|
|
16
|
+
|
|
17
|
+
# Regular columns exclude primary key and timestamps
|
|
18
|
+
columns = all_columns.reject { |c| [resource[:primary_key], 'created_at', 'updated_at'].include?(c[:name]) }
|
|
19
|
+
associations = resource[:associations]
|
|
20
|
+
enums = resource[:enums] || {}
|
|
21
|
+
definition = resource[:definition]
|
|
22
|
+
inputs = definition ? definition[:inputs] : []
|
|
23
|
+
|
|
24
|
+
# Separate association columns (foreign keys) from regular columns
|
|
25
|
+
belongs_to_assocs = associations.select { |a| a[:macro] == 'belongs_to' }
|
|
26
|
+
has_many_assocs = associations.select { |a| a[:macro] == 'has_many' }
|
|
27
|
+
has_one_assocs = associations.select { |a| a[:macro] == 'has_one' }
|
|
28
|
+
|
|
29
|
+
# Foreign key columns to exclude from regular attributes
|
|
30
|
+
fk_columns = belongs_to_assocs.map { |a| a[:foreign_key] }.compact
|
|
31
|
+
regular_columns = columns.reject { |c| fk_columns.include?(c[:name]) }
|
|
32
|
+
|
|
33
|
+
# Check if we have definition-based inputs
|
|
34
|
+
has_definition_inputs = inputs.any?
|
|
35
|
+
-%>
|
|
36
|
+
/**
|
|
37
|
+
* <%= model_name %> resource
|
|
38
|
+
* Table: <%= resource[:table_name] %>
|
|
39
|
+
* Route: <%= resource[:route_path] %>
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
<%- if @single_portal -%>
|
|
43
|
+
import "../common.tsp";
|
|
44
|
+
<%- else -%>
|
|
45
|
+
import "../../common.tsp";
|
|
46
|
+
<%- end -%>
|
|
47
|
+
|
|
48
|
+
using TypeSpec.Http;
|
|
49
|
+
|
|
50
|
+
<%- enums.each do |enum_name, values| -%>
|
|
51
|
+
/** <%= enum_name.humanize %> options */
|
|
52
|
+
enum <%= model_name %><%= enum_name.camelize %> {
|
|
53
|
+
<%- values.each do |value| -%>
|
|
54
|
+
<%= value %>,
|
|
55
|
+
<%- end -%>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
<%- end -%>
|
|
59
|
+
/**
|
|
60
|
+
* <%= model_name %> response model
|
|
61
|
+
*/
|
|
62
|
+
model <%= model_name %> extends ResourceBase {
|
|
63
|
+
<%- if primary_key_column -%>
|
|
64
|
+
@doc("Unique identifier")
|
|
65
|
+
<%= resource[:primary_key] %>: <%= resource[:primary_key_type] %>;
|
|
66
|
+
<%- end -%>
|
|
67
|
+
<%- if created_at_column -%>
|
|
68
|
+
@doc("Creation timestamp")
|
|
69
|
+
created_at: utcDateTime;
|
|
70
|
+
<%- end -%>
|
|
71
|
+
<%- if updated_at_column -%>
|
|
72
|
+
@doc("Last update timestamp")
|
|
73
|
+
updated_at: utcDateTime;
|
|
74
|
+
<%- end -%>
|
|
75
|
+
<%- if (primary_key_column || created_at_column || updated_at_column) && regular_columns.any? -%>
|
|
76
|
+
|
|
77
|
+
<%- end -%>
|
|
78
|
+
<%- regular_columns.each do |column| -%>
|
|
79
|
+
<%- if enums.key?(column[:name]) -%>
|
|
80
|
+
<%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
|
|
81
|
+
<%- else -%>
|
|
82
|
+
<%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
|
|
83
|
+
<%- end -%>
|
|
84
|
+
<%- end -%>
|
|
85
|
+
|
|
86
|
+
<%- belongs_to_assocs.each do |assoc| -%>
|
|
87
|
+
<%- if assoc[:polymorphic] -%>
|
|
88
|
+
/** Polymorphic association type */
|
|
89
|
+
<%= assoc[:name] %>_type?: string;
|
|
90
|
+
/** Polymorphic association ID */
|
|
91
|
+
<%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
|
|
92
|
+
/** Polymorphic association SGID */
|
|
93
|
+
<%= assoc[:name] %>_sgid?: SignedGlobalId;
|
|
94
|
+
<%- else -%>
|
|
95
|
+
/** <%= assoc[:name].humanize %> ID */
|
|
96
|
+
<%= assoc[:foreign_key] || "#{assoc[:name]}_id" %>?: <%= assoc[:foreign_key_type] %>;
|
|
97
|
+
/** <%= assoc[:name].humanize %> SGID */
|
|
98
|
+
<%= assoc[:name] %>_sgid?: SignedGlobalId;
|
|
99
|
+
<%- end -%>
|
|
100
|
+
<%- end -%>
|
|
101
|
+
|
|
102
|
+
<%- has_many_assocs.each do |assoc| -%>
|
|
103
|
+
/** <%= assoc[:name].to_s.singularize.humanize %> IDs */
|
|
104
|
+
<%= assoc[:name].to_s.singularize %>_ids?: <%= assoc[:foreign_key_type] %>[];
|
|
105
|
+
/** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
|
|
106
|
+
<%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
|
|
107
|
+
<%- end -%>
|
|
108
|
+
<%- has_one_assocs.each do |assoc| -%>
|
|
109
|
+
/** <%= assoc[:name].humanize %> ID */
|
|
110
|
+
<%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
|
|
111
|
+
/** <%= assoc[:name].humanize %> SGID */
|
|
112
|
+
<%= assoc[:name] %>_sgid?: SignedGlobalId;
|
|
113
|
+
<%- end -%>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* <%= model_name %> input model for create/update operations
|
|
118
|
+
<%- if has_definition_inputs -%>
|
|
119
|
+
* Based on definition: <%= definition[:class_name] %>
|
|
120
|
+
<%- end -%>
|
|
121
|
+
*/
|
|
122
|
+
model <%= model_name %>Input {
|
|
123
|
+
<%- if has_definition_inputs -%>
|
|
124
|
+
<%# Use definition inputs -%>
|
|
125
|
+
<%- inputs.each do |input| -%>
|
|
126
|
+
<%- next if input[:nested] # Skip nested inputs for now -%>
|
|
127
|
+
<%- if input[:is_association] -%>
|
|
128
|
+
<%- if input[:is_polymorphic] -%>
|
|
129
|
+
/** Polymorphic <%= input[:name] %> (SGID) */
|
|
130
|
+
<%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
|
|
131
|
+
<%- elsif input[:association_macro] == 'has_many' || input[:association_macro] == 'has_and_belongs_to_many' -%>
|
|
132
|
+
/** <%= input[:name].singularize.humanize %> SGIDs */
|
|
133
|
+
<%= input[:name].singularize %>_sgids<%= input[:required] ? '' : '?' %>: SignedGlobalId[];
|
|
134
|
+
<%- else -%>
|
|
135
|
+
/** <%= input[:name].humanize %> (SGID) */
|
|
136
|
+
<%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
|
|
137
|
+
<%- end -%>
|
|
138
|
+
<%- else -%>
|
|
139
|
+
<%= input[:name] %><%= input[:required] ? '' : '?' %>: <%= input[:typespec_type] %>;
|
|
140
|
+
<%- end -%>
|
|
141
|
+
<%- end -%>
|
|
142
|
+
<%- else -%>
|
|
143
|
+
<%# Fallback to column-based inputs -%>
|
|
144
|
+
<%- regular_columns.each do |column| -%>
|
|
145
|
+
<%- next if column[:name].end_with?('_count') # skip counter caches -%>
|
|
146
|
+
<%- if enums.key?(column[:name]) -%>
|
|
147
|
+
<%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
|
|
148
|
+
<%- else -%>
|
|
149
|
+
<%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
|
|
150
|
+
<%- end -%>
|
|
151
|
+
<%- end -%>
|
|
152
|
+
|
|
153
|
+
<%- belongs_to_assocs.each do |assoc| -%>
|
|
154
|
+
<%- if assoc[:polymorphic] -%>
|
|
155
|
+
/** Polymorphic <%= assoc[:name] %> (SGID) */
|
|
156
|
+
<%= assoc[:name] %>_sgid?: SignedGlobalId;
|
|
157
|
+
<%- else -%>
|
|
158
|
+
/** <%= assoc[:name].humanize %> (SGID) */
|
|
159
|
+
<%= assoc[:name] %>_sgid?: SignedGlobalId;
|
|
160
|
+
<%- end -%>
|
|
161
|
+
<%- end -%>
|
|
162
|
+
|
|
163
|
+
<%- has_many_assocs.each do |assoc| -%>
|
|
164
|
+
/** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
|
|
165
|
+
<%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
|
|
166
|
+
<%- end -%>
|
|
167
|
+
<%- end -%>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* <%= model_name %> list response
|
|
172
|
+
*/
|
|
173
|
+
model <%= model_name %>ListResponse {
|
|
174
|
+
<%= model_name.underscore.pluralize %>: <%= model_name %>[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* <%= model_name %> CRUD operations
|
|
179
|
+
*/
|
|
180
|
+
@route("<%= resource[:route_path] %>")
|
|
181
|
+
@tag("<%= model_name.pluralize %>")
|
|
182
|
+
interface <%= model_name %>Operations {
|
|
183
|
+
/**
|
|
184
|
+
* List all <%= model_name.underscore.humanize.pluralize.downcase %>
|
|
185
|
+
*/
|
|
186
|
+
@get
|
|
187
|
+
list(...ListQueryParams): <%= model_name %>ListResponse;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get a single <%= model_name.underscore.humanize.downcase %>
|
|
191
|
+
*/
|
|
192
|
+
@route("{id}")
|
|
193
|
+
@get
|
|
194
|
+
show(@path id: <%= resource[:primary_key_type] %>): <%= model_name %>;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create a new <%= model_name.underscore.humanize.downcase %>
|
|
198
|
+
*/
|
|
199
|
+
@post
|
|
200
|
+
create(@body input: <%= model_name %>Input): {
|
|
201
|
+
@statusCode statusCode: 201;
|
|
202
|
+
@body body: <%= model_name %>;
|
|
203
|
+
} | {
|
|
204
|
+
@statusCode statusCode: 422;
|
|
205
|
+
@body body: ErrorResponse;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Update a <%= model_name.underscore.humanize.downcase %>
|
|
210
|
+
*/
|
|
211
|
+
@route("{id}")
|
|
212
|
+
@patch
|
|
213
|
+
update(@path id: <%= resource[:primary_key_type] %>, @body input: <%= model_name %>Input): <%= model_name %> | {
|
|
214
|
+
@statusCode statusCode: 422;
|
|
215
|
+
@body body: ErrorResponse;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Delete a <%= model_name.underscore.humanize.downcase %>
|
|
220
|
+
*/
|
|
221
|
+
@route("{id}")
|
|
222
|
+
@delete
|
|
223
|
+
destroy(@path id: <%= resource[:primary_key_type] %>): {
|
|
224
|
+
@statusCode statusCode: 204;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../lib/plutonium_generators"
|
|
4
|
+
|
|
5
|
+
module Pu
|
|
6
|
+
module Core
|
|
7
|
+
class TypespecGenerator < Rails::Generators::Base
|
|
8
|
+
include PlutoniumGenerators::Generator
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
desc "Generate TypeSpec API specifications from Plutonium resources"
|
|
13
|
+
|
|
14
|
+
class_option :output, type: :string, default: "typespec", desc: "Output directory for TypeSpec files"
|
|
15
|
+
class_option :portal, type: :string, desc: "Generate specs for a specific portal only"
|
|
16
|
+
|
|
17
|
+
def start
|
|
18
|
+
load_application
|
|
19
|
+
check_pending_migrations!
|
|
20
|
+
detect_portals
|
|
21
|
+
generate_typespec_files
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :portals
|
|
27
|
+
|
|
28
|
+
def load_application
|
|
29
|
+
say_status :loading, "Rails application", :blue
|
|
30
|
+
Rails.application.eager_load!
|
|
31
|
+
Rails.application.reload_routes!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check_pending_migrations!
|
|
35
|
+
context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
|
|
36
|
+
pending = context.migrations.select { |m| !context.get_all_versions.include?(m.version) }
|
|
37
|
+
|
|
38
|
+
return if pending.empty?
|
|
39
|
+
|
|
40
|
+
say_status :error, "Pending migrations detected!", :red
|
|
41
|
+
pending.each do |migration|
|
|
42
|
+
say_status :pending, "#{migration.version} - #{migration.name}", :yellow
|
|
43
|
+
end
|
|
44
|
+
say ""
|
|
45
|
+
say "Run `bin/rails db:migrate` before generating TypeSpec specifications.", :red
|
|
46
|
+
raise Thor::Error, "Cannot generate TypeSpec with pending migrations"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_portals
|
|
50
|
+
say_status :detecting, "portals", :blue
|
|
51
|
+
@portals = []
|
|
52
|
+
|
|
53
|
+
Rails::Engine.subclasses.each do |engine|
|
|
54
|
+
next unless engine.included_modules.any? { |m| m.name&.include?("Plutonium::Portal") }
|
|
55
|
+
|
|
56
|
+
portal_name = engine.name.sub(/::Engine$/, "")
|
|
57
|
+
route_path = find_engine_route_path(engine)
|
|
58
|
+
|
|
59
|
+
next if options[:portal] && portal_name.underscore != options[:portal].underscore
|
|
60
|
+
|
|
61
|
+
@portals << build_portal_data(engine, portal_name, route_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if @portals.empty?
|
|
65
|
+
say_status :warning, "No portals found#{" matching '#{options[:portal]}'" if options[:portal]}", :yellow
|
|
66
|
+
else
|
|
67
|
+
@portals.each do |portal|
|
|
68
|
+
say_status :found, "#{portal[:name]} (#{portal[:resources].size} resources)", :green
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_engine_route_path(engine)
|
|
74
|
+
Rails.application.routes.routes.find do |route|
|
|
75
|
+
route.app.app == engine
|
|
76
|
+
end&.path&.spec&.to_s&.gsub(/\(\.:format\)$/, "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_portal_data(engine, portal_name, route_path)
|
|
80
|
+
resources = []
|
|
81
|
+
|
|
82
|
+
engine.resource_register.resources.each do |resource|
|
|
83
|
+
route_config = engine.routes.resource_route_config_lookup[resource.model_name.plural]
|
|
84
|
+
next unless route_config
|
|
85
|
+
|
|
86
|
+
resource_data = build_resource_data(resource, route_config, portal_name, route_path)
|
|
87
|
+
resources << resource_data if resource_data
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
name: portal_name,
|
|
92
|
+
engine: engine,
|
|
93
|
+
route_path: route_path || "/#{portal_name.underscore}",
|
|
94
|
+
file_name: portal_name.underscore.tr("/", "_"),
|
|
95
|
+
resources: resources
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_resource_data(resource, route_config, portal_name, portal_route_path)
|
|
100
|
+
return nil unless resource.table_exists?
|
|
101
|
+
|
|
102
|
+
# Try portal-specific definition first, then fall back to base definition
|
|
103
|
+
definition_class = safe_constantize("#{portal_name}::#{resource.name.demodulize}Definition") ||
|
|
104
|
+
safe_constantize("#{resource.name}Definition")
|
|
105
|
+
|
|
106
|
+
resource_path = route_config[:route_options][:path]
|
|
107
|
+
full_route = [portal_route_path, resource_path].compact.join("/").gsub(%r{//+}, "/")
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
name: resource.name,
|
|
111
|
+
typespec_name: resource.name.demodulize,
|
|
112
|
+
file_name: resource.name.underscore.tr("/", "_"),
|
|
113
|
+
table_name: resource.table_name,
|
|
114
|
+
route_path: full_route,
|
|
115
|
+
primary_key: resource.primary_key,
|
|
116
|
+
primary_key_type: primary_key_type(resource),
|
|
117
|
+
columns: build_columns_data(resource),
|
|
118
|
+
associations: build_associations_data(resource),
|
|
119
|
+
enums: build_enums_data(resource),
|
|
120
|
+
definition: definition_class ? build_definition_data(definition_class, resource) : nil
|
|
121
|
+
}
|
|
122
|
+
rescue NameError => e
|
|
123
|
+
say_status :skip, "#{resource.name}: #{e.message}", :yellow
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def primary_key_type(resource)
|
|
128
|
+
column_to_typespec_type(resource.columns.find { |c| c.name == resource.primary_key })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def column_to_typespec_type(column)
|
|
132
|
+
return "int64" unless column
|
|
133
|
+
TYPE_MAPPING[column.type] || "int64"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_columns_data(resource)
|
|
137
|
+
resource.columns.map do |column|
|
|
138
|
+
{
|
|
139
|
+
name: column.name,
|
|
140
|
+
type: column.type.to_s,
|
|
141
|
+
null: column.null,
|
|
142
|
+
default: column.default,
|
|
143
|
+
typespec_type: TYPE_MAPPING[column.type] || "string"
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_associations_data(resource)
|
|
149
|
+
resource.reflect_on_all_associations.map do |assoc|
|
|
150
|
+
fk_type = resolve_foreign_key_type(assoc, resource)
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
name: assoc.name.to_s,
|
|
154
|
+
macro: assoc.macro.to_s,
|
|
155
|
+
class_name: safe_association_attr(assoc, :klass)&.name,
|
|
156
|
+
foreign_key: safe_association_attr(assoc, :foreign_key),
|
|
157
|
+
foreign_key_type: fk_type,
|
|
158
|
+
polymorphic: !!assoc.polymorphic?
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def resolve_foreign_key_type(assoc, resource)
|
|
164
|
+
return "int64" if assoc.polymorphic?
|
|
165
|
+
|
|
166
|
+
# For belongs_to, look up the associated model's primary key type
|
|
167
|
+
if assoc.macro == :belongs_to
|
|
168
|
+
target_class = safe_association_attr(assoc, :klass)
|
|
169
|
+
if target_class&.table_exists?
|
|
170
|
+
pk_column = target_class.columns.find { |c| c.name == target_class.primary_key }
|
|
171
|
+
return column_to_typespec_type(pk_column)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# For has_many/has_one, use this resource's primary key type
|
|
176
|
+
primary_key_type(resource)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def safe_association_attr(assoc, attr)
|
|
180
|
+
assoc.public_send(attr)
|
|
181
|
+
rescue NameError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_enums_data(resource)
|
|
186
|
+
return {} unless resource.respond_to?(:defined_enums)
|
|
187
|
+
resource.defined_enums.transform_values(&:keys)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_definition_data(definition_class, resource)
|
|
191
|
+
inputs = extract_inputs(definition_class, resource)
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
class_name: definition_class.name,
|
|
195
|
+
inputs: inputs
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_inputs(definition_class, resource)
|
|
200
|
+
return [] unless definition_class.respond_to?(:defined_inputs)
|
|
201
|
+
|
|
202
|
+
defined_inputs = definition_class.defined_inputs
|
|
203
|
+
return [] if defined_inputs.empty?
|
|
204
|
+
|
|
205
|
+
defined_inputs.map do |name, config|
|
|
206
|
+
input_config = config[:options] || {}
|
|
207
|
+
column = resource.columns_hash[name.to_s]
|
|
208
|
+
assoc = resource.reflect_on_association(name)
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
name: name.to_s,
|
|
212
|
+
as: input_config[:as]&.to_s,
|
|
213
|
+
required: !input_config[:optional],
|
|
214
|
+
type: determine_input_type(name, input_config, column, assoc, resource),
|
|
215
|
+
typespec_type: determine_typespec_input_type(name, input_config, column, assoc, resource),
|
|
216
|
+
is_association: assoc.present?,
|
|
217
|
+
is_polymorphic: assoc&.polymorphic?,
|
|
218
|
+
association_macro: assoc&.macro&.to_s,
|
|
219
|
+
nested: input_config[:nested].present?
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def determine_input_type(name, config, column, assoc, resource)
|
|
225
|
+
return config[:as].to_s if config[:as]
|
|
226
|
+
return "association" if assoc
|
|
227
|
+
return "enum" if resource.defined_enums.key?(name.to_s)
|
|
228
|
+
return column.type.to_s if column
|
|
229
|
+
"string"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def determine_typespec_input_type(name, config, column, assoc, resource)
|
|
233
|
+
# Associations use SGIDs
|
|
234
|
+
if assoc
|
|
235
|
+
return "SignedGlobalId[]" if %i[has_many has_and_belongs_to_many].include?(assoc.macro)
|
|
236
|
+
return "SignedGlobalId"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Enums use the enum type
|
|
240
|
+
return "#{resource.name.demodulize}#{name.to_s.camelize}" if resource.defined_enums.key?(name.to_s)
|
|
241
|
+
|
|
242
|
+
# Use column type
|
|
243
|
+
return TYPE_MAPPING[column.type] || "string" if column
|
|
244
|
+
|
|
245
|
+
# Infer from as: option
|
|
246
|
+
AS_TYPE_MAPPING[config[:as]&.to_sym] || "string"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generate_typespec_files
|
|
250
|
+
say_status :generating, "TypeSpec files", :blue
|
|
251
|
+
|
|
252
|
+
empty_directory output_dir
|
|
253
|
+
@single_portal = @portals.size == 1
|
|
254
|
+
template "common.tsp.tt", "#{output_dir}/common.tsp"
|
|
255
|
+
|
|
256
|
+
if @single_portal
|
|
257
|
+
# Single portal - generate flat structure
|
|
258
|
+
@current_portal = @portals.first
|
|
259
|
+
generate_portal_files(@current_portal, output_dir)
|
|
260
|
+
else
|
|
261
|
+
# Multiple portals - generate per-portal directories
|
|
262
|
+
@portals.each do |portal|
|
|
263
|
+
portal_dir = "#{output_dir}/#{portal[:file_name]}"
|
|
264
|
+
empty_directory portal_dir
|
|
265
|
+
generate_portal_files(portal, portal_dir)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Generate root main.tsp that imports all portals
|
|
269
|
+
template "main_multi.tsp.tt", "#{output_dir}/main.tsp"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
say_status :complete, "TypeSpec files generated in #{output_dir}/", :green
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def generate_portal_files(portal, dir)
|
|
276
|
+
@current_portal = portal
|
|
277
|
+
template "main.tsp.tt", "#{dir}/main.tsp"
|
|
278
|
+
|
|
279
|
+
empty_directory "#{dir}/models"
|
|
280
|
+
|
|
281
|
+
portal[:resources].each do |resource|
|
|
282
|
+
@current_resource = resource
|
|
283
|
+
template "model.tsp.tt", "#{dir}/models/#{resource[:file_name]}.tsp"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def output_dir
|
|
288
|
+
options[:output]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def safe_constantize(name)
|
|
292
|
+
name.constantize
|
|
293
|
+
rescue NameError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
TYPE_MAPPING = {
|
|
298
|
+
string: "string",
|
|
299
|
+
text: "string",
|
|
300
|
+
integer: "int32",
|
|
301
|
+
bigint: "int64",
|
|
302
|
+
float: "float64",
|
|
303
|
+
decimal: "decimal",
|
|
304
|
+
boolean: "boolean",
|
|
305
|
+
date: "plainDate",
|
|
306
|
+
datetime: "utcDateTime",
|
|
307
|
+
time: "plainTime",
|
|
308
|
+
binary: "bytes",
|
|
309
|
+
json: "Record<string, unknown>",
|
|
310
|
+
jsonb: "Record<string, unknown>",
|
|
311
|
+
uuid: "string",
|
|
312
|
+
hstore: "Record<string, string>"
|
|
313
|
+
}.freeze
|
|
314
|
+
|
|
315
|
+
AS_TYPE_MAPPING = {
|
|
316
|
+
text: "string",
|
|
317
|
+
textarea: "string",
|
|
318
|
+
markdown: "string",
|
|
319
|
+
rich_text: "string",
|
|
320
|
+
number: "int32",
|
|
321
|
+
integer: "int32",
|
|
322
|
+
decimal: "decimal",
|
|
323
|
+
boolean: "boolean",
|
|
324
|
+
checkbox: "boolean",
|
|
325
|
+
date: "plainDate",
|
|
326
|
+
datetime: "utcDateTime",
|
|
327
|
+
time: "plainTime",
|
|
328
|
+
file: "bytes",
|
|
329
|
+
attachment: "bytes",
|
|
330
|
+
email: "string",
|
|
331
|
+
url: "url",
|
|
332
|
+
phone: "string",
|
|
333
|
+
password: "string",
|
|
334
|
+
color: "string",
|
|
335
|
+
json: "Record<string, unknown>",
|
|
336
|
+
jsonb: "Record<string, unknown>",
|
|
337
|
+
hstore: "Record<string, string>",
|
|
338
|
+
key_value: "Record<string, string>"
|
|
339
|
+
}.freeze
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -18,7 +18,6 @@ Options:
|
|
|
18
18
|
--entity-model=NAME Entity model name for scoping (default: Entity)
|
|
19
19
|
--user-model=NAME User model name (default: User)
|
|
20
20
|
--membership-model=NAME Membership model name (default: <Entity>User)
|
|
21
|
-
--roles=ROLES Comma-separated roles (default: member,admin)
|
|
22
21
|
--rodauth=NAME Rodauth configuration for signup (default: user)
|
|
23
22
|
--enforce-domain Require user email domain to match entity domain
|
|
24
23
|
|