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.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ # Public: Builds on top of Ruby I/O open3 providing a friendlier experience.
6
+ module TypeSpecFromSerializers::IO
7
+ class << self
8
+ # Internal: A modified version of capture3 that can continuously print stdout.
9
+ def capture(*cmd, with_output: nil, **opts)
10
+ return Open3.capture3(*cmd, **opts) unless with_output
11
+
12
+ Open3.popen3(*cmd, **opts) do |stdin, stdout, stderr, wait_threads|
13
+ stdin.close
14
+ out = Thread.new { read_lines(stdout, &with_output) }
15
+ err = Thread.new { read_lines(stderr, &with_output) }
16
+ [out.value, err.value, wait_threads.value]
17
+ end
18
+ end
19
+
20
+ # Internal: Reads and yield every line in the stream. Returns the full content.
21
+ def read_lines(io)
22
+ buffer = +""
23
+ while (line = io.gets)
24
+ buffer << line
25
+ yield line if block_given?
26
+ end
27
+ buffer
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runner"
4
+
5
+ module TypeSpecFromSerializers
6
+ module OpenAPICompiler
7
+ class CompilationError < StandardError; end
8
+
9
+ class << self
10
+ # Public: Compiles TypeSpec to OpenAPI specification.
11
+ #
12
+ # Returns the Pathname to the generated OpenAPI file.
13
+ def compile
14
+ require "fileutils"
15
+
16
+ typespec_dir = config.output_dir
17
+ FileUtils.mkdir_p(config.openapi_path.dirname)
18
+
19
+ compile_typespec(typespec_dir)
20
+
21
+ config.openapi_path
22
+ end
23
+
24
+ private
25
+
26
+ def config
27
+ TypeSpecFromSerializers.config
28
+ end
29
+
30
+ def runner
31
+ @runner ||= TypeSpecFromSerializers::Runner.new(config)
32
+ end
33
+
34
+ # Internal: Executes TypeSpec compilation with OpenAPI emitter.
35
+ def compile_typespec(typespec_dir)
36
+ args = [
37
+ "compile", "routes.tsp",
38
+ "--emit", "@typespec/openapi3",
39
+ "--option", "@typespec/openapi3.emitter-output-dir=#{config.openapi_path.dirname}",
40
+ "--option", "@typespec/openapi3.output-file=#{config.openapi_path.basename}",
41
+ ]
42
+
43
+ output, error, status = runner.run(args, chdir: typespec_dir, with_output: ->(line) { print line })
44
+
45
+ unless status.success?
46
+ raise CompilationError, "TypeSpec compilation failed:\n#{output}#{error}"
47
+ end
48
+
49
+ unless config.openapi_path.exist?
50
+ raise CompilationError, "OpenAPI output not found at: #{config.openapi_path}"
51
+ end
52
+ rescue TypeSpecFromSerializers::Runner::MissingExecutableError => e
53
+ raise CompilationError, <<~ERROR
54
+ #{e.message}
55
+
56
+ Please install Node.js and a package manager:
57
+ https://nodejs.org/
58
+ ERROR
59
+ end
60
+ end
61
+ end
62
+ end
@@ -33,15 +33,103 @@ class TypeSpecFromSerializers::Railtie < Rails::Railtie
33
33
  # Suitable when triggering code generation manually.
34
34
  rake_tasks do |app|
35
35
  namespace :typespec_from_serializers do
