Skip to content

Add OpenAPI 3.1 export generated from contracts - #3

Open
VSN2015 wants to merge 1 commit into
masterfrom
feat/openapi-export
Open

Add OpenAPI 3.1 export generated from contracts#3
VSN2015 wants to merge 1 commit into
masterfrom
feat/openapi-export

Conversation

@VSN2015

@VSN2015 VSN2015 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Contracts gain a third reader beyond the request validator and the schema-drift guard: an exporter that emits OpenAPI 3.1 from the same frozen contract data the server enforces — so the API docs cannot drift from the validation. Fully additive; no behaviour of existing contracts changes. Bumps to 0.3.0 (master released 0.2.0 with the message: feature while this PR was open, so the branch was rebased and re-versioned).

Why this matters: the most universal pain in API development is documentation drifting from actual validation. Teams hand-maintain OpenAPI YAML (which rots), annotate with rswag/apipie (a second source of truth to keep in sync), or skip docs entirely. Permittable is uniquely positioned to fix this because the contract is the validation: a schema exported from it is correct by construction — the same property the drift guard already gives us against the database, pointed outward at API consumers. One export unlocks hosted docs (Swagger UI, Redoc, Stoplight, Postman), typed frontend clients, and edge/gateway request validation.


How to use it

1. Declare contracts as you already do — optionally annotate them

Two new documentation-only options, desc: and example:, plus desc: on the macro itself. The runtime never reads them, but an example: is validated against its own field's contract at class load, exactly like default: — a lying example fails the boot, not the docs:

class UsersController < ApplicationController
  include Permittable

  permit_params :create, :update, root: :user, unknown: :error, desc: "Create or update a user" do
    required :name,  :string,  length: 1..80, desc: "Display name"
    required :email, :string,  format: /\A[^@\s]+@[^@\s]+\z/, example: "jo@example.com"
    optional :age,   :integer, in: 18..120
    optional :ssn,   :string,  sensitive: true          # exported as writeOnly
    optional :plan,  :string,  in: %w[free pro], default: "free"
    array    :tag_names, of: :string, length: 0..10
    optional :address do
      required :city, :string
      optional :zip,  :string, format: /\A\d{5}\z/
    end
  end
end
# example: gold — but in: is %w[free pro]? The class fails to load:
# Permittable: :example for field :plan violates its own contract (inclusion)

2. Export the whole app (Rails)

bin/rails permittable:openapi                       # JSON to stdout
bin/rails "permittable:openapi[openapi/api.json]"   # write to a file

OPENAPI_TITLE="Acme API" OPENAPI_VERSION="2.3.0" bin/rails permittable:openapi

The task eager-loads the app (which also exercises the schema-drift guard on every contract), collects every ActionController::Base/::API descendant with contracts, and maps documented actions onto paths via the route set — /users/:id(.:format) becomes /users/{id}.

3. What comes out

For the contract above, paths./users.post contains (abridged):

{
  "operationId": "users_create",
  "description": "Create or update a user",
  "requestBody": {
    "required": true,
    "content": { "application/json": { "schema": {
      "type": "object",
      "properties": { "user": {
        "type": "object",
        "properties": {
          "name":  { "type": "string", "minLength": 1, "maxLength": 80, "description": "Display name" },
          "email": { "type": "string", "minLength": 1, "pattern": "^[^@\\s]+@[^@\\s]+$", "examples": ["jo@example.com"] },
          "age":   { "type": "integer", "minimum": 18, "maximum": 120 },
          "ssn":   { "type": "string", "writeOnly": true, "x-permittable-sensitive": true },
          "plan":  { "type": "string", "enum": ["free", "pro"], "default": "free" },
          "tag_names": { "type": "array", "minItems": 0, "maxItems": 10, "items": { "type": "string" } },
          "address": {
            "type": "object",
            "properties": { "city": { "type": "string", "minLength": 1 },
                            "zip":  { "type": "string", "pattern": "^\\d{5}$" } },
            "required": ["city"], "additionalProperties": false
          }
        },
        "required": ["name", "email"], "additionalProperties": false
      } },
      "required": ["user"]
    } } }
  },
  "responses": {
    "400": { "$ref": "#/components/responses/PermittableBadRequest" },
    "422": { "$ref": "#/components/responses/PermittableUnprocessableEntity" }
  }
}

Every operation also references a shared components/schemas/PermittableInvalidParameters schema matching the gem's error envelope ({ success, error: { message, code, details: [{ param, code }] } }) — consumers get typed errors, not just typed inputs. The 400 response appears only on rooted contracts (only a missing root renders 400).

4. Consume it

# Typed frontend client — every request body gets compile-time types
npx openapi-typescript openapi/api.json -o src/api/schema.d.ts

# Hosted docs
npx @redocly/cli preview-docs openapi/api.json
# ...or point Swagger UI / Postman / Stoplight at the file

Recommended workflow: commit the generated file. Output is deterministic (fixed key order, declaration-order properties), so a contract change shows up in the same PR as its documentation diff — reviewers see both together. A CI step running the rake task and diffing against the committed file makes stale docs a build failure.

5. Or build fragments programmatically — no Rails required

Permittable::JsonSchema.rule(UsersController.permit_rule_for(:create))  # request-body schema (Hash)
Permittable::OpenAPI.request_body_for(UsersController, :create)         # OpenAPI requestBody object
Permittable::OpenAPI.operations_for(UsersController)                    # { "create" => operation, ... }
Permittable::OpenAPI.document(controllers: [UsersController], info: { "title" => "My API" })

