Skip to content

Add RFC 9457 problem+json as an app-wide error format - #19

Open
VSN2015 wants to merge 1 commit into
masterfrom
feature/problem-json
Open

Add RFC 9457 problem+json as an app-wide error format#19
VSN2015 wants to merge 1 commit into
masterfrom
feature/problem-json

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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

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

# config/initializers/permittable.rb
Permittable.error_format = :problem
Permittable.problem_base_uri = "https://api.example.com/problems"   # optional
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
  "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" }
  ]
}

errors carries the identical { param:, code: } entries (plus message: when the field declares one) that the envelope puts in details. Nothing about violation reporting changes — only the wrapper.

The details that are easy to get wrong

All pinned by specs:

  • title describes the problem type, not this instance of it (§3.1.2). A missing root: is "Malformed request" (400); a field violation is "Invalid parameters" (422). Two different failures, two different types.
  • 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 resolves it — which RFC 9457 permits.
  • instance is the request path, omitted when the host can't name one (a params duck, a job).
  • type is "about:blank" — RFC 9457's own default — until problem_base_uri is set.
  • Members are emitted in the order the RFC documents them, so the wire format is stable and readable.

One deliberate decision worth reviewing

Choosing :problem opts out of render_error delegation. 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 defining render_error is 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 :problem configured, the shared response components describe the problem schema under application/problem+json instead of the envelope under application/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.mode is 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 golden spec/fixtures/openapi.json still matches byte for byte after the VIOLATION_SCHEMA extraction that lets both shapes share one violation definition.

Verification

  • 211 examples, 0 failures (12 new, written before the implementation), including an integration spec through the real ActionController stack asserting the application/problem+json response header
  • RuboCop clean

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #19: Excellent implementation of RFC 9457 Problem Details for HTTP APIs. Opting into :problem formats structured errors consistently with standards-compliant media types.

# 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
VSN2015 force-pushed the feature/problem-json branch from abe1ff8 to 968859d Compare September 11, 2026 22:01
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