36
+ desc "Install TypeSpec dependencies"
37
+ task setup: :environment do
38
+ require_relative "generator"
39
+ require_relative "runner"
40
+ require "fileutils"
41
+
42
+ config = TypeSpecFromSerializers.config
43
+ root = config.root
44
+
45
+ puts "Setting up TypeSpec dependencies..."
46
+
47
+ # Create package.json in project root if it doesn't exist
48
+ package_json_path = root.join("package.json")
49
+ unless package_json_path.exist?
50
+ puts "Creating package.json in #{root}..."
51
+ File.write(package_json_path, <<~JSON)
52
+ {
53
+ "private": true,
54
+ "type": "module"
55
+ }
56
+ JSON
57
+ end
58
+
59
+ # Install TypeSpec packages
60
+ puts "Installing TypeSpec packages..."
61
+ deps = %w[@typespec/compiler @typespec/http @typespec/openapi3]
62
+
63
+ install_cmd = case config.package_manager
64
+ when "npm"
65
+ ["npm", "install", "--save-dev", *deps]
66
+ when "pnpm"
67
+ ["pnpm", "add", "-D", *deps]
68
+ when "bun"
69
+ ["bun", "add", "-d", *deps]
70
+ when "yarn"
71
+ ["yarn", "add", "-D", *deps]
72
+ end
73
+
74
+ output, error, status = TypeSpecFromSerializers::IO.capture(
75
+ *install_cmd,
76
+ chdir: root.to_s,
77
+ with_output: ->(line) { print line }
78
+ )
79
+
80
+ if status.success?
81
+ puts "\nTypeSpec dependencies installed successfully! 🎉"
82
+ else
83
+ puts "\nFailed to install dependencies:"
84
+ puts error
85
+ exit 1
86
+ end
87
+ end
88
+
36
89
  desc "Generates TypeSpec descriptions for each serializer in the app."
37
90
  task generate: :environment do
38
91
  require_relative "generator"
39
92
  start_time = Time.zone.now
40
93
  print "Generating TypeSpec descriptions..."
41
- serializers = TypeSpecFromSerializers.generate(force: true)
94
+ result = TypeSpecFromSerializers.generate(force: true)
42
95
  puts "completed in #{(Time.zone.now - start_time).round(2)} seconds.\n"
43
- puts "Found #{serializers.size} serializers:"
44
- puts serializers.map { |s| "\t#{s.name}" }.join("\n")
96
+ puts "Found #{result[:serializers].size} serializers:"
97
+ puts result[:serializers].map { |s| "\t#{s.name}" }.join("\n")
98
+ if result[:controllers].any?
99
+ puts "\nFound #{result[:controllers].size} controllers:"
100
+ puts result[:controllers].map { |c| "\t#{c}" }.join("\n")
101
+ end
102
+ end
103
+
104
+ desc "Generates Sorbet RBI files for serializers"
105
+ task generate_rbi: :environment do
106
+ require_relative "generator"
107
+ require_relative "rbi"
108
+ start_time = Time.zone.now
109
+ print "Generating Sorbet RBI files for serializers..."
110
+ # Load serializers first
111
+ TypeSpecFromSerializers.generate(force: true)
112
+ files = TypeSpecFromSerializers::RBI.generate_for_all_serializers
113
+ puts "completed in #{(Time.zone.now - start_time).round(2)} seconds.\n"
114
+ puts "Generated #{files.size} RBI files:"
115
+ puts files.map { |f| "\t#{f}" }.join("\n")
116
+ end
117
+
118
+ desc "Compiles TypeSpec to OpenAPI specification"
119
+ task compile_openapi: :environment do
120
+ require_relative "generator"
121
+ require_relative "openapi_compiler"
122
+
123
+ start_time = Time.zone.now
124
+ print "Generating TypeSpec descriptions..."
125
+ TypeSpecFromSerializers.generate(force: true)
126
+ puts "done."
127
+
128
+ print "Compiling TypeSpec to OpenAPI..."
129
+ output_path = TypeSpecFromSerializers::OpenAPICompiler.compile
130
+ duration = (Time.zone.now - start_time).round(2)
131
+ puts "completed in #{duration} seconds.\n"
132
+ puts "OpenAPI spec generated at: #{output_path}"
45
133
  end
46
134
  end
47
135
  end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbi"
