Skip to main content

ruby_rails_server_generator

Generates a Ruby on Rails server: one plain Ruby class per OpenAPI object schema (hand-written to_h/from_h JSON (de)serialization, no runtime-reflection gem needed), a controller per tag that extracts/validates path/query/header/cookie parameters and the request body against the spec before your code ever sees them, and a Routes module you call from your own config/routes.rb.

Two integration styles, chosen via the controllerMode variable:

  • generated (default) - the controller is fully generated, never edited by hand. You implement a plain Ruby class (no ActionController superclass, no include) per tag, matching the generated interface module, and register one instance of it per tag when you draw your routes.
  • concern - only an ActiveSupport::Concern is generated per tag. You create your own controller under app/controllers/ (an ordinarily autoloaded, hand-written file), include the concern, and implement its on_* methods directly in that controller.

Both produce identical request handling (the same parameter/body validation, the same auth extraction, the same error mapping) - only the integration surface differs. See "Integrating the generated code" below for both.

Usage​

openapi-yagen g -o .generated -g ruby_rails_server_generator openapi.yaml -v moduleName=PetStore
VariableRequiredDescription
moduleNameyesRuby module namespace for the generated code (e.g. PetStore). Also determines the file layout below (<snake_case moduleName>/...).
strictno (default true)true: an unsupported schema/operation aborts generation with an error. false: skip it with a printed warning and generate everything else.
validateno (default true)true: every model gets a validate! method, and every generated controller validates path/query/header/cookie parameters and the request body before calling your code. false: zero validation overhead - only turn this off once an integration is well-tested, since on a server this is the only defense against malformed input from an untrusted client.
controllerModeno (default generated)generated: the controller is fully generated - you register a handler object instead (see below). concern: only an ActiveSupport::Concern is generated - you write your own controller and include it.
baseControllerno (default ActionController::API)Only used when controllerMode is generated: the class the generated controller inherits from - point this at your own base class to share before_action hooks, JSON error shaping, auth, or logging across every generated controller.
publishOpenApiSpecno (default false)true: generates an OpenApiSpecController (both controllerModes) serving the effective OpenAPI document - the one actually used to generate this code, after $ref resolution, --tags filtering, and conversion to this generator's OpenAPI 3.2 - as JSON at openApiSpecPath.
openApiSpecPathno (default /openapi.json)Only used when publishOpenApiSpec is true: the route path the spec is served at.

Output layout​

controllerMode=generated (default):

<module>/models/<name>.rb one file per schema (class / enum module / union dispatch module)
<module>/controllers/<tag>_handler_interface.rb the interface you implement, one module per tag
<module>/controllers/<tag>_controller.rb fully generated ActionController subclass, one per tag
<module>/controllers/openapi_spec_controller.rb only if publishOpenApiSpec=true - serves ../openapi.json
<module>/openapi.json only if publishOpenApiSpec=true - the effective OpenAPI document
<module>/routes.rb Routes.draw(mapper, handlers:) - call from config/routes.rb
<module>/runtime.rb <module>::Runtime - shared validation/parsing/auth helpers
<module>/eager_load_integration.rb require this from an initializer under config.eager_load=true
<module>.rb aggregator - requires every file above, in a safe order

controllerMode=concern:

<module>/models/<name>.rb same as above
<module>/controllers/<tag>_controller_concern.rb the ActiveSupport::Concern you `include`, one per tag
<module>/controllers/openapi_spec_controller.rb only if publishOpenApiSpec=true - serves ../openapi.json
<module>/openapi.json only if publishOpenApiSpec=true - the effective OpenAPI document
<module>/routes.rb Routes.draw(mapper) - call from config/routes.rb
<module>/runtime.rb same as above
<module>/eager_load_integration.rb same as above
<module>.rb same as above

(<module> is moduleName converted to snake_case, e.g. PetStore -> pet_store.)

