problem 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/DESIGN.md +274 -0
- data/LICENSE.txt +21 -0
- data/README.md +328 -0
- data/lib/problem/detailable.rb +139 -0
- data/lib/problem/details.rb +46 -0
- data/lib/problem/document.rb +52 -0
- data/lib/problem/exceptions_app.rb +78 -0
- data/lib/problem/i18nable.rb +87 -0
- data/lib/problem/railtie.rb +28 -0
- data/lib/problem/renderer.rb +34 -0
- data/lib/problem/rescuable.rb +80 -0
- data/lib/problem/retry_after.rb +59 -0
- data/lib/problem/version.rb +10 -0
- data/lib/problem.rb +69 -0
- data/sig/generated/problem/detailable.rbs +90 -0
- data/sig/generated/problem/document.rbs +36 -0
- data/sig/generated/problem/exceptions_app.rbs +40 -0
- data/sig/generated/problem/i18nable.rbs +55 -0
- data/sig/generated/problem/railtie.rbs +12 -0
- data/sig/generated/problem/renderer.rbs +16 -0
- data/sig/generated/problem/rescuable.rbs +54 -0
- data/sig/generated/problem/retry_after.rbs +47 -0
- data/sig/generated/problem/version.rbs +6 -0
- data/sig/generated/problem.rbs +39 -0
- data/sig/manual/core.rbs +5 -0
- data/sig/manual/detailable.rbs +37 -0
- data/sig/manual/details.rbs +24 -0
- data/sig/manual/document.rbs +12 -0
- data/sig/manual/i18nable.rbs +15 -0
- data/sig/manual/rails.rbs +14 -0
- data/sig/manual/rescuable.rbs +19 -0
- metadata +206 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 476a7c373272d8029f0162849ab784ee875af959cbb230b96ebe35d96713fa06
|
|
4
|
+
data.tar.gz: 17659bc6344e26a107943c95c4d37e3d05b282a4020ca22190ca60d50b89562f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 82e739ccf9de64bf38e046c84288e34ad9d252f2fd1ebed7d00ed5bdf36658efb7363b778b23644ab69748518fdd305948e9c18faed198464abbb1714914a9c5
|
|
7
|
+
data.tar.gz: 139c2cd5274e5c57a4b070265a40355455f987622f6df583b7069c8957125240dce027e1d41bff1e5493b502a34d37ee442adbe5f7375f20ee8a3b76e8890e16
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-09-22
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `Problem::Detailable` declares an exception class as an RFC 9457 problem, with a
|
|
8
|
+
class-level `type` / `status` / `title` DSL and a per-occurrence `detail`.
|
|
9
|
+
- `Problem::Rescuable` renders those as `application/problem+json`, with overridable
|
|
10
|
+
steps for building, reporting and rendering.
|
|
11
|
+
- `Problem::ExceptionsApp` answers exceptions that escape the controller.
|
|
12
|
+
- `Problem::I18nable` looks titles up through I18n.
|
|
13
|
+
- `Problem::RetryAfter` publishes a retry interval as a header and an extension member.
|
data/DESIGN.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
Why this library is shaped the way it is. [README.md](README.md) covers using it.
|
|
4
|
+
|
|
5
|
+
Extracted from a production API, where most of these decisions were paid for once
|
|
6
|
+
already.
|
|
7
|
+
|
|
8
|
+
## The declaration belongs on the error class
|
|
9
|
+
|
|
10
|
+
The obvious alternative is a central table in `ApplicationController` pairing exception
|
|
11
|
+
classes with statuses. It was rejected for three reasons.
|
|
12
|
+
|
|
13
|
+
It drifts. The table sits far from the code that raises, so an error added in one place
|
|
14
|
+
and registered in another eventually stops being registered at all.
|
|
15
|
+
|
|
16
|
+
It makes adding an error a two-file change, which is the kind of friction that produces
|
|
17
|
+
bare `head :forbidden` calls instead.
|
|
18
|
+
|
|
19
|
+
It has no answer for a subclass that must render exactly as its parent. When telling two
|
|
20
|
+
failures apart would leak something (which factor of an authentication attempt failed,
|
|
21
|
+
whether a record exists at all), the response has to be byte-identical. With the
|
|
22
|
+
declaration inherited, an empty subclass is the entire implementation. With a table, it
|
|
23
|
+
is a second entry that has to be kept in sync by hand.
|
|
24
|
+
|
|
25
|
+
`class_attribute` is what makes a declaration both inherited and overridable. A constant
|
|
26
|
+
could not be overridden; an ivar on the singleton class would be invisible to subclasses.
|
|
27
|
+
|
|
28
|
+
## Two halves, because the controller is not always there
|
|
29
|
+
|
|
30
|
+
`Problem::Rescuable` is a controller concern. A `rescue_from` handler still has the
|
|
31
|
+
controller, so it can reach `request`, the negotiated locale, and whatever a
|
|
32
|
+
`before_action` computed. Middleware sees a Rack env and can do none of that.
|
|
33
|
+
|
|
34
|
+
But an exception raised before dispatch never reaches a controller at all. A routing
|
|
35
|
+
error, an unreadable body, a failure in another middleware: no `rescue_from` will ever
|
|
36
|
+
run. So `Problem::ExceptionsApp` exists as a separate object, and the two are wired
|
|
37
|
+
independently.
|
|
38
|
+
|
|
39
|
+
`ExceptionsApp` wraps another exceptions app rather than subclassing
|
|
40
|
+
`ActionDispatch::PublicExceptions`. A wrapper lets each layer of a stack claim the
|
|
41
|
+
requests it recognizes and pass on the rest, which is what makes it composable with, say,
|
|
42
|
+
a Connect RPC exceptions app in front of it:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
config.exceptions_app = ConnectExceptions.new(
|
|
46
|
+
Problem::ExceptionsApp.new(ActionDispatch::PublicExceptions.new(Rails.public_path)),
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A subclass can only ever be the innermost layer, and forces whoever wants the JSON branch
|
|
51
|
+
to reimplement the HTML branch they did not want to touch.
|
|
52
|
+
|
|
53
|
+
Its titles are the status text, never the exception message. A message can quote an id, a
|
|
54
|
+
column name or a path, and this is the one place with no controller to have decided what
|
|
55
|
+
a caller may see.
|
|
56
|
+
|
|
57
|
+
## The value object
|
|
58
|
+
|
|
59
|
+
`Problem::Details` is a frozen `Data` with no Rails in it, so building and serializing a
|
|
60
|
+
document needs no controller and no boot.
|
|
61
|
+
|
|
62
|
+
Extension members merge at the top level because RFC 9457 §3.2 defines them as members of
|
|
63
|
+
the problem object, not a nested container. One that shadowed `status` would contradict
|
|
64
|
+
the HTTP status in the same response, so a collision raises when the value object is
|
|
65
|
+
built rather than at render time inside an error path.
|
|
66
|
+
|
|
67
|
+
`to_json` is defined explicitly. The generic `Object#to_json` serializes a `Data` as its
|
|
68
|
+
inspect output, which is a plausible-looking response body that no assertion on the
|
|
69
|
+
status code would catch.
|
|
70
|
+
|
|
71
|
+
Serialization lives in `Problem::Document`, a module written against readers. A `Data`
|
|
72
|
+
cannot gain members by subclassing, so a deployment that wants a *typed* extension member
|
|
73
|
+
defines its own `Data` over the member list:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
TracedProblem = Data.define(*Problem::Details.members, :trace_id) do
|
|
77
|
+
include Problem::Document
|
|
78
|
+
|
|
79
|
+
def to_h = super.merge(trace_id:)
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Nothing requires `#to_problem` to return a `Problem::Details`. `Problem::Rescuable` calls
|
|
84
|
+
`#status`; the renderer calls `#status` and `#to_json`. That is also how a deployment
|
|
85
|
+
keeps rendering through a serializer it already has.
|
|
86
|
+
|
|
87
|
+
`Details` is written as a `Data.define` assignment reopened as a class, rather than
|
|
88
|
+
`class Details < Data.define(...)`. Both rubocop and Steep prefer it: the first flags the
|
|
89
|
+
inheritance form, and the second cannot see into a `Data.define` block.
|
|
90
|
+
|
|
91
|
+
## Type URIs
|
|
92
|
+
|
|
93
|
+
Resolution happens in `Detailable#to_problem`, not in `Details.new`. That keeps the value
|
|
94
|
+
object pure, so a document built by hand is never rewritten behind the caller's back.
|
|
95
|
+
|
|
96
|
+
| declared `type` | result |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `nil` | serialized as `about:blank` |
|
|
99
|
+
| `"about:blank"` | returned untouched |
|
|
100
|
+
| carries a scheme | returned untouched, it is already absolute |
|
|
101
|
+
| anything else | prefixed, or left alone when no prefix is set |
|
|
102
|
+
|
|
103
|
+
`about:blank` is special-cased because prefixing it produces a URI that looks valid and
|
|
104
|
+
identifies nothing, and it is the one `type` value the RFC assigns a meaning to.
|
|
105
|
+
|
|
106
|
+
Leaving a slug unprefixed is legal: RFC 9457 defines `type` as a URI *reference*, not a
|
|
107
|
+
URI. The prefix exists so sixty error classes do not each repeat an authority, which is
|
|
108
|
+
sixty chances to typo it.
|
|
109
|
+
|
|
110
|
+
## Plugging in a problem catalogue
|
|
111
|
+
|
|
112
|
+
A deployment that keeps its problems in a registry (an enum, a YAML file, a table) wants
|
|
113
|
+
type, status and title derived from it. `Problem::I18nable` is the shipped example of the
|
|
114
|
+
simple case, overriding `title` in a `ClassMethods` that sits ahead of the DSL. When the
|
|
115
|
+
catalogue layer is its own concern, prepend a module to the error class's singleton and
|
|
116
|
+
call `super` for anything it does not cover:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
module Typeable
|
|
120
|
+
extend ActiveSupport::Concern
|
|
121
|
+
|
|
122
|
+
module Derivation
|
|
123
|
+
def type(value = nil)
|
|
124
|
+
return super if value || problem_type.nil?
|
|
125
|
+
|
|
126
|
+
CATALOGUE.fetch(problem_type).fetch(:type)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
included { singleton_class.prepend(Derivation) }
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A prepend rather than a pluggable resolver object. A resolver would bless one catalogue
|
|
135
|
+
shape, add global mutable state, and not compose with inheritance. `prepend` and `super`
|
|
136
|
+
compose for free, and a class that wants a literal declaration simply makes one.
|
|
137
|
+
|
|
138
|
+
Five things make this work, none of them visible at a call site. They are the real public
|
|
139
|
+
contract of `Problem::Detailable`, and each fails silently rather than loudly:
|
|
140
|
+
|
|
141
|
+
1. `ClassMethods` is attached with `extend`, so a singleton prepend sits ahead of it.
|
|
142
|
+
2. The signatures stay `status(value = nil)`, `type(value = nil)` and
|
|
143
|
+
`title(value = nil, interpolations: {})`. The keyword *name* `interpolations:` is
|
|
144
|
+
load-bearing: a derivation calls `super` with it, and renaming it raises only while
|
|
145
|
+
rendering an error, in production, on a response that was already an error.
|
|
146
|
+
3. Those three are never defined on the including class itself, only in `ClassMethods`,
|
|
147
|
+
or the prepend stops winning.
|
|
148
|
+
4. `#to_problem` reaches them through `self.class` rather than reading the class
|
|
149
|
+
attributes behind them. Reading `problem_uri` directly is a one-word change that
|
|
150
|
+
disables the whole derivation layer with no error anywhere.
|
|
151
|
+
5. The catalogue layer is included *before* `Problem::Detailable`. Included after, its
|
|
152
|
+
fallbacks shadow the real DSL and every literal declaration reads back `nil`.
|
|
153
|
+
|
|
154
|
+
And one more, which is the trap people actually hit: extending a `ClassMethods` onto a
|
|
155
|
+
subclass puts it *ahead* of the prepend its superclass holds. A concern that mixes in a
|
|
156
|
+
catalogue layer has to re-apply the prepend itself, on every inclusion.
|
|
157
|
+
|
|
158
|
+
`spec/problem/detailable_derivation_spec.rb` builds the whole arrangement and pins it,
|
|
159
|
+
including both silent failure modes. Without that spec the extraction is one refactor
|
|
160
|
+
away from breaking quietly.
|
|
161
|
+
|
|
162
|
+
### Why I18nable needs none of that ceremony
|
|
163
|
+
|
|
164
|
+
`Problem::I18nable` overrides the same method and is a plain `ActiveSupport::Concern`,
|
|
165
|
+
with no prepend at all. It gets away with it by declaring `include Detailable` as a
|
|
166
|
+
concern dependency: `ActiveSupport::Concern` then includes `Detailable` first and extends
|
|
167
|
+
`I18nable::ClassMethods` afterwards, which puts it ahead in the singleton ancestry and
|
|
168
|
+
leaves `super` pointing at the literal DSL. Re-including `Detailable` explicitly, in
|
|
169
|
+
either order, changes nothing, because a module already in the ancestry is not moved.
|
|
170
|
+
|
|
171
|
+
The prepend is only needed when the layer supplies terminal fallbacks of its own, as a
|
|
172
|
+
catalogue concern does. `I18nable` supplies none; it defers to `super`.
|
|
173
|
+
|
|
174
|
+
`i18n` is not a declared dependency of the gem, so `Problem::I18nable` is autoloaded
|
|
175
|
+
rather than required. A host that never references the constant never loads `i18n`
|
|
176
|
+
through it.
|
|
177
|
+
|
|
178
|
+
## Rendering
|
|
179
|
+
|
|
180
|
+
One `rescue_from`, registered against `Problem::Detailable` itself. A Module is matched
|
|
181
|
+
with `===`, so a single registration covers every error class that mixed it in.
|
|
182
|
+
|
|
183
|
+
The handler is split into named steps so a deployment overrides the one it needs, and so
|
|
184
|
+
a different transport replaces only `#render_problem`. A Connect RPC controller carries
|
|
185
|
+
the document as an error detail and answers in the protocol's own shape; nothing else
|
|
186
|
+
about the handler changes.
|
|
187
|
+
|
|
188
|
+
### Localization
|
|
189
|
+
|
|
190
|
+
The gem never calls `I18n`. `around_problem_render` is the seam, and it wraps the whole
|
|
191
|
+
handler rather than just the render for a reason worth writing down:
|
|
192
|
+
`ActionController::Rescue` wraps `AbstractController::Callbacks`, so an `around_action`
|
|
193
|
+
that established the locale has **already unwound** by the time a `rescue_from` handler
|
|
194
|
+
runs. The title lookup happens while the problem is being built, not while it renders, so
|
|
195
|
+
wrapping only the render would miss it.
|
|
196
|
+
|
|
197
|
+
### Reporting
|
|
198
|
+
|
|
199
|
+
`rescue_from` swallows the exception. Nothing logs it, and no error reporter's automatic
|
|
200
|
+
capture sees it, which is a quiet way to lose every 5xx an API returns.
|
|
201
|
+
|
|
202
|
+
Reports go through `ActiveSupport.error_reporter`, the registry Rails error reporters
|
|
203
|
+
subscribe to, rather than naming a vendor. 4xx are not reported: an expected client error
|
|
204
|
+
is not an incident.
|
|
205
|
+
|
|
206
|
+
## Initialization
|
|
207
|
+
|
|
208
|
+
A Railtie, because that is what Rails offers for exactly this, instead of an initializer
|
|
209
|
+
every host copies. The same work is a plain `Problem.install!` for a Rack host or a spec
|
|
210
|
+
that never boots Rails, which is how this gem's own suite reaches it.
|
|
211
|
+
|
|
212
|
+
`problem.config` declares `after: :load_config_initializers`. A railtie initializer
|
|
213
|
+
otherwise runs before `config/initializers` is loaded, which would make
|
|
214
|
+
`config.problem.type_prefix` silently do nothing when set in the file an application
|
|
215
|
+
would most expect to set it in.
|
|
216
|
+
|
|
217
|
+
The renderer takes the status from the object it is handed and calls `to_json` on it,
|
|
218
|
+
requiring nothing more. That is what lets a deployment render through its own serializer.
|
|
219
|
+
|
|
220
|
+
The Railtie deliberately does not touch `config.exceptions_app`. Replacing what an
|
|
221
|
+
application set there is not an initializer's business.
|
|
222
|
+
|
|
223
|
+
## Types
|
|
224
|
+
|
|
225
|
+
Signatures are inline `#:` annotations generated into `sig/generated`, which is checked
|
|
226
|
+
in. CI asserts it is current with `git diff --exit-code`, because an annotation can change
|
|
227
|
+
without anyone running `rake rbs`, and a stale signature means the type check is checking
|
|
228
|
+
a shape the code no longer has.
|
|
229
|
+
|
|
230
|
+
`sig/manual` holds what inline annotations cannot express:
|
|
231
|
+
|
|
232
|
+
* the self-type of a mix-in, since `Problem::Rescuable` needs `render` and `response` from
|
|
233
|
+
whatever controller it lands in, and `Problem::Detailable` needs its own class-level DSL;
|
|
234
|
+
* the accessors `class_attribute` installs at runtime;
|
|
235
|
+
* `Problem::Details`, since a `Data.define` assignment has no inferable shape;
|
|
236
|
+
* a `Data#initialize` stub, because core RBS declares none and a subclass overriding it
|
|
237
|
+
has nothing to call `super` on.
|
|
238
|
+
|
|
239
|
+
Three DSL blocks are marked `steep:ignore`: `included do`, and the renderer registration.
|
|
240
|
+
Their `self` is supplied by Rails at runtime and cannot be named in RBS. `class_methods do`
|
|
241
|
+
was avoided entirely in favour of a nested `ClassMethods` module, which is ordinary code
|
|
242
|
+
that Steep can check and which `ActiveSupport::Concern` picks up by name.
|
|
243
|
+
|
|
244
|
+
## Layout
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
lib/problem/document.rb to_h / as_json / to_json, and the media type
|
|
248
|
+
lib/problem/details.rb the value object
|
|
249
|
+
lib/problem/detailable.rb the class-level DSL, and #to_problem
|
|
250
|
+
lib/problem/i18nable.rb titles from I18n, autoloaded
|
|
251
|
+
lib/problem/retry_after.rb Retry-After, as a worked example of the hooks
|
|
252
|
+
lib/problem/rescuable.rb rescue_from, split into overridable steps
|
|
253
|
+
lib/problem/exceptions_app.rb the config.exceptions_app wrapper
|
|
254
|
+
lib/problem/renderer.rb the application/problem+json renderer
|
|
255
|
+
lib/problem/railtie.rb required only when Rails is present
|
|
256
|
+
sig/manual/ what RBS cannot infer
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Deliberately out of scope
|
|
260
|
+
|
|
261
|
+
* A catalogue of problem types, and anything that reads one. The seam is above.
|
|
262
|
+
* `Accept-Language` negotiation. `Problem::I18nable` translates a title once a locale is
|
|
263
|
+
set; choosing that locale is the application's job, and `around_problem_render` is
|
|
264
|
+
where it plugs in.
|
|
265
|
+
* Validation-error serialization. RFC 9457 defines no `errors` member; use an extension.
|
|
266
|
+
* `application/problem+xml`.
|
|
267
|
+
* Anything Connect or gRPC. `render_problem` is the seam, and
|
|
268
|
+
[connect_rpc_rails](https://github.com/ivry-inc/connect_rpc_rails) is the other half.
|
|
269
|
+
|
|
270
|
+
## A note on the name
|
|
271
|
+
|
|
272
|
+
The top-level constant is `Problem`, generic enough to collide with a `Problem` model in
|
|
273
|
+
a host application. Nothing here reopens or autoloads anything outside `Problem::`, but
|
|
274
|
+
under Zeitwerk a same-named application constant is a conflict you would have to resolve.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sorah Fukumori
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# problem: RFC 9457 Problem Details for Rails APIs
|
|
2
|
+
|
|
3
|
+
`problem` renders the errors a Rails API raises as
|
|
4
|
+
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem details. An error class
|
|
5
|
+
declares its type, title and status once, next to the code that raises it, and a
|
|
6
|
+
controller concern turns any of them into an `application/problem+json` response. A
|
|
7
|
+
companion exceptions app covers what Rails raises before your controller runs, so a
|
|
8
|
+
routing error and a business rule violation come back in the same shape.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class Errors::Forbidden < Errors::ApiError
|
|
12
|
+
type "forbidden"
|
|
13
|
+
status 403
|
|
14
|
+
title "Forbidden"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
raise Errors::Forbidden.new(detail: "Only the owner can cancel this order")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```console
|
|
21
|
+
$ curl -i https://api.example.com/orders/1/cancel
|
|
22
|
+
HTTP/1.1 403 Forbidden
|
|
23
|
+
Content-Type: application/problem+json
|
|
24
|
+
|
|
25
|
+
{"type":"forbidden","title":"Forbidden","status":403,"detail":"Only the owner can cancel this order"}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **One place per error.** Status, title and type live on the class, not in a mapping
|
|
31
|
+
table that drifts away from the code raising it.
|
|
32
|
+
- **Stable identifiers for clients.** Callers dispatch on `type`, so you can reword a
|
|
33
|
+
title without breaking them.
|
|
34
|
+
- **Covers what the controller never sees.** Routing errors, unreadable bodies and
|
|
35
|
+
middleware failures render as problem documents too, carrying the status text rather
|
|
36
|
+
than the exception message.
|
|
37
|
+
- **Localized titles**, keyed by type, with a one-line include.
|
|
38
|
+
- **Small.** Two mixins, an exceptions app and a renderer. Under 250 lines of code, and
|
|
39
|
+
`actionpack` is the only dependency.
|
|
40
|
+
- **Typed.** RBS signatures ship with the gem.
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
Ruby 3.3 or later, and `actionpack` 7.0 or later. `Problem::I18nable` additionally needs
|
|
45
|
+
the `i18n` gem, which Rails already brings.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
bundle add problem
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Rails wires itself up through a railtie. Add an initializer for the URI prefix your type
|
|
54
|
+
identifiers live under:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# config/initializers/problem.rb
|
|
58
|
+
Rails.application.configure do
|
|
59
|
+
config.problem.type_prefix = "https://api-probs.example.com/"
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`config/application.rb` and the environment files work too.
|
|
64
|
+
|
|
65
|
+
Outside Rails, call `Problem.install!` at boot and configure it with
|
|
66
|
+
`Problem.configure { |c| c.type_prefix = "..." }`.
|
|
67
|
+
|
|
68
|
+
## Getting started
|
|
69
|
+
|
|
70
|
+
### 1. Give your errors a common base
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# app/models/errors.rb
|
|
74
|
+
module Errors
|
|
75
|
+
class ApiError < StandardError
|
|
76
|
+
include Problem::Detailable
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class BadRequest < ApiError
|
|
80
|
+
type "bad-request"
|
|
81
|
+
status 400
|
|
82
|
+
title "Bad Request"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class Unauthorized < ApiError
|
|
86
|
+
type "unauthorized"
|
|
87
|
+
status 401
|
|
88
|
+
title "Unauthorized"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Forbidden < ApiError
|
|
92
|
+
type "forbidden"
|
|
93
|
+
status 403
|
|
94
|
+
title "Forbidden"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class NotFound < ApiError
|
|
98
|
+
type "not-found"
|
|
99
|
+
status 404
|
|
100
|
+
title "Not Found"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
One base class carrying the concern is enough. Everything under it inherits the
|
|
106
|
+
declaration and overrides only what differs.
|
|
107
|
+
|
|
108
|
+
### 2. Rescue them once
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# app/controllers/application_controller.rb
|
|
112
|
+
class ApplicationController < ActionController::API
|
|
113
|
+
include Problem::Rescuable
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 3. Raise them
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
raise Errors::NotFound unless @order
|
|
121
|
+
|
|
122
|
+
raise Errors::Forbidden.new(detail: "Only the owner can cancel this order")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`detail` is the part that differs between two occurrences of the same problem. Everything
|
|
126
|
+
else belongs to the class, so it is declared once.
|
|
127
|
+
|
|
128
|
+
## Defining your own errors
|
|
129
|
+
|
|
130
|
+
Inherit from the closest base and declare what changes:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class Orders::AlreadyShipped < Errors::UnprocessableContent
|
|
134
|
+
type "order-already-shipped"
|
|
135
|
+
title "Order Already Shipped"
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A subclass that declares nothing renders exactly as its parent:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
class Errors::ConfidentialClientRequired < Errors::Unauthorized; end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
That is worth knowing deliberately. When a caller must not be able to tell two failures
|
|
146
|
+
apart, an empty subclass is the whole implementation.
|
|
147
|
+
|
|
148
|
+
## Localized titles
|
|
149
|
+
|
|
150
|
+
`title` is the only member written for a human, so it is the only one worth translating.
|
|
151
|
+
Include `Problem::I18nable` in your base class:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
module Errors
|
|
155
|
+
class ApiError < StandardError
|
|
156
|
+
include Problem::I18nable
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
# config/locales/en.yml
|
|
163
|
+
en:
|
|
164
|
+
problem_details:
|
|
165
|
+
titles:
|
|
166
|
+
not_found: "Not Found"
|
|
167
|
+
forbidden: "Forbidden"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Titles are keyed by the declared type with dashes replaced, under
|
|
171
|
+
`problem_details.titles`. Both are adjustable:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class Errors::ApiError < StandardError
|
|
175
|
+
include Problem::I18nable
|
|
176
|
+
|
|
177
|
+
title_scope "errors.titles" # inherited by subclasses
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class Errors::NotFound < Errors::ApiError
|
|
181
|
+
type "gone-missing"
|
|
182
|
+
status 404
|
|
183
|
+
title_key :not_found # when the key should not follow the type
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`Problem::I18nable` brings `Problem::Detailable` with it, so one include covers both. A
|
|
188
|
+
class that spells out a literal `title` keeps it, which lets a codebase move over
|
|
189
|
+
gradually.
|
|
190
|
+
|
|
191
|
+
Then establish the locale around rendering:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class ApplicationController < ActionController::API
|
|
195
|
+
include Problem::Rescuable
|
|
196
|
+
|
|
197
|
+
private def around_problem_render(&) = I18n.with_locale(negotiated_locale, &)
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
That wrapper is not optional if you localize. Rails has already unwound your
|
|
202
|
+
`around_action` by the time an error renders, so the locale has to be re-established
|
|
203
|
+
here. [DESIGN.md](DESIGN.md#localization) explains why.
|
|
204
|
+
|
|
205
|
+
## Catching what escapes the controller
|
|
206
|
+
|
|
207
|
+
A routing error, an unreadable request body or a failure in middleware never reaches a
|
|
208
|
+
controller, so `Problem::Rescuable` never sees it. Wire up the exceptions app:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# config/initializers/problem.rb
|
|
212
|
+
Rails.application.configure do
|
|
213
|
+
config.exceptions_app = Problem::ExceptionsApp.new(
|
|
214
|
+
ActionDispatch::PublicExceptions.new(Rails.public_path),
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Browsers keep getting the static error pages. Everything else gets a problem document,
|
|
220
|
+
with the status Rails already mapped the exception to.
|
|
221
|
+
|
|
222
|
+
## Error reporting
|
|
223
|
+
|
|
224
|
+
5xx problems are reported through `ActiveSupport.error_reporter`, which Sentry and similar
|
|
225
|
+
gems subscribe to. 4xx are not, on the grounds that an expected client error is not an
|
|
226
|
+
incident. Both are overridable:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
private def report_problem(error, _problem) = Sentry.capture_exception(error)
|
|
230
|
+
|
|
231
|
+
private def report_problem?(_error, problem) = problem.status >= 500
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Telling clients when to retry
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class Errors::TooManyRequests < Errors::ApiError
|
|
238
|
+
include Problem::RetryAfter
|
|
239
|
+
|
|
240
|
+
type "too-many-requests"
|
|
241
|
+
status 429
|
|
242
|
+
title "Try again in %{retry_after} seconds"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
raise Errors::TooManyRequests.new(retry_after: 30)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Sends a `Retry-After` header, interpolates the wait into the title, and adds a
|
|
249
|
+
`retry_after` member to the body. A `Time` works in place of seconds.
|
|
250
|
+
|
|
251
|
+
## Extension members
|
|
252
|
+
|
|
253
|
+
RFC 9457 lets a problem carry extra top-level members. Return them from the occurrence:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
class Orders::PaymentDeclined < Errors::UnprocessableContent
|
|
257
|
+
type "payment-declined"
|
|
258
|
+
title "Payment Declined"
|
|
259
|
+
|
|
260
|
+
def problem_extensions = {decline_code: "insufficient_funds"}
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{"type":"payment-declined","title":"Payment Declined","status":422,"decline_code":"insufficient_funds"}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
To fill `instance`, which identifies the occurrence rather than the type, use the request:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
private def problem_for(error) = error.to_problem.with(instance: request.fullpath)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Reference
|
|
275
|
+
|
|
276
|
+
Members of the rendered document:
|
|
277
|
+
|
|
278
|
+
| member | set by | notes |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| `type` | `type "slug"` | Resolved against `type_prefix`. Defaults to `about:blank`. |
|
|
281
|
+
| `title` | `title "..."`, or `Problem::I18nable` | Short, human readable, constant for the type. |
|
|
282
|
+
| `status` | `status 403` | Always matches the HTTP status. |
|
|
283
|
+
| `detail` | `new(detail:)` | Specific to the occurrence. Omitted when absent. |
|
|
284
|
+
| `instance` | `problem_instance` | Omitted when absent. |
|
|
285
|
+
| anything else | `problem_extensions` | Serialized as top-level members. |
|
|
286
|
+
|
|
287
|
+
Hooks on a controller including `Problem::Rescuable`:
|
|
288
|
+
|
|
289
|
+
| hook | for |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `problem_for(error)` | Adding `instance`, a request id, anything request-derived |
|
|
292
|
+
| `around_problem_render(&)` | Locale, or other per-request state the handler needs |
|
|
293
|
+
| `report_problem?(error, problem)` | What counts as reportable |
|
|
294
|
+
| `report_problem(error, problem)` | Where reports go |
|
|
295
|
+
| `render_problem(problem, error)` | Answering in a different shape entirely |
|
|
296
|
+
|
|
297
|
+
## Caveats
|
|
298
|
+
|
|
299
|
+
- The top-level constant is `Problem`. Under Zeitwerk, an application with its own
|
|
300
|
+
`Problem` model has a conflict to resolve.
|
|
301
|
+
- `i18n` is not a declared dependency. `Problem::I18nable` is autoloaded, so a host that
|
|
302
|
+
never references it never loads it.
|
|
303
|
+
- RFC 9457 defines no member for field-level validation errors. Use an extension member.
|
|
304
|
+
- `application/problem+xml` is not implemented.
|
|
305
|
+
|
|
306
|
+
## Development
|
|
307
|
+
|
|
308
|
+
```
|
|
309
|
+
bundle install
|
|
310
|
+
bundle exec rake # specs, then rbs and steep
|
|
311
|
+
hk check --all # rubocop, actionlint, zizmor
|
|
312
|
+
hk install # run the linters on commit
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## See also
|
|
316
|
+
|
|
317
|
+
- [DESIGN.md](DESIGN.md) for why the library is shaped this way, and how to derive
|
|
318
|
+
declarations from a problem catalogue of your own.
|
|
319
|
+
- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), the format itself.
|
|
320
|
+
|
|
321
|
+
## Contributing
|
|
322
|
+
|
|
323
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/problem.
|
|
324
|
+
|
|
325
|
+
## License
|
|
326
|
+
|
|
327
|
+
The gem is available as open source under the terms of the
|
|
328
|
+
[MIT License](https://opensource.org/licenses/MIT). Copyright (c) 2026 Sorah Fukumori.
|