rails-openapi-generator β Approach & Design
Tony Duong
Sep 23, 2026 γ» 19 min
I wrote this as a presentation of the approach behind rails-openapi-generator, the gem I built for Spacely to generate OpenAPI docs from Rails source by static analysis.
Generate an OpenAPI 3.1 document for a Rails app by static source analysis. No controller code is executed.
- Agenda (30 min): Context (4) Β· Options considered (8) Β· How it works (5) Β· Deep dive (9) Β· CI & deployment (3) Β· Summary (1)
- π Docs on GitBook: English Β· ζ₯ζ¬θͺ
1. Context β the problem
API docs drift from the code.
- The Rails app is the real source of truth: routes, params, response shapes
- The docs live somewhere else: hand-written YAML, Postman, wiki
- Every second copy of the truth goes stale
flowchart LR
Code["Rails app<br>(routes, controllers, views)"] -->|"ships features fast"| Reality["Actual API behavior"]
Docs["Hand-maintained<br>OpenAPI / wiki / Postman"] -.->|"updated later,<br>or never"| Reality
Reality -.->|"drift grows over time"| Gap["β Docs lie to consumers"]
style Gap fill:#ffdddd,stroke:#c00
style Code fill:#ddffdd,stroke:#0a0
Pain points
- Manual docs go stale: every new field is an extra edit, and reviewers rarely catch a missing one
- Consumers lose trust: once the spec is wrong, frontend/mobile teams read the source instead
- Old endpoints have no docs at all: retro-documenting hundreds of them by hand never gets budget
- The truth already exists in code:
param!,.json.jbuilder,render json:,rescue_from. It's just not in OpenAPI form.
The insight
The Rails source is the spec. Translate it on every run instead of keeping a parallel copy in sync.
- Legacy backlog solved for free: one run documents every route, with no per-endpoint effort
- Virtuous cycle: to improve the docs, you improve the code (
param!, YARD comments, honest views)
2. Options considered
flowchart TD
Start["Goal:<br>Rails app β OpenAPI 3.1"]
Start --> A["Option A<br>AI / LLM generation"]
Start --> B["Option B<br>OpenAPI from tests<br>(e.g. rswag)"]
Start --> C["Option C<br>DSL / annotation gems<br>(apipie, oas_rails, β¦)"]
Start --> D["Option D β CHOSEN<br>Custom static analysis"]
A --> A1["β Non-deterministic<br>β Cost + latency<br>β Hallucinated shapes"]
B --> B1["β Executes endpoints + hits DB<br>β οΈ A spec per endpoint<br>β οΈ Error paths need error specs"]
C --> C1["β οΈ Re-declare code in a DSL<br>β οΈ Per-action annotations<br>β οΈ No endpoint exec, but authoring per action"]
D --> D1["β
Deterministic<br>β
No endpoint execution / DB<br>β
No per-endpoint authoring<br>β
Reads code you already wrote"]
style D fill:#ddffdd,stroke:#0a0
style D1 fill:#ddffdd,stroke:#0a0
style A1 fill:#ffe8e8,stroke:#c00
Option A β AI / LLM generation (tried before)
- Non-deterministic: the same controller gives different schemas run to run, so specs can't be diffed in PRs
- Cost & latency: running a model over the whole codebase on every generate is slow and expensive
- Hallucination: invents fields that don't exist, or drops real ones
- A spec is a contract: it must be exact and reproducible
Option B β OpenAPI from tests (rswag)
- β Requests really run, so the documented shape is the actual shape
- β CI must boot the app, hit a DB, sign in users, and run every endpoint
- β You write specs to get docs: an extra surface to maintain
- β Error paths (
404,422) only show up if you write error specs - β Dealbreaker: we'd have to write specs for every existing controller, which isn't realistic
# spec/requests/users_spec.rb
RSpec.describe "Users API", type: :request do
path "/users/{id}" do
get "Retrieves a user" do
produces "application/json"
parameter name: :id, in: :path, type: :integer, required: true
response "200", "user found" do
schema type: :object,
properties: { id: { type: :integer }, name: { type: :string }, email: { type: :string } }
run_test! # actually boots the app and hits the endpoint
end
end
end
end
Option C β DSL / annotation gems
- apipie-rails: a DSL above each action (
api,param,returns) - oas_rails: comment tags (
# @summary,# @parameter), served by a mounted engine - swagger-blocks / swagger_yard: hand-written schema as Ruby DSL blocks or YARD tags
- β None of them execute endpoints or need a DB
- β All of them make you re-declare what the controller already says, action by action
- β So they don't help with the legacy backlog: you still touch every action
The same "show a user" endpoint in each:
# apipie-rails
api :GET, "/users/:id", "Show a user"
param :id, :number, required: true, desc: "User ID"
returns code: 200, desc: "The user" do
property :id, Integer
property :name, String
property :email, String
end
def show
render json: @user
end
# oas_rails
# @summary Show a user
# @parameter id(path) [Integer] The user ID
# @response User found(200) [Hash{id: Integer, name: String, email: String}]
def show
render json: @user
end
# swagger-blocks
swagger_path "/users/{id}" do
operation :get do
key :summary, "Show a user"
parameter do
key :name, :id
key :in, :path
key :required, true
key :type, :integer
end
response 200 do
key :description, "user found"
schema do
property(:id) { key :type, :integer }
property(:name) { key :type, :string }
property(:email) { key :type, :string }
end
end
end
end
This gem needs none of that. Just the ordinary controller:
# Show a user
def show
render json: @user # or a users/show.json.jbuilder view
end
Option D β Custom static analysis (this gem) β
Read the code, never execute it.
| Criterion | AI | Tests (rswag) | DSL/annotation gems | Static (this gem) |
|---|---|---|---|---|
| Deterministic / byte-identical output | β | β οΈ | β | β |
| Avoids executing endpoints / needing a DB | β | β | β | β |
| No per-endpoint authoring required | β | β | β | β |
| Uses code you already wrote | β | β | β | β |
| Documents error paths automatically | β | β | β | β |
| Cost / latency in CI | High | High | Low | Low |
| Accuracy (no invented fields) | β | β | β | β |
| No custom tool to maintain in-house | β | β | β | β οΈ (we own it) |
- About booting Rails: all of these load the Rails environment
- Only rswag executes endpoints and needs a DB
- This gem loads Rails only to read the route table; the rest is static source analysis
Costs we accept
- Only literal code is visible:
render json: User.first.serializable_hashbecomes "any" ({}); sidecars cover the gaps - Maintenance: it's a custom tool, so we own it (the response logic alone is ~1,000 lines)
- A genuinely new controller pattern may need a gem change
- As long as we keep writing controllers the way we do today, no gem changes are needed
- Unsupported patterns degrade to a permissive schema; they don't break the build
How it was built: AI-driven, human-supervised
- The gem was written almost entirely by AI; my role was QA (define features, review, validate)
- A safe fit for a "freedom" approach because:
- Independent library: no regression risk for the main app
- Easy to test: Rails source in β OpenAPI document out
- Tests are the safety net: fixture-based tests (a dummy Rails app + expected output) catch regressions on each improvement
3. How the gem works β high level
One route in β one OpenAPI operation out, orchestrated by a single Generator.
flowchart TD
Routes["Rails routes<br>(RouteCollector)"] --> Loop{"For each route"}
Loop --> Locate["Locate controller<br>source file"]
Locate --> Parse["Parse file once:<br>Ripper β AST, YARD β comments<br>(YardParser)"]
Parse --> Params["Extract parameters<br>from param! (ParamExtractor)"]
Parse --> Docs["Split comment into<br>summary / description<br>(DocCommentExtractor)"]
Parse --> Resp["Build response<br>(RenderExtractor β Classifier β ...)"]
Params --> Op["Build one Operation<br>(OperationBuilder)"]
Docs --> Op
Resp --> Op
Op --> Doc["Assemble OpenAPI document<br>(DocumentBuilder)"]
Doc --> Write["Serialize to disk<br>(Writer) β doc/openapi.json"]
style Routes fill:#e8f0ff,stroke:#3366cc
style Write fill:#ddffdd,stroke:#0a0
Two parsers
- Ripper: built into Ruby; turns source into a tree (AST); no extra dependency
- YARD: used only to read the comment above each method
Where the data comes from
| OpenAPI piece | Rails source |
|---|---|
| Paths & HTTP methods | The Rails route table |
summary / description |
YARD comments above the action |
| Parameters & request body | param! calls (incl. nested Hash blocks) |
| Response body | In priority order: .schema.json sidecar βΊ inline render json: βΊ .json.jbuilder view βΊ partials |
| Status codes | head, render status:, redirect_to, or HTTP-method convention |
| Error responses | rescue_from handlers on the controller chain |
Design rules
- Warn, never raise: one broken controller still leaves a full document for the other 99
- Deterministic: same source β byte-identical output, so the spec can be committed and diffed
- Simple: ~30 small single-purpose files, wired together explicitly in one
Generator
4. Deep dive
4.1 End-to-end: one action β OpenAPI operation
- A real action and view, followed by the output of each stage
- The dumps come from running the gem's own classes on this exact input
Input
# app/controllers/api/articles_controller.rb
class Api::ArticlesController < ApplicationController
# Search articles
# Returns published articles matching the given filters, newest first.
def index
param! :q, String, blank: false, description: "Free-text search"
param! :per_page, Integer, in: 1..100, required: false, description: "Page size"
end
end
# app/views/api/articles/index.json.jbuilder
json.array! @articles do |article|
json.id article.id
json.title article.title
json.status "published"
json.author do
json.name article.author_name
end
end
Stage 1 β YARD β DocComment
- YARD returns only the raw comment text
- The gem's
DocCommentExtractorsplits it: first line = summary, the rest = description DocCommentis the gem's own struct, not a YARD type
# ββ Input: YARD docstring (object.docstring.to_s) ββ
"Search articles\nReturns published articles matching the given filters, newest first."
# ββ Output: DocCommentExtractor ββ
#<DocComment
summary="Search articles",
description="Returns published articles matching the given filters, newest first.">
Stage 2 β ParamExtractor: Ripper AST β ParamCall
- Reads the name (symbol), the type (constant) and the options hash from each
param!node LiteralEvaluatorturns literals into Ruby values (1..100βRange)- Non-literals become
UNRESOLVED: the option is dropped,fully_resolvedturnsfalse, and the run reports a warning - Constants are resolved (
Order::STATUSESβ enum); expressions like.sortare not
# ββ Input: param! :q, String, blank: false, description: "Free-text search" ββ
[:command,
[:@ident, "param!", [1, 0]],
[:args_add_block,
[[:symbol_literal, [:symbol, [:@ident, "q", [1, 8]]]], # name β "q"
[:var_ref, [:@const, "String", [1, 11]]], # type β "String"
[:bare_assoc_hash, # options β LiteralEvaluator
[[:assoc_new, [:@label, "blank:", [1, 19]], [:var_ref, [:@kw, "false", [1, 26]]]],
[:assoc_new, [:@label, "description:", [1, 33]],
[:string_literal, [:string_content, [:@tstring_content, "Free-text search", [1, 47]]]]]]]],
false]]
# ββ Output ββ
#<ParamCall name="q", type="String", required=false,
constraints={:blank=>false, :description=>"Free-text search"}, fully_resolved=true, nested=nil>
# ββ Input: param! :per_page, Integer, in: 1..100, required: false, description: "Page size" ββ
[:command,
[:@ident, "param!", [1, 0]],
[:args_add_block,
[[:symbol_literal, [:symbol, [:@ident, "per_page", [1, 8]]]], # name β "per_page"
[:var_ref, [:@const, "Integer", [1, 18]]], # type β "Integer"
[:bare_assoc_hash, # options β LiteralEvaluator
[[:assoc_new, [:@label, "in:", [1, 27]],
[:dot2, [:@int, "1", [1, 31]], [:@int, "100", [1, 34]]]], # :dot2 range β 1..100
[:assoc_new, [:@label, "required:", [1, 39]], [:var_ref, [:@kw, "false", [1, 49]]]],
[:assoc_new, [:@label, "description:", [1, 56]],
[:string_literal, [:string_content, [:@tstring_content, "Page size", [1, 70]]]]]]]],
false]]
# ββ Output ββ
#<ParamCall name="per_page", type="Integer", required=false,
constraints={:in=>1..100, :description=>"Page size"}, fully_resolved=true, nested=nil>
[1, 15]-style pairs are source positions:[line, column]
Stage 3 β SchemaMapper: ParamCall β schema
blank: falseβminLength: 1in: 1..100βminimum/maximum
q β {"type"=>"string", "minLength"=>1, "description"=>"Free-text search"}
per_page β {"type"=>"integer", "minimum"=>1, "maximum"=>100, "description"=>"Page size"}
Stage 4 β JbuilderParser: view AST β response schema
- Parses the template with Ripper; never executes it
json.array!+ block β arrayjson.<key> <literal>β typed property with anexamplejson.<key> <runtime value>β{}(any)json.<key> do β¦ endβ nested objectif/else/casebranches are merged: properties from every branch appear
# ββ Input: the view's Ripper AST (trimmed; `...` = omitted slots) ββ
[:method_add_block,
[:command_call, # json.array! @articles
[:vcall, [:@ident, "json", [1, 0]]], [:@period, ".", [1, 4]],
[:@ident, "array!", [1, 5]], # array! β { type: array }
[:args_add_block, [[:var_ref, [:@ivar, "@articles", [1, 12]]]], false]],
[:do_block,
[:block_var, [:params, [[:@ident, "article", [1, 26]]], ...], false],
[:bodystmt,
[[:command_call, # json.id article.id
[:vcall, [:@ident, "json", [2, 2]]], [:@period, ".", [2, 6]],
[:@ident, "id", [2, 7]], # key "id"
[:args_add_block,
[[:call, [:var_ref, [:@ident, "article", ...]], [:@period, "."], [:@ident, "id", ...]]], # runtime β {}
false]],
[:command_call, # json.title article.title
[:vcall, [:@ident, "json", [3, 2]]], [:@period, "."], [:@ident, "title", [3, 7]],
[:args_add_block, [[:call, ...article.title... ]], false]], # runtime β {}
[:command_call, # json.status "published"
[:vcall, [:@ident, "json", [4, 2]]], [:@period, "."], [:@ident, "status", [4, 7]],
[:args_add_block,
[[:string_literal, [:string_content, [:@tstring_content, "published", [4, 15]]]]], # literal β typed + example
false]],
[:method_add_block, # json.author do ... end
[:call, [:vcall, [:@ident, "json", [5, 2]]], [:@period, "."], [:@ident, "author", [5, 7]]],
[:do_block, nil,
[:bodystmt,
[[:command_call, # json.name article.author_name
[:vcall, [:@ident, "json", [6, 4]]], [:@period, "."], [:@ident, "name", [6, 9]],
[:args_add_block, [[:call, ...article.author_name... ]], false]]], # runtime β {}
...]]]],
...]]]
# ββ Output ββ
{
"type": "array",
"items": {
"type": "object",
"properties": {
"author": { "type": "object", "properties": { "name": {} } },
"id": {},
"status": { "type": "string", "example": "published" },
"title": {}
}
}
}
Stage 5 β OperationBuilder: one route β Endpoint
- Runs once per route; produces the gem's internal struct (not OpenAPI JSON yet)
- Decides where each param goes: path, query or body (for a
GET, query) - Moves each
descriptionfrom the schema onto the parameter - Adds a source reference, the
operationIdand the controller tag
# ββ Input ββ
route = Route(GET "/api/articles" β api/articles#index)
doc_comment = DocComment(...) # Stage 1
param_calls = [ParamCall(q β¦), ParamCall(per_page β¦)] # Stage 2
response = Response(200, body: <jbuilder schema>) # Stage 4
source_location = "app/controllers/api/articles_controller.rb:4"
# ββ Output ββ
#<struct Endpoint
http_method="GET",
path="/api/articles",
summary="Search articles",
description="Returns published articles matching the given filters, newest first.\n\n" +
"_Source: `app/controllers/api/articles_controller.rb:4`_",
parameters=
[#<struct Parameter name="q", location=:query, required=false,
schema={"type"=>"string", "minLength"=>1}, # description moved out of schemaβ¦
description="Free-text search">, # β¦onto the parameter
#<struct Parameter name="per_page", location=:query, required=false,
schema={"type"=>"integer", "minimum"=>1, "maximum"=>100},
description="Page size">],
request_body=nil, # GET β no body
operation_id="get_api_articles",
tag="Api::ArticlesController",
response=
#<struct Response kind=:json, description="Successful response",
entries=[#<struct ResponseEntry status=200, body={"type"=>"array", "items"=>{β¦}}, content_types=nil>]>>
Stage 6 β DocumentBuilder: all Endpoints β OpenAPI document
- Runs once for the whole app
- Adds the top-level
openapi,infoandtags - Groups endpoints by path, then by method; sorts everything (parameters by name) for deterministic output
- Converts
:idβ{id}and buildsresponseswith content types
# ββ Input ββ
endpoints = [Endpoint(GET /api/articles), β¦] # one per route, from Stage 5
configuration.title = "My API" # api_version defaults to "1.0.0"
{
"openapi": "3.1.0",
"info": { "title": "My API", "version": "1.0.0" },
"tags": [{ "name": "Api::ArticlesController" }],
"paths": {
"/api/articles": {
"get": {
"operationId": "get_api_articles",
"tags": ["Api::ArticlesController"],
"summary": "Search articles",
"description": "Returns published articles matching the given filters, newest first.\n\n_Source: `app/controllers/api/articles_controller.rb:4`_",
"parameters": [
{
"name": "per_page",
"in": "query",
"required": false,
"description": "Page size",
"schema": { "type": "integer", "minimum": 1, "maximum": 100 }
},
{
"name": "q",
"in": "query",
"required": false,
"description": "Free-text search",
"schema": { "type": "string", "minLength": 1 }
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"author": { "type": "object", "properties": { "name": {} } },
"id": {},
"status": { "type": "string", "example": "published" },
"title": {}
}
}
}
}
}
}
}
}
}
}
}
- Every field traces back to literal code in the controller or view
- A sidecar would override the
200body if inference wasn't enough (Β§4.4)
4.2 Why JSON Schema for response bodies
flowchart LR
subgraph Sources["Four response sources β in priority order (highest wins)"]
direction TB
S["1 Β· .schema.json sidecar<br>(hand-written override β beats all)"]
J["2 Β· inline render json: {β¦}<br>(literal in the action)"]
T["3 Β· .json.jbuilder view<br>(inferred from template)"]
P["4 Β· partials<br>(resolved recursively inside the view)"]
S --> J --> T --> P
end
Sources --> Schema["JSON Schema<br>(shape, types, examples)"]
Schema --> OAS["OpenAPI 3.1 response body<br>(OAS 3.1 schemas ARE JSON Schema)"]
style S fill:#ddffdd,stroke:#0a0
style Schema fill:#fff3cd,stroke:#e0a800
style OAS fill:#ddffdd,stroke:#0a0
Priority (highest wins)
- Sidecar: overrides everything
- Inline
render json:: beats the view (more specific) .json.jbuilderview: used otherwise- Partials: resolved inside the view, not a separate source
Why JSON Schema
- OpenAPI 3.1 schemas are JSON Schema: no translation layer needed
- It describes shape without data: exactly what we get without running code
- It's the natural override format: hand-written sidecars and inferred schemas are interchangeable
What JSON Schema looks like
- A JSON response is data: one concrete example
{ "id": 42, "email": "alice@example.com", "role": "admin" }
- A JSON Schema describes the shape of every valid response
{
"type": "object",
"required": ["id", "email"],
"properties": {
"id": { "type": "integer", "minimum": 1, "example": 42 },
"email": { "type": "string", "format": "email", "example": "alice@example.com" },
"role": { "type": "string", "enum": ["admin", "member"] }
}
}
type: what kind of value (object, string, integer, arrayβ¦)required: which fields must always be presentformat,enum,minimum: extra rules on the valueexample: a sample value shown in the docs- This is also exactly what you'd write in a
.schema.jsonsidecar (Β§4.4)
The same kind of schema, inferred from a jbuilder view
# app/views/api/users/_user.json.jbuilder
json.extract! user, :id, :name, :email
json.role "member"
json.profile do
json.bio user.bio
end
{
"type": "object",
"properties": {
"id": {},
"name": {},
"email": {},
"role": { "type": "string", "example": "member" },
"profile": {
"type": "object",
"properties": { "bio": {} }
}
}
}
- Literal (
"member") β type + example - Runtime data (
user.bio,extract!) β{}: honest about what we can't know
4.3 Following the code: automatic error responses
- Renders often hide in helpers,
before_actionorrescue_from - The gem follows them through the controller chain (concerns included), up to a configurable depth
- Literal arguments at the call site are passed through to the helper
rescue_from Pundit::NotAuthorizedError, with: :render_forbidden
def render_forbidden
render_error(status: :forbidden, code: "FORBIDDEN", message: "...")
end
def render_error(status:, code:, message:)
render json: { error: { code: code, message: message } }, status: status
end
- β every operation gets a
403response with the right body, found two calls deep - No error-path specs needed (unlike rswag)
4.4 The escape hatch: JSON Schema sidecars
- When inference isn't enough, drop in a
.schema.json - It's used exactly as written, replacing inference
app/views/api/users/_user.schema.json # wherever the partial is used
app/views/api/users/show.schema.json # the action's response
app/views/api/users/create.schema.json # works even with no view (inline render)
- Malformed JSON β a warning, then fallback to inference (never raises)
5. CI and deployment
Before: the api_specification repo (now archived)
- A hand-written spec in a separate repo, with its own CI and S3 upload
- Every API change needed a second PR in a second repo, the drift problem from Β§1
- Archived: generation and deployment now live in spacely_web itself
Now: two workflows in spacely_web
flowchart LR
PR["PR changes a<br>.json.jbuilder / .schema.json"] --> Check["check-openapi-schemas<br>(sidecar exists?)"]
Check -->|"β"| Fix["PR comment:<br>/generate-openapi-schema view-path"]
Manual["Manual run<br>on a PR branch"] --> Gen
Merge["PR merged<br>into develop"] --> Gen["generate-openapi-docs"]
Gen --> Rake["rake openapi:generate<br>β doc/openapi.json"]
Rake --> Redoc["@redocly/cli build-docs<br>β static HTML"]
Redoc --> S3["S3 static site<br>v2-develop.html / v2-PR{N}.html"]
S3 --> Comment["PR comment<br>with preview URL"]
- Setup: the gem comes from git, in the
:developmentgroup. The initializer is guarded withdefined?(RailsOpenapiGenerator)and only setstitleandexclude_source_paths: ["vendor/"]
Workflow 1: generate-openapi-docs.yml: generate and publish
- On merge to develop β
v2-develop.html, one shared URL that always shows the latest develop - Manual run on a PR branch (
workflow_dispatch) β a per-PR preview,v2-PR{N}.html - Steps:
bundle exec rake openapi:generate OUTPUT=doc/openapi.json FORMAT=jsonβ Redoc HTML βaws s3 cp(OIDC role, no stored keys) - PR comment: one comment with the URL and commit SHA, updated in place (found by a hidden marker)
- No database service in the job: only Ruby and Node, because the gem never executes actions
- The
v2-prefix avoids overwriting the old repo'sindex.html/PR{N}.htmlin the same bucket - Access through the VPN
Workflow 2: check-openapi-schemas.yml: the sidecar gate
- Runs on PRs that touch
app/views/**/*.json.jbuilderor*.schema.json - Diffs against the PR base (
git diff --name-status -M base...HEAD):
Change to a .json.jbuilder |
Rule |
|---|---|
| Added / modified | a sibling .schema.json must exist |
| Deleted | its .schema.json must be deleted too |
| Renamed | the sidecar must move with it |
- On failure: file annotations + one PR comment with the exact fix command. The comment is deleted once the PR is fixed
- Checks existence, not correctness: reviewers still verify the sidecar matches the view
Fixing a failure: the /generate-openapi-schema Claude Code skill
- Input: a jbuilder path or an endpoint (
GET /api/v4/...,Controller#action) - Reads the view, its partials, the models and
db/schema.rbβ writes a draft 2020-12 sidecar - Rules live in
app/views/AGENTS.md(nullability as["string", "null"], enums from the model,$defsfor partials) - Coverage today: 250 sidecars for 444 jbuilder views. The gate makes the number grow with every PR that touches a view
Summary
- Problem: docs drift from code, and legacy endpoints were never documented
- Insight: the Rails source is the spec; one run documents every route
- Rejected: AI (non-deterministic), rswag (runs endpoints, a spec per endpoint), DSL gems (re-declare everything)
- Chosen: static analysis with Ripper + YARD: deterministic, no DB, no per-endpoint authoring
- Key choice: JSON Schema for responses, because OpenAPI 3.1 schemas are JSON Schema
- Safety net: warn-never-raise + sidecar overrides
- Trade-off: we maintain it, but current controller conventions need no gem changes
- Delivery: spacely_web CI publishes the docs on every merge to develop. The sidecar gate keeps response schemas growing with each PR
Further reading
- Features: YARD comments Β· Parameters Β· Response Bodies Β· Status Codes Β· Error Responses Β· HTML & File Responses
- Guides: JSON Schema Sidecars Β· Route Filtering Β· Previewing the Spec Β· Programmatic Use
- Getting started: Installation Β· Quick Start Β· Configuration
- Reference & examples: Configuration Options Β· Basic CRUD API Β· Nested Parameters