typespec_from_serializers 0.1.1 → 0.2.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/CHANGELOG.md +40 -1
- data/README.md +3 -8
- data/lib/typespec_from_serializers/dsl/controller.rb +43 -0
- data/lib/typespec_from_serializers/dsl/serializer.rb +74 -0
- data/lib/typespec_from_serializers/dsl.rb +2 -44
- data/lib/typespec_from_serializers/generator.rb +836 -120
- data/lib/typespec_from_serializers/io.rb +30 -0
- data/lib/typespec_from_serializers/openapi_compiler.rb +62 -0
- data/lib/typespec_from_serializers/railtie.rb +91 -3
- data/lib/typespec_from_serializers/rbi.rb +186 -0
- data/lib/typespec_from_serializers/runner.rb +54 -0
- data/lib/typespec_from_serializers/sorbet.rb +461 -0
- data/lib/typespec_from_serializers/version.rb +1 -1
- data/lib/typespec_from_serializers.rb +2 -0
- metadata +51 -2
|
@@ -8,6 +8,23 @@ require "pathname"
|
|
|
8
8
|
module TypeSpecFromSerializers
|
|
9
9
|
DEFAULT_TRANSFORM_KEYS = ->(key) { key.camelize(:lower).chomp("?") }
|
|
10
10
|
|
|
11
|
+
# TypeSpec language keywords that are always problematic
|
|
12
|
+
TYPESPEC_LANGUAGE_KEYWORDS = %w[
|
|
13
|
+
using extends is import model scalar enum union interface
|
|
14
|
+
namespace op alias true false null
|
|
15
|
+
].to_set.freeze
|
|
16
|
+
|
|
17
|
+
# TypeSpec Reflection types that conflict only at global scope
|
|
18
|
+
# When using a namespace, these names are safe (e.g., MyAPI.Model is OK)
|
|
19
|
+
TYPESPEC_REFLECTION_TYPES = %w[
|
|
20
|
+
Model Scalar Enum Union Interface Operation Namespace
|
|
21
|
+
].to_set.freeze
|
|
22
|
+
|
|
23
|
+
# Rails RESTful resource action names
|
|
24
|
+
REST_ACTIONS = %w[index show create update destroy].freeze
|
|
25
|
+
MEMBER_ACTIONS = %w[show update destroy].freeze
|
|
26
|
+
SPECIAL_ACTIONS = %w[new edit].freeze
|
|
27
|
+
|
|
11
28
|
# Internal: Extensions that simplify the implementation of the generator.
|
|
12
29
|
module SerializerRefinements
|
|
13
30
|
refine String do
|
|
@@ -30,7 +47,16 @@ module TypeSpecFromSerializers
|
|
|
30
47
|
refine Class do
|
|
31
48
|
# Internal: Name of the TypeSpec model.
|
|
32
49
|
def tsp_name
|
|
33
|
-
TypeSpecFromSerializers.config.name_from_serializer.call(name).tr_s(":", "")
|
|
50
|
+
transformed = TypeSpecFromSerializers.config.name_from_serializer.call(name).tr_s(":", "")
|
|
51
|
+
|
|
52
|
+
# Only check for reflection type conflicts if no namespace is configured
|
|
53
|
+
# When using a namespace, Model becomes MyNamespace.Model which doesn't conflict
|
|
54
|
+
if TypeSpecFromSerializers.config.namespace.nil? && TYPESPEC_REFLECTION_TYPES.include?(transformed)
|
|
55
|
+
warn "Warning: TypeSpec model name '#{transformed}' conflicts with reserved keyword. Renaming to '#{transformed}_'. Use config.namespace to avoid this."
|
|
56
|
+
"#{transformed}_"
|
|
57
|
+
else
|
|
58
|
+
transformed
|
|
59
|
+
end
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
# Internal: The base name of the TypeSpec file to be written.
|
|
@@ -45,7 +71,9 @@ module TypeSpecFromSerializers
|
|
|
45
71
|
|
|
46
72
|
# Internal: The TypeSpec properties of the serialzeir model.
|
|
47
73
|
def tsp_properties
|
|
48
|
-
@tsp_properties
|
|
74
|
+
return @tsp_properties if @tsp_properties
|
|
75
|
+
|
|
76
|
+
@tsp_properties = begin
|
|
49
77
|
model_class = _serializer_model_name&.to_model
|
|
50
78
|
model_columns = model_class.try(:columns_hash) || {}
|
|
51
79
|
model_enums = model_class.try(:defined_enums) || {}
|
|
@@ -66,7 +94,7 @@ module TypeSpecFromSerializers
|
|
|
66
94
|
multi: options[:association] == :many,
|
|
67
95
|
column_name: options.fetch(:value_from),
|
|
68
96
|
).tap do |property|
|
|
69
|
-
property.infer_typespec_from(model_columns, model_enums, typespec_from)
|
|
97
|
+
property.infer_typespec_from(model_columns, model_enums, typespec_from, self, model_class)
|
|
70
98
|
end
|
|
71
99
|
end
|
|
72
100
|
}
|
|
@@ -91,13 +119,22 @@ module TypeSpecFromSerializers
|
|
|
91
119
|
:output_dir,
|
|
92
120
|
:custom_typespec_dir,
|
|
93
121
|
:name_from_serializer,
|
|
122
|
+
:controller_suffix,
|
|
123
|
+
:param_method_suffix,
|
|
94
124
|
:global_types,
|
|
95
125
|
:sort_properties_by,
|
|
96
126
|
:sql_to_typespec_type_mapping,
|
|
127
|
+
:sorbet_to_typespec_type_mapping,
|
|
97
128
|
:action_to_operation_mapping,
|
|
98
129
|
:skip_serializer_if,
|
|
99
130
|
:transform_keys,
|
|
100
131
|
:namespace,
|
|
132
|
+
:export_if,
|
|
133
|
+
:route_param_types,
|
|
134
|
+
:package_manager,
|
|
135
|
+
:openapi_path,
|
|
136
|
+
:max_line_length,
|
|
137
|
+
:root,
|
|
101
138
|
keyword_init: true,
|
|
102
139
|
) do
|
|
103
140
|
def relative_custom_typespec_dir
|
|
@@ -130,19 +167,46 @@ module TypeSpecFromSerializers
|
|
|
130
167
|
serializer_type_imports = association_serializers.map(&:tsp_model)
|
|
131
168
|
.map { |type| [type.name, relative_path(type.pathname, pathname)] }
|
|
132
169
|
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
# Extract type names from attribute types (strings like "Task[]" or "string | null")
|
|
171
|
+
type_names = attribute_types
|
|
172
|
+
.flat_map { |type|
|
|
173
|
+
# Extract base type name, removing array notation and taking first part of unions
|
|
174
|
+
type.to_s.delete_suffix("[]").split(/\s*[|.]\s*/)
|
|
175
|
+
}
|
|
135
176
|
.uniq
|
|
136
177
|
.reject { |type| global_type?(type) }
|
|
137
|
-
.map { |type|
|
|
138
|
-
type_path = TypeSpecFromSerializers.config.relative_custom_typespec_dir.join(type)
|
|
139
|
-
[type, relative_path(type_path, pathname)]
|
|
140
|
-
}
|
|
141
178
|
|
|
142
|
-
|
|
179
|
+
# Partition into serializer models vs custom types
|
|
180
|
+
serializer_models, custom_types = type_names.partition { |type_name|
|
|
181
|
+
serializer_model_exists?(type_name)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Import serializer models from models/ directory
|
|
185
|
+
serializer_model_imports = serializer_models.map { |type_name|
|
|
186
|
+
# Find the serializer that generates this model
|
|
187
|
+
serializer = TypeSpecFromSerializers.loaded_serializers.find { |ser| ser.tsp_model.name == type_name }
|
|
188
|
+
next unless serializer
|
|
189
|
+
|
|
190
|
+
[type_name, relative_path(serializer.tsp_model.pathname, pathname)]
|
|
191
|
+
}.compact
|
|
192
|
+
|
|
193
|
+
# Import custom types from custom directory
|
|
194
|
+
custom_type_imports = custom_types.map { |type|
|
|
195
|
+
type_path = TypeSpecFromSerializers.config.relative_custom_typespec_dir.join(type)
|
|
196
|
+
[type, relative_path(type_path, pathname)]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
(custom_type_imports + serializer_type_imports + serializer_model_imports)
|
|
143
200
|
.map { |model, filename| %(import "#{filename}.tsp";\n) }
|
|
144
201
|
end
|
|
145
202
|
|
|
203
|
+
def serializer_model_exists?(type_name)
|
|
204
|
+
# Check if any loaded serializer generates a model with this name
|
|
205
|
+
TypeSpecFromSerializers.loaded_serializers.any? { |ser| ser.tsp_model.name == type_name }
|
|
206
|
+
rescue
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
146
210
|
def as_typespec
|
|
147
211
|
indent = TypeSpecFromSerializers.config.namespace ? 2 : 1
|
|
148
212
|
<<~TSP.gsub(/\n$/, "")
|
|
@@ -164,11 +228,6 @@ module TypeSpecFromSerializers
|
|
|
164
228
|
path.start_with?(".") ? path : "./#{path}"
|
|
165
229
|
end
|
|
166
230
|
|
|
167
|
-
# Internal: Extracts any types inside generics or array types.
|
|
168
|
-
def extract_typespec_types(type)
|
|
169
|
-
type.split(".").first
|
|
170
|
-
end
|
|
171
|
-
|
|
172
231
|
# NOTE: Treat uppercase names as custom types.
|
|
173
232
|
# Lowercase names would be native types, such as :string and :boolean.
|
|
174
233
|
def global_type?(type)
|
|
@@ -193,66 +252,192 @@ module TypeSpecFromSerializers
|
|
|
193
252
|
|
|
194
253
|
# Internal: Infers the property's type by checking a corresponding SQL
|
|
195
254
|
# column, or falling back to a TypeSpec model if provided.
|
|
196
|
-
def infer_typespec_from(columns_hash, defined_enums, tsp_model)
|
|
255
|
+
def infer_typespec_from(columns_hash, defined_enums, tsp_model, serializer_class = nil, model_class = nil)
|
|
256
|
+
# Priority 1: Explicit type (already set via DSL)
|
|
197
257
|
if type
|
|
198
|
-
type
|
|
199
|
-
|
|
258
|
+
return type
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Priority 2: Sorbet method signature on serializer (if available)
|
|
262
|
+
if serializer_class && Sorbet.available?
|
|
263
|
+
sorbet_info = Sorbet.extract_type_for(serializer_class, column_name)
|
|
264
|
+
if sorbet_info
|
|
265
|
+
self.type = sorbet_info[:typespec_type]
|
|
266
|
+
self.optional = true if sorbet_info[:nilable]
|
|
267
|
+
self.multi = true if sorbet_info[:array]
|
|
268
|
+
return type
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Priority 2b: Sorbet method signature on model (if available)
|
|
273
|
+
if model_class && Sorbet.available?
|
|
274
|
+
sorbet_info = Sorbet.extract_type_for(model_class, column_name)
|
|
275
|
+
if sorbet_info
|
|
276
|
+
self.type = sorbet_info[:typespec_type]
|
|
277
|
+
self.optional = true if sorbet_info[:nilable]
|
|
278
|
+
self.multi = true if sorbet_info[:array]
|
|
279
|
+
return type
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Priority 3: ActiveRecord enums
|
|
284
|
+
if (enum = defined_enums[column_name.to_s])
|
|
200
285
|
self.type = enum.keys.map(&:inspect).join(" | ")
|
|
201
|
-
|
|
286
|
+
return type
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Priority 4: SQL schema columns
|
|
290
|
+
if (column = columns_hash[column_name.to_s])
|
|
202
291
|
self.multi = true if column.try(:array)
|
|
203
292
|
self.optional = true if column.null && !column.default
|
|
204
293
|
self.type = TypeSpecFromSerializers.config.sql_to_typespec_type_mapping[column.type]
|
|
205
|
-
|
|
294
|
+
return type
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Priority 5: TypeSpec model fallback
|
|
298
|
+
if tsp_model
|
|
206
299
|
self.type = "#{tsp_model}.#{name}::type"
|
|
207
300
|
end
|
|
301
|
+
|
|
302
|
+
type
|
|
208
303
|
end
|
|
209
304
|
|
|
210
305
|
def as_typespec
|
|
211
306
|
type_str = if type.respond_to?(:tsp_name)
|
|
212
307
|
type.tsp_name
|
|
308
|
+
elsif type
|
|
309
|
+
# Map common type symbols/strings through the Sorbet mapping
|
|
310
|
+
mapped = TypeSpecFromSerializers.config.sorbet_to_typespec_type_mapping[type.to_s]
|
|
311
|
+
mapped || type
|
|
213
312
|
else
|
|
214
|
-
|
|
313
|
+
TypeSpecFromSerializers.config.unknown_type
|
|
215
314
|
end
|
|
216
315
|
|
|
217
|
-
|
|
316
|
+
escaped_name = escape_field_name(name)
|
|
317
|
+
"#{escaped_name}#{"?" if optional}: #{type_str}#{"[]" if multi};"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
def escape_field_name(field_name)
|
|
323
|
+
# Escape field names that conflict with TypeSpec keywords using backticks
|
|
324
|
+
all_keywords = TYPESPEC_LANGUAGE_KEYWORDS +
|
|
325
|
+
TYPESPEC_REFLECTION_TYPES.map(&:downcase)
|
|
326
|
+
|
|
327
|
+
if all_keywords.include?(field_name)
|
|
328
|
+
"`#{field_name}`"
|
|
329
|
+
else
|
|
330
|
+
field_name
|
|
331
|
+
end
|
|
218
332
|
end
|
|
219
333
|
end
|
|
220
334
|
|
|
221
335
|
# Internal: Represents a TypeSpec resource interface
|
|
222
|
-
Resource = Struct.new(:name, :path, :operations, keyword_init: true) do
|
|
336
|
+
Resource = Struct.new(:name, :path, :operations, :parent_namespace, keyword_init: true) do
|
|
223
337
|
def as_typespec
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
#{
|
|
228
|
-
#{
|
|
338
|
+
operations_str = operations.map { |op| " #{op.as_typespec(resource_path: path)}" }.join("\n")
|
|
339
|
+
|
|
340
|
+
interface_block = <<~TSP.strip
|
|
341
|
+
@route("#{path}")
|
|
342
|
+
interface #{name} {
|
|
343
|
+
#{operations_str}
|
|
344
|
+
}
|
|
345
|
+
TSP
|
|
346
|
+
|
|
347
|
+
# Wrap in namespace if this is a nested resource
|
|
348
|
+
parent_namespace ? wrap_in_namespace(interface_block) : interface_block
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
def wrap_in_namespace(content)
|
|
354
|
+
indented_content = content.lines.map { |line| " #{line}" }.join.rstrip
|
|
355
|
+
<<~TSP.strip
|
|
356
|
+
namespace #{parent_namespace} {
|
|
357
|
+
#{indented_content}
|
|
358
|
+
}
|
|
229
359
|
TSP
|
|
230
360
|
end
|
|
231
361
|
end
|
|
232
362
|
|
|
233
363
|
# Internal: Represents a TypeSpec operation within a resource
|
|
234
|
-
Operation = Struct.new(:method, :action, :path_params, :response_type, keyword_init: true) do
|
|
235
|
-
def as_typespec
|
|
236
|
-
|
|
237
|
-
"GET" => "get",
|
|
238
|
-
"POST" => "post",
|
|
239
|
-
"PUT" => "put",
|
|
240
|
-
"PATCH" => "patch",
|
|
241
|
-
"DELETE" => "delete",
|
|
242
|
-
}
|
|
243
|
-
tsp_method = method_map[method] || method.downcase
|
|
364
|
+
Operation = Struct.new(:method, :action, :path, :path_params, :body_params, :response_type, keyword_init: true) do
|
|
365
|
+
def as_typespec(resource_path: nil)
|
|
366
|
+
tsp_method = method.downcase
|
|
244
367
|
operation_name = TypeSpecFromSerializers.config.action_to_operation_mapping[action] || action
|
|
368
|
+
route_line = build_route_decorator(resource_path)
|
|
369
|
+
|
|
370
|
+
# Check if we need multiline formatting
|
|
371
|
+
single_line = build_single_line(tsp_method, operation_name)
|
|
372
|
+
|
|
373
|
+
too_long_for_single_line?(single_line) ?
|
|
374
|
+
multiline_format(route_line, tsp_method, operation_name) :
|
|
375
|
+
"#{route_line}#{single_line}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def build_route_decorator(resource_path)
|
|
381
|
+
decorator = operation_route_decorator(resource_path)
|
|
382
|
+
decorator ? "#{decorator}\n " : ""
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def too_long_for_single_line?(line)
|
|
386
|
+
line.length > TypeSpecFromSerializers.config.max_line_length && all_params.any?
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def all_params
|
|
390
|
+
[*format_path_params, *format_body_params]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def build_single_line(tsp_method, operation_name)
|
|
245
394
|
params = params_typespec
|
|
246
395
|
params_str = params.empty? ? "()" : "(#{params})"
|
|
396
|
+
"@#{tsp_method} #{operation_name}#{params_str}: #{response_type.delete(":")};"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def multiline_format(route_line, tsp_method, operation_name)
|
|
400
|
+
params_indented = all_params.map { |p| "\n #{p}," }.join
|
|
401
|
+
return_type = response_type.delete(":")
|
|
402
|
+
|
|
403
|
+
"#{route_line}@#{tsp_method} #{operation_name}(#{params_indented}\n ): #{return_type};"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def operation_route_decorator(resource_path)
|
|
407
|
+
return unless resource_path && path
|
|
408
|
+
|
|
409
|
+
op_path_tsp = path.gsub(/:(\w+)/, '{\1}')
|
|
410
|
+
standard_path = build_standard_path(resource_path)
|
|
411
|
+
|
|
412
|
+
return if op_path_tsp == standard_path || !op_path_tsp.start_with?(resource_path)
|
|
413
|
+
|
|
414
|
+
relative_path = op_path_tsp.delete_prefix(resource_path)
|
|
415
|
+
relative_path = "/#{relative_path}" unless relative_path.start_with?("/")
|
|
416
|
+
|
|
417
|
+
%(@route("#{relative_path}"))
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def build_standard_path(resource_path)
|
|
421
|
+
return resource_path if path_params.empty?
|
|
247
422
|
|
|
248
|
-
|
|
423
|
+
param_names = path_params.map { |p| p.is_a?(Hash) ? p[:name] : p }
|
|
424
|
+
params_path = param_names.map { |p| "{#{p}}" }.join("/")
|
|
425
|
+
"#{resource_path}/#{params_path}"
|
|
249
426
|
end
|
|
250
427
|
|
|
251
428
|
def params_typespec
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
429
|
+
[*format_path_params, *format_body_params].join(", ")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def format_path_params
|
|
433
|
+
path_params.map do |param|
|
|
434
|
+
name, type = param.is_a?(Hash) ? [param[:name], param[:type]] : [param, "string"]
|
|
435
|
+
"@path #{name}: #{type}"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def format_body_params
|
|
440
|
+
body_params&.map { |name, type| "#{name}: #{type}" } || []
|
|
256
441
|
end
|
|
257
442
|
end
|
|
258
443
|
|
|
@@ -313,7 +498,7 @@ module TypeSpecFromSerializers
|
|
|
313
498
|
# Public: Generates code for all serializers in the app.
|
|
314
499
|
def generate(force: ENV["SERIALIZER_TYPESPEC_FORCE"])
|
|
315
500
|
@force_generation = force
|
|
316
|
-
|
|
501
|
+
clean_output_dir if force && config.output_dir.exist?
|
|
317
502
|
|
|
318
503
|
if config.namespace
|
|
319
504
|
load_serializers(all_serializer_files) if force
|
|
@@ -321,11 +506,14 @@ module TypeSpecFromSerializers
|
|
|
321
506
|
generate_index_file
|
|
322
507
|
end
|
|
323
508
|
|
|
324
|
-
generate_routes
|
|
509
|
+
controllers = generate_routes
|
|
325
510
|
|
|
326
|
-
|
|
511
|
+
serializers = loaded_serializers
|
|
512
|
+
serializers.each do |serializer|
|
|
327
513
|
generate_model_for(serializer)
|
|
328
514
|
end
|
|
515
|
+
|
|
516
|
+
{serializers: serializers, controllers: controllers}
|
|
329
517
|
end
|
|
330
518
|
|
|
331
519
|
def generate_changed
|
|
@@ -344,6 +532,10 @@ module TypeSpecFromSerializers
|
|
|
344
532
|
write_if_changed(filename: "models/#{model.filename}", cache_key: model.inspect, extension: "tsp") {
|
|
345
533
|
serializer_model_content(model)
|
|
346
534
|
}
|
|
535
|
+
rescue => e
|
|
536
|
+
$stderr.puts "ERROR in generate_model_for(#{serializer.name}): #{e.class}: #{e.message}"
|
|
537
|
+
$stderr.puts e.backtrace.first(10).join("\n")
|
|
538
|
+
raise
|
|
347
539
|
end
|
|
348
540
|
|
|
349
541
|
# Internal: Allows to import all serializer types from a single file.
|
|
@@ -357,13 +549,16 @@ module TypeSpecFromSerializers
|
|
|
357
549
|
|
|
358
550
|
# Internal: Generates TypeSpec routes from Rails routes
|
|
359
551
|
def generate_routes
|
|
360
|
-
return unless defined?(Rails) && Rails.application
|
|
552
|
+
return [] unless defined?(Rails) && Rails.application
|
|
361
553
|
|
|
362
|
-
routes = collect_rails_routes
|
|
554
|
+
routes, controllers = collect_rails_routes
|
|
363
555
|
cache_key = routes.map { |r| r.operations.map { |op| "#{op.method}#{r.path}#{op.action}" }.join }.join
|
|
364
556
|
write_if_changed(filename: "routes", cache_key: cache_key) {
|
|
365
557
|
routes_content(routes)
|
|
366
558
|
}
|
|
559
|
+
|
|
560
|
+
# Return list of controller class names
|
|
561
|
+
controllers.sort
|
|
367
562
|
end
|
|
368
563
|
|
|
369
564
|
# Internal: Checks if it should avoid generating an model.
|
|
@@ -377,117 +572,589 @@ module TypeSpecFromSerializers
|
|
|
377
572
|
changes
|
|
378
573
|
end
|
|
379
574
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def changes
|
|
387
|
-
@changes ||= Changes.new(config.serializers_dirs)
|
|
575
|
+
# Public: Returns all loaded serializers.
|
|
576
|
+
#
|
|
577
|
+
# Returns Array of serializer classes.
|
|
578
|
+
def serializers
|
|
579
|
+
loaded_serializers
|
|
388
580
|
end
|
|
389
581
|
|
|
390
|
-
|
|
391
|
-
|
|
582
|
+
# Public: Returns the application root path.
|
|
583
|
+
#
|
|
584
|
+
# Returns Pathname
|
|
585
|
+
def root
|
|
586
|
+
(defined?(Rails) && Rails.root) || Pathname.new(Dir.pwd)
|
|
392
587
|
end
|
|
393
588
|
|
|
394
|
-
|
|
395
|
-
|
|
589
|
+
# Public: Returns the RBI base directory path.
|
|
590
|
+
#
|
|
591
|
+
# Returns Pathname
|
|
592
|
+
def rbi_dir
|
|
593
|
+
root.join("sorbet/rbi")
|
|
396
594
|
end
|
|
397
595
|
|
|
596
|
+
# Public: Returns all loaded serializer classes.
|
|
597
|
+
#
|
|
598
|
+
# Returns Array of serializer classes
|
|
398
599
|
def loaded_serializers
|
|
399
600
|
config.base_serializers.map(&:constantize)
|
|
400
601
|
.flat_map(&:descendants)
|
|
401
602
|
.uniq
|
|
603
|
+
.reject { |s| s.name.nil? } # Filter out anonymous classes
|
|
402
604
|
.sort_by(&:name)
|
|
403
605
|
.reject { |s| skip_serializer?(s) }
|
|
404
606
|
rescue NameError
|
|
405
607
|
raise ArgumentError, "Please ensure all your serializers extend BaseSerializer, or configure `config.base_serializers`."
|
|
406
608
|
end
|
|
407
609
|
|
|
610
|
+
private
|
|
611
|
+
|
|
612
|
+
# Internal: Cleans the output directory.
|
|
613
|
+
def clean_output_dir
|
|
614
|
+
config.output_dir.rmtree if config.output_dir.exist?
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def changes
|
|
618
|
+
@changes ||= Changes.new(config.serializers_dirs)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def all_serializer_files
|
|
622
|
+
config.serializers_dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.sort
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def load_serializers(files)
|
|
626
|
+
files.each { |file| require file }
|
|
627
|
+
end
|
|
628
|
+
|
|
408
629
|
# Internal: Collects routes from Rails and groups them into resources
|
|
630
|
+
# Returns [routes_array, controller_class_names_array]
|
|
409
631
|
def collect_rails_routes
|
|
410
|
-
return [] unless defined?(Rails) && Rails.application
|
|
632
|
+
return [[], []] unless defined?(Rails) && Rails.application
|
|
411
633
|
|
|
412
634
|
routes_by_controller = Rails.application.routes.routes.each_with_object(Hash.new { |h, k| h[k] = [] }) do |route, hash|
|
|
635
|
+
# Filter routes based on export_if configuration (similar to js_from_routes)
|
|
413
636
|
next unless route.defaults[:controller] && route.verb.present?
|
|
637
|
+
next unless config.export_if.call(route)
|
|
414
638
|
|
|
415
|
-
controller = route
|
|
639
|
+
controller = namespace_for_route(route)
|
|
416
640
|
action = route.defaults[:action]
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
641
|
+
# Take the last verb from pipe-separated list (matches js_from_routes behavior)
|
|
642
|
+
method = route.verb.split("|").last
|
|
643
|
+
# Use chomp instead of sub for better path extraction (matches js_from_routes)
|
|
644
|
+
path = route.path.spec.to_s.chomp("(.:format)")
|
|
645
|
+
response_type = infer_response_type(route.defaults[:controller], action) || "unknown"
|
|
646
|
+
|
|
647
|
+
# Reject duplicate PUT routes when PATCH exists for update action (js_from_routes pattern)
|
|
648
|
+
next if action == "update" && method == "PUT" && hash[controller].any? { |r| r[:action] == "update" && r[:method] == "PATCH" }
|
|
649
|
+
|
|
650
|
+
# Use Rails-generated route name for unique operation naming
|
|
651
|
+
route_name = route.name
|
|
652
|
+
|
|
653
|
+
# Prefer standard REST actions over custom actions for duplicate routes
|
|
654
|
+
if existing = hash[controller].find { |r| r[:method] == method && r[:path] == path }
|
|
655
|
+
next unless action.in?(REST_ACTIONS) && !existing[:action].in?(REST_ACTIONS)
|
|
656
|
+
hash[controller].delete(existing)
|
|
428
657
|
end
|
|
658
|
+
|
|
659
|
+
hash[controller] << {
|
|
660
|
+
method: method,
|
|
661
|
+
action: action,
|
|
662
|
+
path: path,
|
|
663
|
+
response_type: response_type,
|
|
664
|
+
route_name: route_name,
|
|
665
|
+
param_types: route.defaults[:type] || {},
|
|
666
|
+
}
|
|
429
667
|
end
|
|
430
668
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
base_path = path_segments.any? ? path_segments.first.join("/")&.split("/{")&.first || controller : controller
|
|
669
|
+
# Extract controller class names for reporting
|
|
670
|
+
controller_class_names = routes_by_controller.keys.map { |c| "#{c.camelize}#{config.controller_suffix}" }.uniq
|
|
434
671
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
else
|
|
440
|
-
(route[:action] == "index") ? "#{route[:response_type]}[]" : route[:response_type]
|
|
441
|
-
end
|
|
442
|
-
Operation.new(
|
|
443
|
-
method: route[:method],
|
|
444
|
-
action: route[:action],
|
|
445
|
-
path_params: (route[:action] == "show") ? ["id"] : path_params,
|
|
446
|
-
response_type: response_type,
|
|
447
|
-
)
|
|
448
|
-
end
|
|
449
|
-
Resource.new(
|
|
450
|
-
name: controller.tr("/", "_").camelize,
|
|
451
|
-
path: "/#{base_path}",
|
|
452
|
-
operations: operations,
|
|
453
|
-
)
|
|
672
|
+
routes = routes_by_controller.flat_map do |controller, routes|
|
|
673
|
+
# Group routes by parent namespace (for nested resources)
|
|
674
|
+
routes.group_by { |route| extract_parent_namespace(route[:path]) }
|
|
675
|
+
.map { |parent_ns, ns_routes| build_resource(controller, ns_routes, parent_ns) }
|
|
454
676
|
end
|
|
677
|
+
|
|
678
|
+
[routes, controller_class_names]
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Internal: Builds a Resource from routes for a specific controller and namespace
|
|
682
|
+
def build_resource(controller, ns_routes, parent_namespace)
|
|
683
|
+
paths = ns_routes.map { |r| r[:path] }.uniq
|
|
684
|
+
base_path = calculate_base_path(paths, controller)
|
|
685
|
+
|
|
686
|
+
operations = ns_routes.map { |route| build_operation(controller, route) }
|
|
687
|
+
operations = make_operation_names_unique(operations)
|
|
688
|
+
|
|
689
|
+
Resource.new(
|
|
690
|
+
name: controller.tr("/", "_").camelize,
|
|
691
|
+
path: base_path.start_with?("/") ? base_path : "/#{base_path}",
|
|
692
|
+
operations: operations,
|
|
693
|
+
parent_namespace: parent_namespace,
|
|
694
|
+
)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Internal: Builds an Operation from a route hash
|
|
698
|
+
def build_operation(controller, route)
|
|
699
|
+
path_param_names = route[:path].scan(/:([a-zA-Z_][a-zA-Z0-9_]*)/).flatten
|
|
700
|
+
param_types = extract_all_param_types(controller, route)
|
|
701
|
+
|
|
702
|
+
Operation.new(
|
|
703
|
+
method: route[:method],
|
|
704
|
+
action: simplify_operation_name(route),
|
|
705
|
+
path: route[:path],
|
|
706
|
+
path_params: build_path_params(path_param_names, param_types),
|
|
707
|
+
body_params: build_body_params(route[:method], path_param_names, param_types),
|
|
708
|
+
response_type: infer_operation_response_type(route),
|
|
709
|
+
)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Internal: Extracts all parameter types from route metadata and controller DSL
|
|
713
|
+
def extract_all_param_types(controller, route)
|
|
714
|
+
route_param_types = route[:param_types]
|
|
715
|
+
.transform_keys(&:to_s)
|
|
716
|
+
.transform_values { |v| map_type_class_to_typespec(v) }
|
|
717
|
+
|
|
718
|
+
controller_class = "#{controller.camelize}#{config.controller_suffix}".safe_constantize
|
|
719
|
+
controller_param_types = controller_class&.then { |klass|
|
|
720
|
+
extract_param_types_from_controller(klass, route[:action])
|
|
721
|
+
} || {}
|
|
722
|
+
|
|
723
|
+
controller_param_types.merge(route_param_types)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Internal: Calculates base path from route paths
|
|
727
|
+
def calculate_base_path(paths, controller)
|
|
728
|
+
return paths.first.split(/[{:]/).first if paths.one?
|
|
729
|
+
|
|
730
|
+
common = paths.first.split("/")
|
|
731
|
+
paths.each do |path|
|
|
732
|
+
common = common.zip(path.split("/")).take_while do |(seg, other)|
|
|
733
|
+
seg == other && !seg&.match?(/^[{:]/)
|
|
734
|
+
end.map(&:first)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
common.empty? ? controller : common.join("/")
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Internal: Builds typed path parameters with defaults
|
|
741
|
+
def build_path_params(param_names, param_types)
|
|
742
|
+
param_names.map { |name| {name: name, type: param_types[name] || "string"} }
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Internal: Builds body parameters (excludes path params, only for non-GET)
|
|
746
|
+
def build_body_params(http_method, path_param_names, param_types)
|
|
747
|
+
return {} if http_method == "GET"
|
|
748
|
+
param_types.reject { |key, _| path_param_names.include?(key) }
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Internal: Infers operation response type from route
|
|
752
|
+
def infer_operation_response_type(route)
|
|
753
|
+
type = route[:response_type]
|
|
754
|
+
return "unknown" if type == route[:action]
|
|
755
|
+
route[:action] == "index" ? "#{type}[]" : type
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Internal: Makes operation names unique within an interface
|
|
759
|
+
# When there are duplicates, appends HTTP method as suffix
|
|
760
|
+
def make_operation_names_unique(operations)
|
|
761
|
+
name_counts = operations.group_by(&:action).transform_values(&:count)
|
|
762
|
+
name_usage = Hash.new(0)
|
|
763
|
+
|
|
764
|
+
operations.map do |op|
|
|
765
|
+
next op unless name_counts[op.action] > 1
|
|
766
|
+
|
|
767
|
+
# Multiple operations with same name - differentiate by HTTP method
|
|
768
|
+
name_usage[op.action] += 1
|
|
769
|
+
new_action = name_usage[op.action] == 1 ? op.action : "#{op.action}#{op.method.capitalize}"
|
|
770
|
+
|
|
771
|
+
op.dup.tap { |new_op| new_op.action = new_action }
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Internal: Extracts parent namespace from nested route paths
|
|
776
|
+
# E.g., "/lands/{land_id}/comments" → "Land"
|
|
777
|
+
# "/tasks/{task_id}/comments" → "Task"
|
|
778
|
+
def extract_parent_namespace(path)
|
|
779
|
+
# Match pattern like /resource/:resource_id/nested (Rails uses : notation)
|
|
780
|
+
path[%r{^/([^/]+)/:[^/]+_id/}, 1]&.singularize&.camelize
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Internal: Simplifies operation name using REST conventions
|
|
784
|
+
# Maps HTTP method + path pattern to standard REST actions
|
|
785
|
+
def simplify_operation_name(route)
|
|
786
|
+
action, method = route.values_at(:action, :method)
|
|
787
|
+
|
|
788
|
+
# Check config mapping first
|
|
789
|
+
TypeSpecFromSerializers.config.action_to_operation_mapping[action] ||
|
|
790
|
+
# Apply REST conventions: POST to collection → create, GET → index
|
|
791
|
+
(action == "index" && method == "POST" ? "create" : action)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Internal: Generates operation name from route path and action
|
|
795
|
+
def generate_operation_name(route)
|
|
796
|
+
base = generate_path_helper_name(route[:path], route[:action])
|
|
797
|
+
"#{route[:action]}_#{base}"
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Internal: Generates a path helper-style name from route path and action
|
|
801
|
+
def generate_path_helper_name(path, action)
|
|
802
|
+
segments = path.split("/").reject { |s| s.empty? || s.match?(/^[{:]/) }
|
|
803
|
+
|
|
804
|
+
base = if action.in?(MEMBER_ACTIONS)
|
|
805
|
+
segments.map(&:singularize).join("_")
|
|
806
|
+
else
|
|
807
|
+
segments.map.with_index { |s, i| i < segments.size - 1 ? s.singularize : s }.join("_")
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
base = "#{action}_#{base}" if action.in?(SPECIAL_ACTIONS)
|
|
811
|
+
base.presence || action
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Internal: Extracts namespace from route export config or falls back to controller
|
|
815
|
+
def namespace_for_route(route)
|
|
816
|
+
export = route.defaults[:export]
|
|
817
|
+
(export[:namespace] if export.is_a?(Hash)) || route.defaults[:controller]
|
|
455
818
|
end
|
|
456
819
|
|
|
457
820
|
# Internal: Infers the response type based on controller and action
|
|
458
821
|
def infer_response_type(controller, action)
|
|
459
|
-
controller_class = "#{controller.camelize}
|
|
822
|
+
controller_class = "#{controller.camelize}#{config.controller_suffix}".safe_constantize
|
|
460
823
|
return nil unless controller_class
|
|
461
824
|
|
|
825
|
+
# Try to infer from explicit serializer usage in controller method
|
|
826
|
+
if (serializer_from_method = extract_serializer_from_controller_method(controller_class, action))
|
|
827
|
+
return serializer_from_method.tsp_name
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Try to infer from Sorbet signature on controller method
|
|
831
|
+
if Sorbet.available?
|
|
832
|
+
if (sorbet_type = infer_type_from_controller_sorbet(controller_class, action))
|
|
833
|
+
return sorbet_type
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# Fall back to convention-based inference (controller name → serializer using name_from_serializer)
|
|
462
838
|
model_name = controller.singularize.camelize
|
|
463
|
-
|
|
464
|
-
|
|
839
|
+
loaded_serializers.find { |s| config.name_from_serializer.call(s.name) == model_name }&.tsp_name
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Internal: Extracts serializer class from controller method source using Prism AST
|
|
843
|
+
def extract_serializer_from_controller_method(controller_class, action)
|
|
844
|
+
return nil unless controller_class.method_defined?(action)
|
|
845
|
+
|
|
846
|
+
method = controller_class.instance_method(action)
|
|
847
|
+
source_location = method.source_location
|
|
848
|
+
return nil unless source_location
|
|
849
|
+
|
|
850
|
+
file_path, line_number = source_location
|
|
851
|
+
return nil unless File.exist?(file_path)
|
|
852
|
+
|
|
853
|
+
# Parse the file with Prism
|
|
854
|
+
result = Prism.parse_file(file_path)
|
|
855
|
+
return nil unless result.success?
|
|
856
|
+
|
|
857
|
+
# Find the specific method definition node
|
|
858
|
+
method_finder = MethodFinder.new(action.to_s, line_number)
|
|
859
|
+
method_finder.visit(result.value)
|
|
860
|
+
return nil unless method_finder.method_node
|
|
861
|
+
|
|
862
|
+
# Find serializer references only within this method
|
|
863
|
+
visitor = SerializerVisitor.new
|
|
864
|
+
visitor.visit(method_finder.method_node)
|
|
865
|
+
|
|
866
|
+
# Try to constantize any found serializers and return the first valid one
|
|
867
|
+
visitor.serializer_names.filter_map(&:safe_constantize).first
|
|
868
|
+
rescue
|
|
869
|
+
# File read or parsing error - return nil
|
|
870
|
+
nil
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Internal: Prism visitor to find a specific method definition by name and line
|
|
874
|
+
class MethodFinder < Prism::Visitor
|
|
875
|
+
attr_reader :method_node
|
|
876
|
+
|
|
877
|
+
def initialize(method_name, line_number)
|
|
878
|
+
super()
|
|
879
|
+
@method_name = method_name
|
|
880
|
+
@line_number = line_number
|
|
881
|
+
@method_node = nil
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def visit_def_node(node)
|
|
885
|
+
# Match by method name and line number proximity
|
|
886
|
+
if node.name.to_s == @method_name &&
|
|
887
|
+
node.location.start_line <= @line_number &&
|
|
888
|
+
node.location.end_line >= @line_number
|
|
889
|
+
@method_node = node
|
|
890
|
+
end
|
|
891
|
+
super
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
# Internal: Prism visitor to extract serializer class names from AST
|
|
896
|
+
class SerializerVisitor < Prism::Visitor
|
|
897
|
+
attr_reader :serializer_names
|
|
898
|
+
|
|
899
|
+
def initialize
|
|
900
|
+
super
|
|
901
|
+
@serializer_names = []
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Visit call nodes to find serializer usage patterns
|
|
905
|
+
def visit_call_node(node)
|
|
906
|
+
# Pattern 1: render(..., serializer: FooSerializer)
|
|
907
|
+
if node.name.to_s.in?(%w[render render_page])
|
|
908
|
+
extract_serializer_from_render(node)
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Pattern 2: FooSerializer.one(...) or FooSerializer.many(...)
|
|
912
|
+
if node.name.to_s.in?(%w[one many]) && node.receiver
|
|
913
|
+
extract_serializer_from_class_method(node)
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
super
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
private
|
|
920
|
+
|
|
921
|
+
# Extract serializer from render call keyword arguments
|
|
922
|
+
def extract_serializer_from_render(node)
|
|
923
|
+
return unless node.arguments&.arguments
|
|
924
|
+
|
|
925
|
+
node.arguments.arguments
|
|
926
|
+
.select { |arg| arg.is_a?(Prism::KeywordHashNode) }
|
|
927
|
+
.flat_map(&:elements)
|
|
928
|
+
.select do |el|
|
|
929
|
+
el.is_a?(Prism::AssocNode) &&
|
|
930
|
+
el.key.is_a?(Prism::SymbolNode) &&
|
|
931
|
+
el.key.unescaped == "serializer"
|
|
932
|
+
end
|
|
933
|
+
.each do |element|
|
|
934
|
+
@serializer_names << extract_constant_name(element.value) if constant_node?(element.value)
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# Extract serializer from SomeSerializer.one/many calls
|
|
939
|
+
def extract_serializer_from_class_method(node)
|
|
940
|
+
return unless constant_node?(node.receiver)
|
|
941
|
+
|
|
942
|
+
# Collect any constant called with .one/.many - let constantization filter valid serializers
|
|
943
|
+
extract_constant_name(node.receiver).then { |name| @serializer_names << name if name.present? }
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Check if node is a constant reference
|
|
947
|
+
def constant_node?(node)
|
|
948
|
+
node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# Extract constant name from ConstantReadNode or ConstantPathNode
|
|
952
|
+
def extract_constant_name(node)
|
|
953
|
+
case node
|
|
954
|
+
when Prism::ConstantReadNode
|
|
955
|
+
node.name.to_s
|
|
956
|
+
when Prism::ConstantPathNode
|
|
957
|
+
node.full_name
|
|
958
|
+
else
|
|
959
|
+
""
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Internal: Infers TypeSpec type from Sorbet signature on controller method
|
|
965
|
+
def infer_type_from_controller_sorbet(controller_class, action)
|
|
966
|
+
return nil unless controller_class.method_defined?(action)
|
|
967
|
+
|
|
968
|
+
sorbet_info = Sorbet.extract_type_for(controller_class, action)
|
|
969
|
+
type_class = sorbet_info&.dig(:type_class)
|
|
970
|
+
return nil unless type_class
|
|
971
|
+
|
|
972
|
+
# If it's already a serializer, use it directly
|
|
973
|
+
return type_class.tsp_name if type_class.respond_to?(:tsp_name)
|
|
974
|
+
|
|
975
|
+
# If it's an ActiveRecord model, search for corresponding serializer using name_from_serializer
|
|
976
|
+
if type_class.is_a?(Class) && type_class.ancestors.map(&:name).include?("ActiveRecord::Base")
|
|
977
|
+
loaded_serializers.find { |s| config.name_from_serializer.call(s.name) == type_class.name }&.tsp_name
|
|
978
|
+
end
|
|
979
|
+
rescue
|
|
980
|
+
# Type introspection or constantization error - return nil
|
|
981
|
+
nil
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Internal: Extracts parameter types from controller *_params methods
|
|
985
|
+
def extract_param_types_from_controller(controller_class, action)
|
|
986
|
+
param_types = {}
|
|
987
|
+
|
|
988
|
+
# Extract from *_params methods with type DSL declarations
|
|
989
|
+
# Include private methods since *_params methods are typically private
|
|
990
|
+
param_methods = (controller_class.instance_methods(false) | controller_class.private_instance_methods(false))
|
|
991
|
+
.select { |m| m.to_s.end_with?(config.param_method_suffix) }
|
|
992
|
+
|
|
993
|
+
param_methods.each do |method_name|
|
|
994
|
+
type_found = false
|
|
995
|
+
|
|
996
|
+
# Check for type DSL declaration (same as serializer DSL)
|
|
997
|
+
if controller_class.respond_to?(:type_for_method)
|
|
998
|
+
type_definition = controller_class.type_for_method(method_name)
|
|
999
|
+
if type_definition.is_a?(Hash)
|
|
1000
|
+
type_definition.each do |key, type_value|
|
|
1001
|
+
key_str = key.to_s
|
|
1002
|
+
# Only set if not already defined by route_params DSL
|
|
1003
|
+
unless param_types.key?(key_str)
|
|
1004
|
+
begin
|
|
1005
|
+
typespec_type = map_type_annotation_to_typespec(type_value)
|
|
1006
|
+
param_types[key_str] = typespec_type if typespec_type
|
|
1007
|
+
rescue => e
|
|
1008
|
+
warn "TypeSpec: Failed to map parameter '#{key}' for #{controller_class}##{method_name}: #{e.class} - #{e.message}"
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
end
|
|
1012
|
+
type_found = true
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# 3. Fall back to Sorbet signatures if no type DSL found
|
|
1017
|
+
if !type_found && Sorbet.available?
|
|
1018
|
+
method = controller_class.instance_method(method_name)
|
|
1019
|
+
sig = T::Utils.signature_for_method(method) rescue nil
|
|
1020
|
+
if sig&.return_type
|
|
1021
|
+
# Extract hash type from return signature
|
|
1022
|
+
hash_types = extract_hash_types_from_sorbet(sig.return_type)
|
|
1023
|
+
# Only merge keys that aren't already defined
|
|
1024
|
+
hash_types.each do |key, value|
|
|
1025
|
+
param_types[key] = value unless param_types.key?(key)
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
param_types
|
|
1032
|
+
rescue
|
|
1033
|
+
{}
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Internal: Extracts key-value types from Sorbet hash type
|
|
1037
|
+
def extract_hash_types_from_sorbet(sorbet_type)
|
|
1038
|
+
param_types = {}
|
|
1039
|
+
|
|
1040
|
+
# Handle T::Hash[Symbol, Type] or plain Hash types
|
|
1041
|
+
type_string = sorbet_type.to_s
|
|
1042
|
+
|
|
1043
|
+
# Match shaped hash: {key: Type, ...}
|
|
1044
|
+
if type_string =~ /\{(.+)\}/
|
|
1045
|
+
pairs = $1.scan(/(\w+):\s*([^,}]+)/)
|
|
1046
|
+
pairs.each do |key, type|
|
|
1047
|
+
typespec_type = map_sorbet_type_to_typespec(type.strip)
|
|
1048
|
+
param_types[key] = typespec_type if typespec_type
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
param_types
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Internal: Maps type annotations (Class or Sorbet type) to TypeSpec types
|
|
1056
|
+
def map_type_annotation_to_typespec(type_value)
|
|
1057
|
+
# Handle plain Ruby classes
|
|
1058
|
+
if type_value.is_a?(Class)
|
|
1059
|
+
return map_type_class_to_typespec(type_value)
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Handle Sorbet type objects
|
|
1063
|
+
if Sorbet.available?
|
|
1064
|
+
type_info = Sorbet.send(:sorbet_type_to_typespec, type_value)
|
|
1065
|
+
if type_info
|
|
1066
|
+
base_type = type_info[:typespec_type]
|
|
1067
|
+
base_type = "#{base_type}[]" if type_info[:array]
|
|
1068
|
+
return base_type
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# Fallback: try string mapping
|
|
1073
|
+
map_sorbet_type_to_typespec(type_value.to_s)
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# Internal: Maps Sorbet type strings to TypeSpec types using existing config
|
|
1077
|
+
def map_sorbet_type_to_typespec(sorbet_type)
|
|
1078
|
+
# Use existing config mapping
|
|
1079
|
+
mapped = config.sorbet_to_typespec_type_mapping[sorbet_type]
|
|
1080
|
+
return mapped.to_s if mapped
|
|
1081
|
+
|
|
1082
|
+
# Fallback to string for unknown types
|
|
1083
|
+
"string"
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# Internal: Maps Ruby type classes to TypeSpec types using existing config
|
|
1087
|
+
def map_type_class_to_typespec(type_class)
|
|
1088
|
+
type_str = type_class.to_s
|
|
1089
|
+
mapped = config.sorbet_to_typespec_type_mapping[type_str]
|
|
1090
|
+
return mapped.to_s if mapped
|
|
1091
|
+
|
|
1092
|
+
# Fallback to string for unknown types
|
|
1093
|
+
"string"
|
|
465
1094
|
end
|
|
466
1095
|
|
|
467
1096
|
# Internal: Generates the routes.tsp content with resources
|
|
468
1097
|
def routes_content(routes)
|
|
469
|
-
imports = routes
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
%(import "#{relative_path}";\n)
|
|
474
|
-
end.compact.uniq.join
|
|
475
|
-
|
|
476
|
-
resources = routes.map(&:as_typespec).join("\n").strip
|
|
1098
|
+
imports = generate_route_imports(routes)
|
|
1099
|
+
resources = routes.map { |r| r.as_typespec.strip }.join("\n\n")
|
|
1100
|
+
routes_namespace = wrap_routes_in_namespace(resources)
|
|
1101
|
+
|
|
477
1102
|
<<~TSP
|
|
478
1103
|
//
|
|
479
1104
|
// DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
|
|
480
1105
|
import "@typespec/http";
|
|
481
|
-
|
|
1106
|
+
|
|
482
1107
|
#{imports}
|
|
483
1108
|
using TypeSpec.Http;
|
|
484
1109
|
|
|
485
|
-
|
|
486
|
-
#{resources}
|
|
487
|
-
}
|
|
1110
|
+
#{routes_namespace.strip}
|
|
488
1111
|
TSP
|
|
489
1112
|
end
|
|
490
1113
|
|
|
1114
|
+
# Internal: Generates import statements for route response types
|
|
1115
|
+
def generate_route_imports(routes)
|
|
1116
|
+
routes
|
|
1117
|
+
.flat_map { |r| r.operations.map(&:response_type) }
|
|
1118
|
+
.compact
|
|
1119
|
+
.uniq
|
|
1120
|
+
.map { |type| type.split("[]").first.delete(":") }
|
|
1121
|
+
.reject { |type| type == "unknown" }
|
|
1122
|
+
.uniq
|
|
1123
|
+
.map { |type| %(import "./models/#{type}.tsp";\n) }
|
|
1124
|
+
.join
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Internal: Wraps resources in namespace with proper indentation
|
|
1128
|
+
def wrap_routes_in_namespace(resources)
|
|
1129
|
+
title = config.namespace ? "#{config.namespace} API" : "API"
|
|
1130
|
+
service_decorator = %(@service(\#{\n title: "#{title}",\n}))
|
|
1131
|
+
|
|
1132
|
+
if config.namespace
|
|
1133
|
+
indented = indent_lines(resources, spaces: 4)
|
|
1134
|
+
<<~TSP
|
|
1135
|
+
#{service_decorator}
|
|
1136
|
+
namespace #{config.namespace} {
|
|
1137
|
+
namespace Routes {
|
|
1138
|
+
#{indented}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
TSP
|
|
1142
|
+
else
|
|
1143
|
+
indented = indent_lines(resources, spaces: 2)
|
|
1144
|
+
<<~TSP
|
|
1145
|
+
#{service_decorator}
|
|
1146
|
+
namespace Routes {
|
|
1147
|
+
#{indented}
|
|
1148
|
+
}
|
|
1149
|
+
TSP
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# Internal: Indents each line and strips trailing whitespace
|
|
1154
|
+
def indent_lines(text, spaces:)
|
|
1155
|
+
text.lines.map { |line| "#{' ' * spaces}#{line}".rstrip + "\n" }.join.rstrip
|
|
1156
|
+
end
|
|
1157
|
+
|
|
491
1158
|
def default_config(root)
|
|
492
1159
|
Config.new(
|
|
493
1160
|
# The base serializers that all other serializers extend.
|
|
@@ -501,9 +1168,22 @@ module TypeSpecFromSerializers
|
|
|
501
1168
|
|
|
502
1169
|
# Remove the serializer suffix from the class name.
|
|
503
1170
|
name_from_serializer: ->(name) {
|
|
504
|
-
name.split("::").map { |n| n.delete_suffix("Serializer") }.join("::")
|
|
1171
|
+
transformed = name.split("::").map { |n| n.delete_suffix("Serializer") }.join("::")
|
|
1172
|
+
# Check for TypeSpec language keyword conflicts (always problematic)
|
|
1173
|
+
final_name = transformed.split("::").map do |part|
|
|
1174
|
+
if TYPESPEC_LANGUAGE_KEYWORDS.include?(part)
|
|
1175
|
+
warn "Warning: TypeSpec model name '#{part}' conflicts with reserved keyword. Renaming to '#{part}_'"
|
|
1176
|
+
"#{part}_"
|
|
1177
|
+
else
|
|
1178
|
+
part
|
|
1179
|
+
end
|
|
1180
|
+
end.join("::")
|
|
1181
|
+
final_name
|
|
505
1182
|
},
|
|
506
1183
|
|
|
1184
|
+
# Controller suffix for route generation reporting
|
|
1185
|
+
controller_suffix: "Controller",
|
|
1186
|
+
|
|
507
1187
|
# Types that don't need to be imported in TypeSpec.
|
|
508
1188
|
global_types: [
|
|
509
1189
|
"Array",
|
|
@@ -544,23 +1224,59 @@ module TypeSpecFromSerializers
|
|
|
544
1224
|
uuid: :string,
|
|
545
1225
|
},
|
|
546
1226
|
|
|
547
|
-
# Map Rails actions to TypeSpec operations
|
|
548
|
-
action_to_operation_mapping: {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
"
|
|
553
|
-
"
|
|
1227
|
+
# Map Rails actions to TypeSpec operations (only include mappings that differ)
|
|
1228
|
+
action_to_operation_mapping: {},
|
|
1229
|
+
|
|
1230
|
+
# Maps Sorbet types to TypeSpec types (optional Sorbet integration)
|
|
1231
|
+
sorbet_to_typespec_type_mapping: {
|
|
1232
|
+
"String" => :string,
|
|
1233
|
+
"Integer" => :int32,
|
|
1234
|
+
"Float" => :float64,
|
|
1235
|
+
"TrueClass" => :boolean,
|
|
1236
|
+
"FalseClass" => :boolean,
|
|
1237
|
+
"T::Boolean" => :boolean,
|
|
1238
|
+
"Date" => :plainDate,
|
|
1239
|
+
"DateTime" => :utcDateTime,
|
|
1240
|
+
"Time" => :utcDateTime,
|
|
1241
|
+
"Symbol" => :string,
|
|
1242
|
+
"number" => :float64, # Common alias for numeric types
|
|
1243
|
+
"object" => "Record<unknown>", # Deprecated 'object' type in TypeSpec
|
|
554
1244
|
},
|
|
555
1245
|
|
|
556
1246
|
# Allows to transform keys, useful when converting objects client-side.
|
|
557
1247
|
transform_keys: nil,
|
|
558
1248
|
|
|
559
1249
|
# Allows scoping typespec definitions to a namespace
|
|
560
|
-
|
|
1250
|
+
# Default to Rails app name, or "Schema" as fallback
|
|
1251
|
+
namespace: (defined?(Rails) && Rails.application) ? Rails.application.class.module_parent_name : "Schema",
|
|
1252
|
+
|
|
1253
|
+
# Filter routes to export (similar to js_from_routes)
|
|
1254
|
+
export_if: ->(route) { route.defaults.fetch(:export, nil) },
|
|
1255
|
+
|
|
1256
|
+
# Suffix for param methods to extract types from (e.g., video_params)
|
|
1257
|
+
param_method_suffix: "_params",
|
|
1258
|
+
|
|
1259
|
+
# Package manager to use for TypeSpec compilation (npm, pnpm, bun, yarn)
|
|
1260
|
+
package_manager: detect_package_manager(root),
|
|
1261
|
+
|
|
1262
|
+
# Path where the compiled OpenAPI spec should be placed
|
|
1263
|
+
openapi_path: root.join("public", "openapi.yaml"),
|
|
1264
|
+
|
|
1265
|
+
# Maximum line length before switching to multiline format for operations
|
|
1266
|
+
max_line_length: 100,
|
|
1267
|
+
|
|
1268
|
+
# Project root directory
|
|
1269
|
+
root: root,
|
|
561
1270
|
)
|
|
562
1271
|
end
|
|
563
1272
|
|
|
1273
|
+
def detect_package_manager(root)
|
|
1274
|
+
return "pnpm" if root.join("pnpm-lock.yaml").exist?
|
|
1275
|
+
return "yarn" if root.join("yarn.lock").exist?
|
|
1276
|
+
return "bun" if root.join("bun.lockb").exist?
|
|
1277
|
+
"npm" # default fallback
|
|
1278
|
+
end
|
|
1279
|
+
|
|
564
1280
|
# Internal: Writes if the file does not exist or the cache key has changed.
|
|
565
1281
|
# The cache strategy consists of a comment on the first line of the file.
|
|
566
1282
|
#
|
|
@@ -607,7 +1323,7 @@ module TypeSpecFromSerializers
|
|
|
607
1323
|
<<~TSP
|
|
608
1324
|
//
|
|
609
1325
|
// DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
|
|
610
|
-
#{model.used_imports.
|
|
1326
|
+
#{model.used_imports.join unless model.used_imports.empty?}
|
|
611
1327
|
namespace #{config.namespace} {
|
|
612
1328
|
#{model.as_typespec}
|
|
613
1329
|
}
|