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.
@@ -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 ||= begin
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
- custom_type_imports = attribute_types
134
- .flat_map { |type| extract_typespec_types(type.to_s) }
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
- (custom_type_imports + serializer_type_imports)
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
- elsif (enum = defined_enums[column_name.to_s])
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
- elsif (column = columns_hash[column_name.to_s])
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
- elsif tsp_model
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
- type || TypeSpecFromSerializers.config.unknown_type
313
+ TypeSpecFromSerializers.config.unknown_type
215
314
  end
216
315
 
217
- "#{name}#{"?" if optional}: #{type_str}#{"[]" if multi};"
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
- <<~TSP
225
- #{" " * 1}@route("#{path}")
226
- #{" " * 1}interface #{name} {
227
- #{" " * 1}#{operations.map(&:as_typespec).join("\n ")}
228
- #{" " * 1}}
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
- method_map = {
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
- "#{" " * 1}@#{tsp_method} #{operation_name}#{params_str}: #{response_type.gsub("::", "")};"
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
- params = []
253
- params += path_params.map { |param| "@path #{param}: string" } if path_params.any?
254
- params << "@body body: #{response_type.gsub("::", "")}" if %w[POST PUT PATCH].include?(method)
255
- params.join(", ")
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
- config.output_dir.rmtree if force && config.output_dir.exist?
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
- loaded_serializers.each do |serializer|
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
- private
381
-
382
- def root
383
- defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
384
- end
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
- def all_serializer_files
391
- config.serializers_dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.sort
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
- def load_serializers(files)
395
- files.each { |file| require file }
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.defaults[:controller]
639
+ controller = namespace_for_route(route)
416
640
  action = route.defaults[:action]
417
- method = route.verb.split("|").first
418
- path = route.path.spec.to_s.sub("(.:format)", "")
419
- response_type = infer_response_type(controller, action) || "unknown"
420
-
421
- unless hash[controller].any? { |r| r[:method] == method && r[:action] == action }
422
- hash[controller] << {
423
- method: method,
424
- action: action,
425
- path: path,
426
- response_type: response_type,
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
- routes_by_controller.map do |controller, routes|
432
- path_segments = routes.map { |r| r[:path].split("/")[1..-1] || [] }.uniq.sort_by(&:length)
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
- operations = routes.map do |route|
436
- path_params = route[:path].scan(/{([^}]+)}/).flatten
437
- response_type = if route[:response_type] == route[:action]
438
- "unknown"
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}Controller".safe_constantize
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
- serializer_class = "#{model_name}Serializer".safe_constantize
464
- serializer_class&.tsp_name
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.flat_map { |r| r.operations.map(&:response_type) }.compact.uniq.map do |type|
470
- base_type = (type || "unknown").split("[]").first.gsub("::", "")
471
- next if base_type == "unknown"
472
- relative_path = "./#{base_type}.tsp"
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
- namespace Routes {
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
- "index" => "list",
550
- "show" => "read",
551
- "create" => "create",
552
- "update" => "update",
553
- "destroy" => "delete",
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
- namespace: nil,
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.empty? ? "export {}\n" : model.used_imports.join}
1326
+ #{model.used_imports.join unless model.used_imports.empty?}
611
1327
  namespace #{config.namespace} {
612
1328
  #{model.as_typespec}
613
1329
  }