Keep an OpenAPI 3.1 contract for your Rails JSON API in sync — automatically.
RailsContractSync produces and maintains a single committed openapi.yml for your Rails API by combining two sources of truth:
- Static introspection — reads your routes and
params.require/permitdeclarations (via Prism) to lay down the endpoint + request-parameter skeleton, with zero test runs. - Runtime observation — a lightweight Rack middleware records the actual JSON responses your app returns (in your test suite, or while you click around in development) and fills in real response schemas.
The two are merged into one committed file that is:
- Idempotent — re-run it anytime; the output is byte-stable, so diffs show only real API changes.
- Prose-preserving — your hand-written
summary/description/tagsare never clobbered by a regeneration. - Honest — endpoints you haven't exercised yet are flagged, not faked.
Because the runtime layer reads the response bytes, RailsContractSync is serializer-agnostic — it doesn't care whether you use ActiveModel::Serializers, Jbuilder, Blueprinter, Alba, or plain render json:.
You changed an endpoint. Now your OpenAPI doc is a lie — until someone remembers to hand-edit it. Hand-written API specs rot; fully manual DSLs are tedious; and pure static analysis can't see what your serializers actually emit at runtime. RailsContractSync splits the difference: static analysis gives you an instant, zero-setup skeleton, and your existing tests (or a few minutes of clicking) supply the real response shapes.
Add it to your Gemfile — typically in the development and test groups, since that's where the contract is generated:
group :development, :test do
gem "rails_contract_sync"
endThen:
bundle installThree steps.
bin/rails rails_contract_sync:generateReads your routes and strong-params and writes openapi.yml with paths, HTTP verbs, and request-body parameters. No response schemas yet — that's the next step.
Run your app with RAILS_CONTRACT_SYNC=1 so the capture middleware is active:
RAILS_CONTRACT_SYNC=1 bundle exec rspec # capture from your request/system specs
# or
RAILS_CONTRACT_SYNC=1 bin/rails server # then exercise the app by handEvery JSON response is recorded to tmp/rails_contract_sync/observations.jsonl. The middleware only mounts when RAILS_CONTRACT_SYNC is set, so it never runs in production by accident.
bin/rails rails_contract_sync:buildInfers response schemas from the captured traffic, merges them with the static skeleton and with any descriptions you've added to openapi.yml by hand, and writes the result back. Commit openapi.yml.
Re-run rails_contract_sync:build whenever your API changes. Stale endpoints (present in the file but no longer in your routes) are tagged x-rails-contract-sync-stale: true rather than silently deleted. Pass prune: true to remove them instead.
openapi: 3.1.0
info:
title: API
version: 1.0.0
paths:
"/users":
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
user:
type: object
properties:
name:
type: string
responses:
"201":
description: "" # add your own prose here — it survives rebuilds
content:
application/json:
schema:
type: object
properties:
id: { type: integer }
name: { type: string }
required: [id, name]
"/users/{id}":
get:
responses:
"200":
description: ""
content:
application/json:
schema:
type: object
properties:
id: { type: integer }
name: { type: string }
required: [id, name]Point Swagger UI, openapi-typescript, Postman, or any OpenAPI 3.1 tool at this file.
| Layer | What it does |
|---|---|
Static::RouteExtractor |
Maps Rails.application.routes to OpenAPI paths (/users/:id → /users/{id}). |
Static::ParamsExtractor |
Parses controllers with Prism to read params.require(...).permit(...) (best-effort). |
Runtime::Middleware |
Env-gated Rack middleware; records real request params + response bodies. |
SchemaInferrer |
Turns observed JSON into JSON Schema, widening types across observations. |
Merger |
Reconciles static + observed + your existing file; preserves prose; idempotent. |
RailsContractSync.configuration.output_path # default: "openapi.yml"
RailsContractSync.configuration.observations_path # default: "tmp/rails_contract_sync/observations.jsonl"
RailsContractSync.configuration.enabled? # true when ENV["RAILS_CONTRACT_SYNC"] is truthyYou can override defaults in an initializer:
# config/initializers/rails_contract_sync.rb
RailsContractSync.configuration.output_path = "docs/openapi.yml"
RailsContractSync.configuration.observations_path = "tmp/api_observations.jsonl"RailsContractSync is deliberately focused. It does not try to do everything:
- JSON REST controllers only (
ActionController/ActionController::API). No GraphQL or Grape. - Static strong-params reading is best-effort. It handles literal
permitarguments; conditional or metaprogrammed params are simply filled in by the runtime layer the first time a request hits that endpoint. - Response schemas reflect the traffic you capture. Coverage equals what your tests or manual usage exercise — an endpoint you never call won't get a response schema.
- Not in scope (yet): breaking-change / contract diffing in CI. The committed
openapi.ymlis designed to be the seed for that.
bundle install
bundle exec rspecMIT — see LICENSE.