Point -o at a directory outside version control (e.g. .generated/, added to .gitignore) - nothing under it is meant to be hand-edited, and regenerating it is meant to be a routine step (run it before rails server/tests/deploy, the same way you'd run a database migration), not a one-time scaffold.

Integrating the generated code​

Require the aggregator once, then call Routes.draw from inside your own config/routes.rb's Rails.application.routes.draw do ... end block, passing self and one handler instance per tag:

# config/routes.rb
require Rails.root.join(".generated/pet_store/pet_store")

Rails.application.routes.draw do
# ... your other routes ...

PetStore::Routes.draw(self, handlers: {
pets: PetsHandler.new,
})
end

Routes.draw's mapper argument is literally your own routes-drawing context (self inside Rails.application.routes.draw do ... end), so routes are drawn exactly as the spec's own paths: keys declare them (no prefix applied) but you control the mount path the same way you would any other Rails route - wrap the call in your own scope "/v1" do ... end/namespace if the spec's own servers/basePath implies one.

Your handler is a plain Ruby object - no ActionController superclass, no included module beyond the generated interface itself, which exists purely so a missing/misspelled method fails loudly (NotImplementedError) instead of silently:

# app/api_handlers/pets_handler.rb
class PetsHandler
include PetStore::PetsHandlerInterface

def get_pet_by_id(pet_id:)
PetStore::Pet.from_object(Pet.find(pet_id))
end

def create_pet(body:)
PetStore::Pet.from_object(Pet.create!(body.to_attributes))
end
end

Model.from_object/#to_attributes map between wire and Ruby attribute names - see "Mapping to your own application models" below.

Routes are drawn via to: Controller.action(:name), not a "controller#action" string - the generated controllers are never autoloaded by Zeitwerk.

Production (config.eager_load = true)​

The plain require above is enough in development, but breaks once config.eager_load = true (the default in production): Rails eager-loads all of app/ before config/routes.rb ever runs, so anything under app/ that references the generated module - a handler includeing PetStore::PetsHandlerInterface, a concern-mode controller includeing PetStore::PetsController, or a baseController pointing at your own class - fails with NameError: uninitialized constant PetStore before it's ever defined.

Require the generated eager_load_integration.rb instead, from any config/initializers/*.rb:

# config/initializers/pet_store.rb
require Rails.root.join(".generated/pet_store/eager_load_integration")

This registers a Rails.application.config.to_prepare callback that requires the generated aggregator at the one point in Rails' boot sequence where it's safe - after Zeitwerk's autoloader is set up, but before eager-load reaches your own app/. It also warms up a custom baseController first, if you set one. Keep (or replace) the plain require in config/routes.rb too - to_prepare also re-runs on every code reload in development, so this works unchanged in both environments.

controllerMode=concern​

Here there's no separate handler object/registry - your own controller (an ordinarily autoloaded file under app/controllers/) includes the generated concern directly and implements its on_* methods:

# config/routes.rb
require Rails.root.join(".generated/pet_store/pet_store")

Rails.application.routes.draw do
PetStore::Routes.draw(self)
end
# app/controllers/pets_controller.rb
class PetsController < ApplicationController
include PetStore::PetsController

def on_get_pet_by_id(pet_id:)
PetStore::Pet.from_object(Pet.find(pet_id))
end

def on_create_pet(body:)
PetStore::Pet.from_object(Pet.create!(body.to_attributes))
end
end

Since your controller is autoloaded normally, routes here are drawn with an ordinary to: "pets#get_pet_by_id" string - which means the class name matters: Rails resolves it from the tag's own snake_case name (pets -> PetsController), the same convention any hand-written Rails route already relies on. You choose your controller's own superclass (ApplicationController and everything it gives you - sessions, CSRF, etc., if you need it) - baseController has no effect in this mode.

Errors​

Both a request-validation failure and a constraint violation map to a single error class - Runtime::ValidationError (namespaced under your moduleName, e.g. PetStore::Runtime::ValidationError)

  • regardless of whether it surfaced while parsing a parameter, decoding the request body, or serializing your handler's return value; the generated controller's rescue_from maps it to 422 Unprocessable Content. A missing/invalid authentication credential is a distinct class, Runtime::MissingAuthenticationError, mapped to 401 Unauthorized, and a raw request body with the wrong Content-Type is Runtime::UnsupportedMediaTypeError, mapped to 415 Unsupported Media Type. All default response bodies are {"error": "<message>"} - override render_openapi_validation_error/render_openapi_missing_authentication_error/ render_openapi_unsupported_media_type_error in your own baseController to change the shape, or to add logging/request-id correlation.

Regenerating during development​

Because the generated files aren't autoloaded by Zeitwerk (see above), Rails' own code reloading won't pick up a regeneration on its own - restart the server after re-running openapi-yagen whenever the spec changes.

Request body content types​

Priority order: application/json > multipart/form-data > application/x-www-form-urlencoded > a single remaining media type (text/* as a plain String read via request.raw_post; anything else as a raw body - see "Raw request and response bodies" below). Unlike a JSON body (where JSON.parse already hands back a real Integer/Float/true/false), a multipart/urlencoded form field's type: integer/number/boolean/format: date/date-time property is parsed from its raw wire String the same way a query parameter is (see "Parameters" below) before your model's own from_h ever sees it - both content types restrict form fields to a flat object (scalar/enum properties, or arrays of either).

Raw request and response bodies​

A raw (non-JSON/form/text) request body - application/octet-stream, application/zip, image/png, ... - reaches your handler as body: = the request body IO, still unread and rewound, so each handler decides whether to read it whole or stream it:

def upload_pet_avatar(pet_id:, body:)
Avatar.store(pet_id, body.read) # small payload: read it whole
end

def restore_backup(body:)
File.open(tmp_path, "wb") { |f| IO.copy_stream(body, f) } # large payload: stream it
end

Before the handler runs, the request's Content-Type is checked against the declared media type (which may be a wildcard like application/*) - a mismatch raises Runtime::UnsupportedMediaTypeError (415). A body sent without any Content-Type is treated as application/octet-stream (RFC 9110). An empty body is nil for an optional requestBody and a Runtime::ValidationError (422) for a required one.

A raw response is either a plain binary String, or a Runtime::BinaryContent to add metadata or send a file from disk without reading it into memory:

def download_backup(backup_id:)
PetStore::Runtime::BinaryContent.new(path: backup_path(backup_id), name: "backup.tar") # send_file
end

def export_pets
PetStore::Runtime::BinaryContent.new(data: build_csv_zip, name: "pets.zip") # send_data
end

name becomes Content-Disposition: attachment; filename="..."; content_type overrides the declared media type (a declared wildcard such as application/* is otherwise sent as application/octet-stream).

Multiple success responses​

An operation declaring two or more 2xx responses (e.g. 200 for a synchronous result and 202 for an accepted background job) expects its handler to return a Runtime::Response (namespaced under your moduleName) with the status to respond with and its body - rendered according to what that status declares (a model for JSON, a String/BinaryContent for a raw body, nothing for a response without content):

def create_backup(sync: nil)
if sync
PetStore::Runtime::Response.new(status: 200, body: PetStore::Runtime::BinaryContent.new(data: build_now, name: "backup.zip"))
else
PetStore::Runtime::Response.new(status: 202, body: start_in_background) # a PetStore::Operation
end
end

Returning anything other than a Runtime::Response raises TypeError, and a status the operation doesn't declare raises ArgumentError - both handler bugs, surfacing as a 500. An operation with a single success response keeps returning its plain body.

Parameters​

  • Path: always present once the route matched (Rails' own routing guarantee) - no separate "required" check.
  • Query/header/cookie: read from Rails' params/request.headers/request.cookies and parsed/validated according to the schema (Integer/Float/true-or-false/Date/Time, format: uuid shape-checked, an enum's membership checked) before your handler ever sees it - raises Runtime::ValidationError on a malformed value. Path/header/cookie parameters must resolve to a primitive scalar or enum; query parameters accept any shape (a server has to parse whatever's there, so there's no restriction to enforce ahead of time).
  • Array-typed query parameters (OpenAPI 3's default style: form, explode: true - a repeated key, ?tags=a&tags=b) are collected correctly even though Rails' own params does NOT do this for a plain (non-bracket) repeated key - see runtime.rb's query_array.

Authentication (components.securitySchemes)​

Supported schemes: http/scheme: bearer, apiKey (header/query/cookie), oauth2, openIdConnect (the latter two treated as a bearer token per RFC 6750; no scope/claim validation) - the server verifies presence, it doesn't inject a credential. Each security scheme becomes its own keyword argument on your handler method, named after the scheme (e.g. bearer_auth:, api_key_auth:), holding the raw extracted value so your own code can look it up/verify it:

# security: [{ bearerAuth: [] }]
def delete_pet(pet_id:, bearer_auth:)
user = User.from_bearer_token!(bearer_auth) # your own verification
...
end

A single security requirement makes its scheme(s) required arguments - a missing credential raises MissingAuthenticationError immediately, before your handler is even called. Multiple alternatives (OR) or multiple schemes together (AND) make every scheme an optional (possibly-nil) argument instead, plus a generated resolution check that raises MissingAuthenticationError unless at least one whole alternative was satisfied:

security:
- oauth2Auth: [write:widgets] # alternative 1: needs a bearer token alone
- apiKeyAuth: [] # alternative 2: needs an apiKey alone
def favorite_widget(widget_id:, oauth_2_auth:, api_key_auth:)
# exactly one of oauth_2_auth/api_key_auth is guaranteed non-nil here
end

Mapping to your own application models​

Model.from_object(source) builds a model instance by calling the same-named accessor on source for every property (e.g. source.id, source.name) - handy when your ActiveRecord (or any other) model's attribute names already match the schema's. instance.to_attributes is the reverse direction: a Hash keyed by Ruby attribute name (unlike #to_h, which is wire-shaped), ready for Record#update!/#assign_attributes. Neither recurses into a nested model/array-of-model property - build that yourself first if you need one. A source object missing a matching accessor raises a plain NoMethodError - no attempt is made to guess a different name.

YARD documentation​

Every generated model property and every handler-interface method parameter/return carries a @param/@return YARD tag with its real Ruby type - RubyMine (and any other YARD-aware tool) uses these to show correct types and flag a mismatched call in your own handler implementation.

format: uuid/date/date-time​

Validated/parsed, since a server receives untrusted wire input:

  • format: uuid stays a plain String, shape-checked (canonical 8-4-4-4-12 hex form) via Runtime.require_uuid.
  • format: date/date-time become a real Ruby Date/Time - parsing is the validation (Date.iso8601/Time.iso8601), raising ValidationError on a malformed value.

oneOf/anyOf support​

  • Discriminated (discriminator.propertyName + every variant a $ref to a named schema): reads the discriminator property and delegates to the matching variant class's own from_h/ to_wire.
  • Undiscriminated (or discriminated but unresolvable): dispatches by shape - checking each object variant's own distinguishing property first, then any array/string/number/boolean-shaped variant, then falling back to whatever's left (at most one property-less/unconstrained variant is allowed as that fallback).

Every from_h/to_wire failure here (an unknown discriminator value, no variant matching an undiscriminated union's shape, an invalid enum value) raises Runtime::ValidationError, not a bare ArgumentError - so it's caught by the same rescue_from a constraint violation is, and mapped to the same 422, no matter which model file it originated in.

Publishing the OpenAPI spec​

Set -v publishOpenApiSpec=true to have the generated server publish its own spec:

openapi-yagen g -o .generated -g ruby_rails_server_generator openapi.yaml \
-v moduleName=PetStore -v publishOpenApiSpec=true

This writes <module>/openapi.json - the effective document (after $ref resolution, --tags filtering, and conversion to this generator's OpenAPI 3.2) - and generates an OpenApiSpecController that reads and serves it, wired into Routes.draw at openApiSpecPath (default /openapi.json). No extra setup needed beyond regenerating: Routes.draw's existing call picks up the new route automatically, in both controllerModes.

Known limitations (v1)​

  • Raw responses support no Range/206 Partial Content handling of their own (a path: BinaryContent goes through Rails' send_file, so Rack::Sendfile/an X-Sendfile-capable front server can take over there).

  • Only explicitly numbered 2xx responses become separate response statuses; a range (2XX) or default response is used only when no numbered 2xx response exists.

  • Nothing enforces the PetsController-style naming convention controllerMode=concern relies on at generation time - naming your own controller class anything other than what the tag's snake_case name implies (e.g. not PetsController for tag pets) means the route's to: "pets#..." string simply won't resolve, a plain Rails routing error, not something this generator can catch ahead of time.

  • Request/response bodies support application/json, multipart/form-data, application/x-www-form-urlencoded, and a single remaining media type - two or more media types outside that fixed set is a generator error.

  • Query array parameters are only read in OpenAPI's default repeated-key style (explode: true) - explode: false/spaceDelimited/pipeDelimited aren't parsed.

  • Only http/bearer, apiKey, oauth2, and openIdConnect security schemes are supported - mutualTLS/HTTP Basic is a generator error.

  • moduleName is a single flat Ruby module name - a nested namespace (Foo::Bar) isn't supported.

  • A multipart/urlencoded body's array-typed form field is not currently type-coerced/collected reliably the way an array-typed query parameter is (see runtime.rb's query_array) - stick to a single value, or a JSON body, for an array-typed form field.

  • Nothing checks, at generation or boot time, that a registered handler actually implements every method its tag's interface module declares - a forgotten override only surfaces as a NotImplementedError the first time that specific operation is actually called.

  • Generated files are not run through a formatter - pipe the output through a formatter yourself via -p/--post-process, e.g. -p "rb:rufo %file%".

Try it​

This generator's own self-contained test suite regenerates from a kitchen-sink spec exercising every feature above - twice, once per controllerMode - and drives the real generated code with Rack::Test:

cd generators/ruby_rails_server_generator/test
OPENAPI_YAGEN=/path/to/openapi-yagen bundle install && bundle exec rake test