Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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
```

Expand Down
6 changes: 4 additions & 2 deletions lib/openapi_kit/codegen/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/controllers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/handlers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/openapi_kit/codegen/emit/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/security.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions lib/openapi_kit/codegen/emit/source_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_kit/codegen/emit/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions lib/openapi_kit/codegen/writer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,44 @@ 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

existing.each(&:delete)
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}")
Expand Down
2 changes: 1 addition & 1 deletion spec/dummy/generate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 21 additions & 21 deletions spec/generated/translation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -466,29 +466,29 @@ 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

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)")
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading