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
|
@@ -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
|
-
|
|
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
|