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 (noActionControllersuperclass, noinclude) per tag, matching the generated interface module, and register one instance of it per tag when you draw your routes.concern- only anActiveSupport::Concernis generated per tag. You create your own controller underapp/controllers/(an ordinarily autoloaded, hand-written file),includethe concern, and implement itson_*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
| Variable | Required | Description |
|---|---|---|
moduleName | yes | Ruby module namespace for the generated code (e.g. PetStore). Also determines the file layout below (<snake_case moduleName>/...). |
strict | no (default true) | true: an unsupported schema/operation aborts generation with an error. false: skip it with a printed warning and generate everything else. |
validate | no (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. |
controllerMode | no (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. |
baseController | no (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. |
publishOpenApiSpec | no (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. |
openApiSpecPath | no (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_frommaps it to422 Unprocessable Content. A missing/invalid authentication credential is a distinct class,Runtime::MissingAuthenticationError, mapped to401 Unauthorized, and a raw request body with the wrongContent-TypeisRuntime::UnsupportedMediaTypeError, mapped to415 Unsupported Media Type. All default response bodies are{"error": "<message>"}- overriderender_openapi_validation_error/render_openapi_missing_authentication_error/render_openapi_unsupported_media_type_errorin your ownbaseControllerto 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.cookiesand parsed/validated according to the schema (Integer/Float/true-or-false/Date/Time,format: uuidshape-checked, an enum's membership checked) before your handler ever sees it - raisesRuntime::ValidationErroron 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' ownparamsdoes NOT do this for a plain (non-bracket) repeated key - seeruntime.rb'squery_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: uuidstays a plainString, shape-checked (canonical 8-4-4-4-12 hex form) viaRuntime.require_uuid.format: date/date-timebecome a real RubyDate/Time- parsing is the validation (Date.iso8601/Time.iso8601), raisingValidationErroron a malformed value.
oneOf/anyOf support
- Discriminated (
discriminator.propertyName+ every variant a$refto a named schema): reads the discriminator property and delegates to the matching variant class's ownfrom_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 Contenthandling of their own (apath:BinaryContentgoes through Rails'send_file, soRack::Sendfile/anX-Sendfile-capable front server can take over there). -
Only explicitly numbered
2xxresponses become separate response statuses; a range (2XX) ordefaultresponse is used only when no numbered2xxresponse exists. -
Nothing enforces the
PetsController-style naming conventioncontrollerMode=concernrelies on at generation time - naming your own controller class anything other than what the tag'ssnake_casename implies (e.g. notPetsControllerfor tagpets) means the route'sto: "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/pipeDelimitedaren't parsed. -
Only
http/bearer,apiKey,oauth2, andopenIdConnectsecurity schemes are supported -mutualTLS/HTTP Basic is a generator error. -
moduleNameis 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'squery_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
NotImplementedErrorthe 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