rhino-rails 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/rhino/blueprint/blueprint_parser.rb +198 -0
- data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
- data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
- data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
- data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
- data/lib/rhino/blueprint/manifest_manager.rb +81 -0
- data/lib/rhino/commands/base_command.rb +57 -0
- data/lib/rhino/commands/blueprint_command.rb +529 -0
- data/lib/rhino/commands/export_postman_command.rb +328 -0
- data/lib/rhino/commands/export_types_command.rb +202 -0
- data/lib/rhino/commands/generate_command.rb +535 -0
- data/lib/rhino/commands/install_command.rb +408 -0
- data/lib/rhino/commands/invitation_link_command.rb +107 -0
- data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
- data/lib/rhino/concerns/has_audit_trail.rb +125 -0
- data/lib/rhino/concerns/has_auto_scope.rb +91 -0
- data/lib/rhino/concerns/has_permissions.rb +117 -0
- data/lib/rhino/concerns/has_rhino.rb +93 -0
- data/lib/rhino/concerns/has_uuid.rb +26 -0
- data/lib/rhino/concerns/has_validation.rb +250 -0
- data/lib/rhino/concerns/hidable_columns.rb +180 -0
- data/lib/rhino/configuration.rb +101 -0
- data/lib/rhino/controllers/auth_controller.rb +242 -0
- data/lib/rhino/controllers/invitations_controller.rb +231 -0
- data/lib/rhino/controllers/resources_controller.rb +813 -0
- data/lib/rhino/engine.rb +64 -0
- data/lib/rhino/mailers/invitation_mailer.rb +22 -0
- data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/rhino/models/audit_log.rb +17 -0
- data/lib/rhino/models/organization_invitation.rb +57 -0
- data/lib/rhino/models/rhino_model.rb +387 -0
- data/lib/rhino/policies/invitation_policy.rb +54 -0
- data/lib/rhino/policies/resource_policy.rb +197 -0
- data/lib/rhino/query_builder.rb +278 -0
- data/lib/rhino/railtie.rb +11 -0
- data/lib/rhino/resource_scope.rb +59 -0
- data/lib/rhino/routes.rb +124 -0
- data/lib/rhino/tasks/rhino.rake +47 -0
- data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/rhino/templates/generate/factory.rb.erb +43 -0
- data/lib/rhino/templates/generate/migration.rb.erb +26 -0
- data/lib/rhino/templates/generate/model.rb.erb +55 -0
- data/lib/rhino/templates/generate/policy.rb.erb +52 -0
- data/lib/rhino/templates/generate/scope.rb.erb +31 -0
- data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/rhino/templates/rhino.rb +71 -0
- data/lib/rhino/templates/rhino_model.rb +104 -0
- data/lib/rhino/templates/routes.rb +13 -0
- data/lib/rhino/version.rb +5 -0
- data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/rhino-rails.rb +3 -0
- data/lib/rhino.rb +26 -0
- metadata +282 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rhino/commands/base_command"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rhino
|
|
7
|
+
module Commands
|
|
8
|
+
# Generate a Postman Collection v2.1 for all registered models.
|
|
9
|
+
# Mirrors Laravel `php artisan rhino:export-postman` exactly.
|
|
10
|
+
#
|
|
11
|
+
# Usage: rails rhino:export_postman [--output=postman_collection.json] [--base-url=http://localhost:3000/api]
|
|
12
|
+
class ExportPostmanCommand < BaseCommand
|
|
13
|
+
attr_accessor :options
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
super
|
|
17
|
+
@options = {
|
|
18
|
+
output: "postman_collection.json",
|
|
19
|
+
base_url: "http://localhost:3000/api",
|
|
20
|
+
project_name: nil
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def perform
|
|
25
|
+
output_path = options[:output]
|
|
26
|
+
base_url = options[:base_url].chomp("/")
|
|
27
|
+
project_name = options[:project_name] || Rails.application.class.module_parent_name rescue "API"
|
|
28
|
+
|
|
29
|
+
config = Rhino.config
|
|
30
|
+
route_groups = config.route_groups
|
|
31
|
+
all_models = config.models
|
|
32
|
+
|
|
33
|
+
has_multiple_groups = route_groups.size > 1
|
|
34
|
+
needs_org_variable = any_group_has_org_prefix?(route_groups)
|
|
35
|
+
|
|
36
|
+
variables = build_collection_variables(base_url, needs_org_variable)
|
|
37
|
+
items = []
|
|
38
|
+
|
|
39
|
+
items << build_auth_folder
|
|
40
|
+
|
|
41
|
+
if has_multiple_groups
|
|
42
|
+
# Multiple groups: Project -> Auth, GroupName -> resources
|
|
43
|
+
route_groups.each do |group_name, group_config|
|
|
44
|
+
group_models = resolve_models_for_group(all_models, config, group_name)
|
|
45
|
+
next if group_models.empty?
|
|
46
|
+
|
|
47
|
+
group_prefix = group_config[:prefix] || ""
|
|
48
|
+
|
|
49
|
+
group_items = []
|
|
50
|
+
group_models.each do |slug|
|
|
51
|
+
model_class_name = all_models[slug]
|
|
52
|
+
model_class = begin
|
|
53
|
+
model_class_name.constantize
|
|
54
|
+
rescue NameError
|
|
55
|
+
say "Model class does not exist: #{model_class_name}", :red
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
model_meta = introspect_model(model_class, slug)
|
|
60
|
+
group_items << {
|
|
61
|
+
name: slug.to_s,
|
|
62
|
+
item: build_action_folders(slug, model_meta, group_prefix)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if group_items.any?
|
|
67
|
+
items << {
|
|
68
|
+
name: group_name.to_s,
|
|
69
|
+
item: group_items
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
# Single group: Project -> Auth, resources (flat, backward compatible)
|
|
75
|
+
group_config = route_groups.values.first || {}
|
|
76
|
+
group_prefix = group_config[:prefix] || ""
|
|
77
|
+
group_name = route_groups.keys.first
|
|
78
|
+
|
|
79
|
+
group_models = resolve_models_for_group(all_models, config, group_name)
|
|
80
|
+
group_models.each do |slug|
|
|
81
|
+
model_class_name = all_models[slug]
|
|
82
|
+
model_class = begin
|
|
83
|
+
model_class_name.constantize
|
|
84
|
+
rescue NameError
|
|
85
|
+
say "Model class does not exist: #{model_class_name}", :red
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
model_meta = introspect_model(model_class, slug)
|
|
90
|
+
items << {
|
|
91
|
+
name: slug.to_s,
|
|
92
|
+
item: build_action_folders(slug, model_meta, group_prefix)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
collection = {
|
|
98
|
+
info: {
|
|
99
|
+
name: project_name,
|
|
100
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
101
|
+
},
|
|
102
|
+
variable: variables,
|
|
103
|
+
item: items
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
json = JSON.pretty_generate(collection)
|
|
107
|
+
File.write(output_path, json)
|
|
108
|
+
|
|
109
|
+
say "Postman collection written to #{output_path}", :green
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def any_group_has_org_prefix?(route_groups)
|
|
115
|
+
route_groups.any? { |_name, cfg| prefix_has_param?(cfg[:prefix] || "") }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prefix_has_param?(prefix)
|
|
119
|
+
prefix.present? && prefix.include?(":")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_models_for_group(all_models, config, group_name)
|
|
123
|
+
config.models_for_group(group_name)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_collection_variables(base_url, needs_org_prefix)
|
|
127
|
+
vars = [
|
|
128
|
+
{ key: "baseUrl", value: base_url },
|
|
129
|
+
{ key: "modelId", value: "1" },
|
|
130
|
+
{ key: "token", value: "" }
|
|
131
|
+
]
|
|
132
|
+
vars << { key: "organization", value: "organization-1" } if needs_org_prefix
|
|
133
|
+
vars
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_auth_folder
|
|
137
|
+
headers = default_headers
|
|
138
|
+
json_headers = headers + [{ key: "Content-Type", value: "application/json" }]
|
|
139
|
+
|
|
140
|
+
login_test = <<~JS
|
|
141
|
+
const json = pm.response.json();
|
|
142
|
+
if (json.token) {
|
|
143
|
+
pm.collectionVariables.set("token", json.token);
|
|
144
|
+
}
|
|
145
|
+
if (json.organization_slug) {
|
|
146
|
+
pm.collectionVariables.set("organization", json.organization_slug);
|
|
147
|
+
}
|
|
148
|
+
JS
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
name: "Authentication",
|
|
152
|
+
item: [
|
|
153
|
+
request_item("Login", "POST", "{{baseUrl}}/auth/login", {}, json_headers,
|
|
154
|
+
{ email: "user@example.com", password: "password" }, login_test.strip),
|
|
155
|
+
request_item("Logout", "POST", "{{baseUrl}}/auth/logout", {}, headers),
|
|
156
|
+
request_item("Password recover", "POST", "{{baseUrl}}/auth/password/recover", {}, json_headers,
|
|
157
|
+
{ email: "user@example.com" }),
|
|
158
|
+
request_item("Password reset", "POST", "{{baseUrl}}/auth/password/reset", {}, json_headers,
|
|
159
|
+
{ token: "{{token}}", email: "user@example.com", password: "new-password", password_confirmation: "new-password" }),
|
|
160
|
+
request_item("Register (with invitation)", "POST", "{{baseUrl}}/auth/register", {}, json_headers,
|
|
161
|
+
{ invitation_token: "{{token}}", name: "New User", password: "password", password_confirmation: "password" }),
|
|
162
|
+
request_item("Accept invitation", "POST", "{{baseUrl}}/invitations/accept", {}, json_headers,
|
|
163
|
+
{ token: "invitation-token" })
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def introspect_model(model_class, slug)
|
|
169
|
+
{
|
|
170
|
+
slug: slug,
|
|
171
|
+
except_actions: model_class.try(:rhino_except_actions_list) || [],
|
|
172
|
+
uses_soft_deletes: model_class.try(:uses_soft_deletes?) || false,
|
|
173
|
+
allowed_filters: model_class.try(:allowed_filters) || [],
|
|
174
|
+
allowed_sorts: model_class.try(:allowed_sorts) || [],
|
|
175
|
+
allowed_fields: model_class.try(:allowed_fields) || [],
|
|
176
|
+
allowed_includes: model_class.try(:allowed_includes) || [],
|
|
177
|
+
allowed_search: model_class.try(:allowed_search) || [],
|
|
178
|
+
default_sort: model_class.try(:default_sort_field)
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_action_folders(slug, meta, group_prefix)
|
|
183
|
+
folders = []
|
|
184
|
+
base = base_path(slug, group_prefix)
|
|
185
|
+
except = meta[:except_actions]
|
|
186
|
+
|
|
187
|
+
folders << { name: "Index", item: build_index_requests(base, slug, meta) } unless except.include?("index")
|
|
188
|
+
folders << { name: "Show", item: build_show_requests(base, slug, meta) } unless except.include?("show")
|
|
189
|
+
folders << { name: "Store", item: build_store_requests(base) } unless except.include?("store")
|
|
190
|
+
folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
|
|
191
|
+
folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
|
|
192
|
+
|
|
193
|
+
if meta[:uses_soft_deletes]
|
|
194
|
+
folders << { name: "Trashed", item: build_trashed_requests(base) } unless except.include?("trashed")
|
|
195
|
+
folders << { name: "Restore", item: build_restore_requests(base) } unless except.include?("restore")
|
|
196
|
+
folders << { name: "Force Delete", item: build_force_delete_requests(base) } unless except.include?("forceDelete")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
folders
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def base_path(slug, group_prefix)
|
|
203
|
+
if group_prefix.present?
|
|
204
|
+
# Replace :param route params with {{param}} Postman variables
|
|
205
|
+
postman_prefix = group_prefix.gsub(/:(\w+)/, '{{\1}}')
|
|
206
|
+
"{{baseUrl}}/#{postman_prefix}/#{slug}"
|
|
207
|
+
else
|
|
208
|
+
"{{baseUrl}}/#{slug}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def build_index_requests(base, slug, meta)
|
|
213
|
+
headers = default_headers
|
|
214
|
+
requests = [request_item("List all", "GET", base, {}, headers)]
|
|
215
|
+
|
|
216
|
+
meta[:allowed_filters].each do |filter|
|
|
217
|
+
requests << request_item("Filter by #{filter}", "GET", base, { "filter[#{filter}]" => "example" }, headers)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
meta[:allowed_sorts].each do |sort|
|
|
221
|
+
requests << request_item("Sort by #{sort} (asc)", "GET", base, { sort: sort.to_s }, headers)
|
|
222
|
+
requests << request_item("Sort by #{sort} (desc)", "GET", base, { sort: "-#{sort}" }, headers)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
meta[:allowed_includes].each do |inc|
|
|
226
|
+
requests << request_item("Include #{inc}", "GET", base, { include: inc.to_s }, headers)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
unless meta[:allowed_fields].empty?
|
|
230
|
+
requests << request_item("Select fields", "GET", base,
|
|
231
|
+
{ "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
unless meta[:allowed_search].empty?
|
|
235
|
+
requests << request_item("Search", "GET", base, { search: "example" }, headers)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
requests << request_item("Paginate", "GET", base, { per_page: "5", page: "1" }, headers)
|
|
239
|
+
|
|
240
|
+
requests
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_show_requests(base, slug, meta)
|
|
244
|
+
path = "#{base}/{{modelId}}"
|
|
245
|
+
headers = default_headers
|
|
246
|
+
requests = [request_item("Show by ID", "GET", path, {}, headers)]
|
|
247
|
+
|
|
248
|
+
unless meta[:allowed_includes].empty?
|
|
249
|
+
requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
requests
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_store_requests(base)
|
|
256
|
+
headers = default_headers + [{ key: "Content-Type", value: "application/json" }]
|
|
257
|
+
[request_item("Create", "POST", base, {}, headers, { title: "Example" })]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def build_update_requests(base)
|
|
261
|
+
path = "#{base}/{{modelId}}"
|
|
262
|
+
headers = default_headers + [{ key: "Content-Type", value: "application/json" }]
|
|
263
|
+
[request_item("Update", "PUT", path, {}, headers, { title: "Updated" })]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def build_destroy_requests(base)
|
|
267
|
+
path = "#{base}/{{modelId}}"
|
|
268
|
+
[request_item("Delete by ID", "DELETE", path, {}, default_headers)]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def build_trashed_requests(base)
|
|
272
|
+
[request_item("List trashed", "GET", "#{base}/trashed", {}, default_headers)]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def build_restore_requests(base)
|
|
276
|
+
[request_item("Restore by ID", "POST", "#{base}/{{modelId}}/restore", {}, default_headers)]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def build_force_delete_requests(base)
|
|
280
|
+
[request_item("Force delete by ID", "DELETE", "#{base}/{{modelId}}/force-delete", {}, default_headers)]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def default_headers
|
|
284
|
+
[
|
|
285
|
+
{ key: "Accept", value: "application/json" },
|
|
286
|
+
{ key: "Authorization", value: "Bearer {{token}}" }
|
|
287
|
+
]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def request_item(name, method, path, query_params, headers, body = nil, test_script = nil)
|
|
291
|
+
query = query_params.map { |k, v| { key: k.to_s, value: v.to_s } }
|
|
292
|
+
|
|
293
|
+
raw = path
|
|
294
|
+
unless query.empty?
|
|
295
|
+
raw += "?" + query.map { |q| "#{q[:key]}=#{q[:value]}" }.join("&")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
parts = path.split("/").reject(&:empty?)
|
|
299
|
+
url = {
|
|
300
|
+
raw: raw,
|
|
301
|
+
host: [parts.shift || "{{baseUrl}}"],
|
|
302
|
+
path: parts
|
|
303
|
+
}
|
|
304
|
+
url[:query] = query unless query.empty?
|
|
305
|
+
|
|
306
|
+
req = { method: method, header: headers, url: url }
|
|
307
|
+
|
|
308
|
+
if body && %w[POST PUT PATCH].include?(method)
|
|
309
|
+
req[:body] = {
|
|
310
|
+
mode: "raw",
|
|
311
|
+
raw: JSON.pretty_generate(body),
|
|
312
|
+
options: { raw: { language: "json" } }
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
item = { name: name }
|
|
317
|
+
if test_script
|
|
318
|
+
item[:event] = [{
|
|
319
|
+
listen: "test",
|
|
320
|
+
script: { exec: test_script.split("\n"), type: "text/javascript" }
|
|
321
|
+
}]
|
|
322
|
+
end
|
|
323
|
+
item[:request] = req
|
|
324
|
+
item
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rhino/commands/base_command"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "shellwords"
|
|
8
|
+
|
|
9
|
+
module Rhino
|
|
10
|
+
module Commands
|
|
11
|
+
# Generate TypeScript interfaces from registered Rhino models via
|
|
12
|
+
# OpenAPI intermediate format + npx openapi-typescript.
|
|
13
|
+
#
|
|
14
|
+
# Mirrors the Laravel `php artisan rhino:export-types` command exactly.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# rails rhino:export_types
|
|
18
|
+
# rails rhino:export_types -- --output=path/to/types.d.ts
|
|
19
|
+
class ExportTypesCommand < BaseCommand
|
|
20
|
+
attr_accessor :options
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
@options = { output: nil }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def perform
|
|
28
|
+
models = Rhino.config.models
|
|
29
|
+
|
|
30
|
+
if models.empty?
|
|
31
|
+
say "No models registered in Rhino configuration.", :yellow
|
|
32
|
+
return true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
output_paths = resolve_output_paths
|
|
36
|
+
|
|
37
|
+
if output_paths.empty?
|
|
38
|
+
say "No output paths configured. Set RHINO_CLIENT_PATH and/or RHINO_MOBILE_PATH in .env, or use --output flag.", :red
|
|
39
|
+
return false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
schemas = {}
|
|
43
|
+
|
|
44
|
+
models.each do |slug, model_class_name|
|
|
45
|
+
model_class = begin
|
|
46
|
+
model_class_name.constantize
|
|
47
|
+
rescue NameError
|
|
48
|
+
say "Model class does not exist: #{model_class_name}", :red
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
interface_name = slug_to_interface_name(slug)
|
|
53
|
+
properties = introspect_columns(model_class)
|
|
54
|
+
|
|
55
|
+
if properties.empty?
|
|
56
|
+
say "No columns found for model: #{slug} (#{model_class_name})", :yellow
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
schemas[interface_name] = {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: properties
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if schemas.empty?
|
|
67
|
+
say "No schemas generated.", :yellow
|
|
68
|
+
return true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
openapi_spec = build_openapi_spec(schemas)
|
|
72
|
+
|
|
73
|
+
temp_file = Tempfile.new(["rhino_openapi_", ".json"])
|
|
74
|
+
begin
|
|
75
|
+
temp_file.write(JSON.pretty_generate(openapi_spec))
|
|
76
|
+
temp_file.flush
|
|
77
|
+
|
|
78
|
+
output_paths.each do |output_path|
|
|
79
|
+
dir = File.dirname(output_path)
|
|
80
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
81
|
+
|
|
82
|
+
exit_code = run_openapi_typescript(temp_file.path, output_path)
|
|
83
|
+
|
|
84
|
+
if exit_code != 0
|
|
85
|
+
say "Failed to generate types at #{output_path}. Is openapi-typescript installed? Run: npm install -g openapi-typescript", :red
|
|
86
|
+
return false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
say "Generated TypeScript types at: #{output_path}", :green
|
|
90
|
+
end
|
|
91
|
+
ensure
|
|
92
|
+
temp_file.close
|
|
93
|
+
temp_file.unlink
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Resolve target paths from --output flag, config, or env vars.
|
|
102
|
+
def resolve_output_paths
|
|
103
|
+
explicit = options[:output]
|
|
104
|
+
return [explicit] if explicit
|
|
105
|
+
|
|
106
|
+
paths = []
|
|
107
|
+
|
|
108
|
+
client_path = Rhino.config.respond_to?(:client_path) && Rhino.config.client_path ||
|
|
109
|
+
ENV["RHINO_CLIENT_PATH"]
|
|
110
|
+
if client_path && !client_path.empty?
|
|
111
|
+
paths << File.join(client_path.chomp("/"), "src", "types", "rhino.d.ts")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
mobile_path = Rhino.config.respond_to?(:mobile_path) && Rhino.config.mobile_path ||
|
|
115
|
+
ENV["RHINO_MOBILE_PATH"]
|
|
116
|
+
if mobile_path && !mobile_path.empty?
|
|
117
|
+
paths << File.join(mobile_path.chomp("/"), "src", "types", "rhino.d.ts")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
paths
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Convert slug to PascalCase singular interface name.
|
|
124
|
+
# posts -> Post, blog_categories -> BlogCategory, blog-categories -> BlogCategory
|
|
125
|
+
def slug_to_interface_name(slug)
|
|
126
|
+
slug.to_s.underscore.singularize.camelize
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Introspect ActiveRecord columns and return OpenAPI property definitions.
|
|
130
|
+
def introspect_columns(model_class)
|
|
131
|
+
return {} unless model_class.respond_to?(:columns_hash)
|
|
132
|
+
|
|
133
|
+
properties = {}
|
|
134
|
+
|
|
135
|
+
model_class.columns_hash.each do |name, column|
|
|
136
|
+
openapi_type = map_column_type(column.type.to_s)
|
|
137
|
+
prop = openapi_type.dup
|
|
138
|
+
|
|
139
|
+
if column.null
|
|
140
|
+
prop[:nullable] = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
properties[name] = prop
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
properties
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Map ActiveRecord column type to OpenAPI type definition.
|
|
150
|
+
def map_column_type(db_type)
|
|
151
|
+
case db_type.downcase
|
|
152
|
+
when "integer", "int", "bigint", "smallint", "tinyint", "mediumint"
|
|
153
|
+
{ type: "integer" }
|
|
154
|
+
when "decimal", "float", "double", "real", "numeric"
|
|
155
|
+
{ type: "number" }
|
|
156
|
+
when "boolean", "bool"
|
|
157
|
+
{ type: "boolean" }
|
|
158
|
+
when "timestamp", "datetime", "timestamptz", "date", "time"
|
|
159
|
+
{ type: "string", format: "date-time" }
|
|
160
|
+
when "json", "jsonb"
|
|
161
|
+
{ type: "object" }
|
|
162
|
+
else
|
|
163
|
+
{ type: "string" }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Build a minimal OpenAPI 3.0.3 spec containing only component schemas.
|
|
168
|
+
def build_openapi_spec(schemas)
|
|
169
|
+
app_name = begin
|
|
170
|
+
Rails.application.class.module_parent_name
|
|
171
|
+
rescue StandardError
|
|
172
|
+
"API"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
openapi: "3.0.3",
|
|
177
|
+
info: {
|
|
178
|
+
title: "#{app_name} Models",
|
|
179
|
+
version: "1.0.0"
|
|
180
|
+
},
|
|
181
|
+
paths: {},
|
|
182
|
+
components: {
|
|
183
|
+
schemas: schemas
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Shell out to npx openapi-typescript to produce the .d.ts file.
|
|
189
|
+
def run_openapi_typescript(input_file, output_file)
|
|
190
|
+
command = "npx openapi-typescript #{Shellwords.escape(input_file)} -o #{Shellwords.escape(output_file)} 2>&1"
|
|
191
|
+
output = `#{command}`
|
|
192
|
+
exit_code = $?.exitstatus
|
|
193
|
+
|
|
194
|
+
if exit_code != 0
|
|
195
|
+
say output, :red
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
exit_code
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|