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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5550e77850ca97ac6515c085ee91e5a7559242daacdc1d98267712b0868ee3b
4
+ data.tar.gz: 90c4c60ab7d1963caaefc61619afda4ccbfb621997ee21b75f512af13f95d96f
5
+ SHA512:
6
+ metadata.gz: 20ff7f8b8401dade7171ea90707166a7de1f9f63c99ce9494d31031e796a18370c7f622803f02db9b9c8af8de540c7f576ff5e4435fedbf7ac0c7cb41a2832f9
7
+ data.tar.gz: 51edddb16aeb024b94a14a2d893dcc933be4d598eac6a4760ea0a1e456f9ce026cef7712a80c3b7c3f8b83ed19474ea2b5218112591dc67b439b87baf8f67ae3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nexus Mods
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,385 @@
1
+ # openapi_kit
2
+
3
+ Generates Sorbet-typed Rails server stubs from an OpenAPI 3 document. Handlers are strict
4
+ interfaces bound through a generated registry, responses are sealed, and authentication is
5
+ enforced where the document says it should be. Change the document and the build tells you
6
+ what no longer compiles.
7
+
8
+ ```ruby
9
+ gem "openapi_kit" # what generated code calls
10
+
11
+ group :development do
12
+ gem "openapi_kit-codegen" # the generator
13
+ end
14
+ ```
15
+
16
+ ## Contents
17
+
18
+ - [Generating](#generating)
19
+ - [Integrating with Rails](#integrating-with-rails)
20
+ - [Security](#security)
21
+ - [Custom types](#custom-types)
22
+ - [Files and binary responses](#files-and-binary-responses)
23
+ - [Not supported yet](#not-supported-yet)
24
+ - [Development](#development)
25
+
26
+ ## Generating
27
+
28
+ ```yaml
29
+ # openapi_kit.yml
30
+ spec: openapi/petstore.yaml
31
+ output: app/api
32
+ modules: [Petstore, V1]
33
+ controller_base: Api::BaseController
34
+ principal: "::Petstore::Principal"
35
+ ```
36
+
37
+ | Option | Meaning |
38
+ | --- | --- |
39
+ | `spec` | root OpenAPI document, resolved relative to this file |
40
+ | `output` | directory the tree is written to, which openapi_kit owns |
41
+ | `modules` | namespace for every generated constant, so `Petstore::V1::Types::Pet` |
42
+ | `controller_base` | class the generated controllers inherit from |
43
+ | `principal` | the class a successful authentication produces |
44
+ | `type_mappings` | your Ruby type for a `type:format` pair |
45
+ | `name_overrides` | a different Ruby name for a schema |
46
+
47
+ The first four are required.
48
+
49
+ ```console
50
+ $ bundle exec openapi_kit generate -c openapi_kit.yml
51
+ app/api/petstore/v1/types/pet.rb
52
+ app/api/petstore/v1/operations/list_pets.rb
53
+ app/api/petstore/v1/handlers/pets.rb
54
+ app/api/petstore/v1/controllers/pets_controller.rb
55
+ app/api/petstore/v1/security.rb
56
+ app/api/petstore/v1/registry.rb
57
+ app/api/petstore/v1/routes.rb
58
+ ```
59
+
60
+ One constant per file at the path that constant implies, so Rails autoloads it. Each run
61
+ wipes `output`, but only after checking openapi_kit generated every `.rb` in it.
62
+
63
+ ## Integrating with Rails
64
+
65
+ **Autoload the output**, if it is not already under `app/`. An acronym in `modules`
66
+ becomes a directory, so it needs an inflection like any other acronym constant.
67
+
68
+ ```ruby
69
+ # config/application.rb
70
+ config.autoload_paths << Rails.root.join("app/api").to_s
71
+ ```
72
+
73
+ **Draw the routes.** They carry no prefix of their own, so mount them where you like.
74
+
75
+ ```ruby
76
+ # config/routes.rb
77
+ Rails.application.routes.draw do
78
+ scope "/v1" do
79
+ Petstore::V1::Routes.draw(self)
80
+ end
81
+ end
82
+ ```
83
+
84
+ **Implement one handler per tag.** Miss an operation and Sorbet names the abstract
85
+ method. Return an undeclared response variant and it will not compile.
86
+
87
+ ```ruby
88
+ class PetsHandler
89
+ extend T::Sig
90
+ include Petstore::V1::Handlers::Pets
91
+
92
+ sig do
93
+ override.params(request: Petstore::V1::Operations::ListPets::Request)
94
+ .returns(Petstore::V1::Operations::ListPets::Response)
95
+ end
96
+ def list_pets(request:)
97
+ pets = Pet.where(store: request.path.store_id).page(request.query.page)
98
+
99
+ Petstore::V1::Operations::ListPets::Ok.new(body: pets.map { |pet| present(pet) })
100
+ end
101
+ end
102
+ ```
103
+
104
+ **Give the controllers a base class.** They inherit whatever you put there and need
105
+ nothing from it, so this is where your own concerns and error mapping live. A request
106
+ openapi_kit cannot decode raises `OpenAPIKit::DecodeError`, carrying `detail` and a `json_pointer`
107
+ naming the field, and openapi_kit takes no view on the wire format.
108
+
109
+ ```ruby
110
+ # app/controllers/api/base_controller.rb
111
+ module Api
112
+ class BaseController < ApplicationController
113
+ rescue_from OpenAPIKit::DecodeError, with: :unprocessable
114
+ rescue_from OpenAPIKit::SecurityError, with: :unauthorized
115
+
116
+ private
117
+
118
+ def unauthorized = head(:unauthorized)
119
+
120
+ def unprocessable(error)
121
+ render json: { detail: error.detail, pointer: error.json_pointer },
122
+ status: 422
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ **Assign the registry.** openapi_kit generates a `Registry` struct with one slot per handler and
129
+ authenticator, and controllers read it from `Petstore::V1.registry`. Rails instantiates
130
+ controllers itself, so they cannot be handed one. Omit a slot, or pass something that does
131
+ not implement its interface, and it does not compile.
132
+
133
+ ```ruby
134
+ # config/initializers/openapi_kit.rb
135
+ Rails.application.config.to_prepare do
136
+ Petstore::V1.registry = Petstore::V1::Registry.new(
137
+ pets: PetsHandler.new(repo: PetRepo.new),
138
+ system: SystemHandler.new,
139
+ bearer_auth: BearerAuthenticator.new(decoder: TokenDecoder.new)
140
+ )
141
+ end
142
+ ```
143
+
144
+ The registry is openapi_kit's boundary and nothing more. What a handler needs behind it is
145
+ yours, and unlike controllers *you* construct handlers, so they take whatever they need.
146
+
147
+ Building the registry in `to_prepare` constructs every handler at boot, along with
148
+ whatever their constructors resolve, and reassigns them on a code reload.
149
+
150
+ ## Security
151
+
152
+ Where the document declares `security`, the generated action authenticates before decoding
153
+ anything, and your handler receives the principal. Nothing else can reach the handler.
154
+
155
+ ```yaml
156
+ security: [{ bearerAuth: [] }] # the document's default
157
+ paths:
158
+ /stores/{storeId}/pets:
159
+ post:
160
+ security: # this operation overrides it
161
+ - bearerAuth: [pets:write]
162
+ - apiKeyAuth: []
163
+ ```
164
+
165
+ Name what authentication produces, and seal it if your schemes produce different shapes:
166
+ that is what makes a handler's `case` exhaustive.
167
+
168
+ ```ruby
169
+ module Petstore::Principal
170
+ extend T::Helpers
171
+ sealed!
172
+
173
+ class Token < T::Struct # a JWT carries its permissions
174
+ include Petstore::Principal
175
+ const :user_id, Integer
176
+ const :permissions, T::Array[String]
177
+ end
178
+
179
+ class Key < T::Struct # an API key does not
180
+ include Petstore::Principal
181
+ const :client, String
182
+ end
183
+ end
184
+ ```
185
+
186
+ Then write one authenticator per scheme. Return `nil` to say this alternative was not
187
+ satisfied, so openapi_kit tries the next one. Where the credential lives is what the document
188
+ declares, so `credential` is implemented for you.
189
+
190
+ ```ruby
191
+ class BearerAuthenticator
192
+ extend T::Sig
193
+ include Petstore::V1::Security::BearerAuth
194
+
195
+ sig { params(decoder: TokenDecoder).void }
196
+ def initialize(decoder:)
197
+ @decoder = decoder
198
+ end
199
+
200
+ sig do
201
+ override.params(request: ActionDispatch::Request, scopes: T::Array[String])
202
+ .returns(T.nilable(Petstore::Principal::Token))
203
+ end
204
+ def authenticate(request:, scopes:)
205
+ token = credential(request) or return nil
206
+ claims = @decoder.decode(token) or return nil
207
+ return nil unless scopes.all? { |scope| claims.scopes.include?(scope) }
208
+
209
+ Petstore::Principal::Token.new(user_id: claims.sub, permissions: claims.scopes)
210
+ end
211
+ end
212
+ ```
213
+
214
+ `credential` reads the `Authorization` header and strips the declared scheme for `http`,
215
+ `oauth2` and `openIdConnect`, and reads the named header, query parameter or cookie for
216
+ `apiKey`. A `basic` scheme also gets `basic_credential`, returning the decoded
217
+ `[user, password]`.
218
+
219
+ Your handler then reads `request.principal`:
220
+
221
+ ```ruby
222
+ def create_pet(request:)
223
+ owner = case request.principal
224
+ when Petstore::Principal::Token then "user-#{request.principal.user_id}"
225
+ when Petstore::Principal::Key then request.principal.client
226
+ else T.absurd(request.principal)
227
+ end
228
+ end
229
+ ```
230
+
231
+ Alternatives are tried in document order and the first to produce a principal wins. If
232
+ none do, openapi_kit raises `OpenAPIKit::SecurityError`. An operation offering anonymous
233
+ access (`security: [..., {}]`) makes the context `T.nilable` and raises nothing.
234
+
235
+ ### What each scheme type gives you
236
+
237
+ `SCHEMES` is the document's `securitySchemes` as typed values, so an authenticator reads
238
+ its own configuration rather than restating the document.
239
+
240
+ | `type` | The scheme carries |
241
+ | --- | --- |
242
+ | `http` | `scheme`, `bearer_format` |
243
+ | `apiKey` | `location`, `parameter_name` |
244
+ | `oauth2` | the declared `scopes` catalogue |
245
+ | `openIdConnect` | `url`, the discovery document |
246
+
247
+ Plus any `x-` keys, on all four. OpenAPI 3.0 fixes these four types, so a bespoke scheme
248
+ is an `http` one with your own name, and anything the type cannot express goes in `x-`:
249
+
250
+ ```yaml
251
+ securitySchemes:
252
+ hmacAuth:
253
+ type: http
254
+ scheme: HMAC-SHA256
255
+ x-signing-key: SIGNING_KEY
256
+ ```
257
+
258
+ That reaches the scheme as `extensions`, which is also where an `oauth2` scheme's issuer
259
+ and JWKS URI belong, since OpenAPI has no field for them. An `openIdConnect` scheme needs
260
+ neither, because `url` discovers both.
261
+
262
+ ## Custom types
263
+
264
+ A `type:format` pair with no built-in mapping is an error naming the config to add:
265
+
266
+ ```yaml
267
+ type_mappings:
268
+ "string:money":
269
+ type: "::Money"
270
+ codec: "MyApp::MoneyCodec"
271
+ ```
272
+
273
+ `type` appears in signatures. `codec` converts it, and `Value` ties the halves together so
274
+ a codec cannot decode one type and encode another:
275
+
276
+ ```ruby
277
+ module MyApp::MoneyCodec
278
+ extend T::Sig
279
+ extend T::Generic
280
+ extend OpenAPIKit::Codec::Contract
281
+
282
+ Value = type_template { { fixed: ::Money } }
283
+
284
+ sig { override.params(value: OpenAPIKit::Wire).returns(::Money) }
285
+ def self.from_wire(value) = ::Money.parse(OpenAPIKit::Codec::String.from_wire(value))
286
+
287
+ sig { override.params(value: ::Money).returns(OpenAPIKit::Wire) }
288
+ def self.to_wire(value) = value.format
289
+ end
290
+ ```
291
+
292
+ `codec` need only name a constant answering `from_wire` and `to_wire`, so a codec that
293
+ needs configuration can `include` the contract instead and you name the instance you
294
+ built. The same override works inline, for one property rather than every occurrence of a
295
+ format:
296
+
297
+ ```yaml
298
+ price:
299
+ type: string
300
+ x-ruby-type: "::Money"
301
+ x-ruby-codec: "MyApp::MoneyCodec"
302
+ ```
303
+
304
+ ## Files and binary responses
305
+
306
+ A file is the one thing outside `OpenAPIKit::Wire`, the value model every media type shares,
307
+ so it never goes through a codec. `format: binary` is handled in two places instead, and
308
+ refused everywhere else.
309
+
310
+ **An upload is a property of a `multipart/form-data` request body**, decoded through a
311
+ `Form` where other types carry a `Codec`:
312
+
313
+ ```ruby
314
+ class UploadPetPhotoBody < T::Struct
315
+ const :photo, ::ActionDispatch::Http::UploadedFile
316
+ const :description, T.nilable(::String)
317
+ end
318
+
319
+ def upload_pet_photo(request:)
320
+ Blob.store!(io: request.body.photo.tempfile)
321
+
322
+ Petstore::V1::Operations::UploadPetPhoto::NoContent.new
323
+ end
324
+ ```
325
+
326
+ **A binary response is the whole body**, taking a file on disk or a block that writes bytes:
327
+
328
+ ```ruby
329
+ body: OpenAPIKit::Body::File.new(path: Rails.root.join("photos", name))
330
+
331
+ body: OpenAPIKit::Body::Stream.new(
332
+ body: ->(sink) { Archive.open(pet) { |zip| IO.copy_stream(zip, sink) } }
333
+ )
334
+
335
+ body: OpenAPIKit::Body::Stream.new(body: ->(sink) { sink << header << row })
336
+ ```
337
+
338
+ A path names the file and nothing more, so the server sends it however it likes: `sendfile`
339
+ under Puma, an `X-Accel-Redirect` or `X-Sendfile` under nginx and Apache through
340
+ `Rack::Sendfile`, and `Content-Length` comes off the file. A stream sends no
341
+ `Content-Length`, and whatever its block opens it also closes. Neither supports `Range`.
342
+
343
+ Every response variant answers `to_body`, a sealed `OpenAPIKit::Body` of `Empty`, `Json`,
344
+ `Stream` or `File`. `string:binary` takes no [`type_mappings`](#custom-types) entry, since a
345
+ file has nothing for a codec to convert.
346
+
347
+ ## Not supported yet
348
+
349
+ Refused at generation time, rather than mis-generated:
350
+
351
+ - Parameter styles other than `simple` for path and `form` for query.
352
+ - One content type per request body, and it must be `application/json`, a `+json` type,
353
+ `application/x-www-form-urlencoded` or `multipart/form-data`. A response body whose
354
+ schema is `format: binary` may declare any content type at all.
355
+ - Two security schemes required together in one alternative (`{a: [], b: []}`). One scheme
356
+ per alternative.
357
+ - A path template Rails cannot route, such as `{pet-id}`.
358
+
359
+ Documented behaviour to know about:
360
+
361
+ - Array and object query parameters follow Rails' conventions, not OpenAPI's: send
362
+ `?tags[]=a&tags[]=b` and `?filter[lat]=1`, not `?tags=a&tags=b` or an exploded `?lat=1`.
363
+ - Schema keyword validation (`minLength`, `pattern`, `minimum`) is not enforced. Types and
364
+ formats only.
365
+ - Codecs coerce strings, since path, query and header values arrive as strings. That
366
+ leniency also applies to bodies, so `{"count": "42"}` satisfies `type: integer`.
367
+ - `format: binary` is only valid as a top-level property of a `multipart/form-data`
368
+ request body, or as the whole schema of a response body. Anywhere else is refused: a
369
+ file is bytes rather than a parsed value, so no codec can convert it. Use `format: byte`
370
+ to carry bytes inside a value.
371
+ - An OAuth2 flow's `authorizationUrl`, `tokenUrl` and `refreshUrl` are not carried, since
372
+ they tell a client where to obtain a token and a resource server never calls them.
373
+
374
+ ## Development
375
+
376
+ ```console
377
+ $ bundle exec rake golden # regenerate the output the specs compare against
378
+ $ bundle exec rake # rspec, srb tc, rubocop
379
+ ```
380
+
381
+ `srb tc` covers `spec/golden` as well as the generator, so output that does not typecheck
382
+ fails the build. `spec/dummy` is a Rails application whose `app/api` is generated the same
383
+ way, and `spec/generated/rails_request_spec.rb` issues real requests against it.
384
+
385
+ [ARCHITECTURE.md](ARCHITECTURE.md) covers the pipeline and where to change what.
data/exe/openapi_kit ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "openapi_kit-codegen"
5
+
6
+ exit(OpenAPIKit::Codegen::CLI.run(ARGV))
@@ -0,0 +1,72 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ module OpenAPIKit
7
+ module Codegen
8
+ class CLI
9
+ extend T::Sig
10
+
11
+ sig { params(argv: T::Array[String], out: T.any(IO, StringIO), err: T.any(IO, StringIO)).returns(Integer) }
12
+ def self.run(argv, out: $stdout, err: $stderr)
13
+ new(out: out, err: err).run(argv)
14
+ end
15
+
16
+ sig { params(out: T.any(IO, StringIO), err: T.any(IO, StringIO)).void }
17
+ def initialize(out:, err:)
18
+ @out = out
19
+ @err = err
20
+ end
21
+
22
+ sig { params(argv: T::Array[String]).returns(Integer) }
23
+ def run(argv)
24
+ path = T.let("openapi_kit.yml", String)
25
+
26
+ parser = OptionParser.new do |options|
27
+ options.banner = "Usage: openapi_kit generate [-c CONFIG]"
28
+ options.on("-c", "--config PATH",
29
+ "Path to openapi_kit.yml (default: openapi_kit.yml)") { |value| path = value }
30
+ options.on("-v", "--version", "Print the version") do
31
+ @out.puts(VERSION)
32
+ return 0
33
+ end
34
+ options.on("-h", "--help", "Print this message") do
35
+ @out.puts(options)
36
+ return 0
37
+ end
38
+ end
39
+
40
+ rest = parser.parse(argv)
41
+ command = rest.first || "generate"
42
+
43
+ unless command == "generate"
44
+ @err.puts("Unknown command #{command.inspect}.")
45
+ @err.puts(parser)
46
+ return 1
47
+ end
48
+
49
+ generate(path)
50
+ rescue OptionParser::ParseError => e
51
+ @err.puts(e.message)
52
+ 1
53
+ end
54
+
55
+ private
56
+
57
+ sig { params(path: String).returns(Integer) }
58
+ def generate(path)
59
+ config = Config.from_file(path)
60
+ generator = Generator.new(config: config)
61
+ written = generator.generate
62
+
63
+ generator.warnings.each { |warning| @err.puts("warning: #{warning}\n\n") }
64
+ written.each { |file| @out.puts(file.relative_path_from(Pathname.pwd)) }
65
+ 0
66
+ rescue ConfigError, SchemaError, Error => e
67
+ @err.puts(e.message)
68
+ 1
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,114 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "pathname"
6
+
7
+ module OpenAPIKit
8
+ module Codegen
9
+ class Config < T::Struct
10
+ extend T::Sig
11
+
12
+ const :spec, Pathname
13
+ const :output, Pathname
14
+ const :modules, T::Array[String]
15
+ const :controller_base, String
16
+ const :type_mappings, T::Hash[String, RubyType], default: {}
17
+ const :name_overrides, T::Hash[String, String], default: {}
18
+ const :principal, T.nilable(String), default: nil
19
+
20
+ KNOWN_KEYS = T.let(
21
+ %w[
22
+ spec output modules controller_base
23
+ type_mappings name_overrides principal
24
+ ].freeze,
25
+ T::Array[String]
26
+ )
27
+
28
+ sig { params(path: T.any(String, Pathname)).returns(Config) }
29
+ def self.from_file(path)
30
+ file = Pathname.new(path).expand_path
31
+ raise ConfigError, "No such config file: #{file}" unless file.file?
32
+
33
+ from_yaml(file.read, relative_to: file)
34
+ end
35
+
36
+ sig { params(yaml: String, relative_to: Pathname).returns(Config) }
37
+ def self.from_yaml(yaml, relative_to:)
38
+ raw = YAML.safe_load(yaml, permitted_classes: [], aliases: true) || {}
39
+ raise ConfigError, "#{relative_to}: expected a YAML mapping, got #{raw.class}" unless raw.is_a?(Hash)
40
+
41
+ reject_unknown_options!(raw, relative_to)
42
+ reject_missing_options!(raw, relative_to)
43
+ base = relative_to.dirname
44
+
45
+ new(
46
+ spec: base.join(raw.fetch("spec").to_s).expand_path,
47
+ output: base.join(raw.fetch("output").to_s).expand_path,
48
+ modules: modules_in(raw, relative_to),
49
+ controller_base: raw.fetch("controller_base").to_s,
50
+ type_mappings: parse_type_mappings(raw["type_mappings"]),
51
+ name_overrides: stringify(raw["name_overrides"]),
52
+ principal: principal_type(raw["principal"], "#{relative_to}: ")
53
+ )
54
+ end
55
+
56
+ sig { params(raw: T::Hash[String, T.untyped], file: Pathname).void }
57
+ def self.reject_unknown_options!(raw, file)
58
+ unknown = raw.keys.map(&:to_s) - KNOWN_KEYS
59
+ return if unknown.empty?
60
+
61
+ raise ConfigError,
62
+ "#{file}: unknown option#{"s" if unknown.size > 1} #{unknown.sort.join(", ")}. " \
63
+ "Known options: #{KNOWN_KEYS.join(", ")}."
64
+ end
65
+
66
+ sig { params(raw: T::Hash[String, T.untyped], file: Pathname).void }
67
+ def self.reject_missing_options!(raw, file)
68
+ %w[spec output modules controller_base].each do |key|
69
+ raise ConfigError, "#{file}: `#{key}` is required." unless raw[key]
70
+ end
71
+ end
72
+
73
+ sig { params(raw: T::Hash[String, T.untyped], file: Pathname).returns(T::Array[String]) }
74
+ def self.modules_in(raw, file)
75
+ modules = Array(raw["modules"]).map(&:to_s)
76
+ return modules unless modules.empty?
77
+
78
+ raise ConfigError, "#{file}: `modules` must name at least one namespace, e.g. [API, V3]."
79
+ end
80
+
81
+ sig { params(value: T.untyped).returns(T::Hash[String, RubyType]) }
82
+ def self.parse_type_mappings(value)
83
+ (value || {}).to_h do |key, mapping|
84
+ [key.to_s, RubyType.parse("type_mappings[#{key.to_s.inspect}]", mapping)]
85
+ end
86
+ end
87
+
88
+ # Authenticating produces a principal of a type only the application knows, so it
89
+ # names it here and every authenticator interface returns it.
90
+ sig { params(value: T.untyped, where: String).returns(T.nilable(String)) }
91
+ def self.principal_type(value, where)
92
+ return nil if value.nil?
93
+
94
+ type = value.to_s
95
+ return type if type.match?(RubyType::CONSTANT_PATH)
96
+
97
+ raise ConfigError,
98
+ "#{where}`principal` is #{type.inspect}, which is not a Ruby constant path. It " \
99
+ "must name the class a successful authentication produces, e.g. \"MyApp::Principal\"."
100
+ end
101
+
102
+ sig { params(value: T.untyped).returns(T::Hash[String, String]) }
103
+ def self.stringify(value)
104
+ (value || {}).to_h { |k, v| [k.to_s, v.to_s] }
105
+ end
106
+
107
+ sig { returns(String) }
108
+ def namespace = modules.join("::")
109
+
110
+ sig { returns(String) }
111
+ def module_path = modules.map { |name| Naming.snake(name) }.join("/")
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,92 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module OpenAPIKit
5
+ module Codegen
6
+ module Emit
7
+ class Buffer
8
+ extend T::Sig
9
+
10
+ sig { void }
11
+ def initialize
12
+ @lines = T.let([], T::Array[String])
13
+ @depth = T.let(0, Integer)
14
+ end
15
+
16
+ sig { params(text: String).returns(Buffer) }
17
+ def line(text)
18
+ @lines << (text.empty? ? "" : "#{" " * @depth}#{text}")
19
+ self
20
+ end
21
+
22
+ sig { returns(Buffer) }
23
+ def blank
24
+ @lines << "" unless @lines.last == ""
25
+ self
26
+ end
27
+
28
+ sig { params(block: T.proc.void).returns(Buffer) }
29
+ def indent(&block)
30
+ @depth += 1
31
+ block.call
32
+ @depth -= 1
33
+ self
34
+ end
35
+
36
+ sig { params(header: String, block: T.proc.void).returns(Buffer) }
37
+ def nest(header, &block)
38
+ line(header)
39
+ indent(&block)
40
+ line("end")
41
+ end
42
+
43
+ sig do
44
+ params(call: String, arguments: T::Array[[String, String]], tail: String).returns(Buffer)
45
+ end
46
+ def nest_call(call, arguments, tail: ",")
47
+ line("#{call}(")
48
+ indent do
49
+ arguments.each_with_index do |(keyword, value), index|
50
+ comma = index == arguments.size - 1 ? "" : ","
51
+ line("#{keyword}: #{value}#{comma}")
52
+ end
53
+ end
54
+ line(")#{tail}")
55
+ end
56
+
57
+ sig { params(pattern: String, block: T.proc.void).returns(Buffer) }
58
+ def when_of(pattern, &block)
59
+ line("when #{pattern}")
60
+ indent(&block)
61
+ end
62
+
63
+ sig { params(subject: String, block: T.proc.void).returns(Buffer) }
64
+ def case_of(subject, &block)
65
+ line("case #{subject}")
66
+ block.call
67
+ line("end")
68
+ end
69
+
70
+ sig { params(names: T::Array[String], block: T.proc.void).returns(Buffer) }
71
+ def nest_modules(names, &block)
72
+ nest_all(names.map { |name| "module #{name}" }, &block)
73
+ end
74
+
75
+ sig { returns(String) }
76
+ def to_s = "#{@lines.join("\n").rstrip}\n"
77
+
78
+ private
79
+
80
+ sig { params(headers: T::Array[String], block: T.proc.void).returns(Buffer) }
81
+ def nest_all(headers, &block)
82
+ if headers.empty?
83
+ block.call
84
+ return self
85
+ end
86
+
87
+ nest(T.must(headers.first)) { nest_all(T.must(headers[1..]), &block) }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end