4
+
5
+ module TypeSpecFromSerializers
6
+ # Public: Generates Sorbet RBI files for serializers.
7
+ #
8
+ # This generator creates type signatures for serializer class methods
9
+ # (.one, .many, .one_as_hash, .many_as_hash) that can be used by
10
+ # Sorbet for static type checking.
11
+ module RBI
12
+ # Internal: Reusable type builders for common Sorbet types.
13
+ module Types
14
+ module_function
15
+
16
+ # T::Hash[Symbol, T.untyped]
17
+ def hash
18
+ @hash ||= ::RBI::Type.generic("T::Hash", [
19
+ ::RBI::Type.simple("Symbol"),
20
+ ::RBI::Type.untyped,
21
+ ])
22
+ end
23
+
24
+ # T.nilable(T::Hash[Symbol, T.untyped])
25
+ def optional_hash
26
+ @optional_hash ||= ::RBI::Type.nilable(hash)
27
+ end
28
+
29
+ # T::Array[T::Hash[Symbol, T.untyped]]
30
+ def array_of_hashes
31
+ @array_of_hashes ||= ::RBI::Type.generic("T::Array", [hash])
32
+ end
33
+
34
+ # T.any(T::Array[ModelType], ActiveRecord::Relation)
35
+ def items_type(model_type)
36
+ ::RBI::Type.any(
37
+ ::RBI::Type.generic("T::Array", [::RBI::Type.simple(model_type)]),
38
+ ::RBI::Type.simple("ActiveRecord::Relation"),
39
+ )
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Public: Generate RBI content for a single serializer.
45
+ #
46
+ # serializer_class - The serializer class to generate RBI for
47
+ #
48
+ # Returns String with RBI content or nil
49
+ def generate_for_serializer(serializer_class)
50
+ model_class = infer_model_class(serializer_class)
51
+ return nil unless model_class
52
+
53
+ build_rbi_file(serializer_class, model_class).string
54
+ end
55
+
56
+ # Public: Generate RBI files for all serializers.
57
+ #
58
+ # output_dir - Optional output directory (defaults to sorbet/rbi/dsl)
59
+ #
60
+ # Returns Array of file paths that were written
61
+ def generate_for_all_serializers(output_dir: nil)
62
+ output_dir ||= default_output_dir
63
+ output_dir.mkpath unless output_dir.exist?
64
+
65
+ formatter = ::RBI::Formatter.new(sort_nodes: true, group_nodes: true)
66
+
67
+ TypeSpecFromSerializers.serializers.filter_map do |serializer_class|
68
+ model_class = infer_model_class(serializer_class)
69
+ next unless model_class
70
+
71
+ file_name = serializer_class.name.underscore.tr("/", "_")
72
+ file_path = output_dir.join("#{file_name}.rbi")
73
+
74
+ file = build_rbi_file(serializer_class, model_class)
75
+ File.write(file_path, formatter.print_file(file))
76
+ file_path
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Internal: Build RBI file for a serializer.
83
+ #
84
+ # serializer_class - The serializer class
85
+ # model_class - The inferred model class
86
+ #
87
+ # Returns RBI::File
88
+ def build_rbi_file(serializer_class, model_class)
89
+ ::RBI::File.new(strictness: "strong") do |file|
90
+ file << ::RBI::Class.new(serializer_class.name) do |klass|
91
+ build_serializer_methods(klass, model_class.name)
92
+ end
93
+ end
94
+ end
95
+
96
+ # Internal: Add all serializer methods to a class.
97
+ #
98
+ # klass - RBI::Class to add methods to
99
+ # model_type - Name of the model class (String)
100
+ #
101
+ # Returns nothing
102
+ def build_serializer_methods(klass, model_type)
103
+ [
104
+ ["one", false],
105
+ ["one_as_hash", false],
106
+ ["many", true],
107
+ ["many_as_hash", true],
108
+ ].each do |method_name, is_array|
109
+ klass << build_method(method_name, model_type, is_array: is_array)
110
+ end
111
+ end
112
+
113
+ # Internal: Build a serializer method with signature.
114
+ #
115
+ # method_name - Name of the method (String)
116
+ # model_type - Name of the model class (String)
117
+ # is_array - Boolean indicating if method returns array
118
+ #
119
+ # Returns RBI::Method
120
+ def build_method(method_name, model_type, is_array:)
121
+ # Build signature and method parameters
122
+ sig_params, method_params = if is_array
123
+ [
124
+ [
125
+ ::RBI::SigParam.new("items", Types.items_type(model_type).to_s),
126
+ ::RBI::SigParam.new("options", Types.optional_hash.to_s),
127
+ ],
128
+ [
129
+ ::RBI::ReqParam.new("items"),
130
+ ::RBI::OptParam.new("options", "nil"),
131
+ ],
132
+ ]
133
+ else
134
+ [
135
+ [
136
+ ::RBI::SigParam.new("item", model_type),
137
+ ::RBI::SigParam.new("options", Types.optional_hash.to_s),
138
+ ],
139
+ [
140
+ ::RBI::ReqParam.new("item"),
141
+ ::RBI::OptParam.new("options", "nil"),
142
+ ],
143
+ ]
144
+ end
145
+
146
+ # Determine return type
147
+ return_type = is_array ? Types.array_of_hashes.to_s : Types.hash.to_s
148
+
149
+ # Create method and add signature
150
+ ::RBI::Method.new(method_name, is_singleton: true, params: method_params).tap do |method|
151
+ method.add_sig(params: sig_params, return_type: return_type)
152
+ end
153
+ end
154
+
155
+ # Internal: Get the default output directory for RBI files.
156
+ #
157
+ # Returns Pathname
158
+ def default_output_dir
159
+ TypeSpecFromSerializers.rbi_dir.join("dsl")
160
+ end
161
+
162
+ # Internal: Infer the model class for a serializer.
163
+ #
164
+ # serializer_class - The serializer class
165
+ #
166
+ # Returns Class or nil
167
+ def infer_model_class(serializer_class)
168
+ # Try to get from object_as declaration
169
+ if serializer_class.respond_to?(:object_name) && serializer_class.object_name
170
+ model_name = serializer_class.object_name.to_s.camelize
171
+ model_class = model_name.safe_constantize
172
+ return model_class if model_class
173
+ end
174
+
175
+ # Try to get from explicit model declaration
176
+ if serializer_class.respond_to?(:model_name) && serializer_class.model_name
177
+ return serializer_class.model_name.safe_constantize
178
+ end
179
+
180
+ # Fall back to naming convention using configured name transformer
181
+ inferred_name = TypeSpecFromSerializers.config.name_from_serializer.call(serializer_class.name)
182
+ inferred_name.safe_constantize
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "io"
4
+
5
+ # Public: Executes TypeSpec commands, providing conveniences for debugging.
6
+ class TypeSpecFromSerializers::Runner
7
+ class MissingExecutableError < StandardError; end
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Public: Executes TypeSpec with the specified arguments.
14
+ def run(argv, chdir: nil, with_output: nil)
15
+ cmd = command_for(argv)
16
+ opts = {}
17
+ opts[:chdir] = chdir if chdir
18
+
19
+ TypeSpecFromSerializers::IO.capture(*cmd, with_output: with_output, **opts)
20
+ rescue Errno::ENOENT => error
21
+ raise MissingExecutableError, "TypeSpec executable not found: #{error.message}"
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :config
27
+
28
+ # Internal: Returns an Array with the command to run.
29
+ def command_for(args)
30
+ [].tap do |cmd|
31
+ cmd.push(*tsp_executable)
32
+ cmd.push(*args)
33
+ end
34
+ end
35
+
36
+ # Internal: Resolves to an executable for TypeSpec.
37
+ def tsp_executable
38
+ local_tsp = config.root.join("node_modules", ".bin", "tsp")
39
+ return [local_tsp.to_s] if local_tsp.exist?
40
+
41
+ case config.package_manager
42
+ when "npm"
43
+ %w[npx --yes --package=@typespec/compiler tsp]
44
+ when "pnpm"
45
+ %w[pnpm dlx --package=@typespec/compiler tsp]
46
+ when "bun"
47
+ %w[bunx --bun @typespec/compiler tsp]
48
+ when "yarn"
49
+ %w[yarn dlx --package=@typespec/compiler tsp]
50
+ else
51
+ raise ArgumentError, "Unknown package manager: #{config.package_manager.inspect}"
52
+ end
53
+ end
54
+ end