openapi_kit-codegen 0.1.0.pre.1

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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +385 -0
  4. data/exe/openapi_kit +6 -0
  5. data/lib/openapi_kit/codegen/cli.rb +72 -0
  6. data/lib/openapi_kit/codegen/config.rb +114 -0
  7. data/lib/openapi_kit/codegen/emit/buffer.rb +92 -0
  8. data/lib/openapi_kit/codegen/emit/codecs.rb +221 -0
  9. data/lib/openapi_kit/codegen/emit/controllers.rb +263 -0
  10. data/lib/openapi_kit/codegen/emit/decode.rb +43 -0
  11. data/lib/openapi_kit/codegen/emit/defaults.rb +42 -0
  12. data/lib/openapi_kit/codegen/emit/emitter.rb +17 -0
  13. data/lib/openapi_kit/codegen/emit/forms.rb +56 -0
  14. data/lib/openapi_kit/codegen/emit/handlers.rb +65 -0
  15. data/lib/openapi_kit/codegen/emit/literal.rb +28 -0
  16. data/lib/openapi_kit/codegen/emit/operations.rb +277 -0
  17. data/lib/openapi_kit/codegen/emit/registry.rb +101 -0
  18. data/lib/openapi_kit/codegen/emit/routes.rb +77 -0
  19. data/lib/openapi_kit/codegen/emit/security.rb +183 -0
  20. data/lib/openapi_kit/codegen/emit/source_file.rb +30 -0
  21. data/lib/openapi_kit/codegen/emit/types.rb +153 -0
  22. data/lib/openapi_kit/codegen/generator.rb +48 -0
  23. data/lib/openapi_kit/codegen/loader.rb +730 -0
  24. data/lib/openapi_kit/codegen/model/document.rb +302 -0
  25. data/lib/openapi_kit/codegen/model/schema.rb +110 -0
  26. data/lib/openapi_kit/codegen/model/type_def.rb +89 -0
  27. data/lib/openapi_kit/codegen/naming.rb +78 -0
  28. data/lib/openapi_kit/codegen/ruby_type.rb +48 -0
  29. data/lib/openapi_kit/codegen/type_registry.rb +325 -0
  30. data/lib/openapi_kit/codegen/writer.rb +92 -0
  31. data/lib/openapi_kit/codegen.rb +37 -0
  32. data/lib/openapi_kit-codegen.rb +4 -0
  33. metadata +118 -0
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ class Handlers
8
+ extend T::Sig
9
+ include Emitter
10
+
11
+ sig { params(document: Model::Document, config: Config).void }
12
+ def initialize(document:, config:)
13
+ @document = document
14
+ @config = config
15
+ end
16
+
17
+ sig { params(tag: String).returns(String) }
18
+ def self.module_name(tag) = Naming.pascal(tag)
19
+
20
+ sig { override.returns(T::Array[SourceFile]) }
21
+ def render
22
+ by_tag.map do |tag, operations|
23
+ Source.file(path: "#{@config.module_path}/handlers/#{Naming.snake(tag)}.rb",
24
+ modules: @config.modules + ["Handlers"]) do |buffer|
25
+ emit_interface(buffer, tag, operations)
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ sig { returns(T::Hash[String, T::Array[Model::Operation]]) }
33
+ def by_tag = @document.operations.group_by(&:tag)
34
+
35
+ sig { params(buffer: Buffer, tag: String, operations: T::Array[Model::Operation]).void }
36
+ def emit_interface(buffer, tag, operations)
37
+ buffer.nest("module #{Handlers.module_name(tag)}") do
38
+ buffer.line("extend T::Sig")
39
+ buffer.line("extend T::Helpers")
40
+ buffer.line("interface!")
41
+
42
+ operations.each do |operation|
43
+ buffer.blank
44
+ emit_method(buffer, operation)
45
+ end
46
+ end
47
+ end
48
+
49
+ sig { params(buffer: Buffer, operation: Model::Operation).void }
50
+ def emit_method(buffer, operation)
51
+ scope = "#{@config.namespace}::Operations::#{Emit::Operations.module_name(operation)}"
52
+
53
+ buffer.nest("sig do") do
54
+ buffer.line("abstract")
55
+ buffer.indent do
56
+ buffer.line(".params(request: #{scope}::Request)")
57
+ buffer.line(".returns(#{scope}::Response)")
58
+ end
59
+ end
60
+ buffer.line("def #{Naming.identifier(operation.id)}(request:); end")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ module Literal
8
+ extend T::Sig
9
+
10
+ class Expr < T::Struct
11
+ const :code, String
12
+ end
13
+
14
+ sig { params(code: String).returns(Expr) }
15
+ def self.expr(code) = Expr.new(code: code)
16
+
17
+ sig { params(parts: T.any(String, Expr)).returns(String) }
18
+ def self.string(*parts)
19
+ body = parts.map do |part|
20
+ part.is_a?(Expr) ? "\#{#{part.code}}" : T.must(part.dump[1..-2])
21
+ end
22
+
23
+ %("#{body.join}")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,277 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ class Operations
8
+ extend T::Sig
9
+ include Emitter
10
+
11
+ sig { params(document: Model::Document, registry: TypeRegistry, config: Config).void }
12
+ def initialize(document:, registry:, config:)
13
+ @document = document
14
+ @registry = registry
15
+ @config = config
16
+ end
17
+
18
+ sig { override.returns(T::Array[SourceFile]) }
19
+ def render
20
+ @document.operations.map do |operation|
21
+ Source.file(path: "#{@config.module_path}/operations/#{Naming.snake(operation.id)}.rb",
22
+ modules: @config.modules + ["Operations"]) do |buffer|
23
+ emit_operation(buffer, operation)
24
+ end
25
+ end
26
+ end
27
+
28
+ sig { params(operation: Model::Operation).returns(String) }
29
+ def self.module_name(operation) = Naming.pascal(operation.id)
30
+
31
+ GROUPS = T.let(
32
+ {
33
+ "Path" => Model::PathParameter,
34
+ "Query" => Model::QueryParameter,
35
+ "Headers" => Model::HeaderParameter,
36
+ "Cookies" => Model::CookieParameter
37
+ }.freeze,
38
+ T::Hash[String, T::Class[T.anything]]
39
+ )
40
+
41
+ private
42
+
43
+ sig { params(buffer: Buffer, operation: Model::Operation).void }
44
+ def emit_operation(buffer, operation)
45
+ buffer.nest("module #{Operations.module_name(operation)}") do
46
+ emit_parameter_structs(buffer, operation)
47
+ emit_request(buffer, operation)
48
+ buffer.blank
49
+ emit_response(buffer, operation)
50
+ end
51
+ end
52
+
53
+ sig { params(operation: Model::Operation).returns(T::Hash[String, T::Array[Model::Parameter]]) }
54
+ def groups(operation)
55
+ GROUPS.filter_map do |name, kind|
56
+ found = operation.parameters.grep(kind)
57
+ found.empty? ? nil : [name, found]
58
+ end.to_h
59
+ end
60
+
61
+ sig { params(buffer: Buffer, operation: Model::Operation).void }
62
+ def emit_parameter_structs(buffer, operation)
63
+ groups(operation).each do |name, parameters|
64
+ buffer.nest("class #{name} < T::Struct") do
65
+ buffer.line("extend T::Sig")
66
+ buffer.blank
67
+ parameters.each { |parameter| buffer.line(parameter_prop(parameter)) }
68
+ end
69
+ buffer.blank
70
+ end
71
+ end
72
+
73
+ sig { params(parameter: Model::Parameter).returns(String) }
74
+ def parameter_prop(parameter)
75
+ info = Model::Parameter.info(parameter)
76
+ meta = Model::Schema.meta(info.schema)
77
+ base = @registry.sorbet_type(info.schema)
78
+ default = meta.default
79
+
80
+ if default
81
+ clause = Defaults.clause(schema: info.schema, default: default, registry: @registry)
82
+ type = Defaults.nilable?(required: true, meta: meta) ? "T.nilable(#{base})" : base
83
+ return "const :#{info.identifier}, #{type}, #{clause}"
84
+ end
85
+
86
+ return "const :#{info.identifier}, #{base}" if Model::Parameter.required?(parameter) && !meta.nullable
87
+
88
+ "const :#{info.identifier}, T.nilable(#{base})"
89
+ end
90
+
91
+ sig { params(buffer: Buffer, operation: Model::Operation).void }
92
+ def emit_request(buffer, operation)
93
+ buffer.nest("class Request < T::Struct") do
94
+ buffer.line("extend T::Sig")
95
+ buffer.blank
96
+ groups(operation).each_key { |name| buffer.line("const :#{Naming.identifier(name)}, #{name}") }
97
+ body = body_type(operation)
98
+ buffer.line("const :body, #{body}") if body
99
+ principal = principal_type(operation)
100
+ buffer.line("const :principal, #{principal}") if principal
101
+ buffer.line("const :http_request, ::ActionDispatch::Request")
102
+ end
103
+ end
104
+
105
+ # Present only when `principals` is configured and the operation is protected:
106
+ # whatever the alternative that authenticated the request produced.
107
+ sig { params(operation: Model::Operation).returns(T.nilable(String)) }
108
+ def principal_type(operation)
109
+ return nil if @config.principal.nil?
110
+
111
+ requirements = @document.security_for(operation)
112
+ return nil if requirements.empty?
113
+
114
+ Security.principal_type(requirements, @config)
115
+ end
116
+
117
+ sig { params(operation: Model::Operation).returns(T.nilable(String)) }
118
+ def body_type(operation)
119
+ body = operation.request_body
120
+ return nil if body.nil?
121
+
122
+ content = single_content(body.contents, "the request body of #{operation.id}")
123
+ schema = content&.schema
124
+ return nil if schema.nil?
125
+
126
+ type = @registry.sorbet_type(schema)
127
+ body.required ? type : "T.nilable(#{type})"
128
+ end
129
+
130
+ sig { params(contents: T::Array[Model::Content], where: String).returns(T.nilable(Model::Content)) }
131
+ def single_content(contents, where)
132
+ return nil if contents.empty?
133
+ return contents.first if contents.one?
134
+
135
+ raise SchemaError,
136
+ "#{where} declares #{contents.size} content types " \
137
+ "(#{contents.map(&:media_type).join(", ")}). openapi_kit supports one content type per " \
138
+ "request body; split the alternatives into separate operations."
139
+ end
140
+
141
+ sig { params(buffer: Buffer, operation: Model::Operation).void }
142
+ def emit_response(buffer, operation)
143
+ variants = variants_for(operation)
144
+
145
+ buffer.nest("module Response") do
146
+ buffer.line("extend T::Helpers")
147
+ buffer.line("include ::OpenAPIKit::Response")
148
+ buffer.line("abstract!")
149
+ buffer.line("sealed!")
150
+ end
151
+
152
+ variants.each do |variant|
153
+ buffer.blank
154
+ emit_variant(buffer, variant)
155
+ end
156
+ end
157
+
158
+ module Payload
159
+ extend T::Helpers
160
+ abstract!
161
+ sealed!
162
+
163
+ class Empty < T::Struct
164
+ include Payload
165
+ end
166
+
167
+ class Json < T::Struct
168
+ include Payload
169
+
170
+ const :schema, Model::Schema
171
+ end
172
+
173
+ class Stream < T::Struct
174
+ include Payload
175
+ end
176
+ end
177
+
178
+ class Variant < T::Struct
179
+ const :name, String
180
+ const :status, Model::Status
181
+ const :media_type, T.nilable(String)
182
+ const :payload, Payload
183
+ end
184
+
185
+ sig { params(operation: Model::Operation).returns(T::Array[Variant]) }
186
+ def variants_for(operation)
187
+ operation.responses.flat_map do |response|
188
+ base = Model::Status.constant(response.status)
189
+ next [empty_variant(base, response.status)] if response.contents.empty?
190
+
191
+ multiple = response.contents.size > 1
192
+ response.contents.map do |content|
193
+ suffix = multiple ? Naming.pascal(content.media_type.split("/").last.to_s.split("+").first.to_s) : ""
194
+ Variant.new(name: "#{base}#{suffix}", status: response.status,
195
+ media_type: content.media_type, payload: payload_for(content))
196
+ end
197
+ end
198
+ end
199
+
200
+ sig { params(name: String, status: Model::Status).returns(Variant) }
201
+ def empty_variant(name, status)
202
+ Variant.new(name: name, status: status, media_type: nil, payload: Payload::Empty.new)
203
+ end
204
+
205
+ sig { params(content: Model::Content).returns(Payload) }
206
+ def payload_for(content)
207
+ schema = content.schema
208
+ return Payload::Empty.new if schema.nil?
209
+ return Payload::Stream.new if Model::Schema.file?(schema)
210
+
211
+ Payload::Json.new(schema: schema)
212
+ end
213
+
214
+ sig { params(buffer: Buffer, variant: Variant).void }
215
+ def emit_variant(buffer, variant)
216
+ buffer.nest("class #{variant.name} < T::Struct") do
217
+ buffer.line("extend T::Sig")
218
+ buffer.line("include Response")
219
+ buffer.blank
220
+
221
+ props = variant_props(variant)
222
+ props.each { |prop| buffer.line(prop) }
223
+ buffer.blank unless props.empty?
224
+
225
+ buffer.line("sig { override.returns(::Integer) }")
226
+ buffer.line(status_method(variant.status))
227
+ buffer.blank
228
+ buffer.line("sig { override.returns(::OpenAPIKit::Body) }")
229
+ buffer.line("def to_body = #{to_body(variant.payload)}")
230
+ buffer.blank
231
+ buffer.line("sig { override.returns(T.nilable(::String)) }")
232
+ buffer.line("def content_type = #{variant.media_type.inspect}")
233
+ end
234
+ end
235
+
236
+ sig { params(variant: Variant).returns(T::Array[String]) }
237
+ def variant_props(variant)
238
+ props = payload_props(variant.payload)
239
+ return props unless variant.status.is_a?(Model::DefaultStatus)
240
+
241
+ props + ["const :status_code, ::Integer"]
242
+ end
243
+
244
+ sig { params(payload: Payload).returns(T::Array[String]) }
245
+ def payload_props(payload)
246
+ case payload
247
+ when Payload::Empty then []
248
+ when Payload::Json then ["const :body, #{@registry.sorbet_type(payload.schema)}"]
249
+ when Payload::Stream then ["const :body, #{TypeRegistry::BINARY}"]
250
+ else T.absurd(payload)
251
+ end
252
+ end
253
+
254
+ sig { params(payload: Payload).returns(String) }
255
+ def to_body(payload)
256
+ case payload
257
+ when Payload::Empty then "::OpenAPIKit::Body::Empty.new"
258
+ when Payload::Json
259
+ "::OpenAPIKit::Body::Json.new(wire: #{@registry.to_wire_expr(payload.schema, value: "body")})"
260
+ when Payload::Stream then "body"
261
+ else T.absurd(payload)
262
+ end
263
+ end
264
+
265
+ sig { params(status: Model::Status).returns(String) }
266
+ def status_method(status)
267
+ case status
268
+ when Model::StatusCode then "def status = #{status.code}"
269
+ when Model::StatusRange then "def status = #{status.hundreds * 100}"
270
+ when Model::DefaultStatus then "def status = status_code"
271
+ else T.absurd(status)
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,101 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ # The typed boundary between openapi_kit's interfaces and an application's objects. Rails
8
+ # instantiates controllers itself, so a controller cannot be handed the registry: it
9
+ # reads it from the accessor, which an application assigns at boot.
10
+ class Registry
11
+ extend T::Sig
12
+ include Emitter
13
+
14
+ # One entry per thing an application must supply.
15
+ class Slot < T::Struct
16
+ const :reader, String
17
+ const :interface, String
18
+ end
19
+
20
+ sig { params(document: Model::Document, config: Config).void }
21
+ def initialize(document:, config:)
22
+ @document = document
23
+ @config = config
24
+ end
25
+
26
+ sig { override.returns(T::Array[SourceFile]) }
27
+ def render
28
+ return [] if slots.empty?
29
+
30
+ [
31
+ Source.file(path: "#{@config.module_path}/registry.rb",
32
+ modules: @config.modules) { |buffer| emit_registry(buffer) },
33
+ Source.file(path: "#{@config.module_path}.rb",
34
+ modules: @config.modules) { |buffer| emit_accessor(buffer) }
35
+ ]
36
+ end
37
+
38
+ private
39
+
40
+ sig { returns(T::Array[Slot]) }
41
+ def slots
42
+ handlers = @document.tags.map do |tag|
43
+ Slot.new(reader: Naming.snake(tag),
44
+ interface: "#{@config.namespace}::Handlers::#{Emit::Handlers.module_name(tag)}")
45
+ end
46
+
47
+ handlers + authenticated.map do |name|
48
+ Slot.new(reader: Naming.snake(name),
49
+ interface: "#{@config.namespace}::Security::#{Emit::Security.module_name(name)}")
50
+ end
51
+ end
52
+
53
+ sig { returns(T::Array[String]) }
54
+ def authenticated
55
+ return [] if @config.principal.nil?
56
+
57
+ @document.operations.flat_map do |operation|
58
+ @document.security_for(operation).flat_map { |requirement| requirement.schemes.keys }
59
+ end.uniq
60
+ end
61
+
62
+ sig { params(buffer: Buffer).void }
63
+ def emit_registry(buffer)
64
+ buffer.nest("class Registry < T::Struct") do
65
+ slots.each { |slot| buffer.line("const :#{slot.reader}, #{slot.interface}") }
66
+ end
67
+ end
68
+
69
+ # Rails instantiates controllers itself, so a controller cannot be handed the
70
+ # registry. It reads it from here, and an application assigns it at boot.
71
+ sig { params(buffer: Buffer).void }
72
+ def emit_accessor(buffer)
73
+ buffer.line("extend T::Sig")
74
+ buffer.blank
75
+ buffer.line("@registry = T.let(nil, T.nilable(Registry))")
76
+ buffer.blank
77
+ buffer.line("sig { params(registry: Registry).void }")
78
+ buffer.nest("def self.registry=(registry)") do
79
+ buffer.line("@registry = registry")
80
+ end
81
+ buffer.blank
82
+ buffer.line("sig { returns(Registry) }")
83
+ buffer.nest("def self.registry") do
84
+ buffer.line("@registry || raise(")
85
+ buffer.indent { unset_message.each { |line| buffer.line(line) } }
86
+ buffer.line(")")
87
+ end
88
+ end
89
+
90
+ sig { returns(T::Array[String]) }
91
+ def unset_message
92
+ namespace = @config.namespace
93
+ [
94
+ %("#{namespace}.registry has not been assigned. Build one in an initializer, " \\),
95
+ %("e.g. #{namespace}.registry = #{namespace}::Registry.new(...).")
96
+ ]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,77 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ class Routes
8
+ extend T::Sig
9
+ include Emitter
10
+
11
+ sig { params(document: Model::Document, config: Config).void }
12
+ def initialize(document:, config:)
13
+ @document = document
14
+ @config = config
15
+ end
16
+
17
+ sig { override.returns(T::Array[SourceFile]) }
18
+ def render
19
+ return [] if @document.operations.empty?
20
+
21
+ [
22
+ Source.file(path: "#{@config.module_path}/routes.rb",
23
+ modules: @config.modules + ["Routes"]) { |buffer| emit_body(buffer) }
24
+ ]
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(buffer: Buffer).void }
30
+ def emit_body(buffer)
31
+ buffer.line("extend T::Sig")
32
+ buffer.blank
33
+ buffer.line("sig { params(mapper: ::ActionDispatch::Routing::Mapper).void }")
34
+ buffer.nest("def self.draw(mapper)") do
35
+ ordered(@document.operations).each { |operation| buffer.line(route_for(operation)) }
36
+ end
37
+ end
38
+
39
+ # Rails matches routes in declaration order, so a templated segment declared first
40
+ # swallows a concrete one: /mods/{id} would answer GET /mods/featured. OpenAPI paths
41
+ # are unordered, so the emitter imposes concrete-before-templated per segment.
42
+ sig { params(operations: T::Array[Model::Operation]).returns(T::Array[Model::Operation]) }
43
+ def ordered(operations)
44
+ operations.each_with_index.sort_by { |operation, index| [template_flags(operation), index] }
45
+ .map(&:first)
46
+ end
47
+
48
+ sig { params(operation: Model::Operation).returns(T::Array[Integer]) }
49
+ def template_flags(operation)
50
+ operation.path.split("/").reject(&:empty?).map { |segment| segment.start_with?("{") ? 1 : 0 }
51
+ end
52
+
53
+ sig { params(operation: Model::Operation).returns(String) }
54
+ def route_for(operation)
55
+ verb = operation.http_method.serialize
56
+ controller = "#{@config.module_path}/controllers/#{Naming.snake(operation.tag)}"
57
+ target = "#{controller}##{Naming.identifier(operation.id)}"
58
+
59
+ "mapper.#{verb}(#{path_for(operation).inspect}, to: #{target.inspect}, format: false)"
60
+ end
61
+
62
+ sig { params(operation: Model::Operation).returns(String) }
63
+ def path_for(operation)
64
+ operation.path.scan(/\{([^}]*)\}/).flatten.each do |name|
65
+ next if name.match?(/\A\w+\z/)
66
+
67
+ raise SchemaError,
68
+ "#{operation.path} has the path template {#{name}}, which Rails cannot route. " \
69
+ "A path parameter name may contain only letters, digits and underscores."
70
+ end
71
+
72
+ operation.path.gsub(/\{(\w+)}/, ':\1')
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end