Add OpenAPI 3.1 export generated from contracts - #3
Open
VSN2015 wants to merge 1 commit into
Open
Conversation
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>
VSN2015
force-pushed
the
feat/openapi-export
branch
from
August 24, 2026 06:37
b02ef49 to
08c3812
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:andexample:, plusdesc:on the macro itself. The runtime never reads them, but anexample:is validated against its own field's contract at class load, exactly likedefault:— a lying example fails the boot, not the docs:2. Export the whole app (Rails)
The task eager-loads the app (which also exercises the schema-drift guard on every contract), collects every
ActionController::Base/::APIdescendant with contracts, and maps documented actions ontopathsvia the route set —/users/:id(.:format)becomes/users/{id}.3. What comes out
For the contract above,
paths./users.postcontains (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/PermittableInvalidParametersschema matching the gem's error envelope ({ success, error: { message, code, details: [{ param, code }] } }) — consumers get typed errors, not just typed inputs. The400response appears only on rooted contracts (only a missing root renders 400).4. Consume it
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
Useful for merging into a hand-authored spec, serving a live
/openapi.jsonendpoint, or feeding contract tests.How contracts map
required/optionalrequired:array; required strings also getminLength: 1(""is absent in Permittable):string:integer:float:booleanstring/integer/number/boolean:date/:datetimestring+format: date/date-time:decimaltype: ["string","number"]+format: decimal(string is the precision-safe encoding)in:Array / numeric Rangeenum/minimum+maximum(...→exclusiveMaximum)length:minLength/maxLengthon strings,minItems/maxItemson arrays; bare Integer → exactformat:pattern,\A/\ztranslated to^/$default:/desc:/example:default/description/examples(Date/Time/BigDecimal re-encoded as JSON scalars)arrayobject+properties/array+itemsunknown: :erroradditionalProperties: falseat every nesting levelroot:sensitive: truewriteOnly: true+x-permittable-sensitiveUnrepresentable things stay visible instead of being guessed:
format:regexp using Ruby-only constructs (\h, POSIX classes, possessive quantifiers, inline flags) or regexp flags exports asx-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-transformedflags.action_methods) appear under"*"withx-permittable-catch-all.x-permittable-controllersrather than being silently dropped."42","true") for form/query payloads.Design decisions worth reviewing
action_methodsreportspermitted_params,enforce_params_contract, andrender_invalid_parametersas 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.permit_rule_for, never by dumping rules raw — so last-matching-rule-wins holds in the docs exactly as at request time.requestBody.requiredmirrors the runtime:truefor rooted contracts (missing root → 400) and for any top-level required field.spec/fixtures/openapi.jsonis a committed golden file compared byte-for-byte against a full generated document.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
lib/permittable/json_schema.rblib/permittable/open_api.rblib/permittable/tasks/openapi.rakelib/permittable.rbdesc:/example:options, generalized authored-value validation, requireslib/permittable/railtie.rbrake_taskshooklib/permittable/version.rbspec/json_schema_spec.rb,spec/open_api_spec.rbspec/fixtures/openapi.jsonspec/permittable_spec.rbdesc:/example:README.md,CHANGELOG.md,Gemfile.lockTesting
bundle exec rspec— 112 examples, 0 failures (28 new; the suite includes themessage: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