RailsSync
Keep an OpenAPI 3.1 contract for your Rails JSON API in sync — automatically.
RailsSync 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, RailsSync is serializer-agnostic — it doesn't care whether you use ActiveModel::Serializers, Jbuilder, Blueprinter, Alba, or plain render json:.
Why
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. RailsSync 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.
Installation
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_sync"
end
Then:
bundle install
Usage
Three steps.
1. Generate the static skeleton
bin/rails rails_sync:generate
Reads 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.
2. Capture real responses
Run your app with RAILS_SYNC=1 so the capture middleware is active:
RAILS_SYNC=1 bundle exec rspec # capture from your request/system specs
# or
RAILS_SYNC=1 bin/rails server # then exercise the app by hand
Every JSON response is recorded to tmp/rails_sync/observations.jsonl. The middleware only mounts when RAILS_SYNC is set, so it never runs in production by accident.
3. Build the full contract
bin/rails rails_sync:build
Infers 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_sync:build whenever your API changes. Stale endpoints (present in the file but no longer in your routes) are tagged x-rails-sync-stale: true rather than silently deleted.
What the output looks like
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.
How it works
| 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. |
Configuration
RailsSync.configuration.output_path # default: "openapi.yml"
RailsSync.configuration.observations_path # default: "tmp/rails_sync/observations.jsonl"
RailsSync.configuration.enabled? # true when ENV["RAILS_SYNC"] is truthy
Scope & limitations (v1)
RailsSync 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, and over-the-air bundle delivery. The committed
openapi.ymlis designed to be the seed for the former.
Development
bundle install
bundle exec rspec
License
MIT — see LICENSE.