diff --git a/README.md b/README.md index 5f98cd6..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 @@ -221,10 +227,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 ``` 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 )