From 890faacb4128fb2cc98e914b99ef4bd0805d3924 Mon Sep 17 00:00:00 2001 From: Jack Robertson Date: Mon, 14 Sep 2026 14:01:31 +0100 Subject: [PATCH 1/2] Align the principal case in the README `when`, `else` and `end` sat one column right of `case`, which is what `Layout/CaseIndentation` and `Layout/EndAlignment` reject. The rest of the document's Ruby already agrees with the cops. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5f98cd6..27e9b9d 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,10 @@ Your handler then reads `request.principal`: ```ruby def create_pet(request:) owner = case request.principal - when Petstore::Principal::Token then "user-#{request.principal.user_id}" - when Petstore::Principal::Key then request.principal.client - else T.absurd(request.principal) - end + when Petstore::Principal::Token then "user-#{request.principal.user_id}" + when Petstore::Principal::Key then request.principal.client + else T.absurd(request.principal) + end end ``` From 08c60de4d5acf4489a06eb5795395ab2d99fcbad Mon Sep 17 00:00:00 2001 From: Jack Robertson Date: Mon, 14 Sep 2026 14:32:13 +0100 Subject: [PATCH 2/2] Write to output, rather than below it `output` was a root that the namespace was appended to, so a tree declaring `Api::V3::Generated` landed in `output/api/v3/generated`. Inferring a file layout from a namespace only holds where `output` is an autoload root, and it forced the tree into a directory of its own, because the generator claimed everything under `output` and nothing hand-written could share it. `output` is now the directory itself and `modules` only says what the code declares. Whether the two agree, so Zeitwerk resolves the constants, is the caller's to arrange, which is what lets the tree sit beside the code that implements it. What the generator owns shrinks to match: `output`, and the `.rb` beside it that names the namespace, because Zeitwerk looks for `Api::V3::Generated` in `generated.rb` rather than inside `generated/`. The rest of the parent directory is not its business. `module_path` survives for Rails routing, where `to:` wants a constant path and not a file path. Golden output is byte-identical, since the fixtures now point `output` at the directory the old convention derived. --- README.md | 16 +++++--- lib/openapi_kit/codegen/config.rb | 6 ++- lib/openapi_kit/codegen/emit/controllers.rb | 2 +- lib/openapi_kit/codegen/emit/handlers.rb | 2 +- lib/openapi_kit/codegen/emit/operations.rb | 2 +- lib/openapi_kit/codegen/emit/registry.rb | 6 +-- lib/openapi_kit/codegen/emit/routes.rb | 2 +- lib/openapi_kit/codegen/emit/security.rb | 2 +- lib/openapi_kit/codegen/emit/source_file.rb | 9 +++-- lib/openapi_kit/codegen/emit/types.rb | 2 +- lib/openapi_kit/codegen/writer.rb | 25 ++++++++---- spec/dummy/generate.rb | 2 +- spec/generated/translation_spec.rb | 42 ++++++++++----------- spec/openapi_kit/codegen/writer_spec.rb | 34 +++++++++++++++++ spec/support/generator_helper.rb | 14 +++++-- spec/support/golden.rb | 3 +- 16 files changed, 114 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 27e9b9d..d5de1fd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ end ```yaml # openapi_kit.yml spec: openapi/petstore.yaml -output: app/api +output: app/api/petstore/v1 modules: [Petstore, V1] controller_base: Api::BaseController principal: "::Petstore::Principal" @@ -37,8 +37,8 @@ principal: "::Petstore::Principal" | Option | Meaning | | --- | --- | | `spec` | root OpenAPI document, resolved relative to this file | -| `output` | directory the tree is written to, which openapi_kit owns | -| `modules` | namespace for every generated constant, so `Petstore::V1::Types::Pet` | +| `output` | the directory the tree is written to, which openapi_kit owns | +| `modules` | namespace every generated constant declares, so `Petstore::V1::Types::Pet` | | `controller_base` | class the generated controllers inherit from | | `principal` | the class a successful authentication produces | | `type_mappings` | your Ruby type for a `type:format` pair | @@ -57,8 +57,14 @@ app/api/petstore/v1/registry.rb app/api/petstore/v1/routes.rb ``` -One constant per file at the path that constant implies, so Rails autoloads it. Each run -wipes `output`, but only after checking openapi_kit generated every `.rb` in it. +One constant per file. `output` says where the tree goes and `modules` says what it +declares, so laying `output` out to match `modules` under an autoload root is what makes +Rails resolve it — above, `app/api` is the root and `petstore/v1` is the namespace. + +Each run wipes `output` and the `.rb` beside it that names the namespace +(`app/api/petstore/v1.rb`), but only after checking openapi_kit generated every one of +them. Nothing else in the parent directory is touched, so the tree can sit beside +hand-written code that shares its namespace. ## Integrating with Rails diff --git a/lib/openapi_kit/codegen/config.rb b/lib/openapi_kit/codegen/config.rb index db86ab9..bd3f0b1 100644 --- a/lib/openapi_kit/codegen/config.rb +++ b/lib/openapi_kit/codegen/config.rb @@ -104,11 +104,13 @@ def self.stringify(value) (value || {}).to_h { |k, v| [k.to_s, v.to_s] } end + # The namespace as a path, which is what Rails routing wants in `to:`. File paths + # come from `output` instead, so this no longer decides where anything is written. sig { returns(String) } - def namespace = modules.join("::") + def module_path = modules.map { |name| Naming.snake(name) }.join("/") sig { returns(String) } - def module_path = modules.map { |name| Naming.snake(name) }.join("/") + def namespace = modules.join("::") end end end diff --git a/lib/openapi_kit/codegen/emit/controllers.rb b/lib/openapi_kit/codegen/emit/controllers.rb index d565bd3..87868e5 100644 --- a/lib/openapi_kit/codegen/emit/controllers.rb +++ b/lib/openapi_kit/codegen/emit/controllers.rb @@ -28,7 +28,7 @@ def initialize(document:, registry:, config:) sig { override.returns(T::Array[SourceFile]) } def render @document.operations.group_by(&:tag).map do |tag, operations| - Source.file(path: "#{@config.module_path}/controllers/#{Naming.snake(tag)}_controller.rb", + Source.file(path: "controllers/#{Naming.snake(tag)}_controller.rb", modules: @config.modules + ["Controllers"]) do |buffer| emit_controller(buffer, tag, operations) end diff --git a/lib/openapi_kit/codegen/emit/handlers.rb b/lib/openapi_kit/codegen/emit/handlers.rb index 828efa2..bc0c2ce 100644 --- a/lib/openapi_kit/codegen/emit/handlers.rb +++ b/lib/openapi_kit/codegen/emit/handlers.rb @@ -20,7 +20,7 @@ def self.module_name(tag) = Naming.pascal(tag) sig { override.returns(T::Array[SourceFile]) } def render by_tag.map do |tag, operations| - Source.file(path: "#{@config.module_path}/handlers/#{Naming.snake(tag)}.rb", + Source.file(path: "handlers/#{Naming.snake(tag)}.rb", modules: @config.modules + ["Handlers"]) do |buffer| emit_interface(buffer, tag, operations) end diff --git a/lib/openapi_kit/codegen/emit/operations.rb b/lib/openapi_kit/codegen/emit/operations.rb index 7ece46e..c0be979 100644 --- a/lib/openapi_kit/codegen/emit/operations.rb +++ b/lib/openapi_kit/codegen/emit/operations.rb @@ -18,7 +18,7 @@ def initialize(document:, registry:, config:) sig { override.returns(T::Array[SourceFile]) } def render @document.operations.map do |operation| - Source.file(path: "#{@config.module_path}/operations/#{Naming.snake(operation.id)}.rb", + Source.file(path: "operations/#{Naming.snake(operation.id)}.rb", modules: @config.modules + ["Operations"]) do |buffer| emit_operation(buffer, operation) end diff --git a/lib/openapi_kit/codegen/emit/registry.rb b/lib/openapi_kit/codegen/emit/registry.rb index d80cbe8..0f665a0 100644 --- a/lib/openapi_kit/codegen/emit/registry.rb +++ b/lib/openapi_kit/codegen/emit/registry.rb @@ -28,10 +28,10 @@ def render return [] if slots.empty? [ - Source.file(path: "#{@config.module_path}/registry.rb", + Source.file(path: "registry.rb", modules: @config.modules) { |buffer| emit_registry(buffer) }, - Source.file(path: "#{@config.module_path}.rb", - modules: @config.modules) { |buffer| emit_accessor(buffer) } + Source.file(path: "#{@config.output.basename}.rb", modules: @config.modules, + beside_output: true) { |buffer| emit_accessor(buffer) } ] end diff --git a/lib/openapi_kit/codegen/emit/routes.rb b/lib/openapi_kit/codegen/emit/routes.rb index 965d4af..33bdc5d 100644 --- a/lib/openapi_kit/codegen/emit/routes.rb +++ b/lib/openapi_kit/codegen/emit/routes.rb @@ -19,7 +19,7 @@ def render return [] if @document.operations.empty? [ - Source.file(path: "#{@config.module_path}/routes.rb", + Source.file(path: "routes.rb", modules: @config.modules + ["Routes"]) { |buffer| emit_body(buffer) } ] end diff --git a/lib/openapi_kit/codegen/emit/security.rb b/lib/openapi_kit/codegen/emit/security.rb index 03b37e0..12f2fef 100644 --- a/lib/openapi_kit/codegen/emit/security.rb +++ b/lib/openapi_kit/codegen/emit/security.rb @@ -35,7 +35,7 @@ def render return [] if @document.security_schemes.empty? [ - Source.file(path: "#{@config.module_path}/security.rb", + Source.file(path: "security.rb", modules: @config.modules + ["Security"]) { |buffer| emit_body(buffer) } ] end diff --git a/lib/openapi_kit/codegen/emit/source_file.rb b/lib/openapi_kit/codegen/emit/source_file.rb index 68f9835..437b577 100644 --- a/lib/openapi_kit/codegen/emit/source_file.rb +++ b/lib/openapi_kit/codegen/emit/source_file.rb @@ -7,22 +7,23 @@ module Emit class SourceFile < T::Struct const :path, String const :contents, String + const :beside_output, T::Boolean, default: false end module Source extend T::Sig sig do - params(path: String, modules: T::Array[String], block: T.proc.params(buffer: Buffer).void) - .returns(SourceFile) + params(path: String, modules: T::Array[String], beside_output: T::Boolean, + block: T.proc.params(buffer: Buffer).void).returns(SourceFile) end - def self.file(path:, modules:, &block) + def self.file(path:, modules:, beside_output: false, &block) buffer = Buffer.new buffer.line("# typed: strict") buffer.line("# frozen_string_literal: true") buffer.blank buffer.nest_modules(modules) { block.call(buffer) } - SourceFile.new(path: path, contents: buffer.to_s) + SourceFile.new(path: path, contents: buffer.to_s, beside_output: beside_output) end end end diff --git a/lib/openapi_kit/codegen/emit/types.rb b/lib/openapi_kit/codegen/emit/types.rb index f7db167..577c441 100644 --- a/lib/openapi_kit/codegen/emit/types.rb +++ b/lib/openapi_kit/codegen/emit/types.rb @@ -21,7 +21,7 @@ def initialize(document:, registry:, config:) def render declared_types.map do |type| name = Model::TypeDef.name_of(type) - Source.file(path: "#{@config.module_path}/types/#{Naming.snake(name)}.rb", + Source.file(path: "types/#{Naming.snake(name)}.rb", modules: @config.modules + ["Types"]) do |buffer| emit_declaration(buffer, type) end diff --git a/lib/openapi_kit/codegen/writer.rb b/lib/openapi_kit/codegen/writer.rb index a36c3c7..440542c 100644 --- a/lib/openapi_kit/codegen/writer.rb +++ b/lib/openapi_kit/codegen/writer.rb @@ -17,15 +17,13 @@ def initialize(output:) sig { void } def clean! - return unless output.directory? - - existing = output.glob("**/*.rb").sort + existing = owned.select(&:file?).sort foreign = existing.reject { |path| path.read.start_with?(MARKER) } unless foreign.empty? raise Error, "Refusing to clean #{output}: #{foreign.size} file#{"s" if foreign.size > 1} " \ "not generated by openapi_kit " \ - "(#{foreign.first(3).map { |f| f.relative_path_from(output) }.join(", ")}" \ + "(#{foreign.first(3).map { |f| f.relative_path_from(output.dirname) }.join(", ")}" \ "#{"…" if foreign.size > 3}). Point `output` at a directory openapi_kit owns." end @@ -33,19 +31,30 @@ def clean! prune_empty_directories end + # openapi_kit owns the output directory and the file beside it that names the + # namespace, because Zeitwerk expects `Api::V3::Generated` in generated.rb rather + # than inside generated/. + sig { returns(T::Array[Pathname]) } + def owned + (output.directory? ? output.glob("**/*.rb") : []) + [namespace_file] + end + + sig { returns(Pathname) } + def namespace_file = output.dirname.join("#{output.basename}.rb") + sig { params(files: T::Array[Emit::SourceFile]).returns(T::Array[Pathname]) } def write_all(files) files.each { |file| verify_syntax!(file.path, file.contents) } reject_duplicate_paths!(files) clean! - files.map { |file| write(file.path, file.contents) } + files.map { |file| write(file.path, file.contents, beside_output: file.beside_output) } end - sig { params(name: String, content: String).returns(Pathname) } - def write(name, content) + sig { params(name: String, content: String, beside_output: T::Boolean).returns(Pathname) } + def write(name, content, beside_output: false) verify_syntax!(name, content) - path = output.join(name) + path = beside_output ? output.dirname.join(name) : output.join(name) FileUtils.mkdir_p(path.dirname) body = content.end_with?("\n") ? content : "#{content}\n" path.write("#{MARKER}\n#{body}") diff --git a/spec/dummy/generate.rb b/spec/dummy/generate.rb index fdaf0dd..2795594 100644 --- a/spec/dummy/generate.rb +++ b/spec/dummy/generate.rb @@ -11,7 +11,7 @@ module Generate def self.call config = OpenAPIKit::Codegen::Config.new( spec: ROOT.join("../fixtures/schemas/server.yaml").expand_path, - output: ROOT.join("app/api").expand_path, + output: ROOT.join("app/api/dummy/v1").expand_path, modules: %w[Dummy V1], controller_base: "Dummy::BaseController", principal: "::Principal" diff --git a/spec/generated/translation_spec.rb b/spec/generated/translation_spec.rb index 8b53403..78f0e19 100644 --- a/spec/generated/translation_spec.rb +++ b/spec/generated/translation_spec.rb @@ -34,7 +34,7 @@ def multi_file_document error: { $ref: "shared.yaml#/components/schemas/ProblemDetails" } YAML config = OpenAPIKit::Codegen::Config.new( - spec: dir.join("api.yaml"), output: dir.join("generated"), + spec: dir.join("api.yaml"), output: dir.join("generated/api"), modules: %w[Api], controller_base: "ApiBaseController" ) OpenAPIKit::Codegen::Generator.new(config: config).generate @@ -50,7 +50,7 @@ def multi_file_document meta: { type: object, properties: { note: { type: string } } } YAML - expect(generated.paths).to include("api/types/mod_meta.rb") + expect(generated.paths).to include("types/mod_meta.rb") expect(generated.type("mod")).to include("const :meta, T.nilable(Api::Types::ModMeta)") end @@ -238,7 +238,7 @@ def property(schema) = schemas("Mod: { type: object, required: [it], properties: "sig { override.params(parts: ::OpenAPIKit::Form::Parts).returns(Api::Types::PostFileBody) }", %(upload: ::OpenAPIKit::Decode.required(parts, "upload") { |v| ::OpenAPIKit::Decode.file(v) }) ) - expect(generated["api/controllers/files_controller.rb"]).to include( + expect(generated["controllers/files_controller.rb"]).to include( "Api::Types::PostFileBody::Form.from_parts(request.request_parameters)" ) end @@ -280,7 +280,7 @@ def multipart_component(properties) generated = generate_from(multipart_component("name" => "{ type: string }")) expect(generated.type("fields")).to include("module Codec", "def self.to_wire(value)") - expect(generated["api/controllers/files_controller.rb"]).to include( + expect(generated["controllers/files_controller.rb"]).to include( "Api::Types::Fields::Codec.from_wire(request.request_parameters)" ) end @@ -375,7 +375,7 @@ def multipart_component(properties) " tags: [mods]", %( responses: { "204": { description: done } })) - routes = generated["api/routes.rb"].lines.grep(/mapper\./).map(&:strip) + routes = generated["routes.rb"].lines.grep(/mapper\./).map(&:strip) expect(routes.first).to include("/mods/featured") expect(routes.last).to include("/mods/:id") end @@ -386,13 +386,13 @@ def multipart_component(properties) " /health:", " get:", " operationId: getHealth", " tags: [system]", %( responses: { "204": { description: done } })) - expect(generated["api/routes.rb"]).to include("format: false") + expect(generated["routes.rb"]).to include("format: false") end it "emits no routes, controllers or registry for a components-only document" do generated = schemas("Mod: { type: object, properties: { id: { type: integer } } }") - expect(generated.paths).to eq(["api/types/mod.rb"]) + expect(generated.paths).to eq(["types/mod.rb"]) end describe "shapes Rails hands over differently" do @@ -409,13 +409,13 @@ def body(schema) it "reads a top-level array body from the key Rails wraps it under" do generated = body("{ type: array, items: { type: string } }") - expect(generated["api/controllers/things_controller.rb"]).to include(%(request.request_parameters["_json"])) + expect(generated["controllers/things_controller.rb"]).to include(%(request.request_parameters["_json"])) end it "reads an object body from request_parameters itself" do generated = body("{ type: object, properties: { a: { type: string } } }") - source = generated["api/controllers/things_controller.rb"] + source = generated["controllers/things_controller.rb"] expect(source).to include("from_wire(request.request_parameters)") expect(source).not_to include("_json") end @@ -439,7 +439,7 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") it "carries a custom http scheme name through" do generated = secured("customAuth: { type: http, scheme: HMAC-SHA256 }") - expect(generated["api/security.rb"]).to include(%(scheme: "hmac-sha256")) + expect(generated["security.rb"]).to include(%(scheme: "hmac-sha256")) end # x- is where anything the four types cannot express has to go, so it must survive. @@ -451,13 +451,13 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") x-signing-key: SIGNING_KEY YAML - expect(generated["api/security.rb"]).to include(%(extensions: {"x-signing-key" => "SIGNING_KEY"})) + expect(generated["security.rb"]).to include(%(extensions: {"x-signing-key" => "SIGNING_KEY"})) end it "gives each scheme an authenticator returning the configured principal" do generated = secured("customAuth: { type: http, scheme: bearer }") - expect(generated["api/security.rb"]) + expect(generated["security.rb"]) .to include("module CustomAuth", "abstract!", "returns(T.nilable(::SpecPrincipal))") end @@ -466,7 +466,7 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") it "implements credential extraction from the declaration" do generated = secured("customAuth: { type: apiKey, in: cookie, name: session }") - expect(generated["api/security.rb"]) + expect(generated["security.rb"]) .to include("def scheme = CUSTOM_AUTH", "def credential(request) = ::OpenAPIKit::Security.credential(scheme, request)") end @@ -474,21 +474,21 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") it "decodes a basic scheme's credentials, since base64 is no use undecoded" do generated = secured("customAuth: { type: http, scheme: basic }") - expect(generated["api/security.rb"]) + expect(generated["security.rb"]) .to include("def basic_credential(request) = ::OpenAPIKit::Security.basic(scheme, request)") end it "offers no basic decoding for a scheme that is not basic" do generated = secured("customAuth: { type: http, scheme: bearer }") - expect(generated["api/security.rb"]).not_to include("basic_credential") + expect(generated["security.rb"]).not_to include("basic_credential") end it "puts the principal on the request and resolves it in the controller" do generated = secured("customAuth: { type: http, scheme: bearer }") - expect(generated["api/operations/get_a.rb"]).to include("const :principal, ::SpecPrincipal") - expect(generated["api/controllers/t_controller.rb"]) + expect(generated["operations/get_a.rb"]).to include("const :principal, ::SpecPrincipal") + expect(generated["controllers/t_controller.rb"]) .to include("principal = authenticate_get_a", "Api.registry.custom_auth.authenticate(request: request, scopes: [])", "raise(::OpenAPIKit::SecurityError)") @@ -500,7 +500,7 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") requirement: "customAuth: [read] }, { keyAuth: []" ) - attempts = generated["api/controllers/t_controller.rb"].lines.grep(/-> \{/).map(&:strip) + attempts = generated["controllers/t_controller.rb"].lines.grep(/-> \{/).map(&:strip) expect(attempts.first).to include("custom_auth", %(scopes: ["read"])) expect(attempts.last).to include("key_auth", "scopes: []") end @@ -510,8 +510,8 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") generated = secured("customAuth: { type: http, scheme: bearer }", requirement: "customAuth: [] }, {") - expect(generated["api/operations/get_a.rb"]).to include("const :principal, T.nilable(::SpecPrincipal)") - expect(generated["api/controllers/t_controller.rb"]).not_to include("raise(::OpenAPIKit::SecurityError)") + expect(generated["operations/get_a.rb"]).to include("const :principal, T.nilable(::SpecPrincipal)") + expect(generated["controllers/t_controller.rb"]).not_to include("raise(::OpenAPIKit::SecurityError)") end it "refuses a document that declares security with no principal configured" do @@ -542,7 +542,7 @@ def secured(scheme, requirement: "customAuth: []", principal: "::SpecPrincipal") scopes: { read: "Read as a machine", write: "Write" } YAML - expect(generated["api/security.rb"]).to include(%(scopes: {"read" => "Read as a user", "write" => "Write"})) + expect(generated["security.rb"]).to include(%(scopes: {"read" => "Read as a user", "write" => "Write"})) expect(generated.warnings.join).to include(%(The scope "read" of security scheme "oauth" is described two ways)) end diff --git a/spec/openapi_kit/codegen/writer_spec.rb b/spec/openapi_kit/codegen/writer_spec.rb index 9ac98f7..5e8166b 100644 --- a/spec/openapi_kit/codegen/writer_spec.rb +++ b/spec/openapi_kit/codegen/writer_spec.rb @@ -23,6 +23,40 @@ expect(writer.write("a/b/types.rb", "x").read).to include("x") end + describe "the namespace file beside the output" do + it "writes it outside the directory, where Zeitwerk looks for the namespace" do + path = writer.write("out.rb", "module Out; end", beside_output: true) + + expect(path).to eq(@dir.join("out.rb")) + expect(path.read).to start_with("#{OpenAPIKit::Codegen::MARKER}\n") + end + + it "cleans it, since it is ours" do + writer.write("out.rb", "module Out; end", beside_output: true) + writer.write("types.rb", "module Types; end") + + writer.clean! + + expect(@dir.join("out.rb")).not_to exist + end + + it "refuses when a file of that name is not ours" do + @dir.join("out.rb").write("module Out; end") + + expect { writer.clean! } + .to raise_error(OpenAPIKit::Error, /Refusing to clean.*out\.rb/m) + end + + it "leaves the rest of the parent directory alone" do + @dir.join("neighbour.rb").write("module Neighbour; end") + writer.write("types.rb", "module Types; end") + + writer.clean! + + expect(@dir.join("neighbour.rb")).to exist + end + end + # typify cannot emit unparseable Rust because quote! yields a TokenStream. Ruby has no # equivalent, so the writer parses instead: an escaping mistake in an emitter fails at # generation time rather than reaching a golden file. diff --git a/spec/support/generator_helper.rb b/spec/support/generator_helper.rb index b4df7b9..465655a 100644 --- a/spec/support/generator_helper.rb +++ b/spec/support/generator_helper.rb @@ -9,7 +9,8 @@ module GeneratorHelper def generate(fixture, modules: %w[Demo V1], **options) dir = Pathname.new(Dir.mktmpdir) @generated_dirs << dir - config = config_for(spec: FIXTURES.join(fixture), output: dir.join("generated"), + config = config_for(spec: FIXTURES.join(fixture), + output: dir.join("generated", module_path(modules)), modules: modules, **options) generator = OpenAPIKit::Codegen::Generator.new(config: config) generator.generate @@ -20,6 +21,10 @@ def golden(name) GOLDEN.join(name) end + # `output` is the exact directory now, so a caller that wants Zeitwerk to resolve the + # namespace lays the path out to match it, the same way an application does. + def module_path(modules) = modules.map { |name| OpenAPIKit::Codegen::Naming.snake(name) }.join("/") + def config_for(spec:, output:, modules:, principal: "::SpecPrincipal", **options) OpenAPIKit::Codegen::Config.new( spec: spec, output: output, modules: modules, @@ -38,7 +43,8 @@ def generate_from(yaml, modules: %w[Api], **options) dir = scratch_dir dir.join("api.yaml").write(yaml) - config = config_for(spec: dir.join("api.yaml"), output: dir.join("generated"), + config = config_for(spec: dir.join("api.yaml"), + output: dir.join("generated", module_path(modules)), modules: modules, **options) generator = OpenAPIKit::Codegen::Generator.new(config: config) sources = generator.sources.to_h { |file| [file.path, file.contents] } @@ -59,10 +65,10 @@ def [](path) = sources.fetch(path) { raise KeyError, "no #{path} in #{paths.insp def paths = sources.keys.sort sig { params(name: String).returns(String) } - def type(name) = self["api/types/#{name}.rb"] + def type(name) = self["types/#{name}.rb"] sig { params(name: String).returns(String) } - def operation(name) = self["api/operations/#{name}.rb"] + def operation(name) = self["operations/#{name}.rb"] end def relative_paths(dir) diff --git a/spec/support/golden.rb b/spec/support/golden.rb index 9f96d0a..081bfe3 100644 --- a/spec/support/golden.rb +++ b/spec/support/golden.rb @@ -15,9 +15,10 @@ module Golden def self.call FIXTURES.map do |name, options| + modules = options.fetch(:modules) config = OpenAPIKit::Codegen::Config.new( spec: ROOT.join("../fixtures/schemas/#{name}.yaml").expand_path, - output: ROOT.join(name).expand_path, + output: ROOT.join(name, modules.map { |m| OpenAPIKit::Codegen::Naming.snake(m) }.join("/")).expand_path, controller_base: "ApiBaseController", **options )