Useful for merging into a hand-authored spec, serving a live /openapi.json endpoint, or feeding contract tests.


How contracts map

Contract Emitted schema
required / optional object required: array; required strings also get minLength: 1 ("" is absent in Permittable)
:string :integer :float :boolean string / integer / number / boolean
:date / :datetime string + format: date / date-time
:decimal type: ["string","number"] + format: decimal (string is the precision-safe encoding)
in: Array / numeric Range enum / minimum+maximum (...exclusiveMaximum)
length: minLength/maxLength on strings, minItems/maxItems on arrays; bare Integer → exact
format: pattern, \A/\z translated to ^/$
default: / desc: / example: default / description / examples (Date/Time/BigDecimal re-encoded as JSON scalars)
nested block / array object+properties / array+items
unknown: :error additionalProperties: false at every nesting level
root: required wrapper object (wrapper stays permissive — the runtime never inspects the root's siblings)
sensitive: true writeOnly: true + x-permittable-sensitive

Unrepresentable things stay visible instead of being guessed:

  • A format: regexp using Ruby-only constructs (\h, POSIX classes, possessive quantifiers, inline flags) or regexp flags exports as x-permittable-pattern — a wrong pattern in published docs is worse than a missing one.
  • validate:/transform: are opaque callables → x-permittable-custom-validation / x-permittable-transformed flags.
  • Actions covered only by a catch-all rule on a plain-Ruby host (no action_methods) appear under "*" with x-permittable-catch-all.
  • Operations with no matching route land under x-permittable-controllers rather than being silently dropped.
  • The schema documents the canonical JSON encoding; the runtime additionally accepts string-encoded scalars ("42", "true") for form/query payloads.

Design decisions worth reviewing

  • Catch-all expansion excludes the concern's own methods. Rails' action_methods reports permitted_params, enforce_params_contract, and render_invalid_parameters as actions on every including controller (they are public by design). Without the exclusion, catch-all contracts would document them as endpoints. Covered by an integration spec through the real ActionController stack.
  • Operations resolve through permit_rule_for, never by dumping rules raw — so last-matching-rule-wins holds in the docs exactly as at request time.
  • requestBody.required mirrors the runtime: true for rooted contracts (missing root → 400) and for any top-level required field.
  • Determinism is a spec'd guarantee: spec/fixtures/openapi.json is a committed golden file compared byte-for-byte against a full generated document.
  • Load order: exporter requires sit at the bottom of lib/permittable.rb (like the Railtie) and the concern-method list resolves lazily, so requiring the exporter standalone can't freeze an empty method list.

Files Changed

File Type
lib/permittable/json_schema.rb Added — contract → JSON Schema converter
lib/permittable/open_api.rb Added — OpenAPI 3.1 document assembly
lib/permittable/tasks/openapi.rake Added — export rake task
lib/permittable.rb Modified — desc:/example: options, generalized authored-value validation, requires
lib/permittable/railtie.rb Modified — rake_tasks hook
lib/permittable/version.rb Modified — 0.2.0 → 0.3.0
spec/json_schema_spec.rb, spec/open_api_spec.rb Added — 26 examples over the new modules
spec/fixtures/openapi.json Added — committed golden document (determinism guarantee)
spec/permittable_spec.rb Modified — macro-validation examples for desc:/example:
README.md, CHANGELOG.md, Gemfile.lock Modified — docs ("Exporting OpenAPI" section) + version sync

Testing

  • bundle exec rspec — 112 examples, 0 failures (28 new; the suite includes the message: feature merged from master): per-mapping unit specs (types, bounds, enums, pattern translation incl. untranslatable fallbacks, arrays, nesting, root wrapping), OpenAPI assembly specs (operations, catch-all expansion, route placement, Journey-shaped route extraction), a byte-for-byte golden-document comparison, and an integration spec through the real ActionController stack.
  • bundle exec rubocop — clean.
  • gem build — verified the three new files are packaged.

Related Issues

None

🤖 Generated with Claude Code

Contracts gain a third reader beyond the validator and the schema-drift
guard: an exporter that emits OpenAPI 3.1 from the same frozen contract
data the server enforces, so the docs cannot drift from the validation.

- Permittable::JsonSchema converts rules and fields to JSON Schema
  (draft 2020-12): canonical type encodings, in:/length:/format: bounds,
  additionalProperties under unknown: :error, required root wrappers,
  writeOnly for sensitive fields, and minLength 1 on required strings
  ("" is absent). Ruby-only or flagged regexps, opaque callables, and
  non-numeric Ranges surface as x-permittable-* extensions rather than
  being mistranslated. Output key order is deterministic, so generated
  documents are committable and diff-stable (golden-file spec).
- Permittable::OpenAPI assembles documents and fragments in plain Ruby:
  operations resolve through permit_rule_for (last rule wins, as at
  request time), shared components type the 400/422 error envelope,
  catch-all rules expand via action_methods with the concern's own
  public methods excluded, and unrouted operations stay visible under
  x-permittable-controllers.
- bin/rails permittable:openapi[output] (Railtie-loaded rake task)
  eager-loads the app and maps documented actions onto paths.
- New desc:/example: field options and desc: on permit_params carry
  documentation on the contract; example: is validated against its own
  field's contract at class load, exactly like default:.

Bump to 0.3.0 (additive; existing contract behaviour unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant