Add RFC 9457 problem+json as an app-wide error format - #19
Open
VSN2015 wants to merge 1 commit into
Open
Conversation
VSN2015
commented
Sep 5, 2026
| # Kept as a literal so a problem document can carry a numeric status | ||
| # without activesupport-only hosts needing Rack; anything else defers to | ||
| # Rack::Utils when the host has it. | ||
| STATUS_CODES = { bad_request: 400, unprocessable_entity: 422, unprocessable_content: 422 }.freeze |
Owner
Author
There was a problem hiding this comment.
Defining status code mappings statically (bad_request: 400, unprocessable_entity: 422, unprocessable_content: 422) allows problem details to provide numeric HTTP status codes without requiring Rack::Utils in minimal ActiveSupport environments.
| problem[:detail] = message | ||
| instance = request_path(controller) | ||
| problem[:instance] = instance if instance | ||
| problem[:errors] = details if details && !details.empty? |
Owner
Author
There was a problem hiding this comment.
Rendering with content_type: PROBLEM_MEDIA_TYPE ensures responses comply with RFC 9457 standards (application/problem+json), which also aligns with the OpenAPI response definition updates.
For a public API the standard shape for an error is RFC 9457 problem
details, and the gem rendered only its own envelope.
ErrorEnvelope's own comment named this as the change it was waiting
for: "the single place to change when e.g. an RFC 9457 problem+json
mode lands."
Permittable.error_format = :problem renders application/problem+json
with type/title/status/detail/instance and the field violations as the
`errors` extension member — the identical { param:, code: } entries
the envelope puts in `details`, so nothing about violation reporting
changes, only the wrapper.
Details that are easy to get wrong, and are pinned by specs:
* `title` describes the problem TYPE rather than this instance of it,
so a missing root: reads "Malformed request" (400) and a field
violation "Invalid parameters" (422).
* `status` is numeric. The two statuses this gem raises resolve from a
literal, so an activesupport-only host needs no Rack; anything else
defers to Rack::Utils, and is omitted rather than guessed when
nothing can resolve it, which RFC 9457 permits.
* `instance` is the request path, omitted when the host cannot name
one — a params duck or a job has no request.
* `type` is RFC 9457's own default of "about:blank" until
problem_base_uri is set, at which point each type gets a URI under
it.
Choosing :problem deliberately opts out of render_error delegation: a
host envelope and a problem document are two answers to the same
question, and the explicit setting is the one to honour. The setting is
app-wide rather than per-contract because the error format of an API is
a property of the API.
Exported OpenAPI follows it. Unlike a rule's monitor mode — which the
exporter reads only from contract data, never from runtime
configuration — the error format has no per-contract declaration to
read, and an export runs inside the app that made the setting, so
reading it is what keeps the documented response shape from drifting
from the rendered one. The envelope schema itself is unchanged; the
golden fixture still matches byte for byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VSN2015
force-pushed
the
feature/problem-json
branch
from
September 11, 2026 22:01
abe1ff8 to
968859d
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.
ErrorEnvelope's own comment named this as the change it was waiting for:For a public API, RFC 9457 Problem Details is the standard shape for an error, and the gem rendered only its own envelope.
The setting
{ "type": "https://api.example.com/problems/invalid-parameters", "title": "Invalid parameters", "status": 422, "detail": "Invalid parameters: user.email (format), user.age (inclusion)", "instance": "/users", "errors": [ { "param": "user.email", "code": "format" }, { "param": "user.age", "code": "inclusion" } ] }errorscarries the identical{ param:, code: }entries (plusmessage:when the field declares one) that the envelope puts indetails. Nothing about violation reporting changes — only the wrapper.The details that are easy to get wrong
All pinned by specs:
titledescribes the problem type, not this instance of it (§3.1.2). A missingroot:is"Malformed request"(400); a field violation is"Invalid parameters"(422). Two different failures, two different types.statusis numeric. The two statuses this gem raises resolve from a literal, so an activesupport-only host needs no Rack; anything else defers toRack::Utils, and is omitted rather than guessed when nothing resolves it — which RFC 9457 permits.instanceis the request path, omitted when the host can't name one (a params duck, a job).typeis"about:blank"— RFC 9457's own default — untilproblem_base_uriis set.One deliberate decision worth reviewing
Choosing
:problemopts out ofrender_errordelegation. A host envelope (e.g. concerns_on_rails'Respondable) and a problem document are two answers to the same question; the explicit setting is the one to honour. A spec pins that a controller definingrender_erroris not called under:problem.The setting is app-wide rather than per-contract because the error format of an API is a property of the API, not of any one contract.
Exported OpenAPI follows it
With
:problemconfigured, the shared response components describe the problem schema underapplication/problem+jsoninstead of the envelope underapplication/json.This is a deliberate departure from how the exporter treats monitor mode, so it's worth being explicit about why. A rule's mode has a per-contract declaration to export, and the app-wide
Permittable.modeis runtime config the exporter refuses to read. The error format has no per-contract declaration at all — and an export runs inside the app that made the setting. Reading it is what keeps the documented response shape from drifting from the rendered one, which is the whole pitch.Compatibility
Default is
:envelope; contracts that don't opt in are byte-for-byte unaffected, and the exported envelope schema is unchanged — the goldenspec/fixtures/openapi.jsonstill matches byte for byte after theVIOLATION_SCHEMAextraction that lets both shapes share one violation definition.Verification
application/problem+jsonresponse header