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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "active_support/concern"
|
|
8
|
+
require "active_support/core_ext/class/attribute"
|
|
9
|
+
require "problem/details"
|
|
10
|
+
|
|
11
|
+
module Problem
|
|
12
|
+
# Declares an exception class as an RFC 9457 problem.
|
|
13
|
+
#
|
|
14
|
+
# class InsufficientBalance < StandardError
|
|
15
|
+
# include Problem::Detailable
|
|
16
|
+
#
|
|
17
|
+
# type "insufficient-balance"
|
|
18
|
+
# status 403
|
|
19
|
+
# title "Insufficient Balance"
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# raise InsufficientBalance.new(detail: "Required: 100, Available: 50")
|
|
23
|
+
#
|
|
24
|
+
# This holds only what RFC 9457 defines: a type, a title, a status, a per-occurrence
|
|
25
|
+
# detail, and the conversion to Problem::Details. Each is declared literally, which is
|
|
26
|
+
# all a deployment needs to render an error.
|
|
27
|
+
#
|
|
28
|
+
# A deployment that keeps a catalogue of its problems — a registry, an enum, a locale
|
|
29
|
+
# file — derives those three from it instead, by prepending a module to the error
|
|
30
|
+
# class's singleton that overrides `type`, `status` and `title` and calls `super` for a
|
|
31
|
+
# class the catalogue does not cover. Three things make that work and none of them are
|
|
32
|
+
# visible at the call site: ClassMethods is attached with `extend`, so a singleton
|
|
33
|
+
# prepend sits ahead of it; the gem never defines those three on the including class
|
|
34
|
+
# itself; and #to_problem reaches them through `self.class` rather than reading the
|
|
35
|
+
# class attributes behind them.
|
|
36
|
+
#
|
|
37
|
+
# @rbs module-self ::Exception
|
|
38
|
+
# @rbs module-self _DetailableSelf
|
|
39
|
+
module Detailable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
# The occurrence's own explanation, which RFC 9457 §3.1 defines as specific to this
|
|
43
|
+
# occurrence rather than to the problem type.
|
|
44
|
+
attr_reader :detail #: String?
|
|
45
|
+
|
|
46
|
+
# `self` inside an ActiveSupport::Concern block is the including class, which RBS has
|
|
47
|
+
# no way to name; the accessors these install are declared in sig/manual instead.
|
|
48
|
+
# steep:ignore:start
|
|
49
|
+
included do
|
|
50
|
+
class_attribute :problem_status, instance_accessor: false
|
|
51
|
+
class_attribute :problem_title, instance_accessor: false
|
|
52
|
+
class_attribute :problem_uri, instance_accessor: false
|
|
53
|
+
class_attribute :problem_type_prefix, instance_accessor: false
|
|
54
|
+
end
|
|
55
|
+
# steep:ignore:end
|
|
56
|
+
|
|
57
|
+
# Extended into the including class by ActiveSupport::Concern, which picks up a nested
|
|
58
|
+
# ClassMethods by name. Written as a module rather than a `class_methods` block so the
|
|
59
|
+
# body is ordinary code that RBS can describe.
|
|
60
|
+
#
|
|
61
|
+
# @rbs module-self _DetailableClass
|
|
62
|
+
module ClassMethods
|
|
63
|
+
# Each reader doubles as its own setter so a subclass can override one declaration
|
|
64
|
+
# and inherit the rest, which is what class_attribute buys over a constant.
|
|
65
|
+
#: (?Integer?) -> Integer?
|
|
66
|
+
def status(value = nil)
|
|
67
|
+
value ? self.problem_status = value : problem_status
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The identifier this class publishes, resolved against the type prefix when the
|
|
71
|
+
# occurrence is converted.
|
|
72
|
+
#: (?String?) -> String?
|
|
73
|
+
def type(value = nil)
|
|
74
|
+
value ? self.problem_uri = value : problem_uri
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `interpolations` is part of the signature even though a literal title rarely needs
|
|
78
|
+
# it: a catalogue-backed override reads a template, and this is where the occurrence
|
|
79
|
+
# hands it the values.
|
|
80
|
+
#: (?String?, ?interpolations: Hash[Symbol, untyped]) -> String?
|
|
81
|
+
def title(value = nil, interpolations: {})
|
|
82
|
+
return self.problem_title = value if value
|
|
83
|
+
|
|
84
|
+
template = problem_title
|
|
85
|
+
return template if template.nil? || interpolations.empty?
|
|
86
|
+
|
|
87
|
+
template % interpolations
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Falls back to the global setting, so a deployment declares its authority once.
|
|
91
|
+
#: (?String?) -> String?
|
|
92
|
+
def type_prefix(value = nil)
|
|
93
|
+
return self.problem_type_prefix = value if value
|
|
94
|
+
|
|
95
|
+
problem_type_prefix || Problem.config.type_prefix
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Intercepts `detail:` and forwards everything else to the exception, so
|
|
100
|
+
# `raise Klass, "message"` keeps working.
|
|
101
|
+
#: (*untyped, ?detail: String?, **untyped) -> void
|
|
102
|
+
def initialize(*args, detail: nil, **kwargs)
|
|
103
|
+
@detail = detail #: String?
|
|
104
|
+
super(*args, **kwargs)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Values the title template interpolates for this occurrence.
|
|
108
|
+
#: () -> Hash[Symbol, untyped]
|
|
109
|
+
def title_interpolations = {}
|
|
110
|
+
|
|
111
|
+
# Extension members this occurrence publishes alongside the RFC 9457 ones.
|
|
112
|
+
#: () -> Hash[Symbol, untyped]
|
|
113
|
+
def problem_extensions = {}
|
|
114
|
+
|
|
115
|
+
# A URI identifying this occurrence, which only the caller's request can supply.
|
|
116
|
+
#: () -> String?
|
|
117
|
+
def problem_instance = nil
|
|
118
|
+
|
|
119
|
+
# Response headers this occurrence needs, applied by Problem::Rescuable.
|
|
120
|
+
#: () -> Hash[String, String]
|
|
121
|
+
def problem_headers = {}
|
|
122
|
+
|
|
123
|
+
# This occurrence as an RFC 9457 document.
|
|
124
|
+
#: () -> Details
|
|
125
|
+
def to_problem
|
|
126
|
+
status = self.class.status
|
|
127
|
+
raise ArgumentError, "#{self.class} declares no problem status" if status.nil?
|
|
128
|
+
|
|
129
|
+
Details.new(
|
|
130
|
+
type: Problem.type_uri(self.class.type, prefix: self.class.type_prefix),
|
|
131
|
+
title: self.class.title(interpolations: title_interpolations),
|
|
132
|
+
status: status,
|
|
133
|
+
detail: detail,
|
|
134
|
+
instance: problem_instance,
|
|
135
|
+
extensions: problem_extensions,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 Sorah Fukumori
|
|
4
|
+
# SPDX-License-Identifier: MIT
|
|
5
|
+
|
|
6
|
+
require "problem/document"
|
|
7
|
+
|
|
8
|
+
module Problem
|
|
9
|
+
# The members of an RFC 9457 problem document, plus the extension members a problem
|
|
10
|
+
# adds to them. Behaviour is defined on the reopening below.
|
|
11
|
+
Details = Data.define(:type, :title, :status, :detail, :instance, :extensions)
|
|
12
|
+
|
|
13
|
+
# An RFC 9457 problem document as a value object.
|
|
14
|
+
#
|
|
15
|
+
# `type` is stored verbatim: resolving a declared slug against a prefix is
|
|
16
|
+
# Problem::Detailable's job, so a Details built by hand is never rewritten.
|
|
17
|
+
#
|
|
18
|
+
# Reopened rather than customized in a `Data.define` block, so the body is an ordinary
|
|
19
|
+
# class body that RBS can describe and Steep can check.
|
|
20
|
+
class Details
|
|
21
|
+
include Document
|
|
22
|
+
|
|
23
|
+
# `status` is required and must be the HTTP status of the response carrying it;
|
|
24
|
+
# every other member is optional, and `extensions` holds the members RFC 9457 §3.2
|
|
25
|
+
# lets a problem add.
|
|
26
|
+
#: (status: Integer, ?type: String?, ?title: String?, ?detail: String?, ?instance: String?, ?extensions: Hash[untyped, untyped]) -> void
|
|
27
|
+
def initialize(status:, type: nil, title: nil, detail: nil, instance: nil, extensions: {})
|
|
28
|
+
raise ArgumentError, "status must be an Integer, got #{status.class}" unless status.is_a?(Integer)
|
|
29
|
+
|
|
30
|
+
super(type:, title:, status:, detail:, instance:, extensions: sanitized_extensions(extensions))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An extension that shadowed `status` would contradict the HTTP status in the same
|
|
34
|
+
# response, so a collision is refused here rather than at render time.
|
|
35
|
+
private def sanitized_extensions(extensions)
|
|
36
|
+
sanitized = extensions.to_h { |key, value| [key.to_sym, value] }
|
|
37
|
+
collisions = sanitized.keys & Document::MEMBERS
|
|
38
|
+
|
|
39
|
+
unless collisions.empty?
|
|
40
|
+
raise ArgumentError, "extension members shadow RFC 9457 members: #{collisions.join(", ")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sanitized.freeze
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Problem
|
|
10
|
+
# Serializes an RFC 9457 problem document.
|
|
11
|
+
#
|
|
12
|
+
# Included by Problem::Details, and by any value object that carries the same readers —
|
|
13
|
+
# the way to add typed members is to define a Data over Problem::Details.members rather
|
|
14
|
+
# than to subclass Details, which cannot gain members.
|
|
15
|
+
#
|
|
16
|
+
# @rbs module-self _Document
|
|
17
|
+
module Document
|
|
18
|
+
# The members RFC 9457 §3.1 defines, in the order they are serialized.
|
|
19
|
+
MEMBERS = %i[type title status detail instance].freeze #: Array[Symbol]
|
|
20
|
+
|
|
21
|
+
# The type of a problem that says nothing beyond its HTTP status (RFC 9457 §4.2.1).
|
|
22
|
+
ABOUT_BLANK = "about:blank" #: String
|
|
23
|
+
|
|
24
|
+
# The media type a problem document is served as (RFC 9457 §3).
|
|
25
|
+
CONTENT_TYPE = "application/problem+json" #: String
|
|
26
|
+
|
|
27
|
+
# Extension members are members of the problem object itself, not a nested container,
|
|
28
|
+
# so they merge at the top level. An absent member is omitted rather than sent as null.
|
|
29
|
+
#: () -> Hash[Symbol, untyped]
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
type: type || ABOUT_BLANK,
|
|
33
|
+
title: title,
|
|
34
|
+
status: status,
|
|
35
|
+
detail: detail,
|
|
36
|
+
instance: instance,
|
|
37
|
+
}.reject { |_, value| value.nil? || value == "" }.merge(extensions)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The document with string keys, so a member whose value knows how to render itself
|
|
41
|
+
# gets the chance before JSON.generate sees it.
|
|
42
|
+
#: (?untyped) -> Hash[String, untyped]
|
|
43
|
+
def as_json(options = nil)
|
|
44
|
+
to_h.to_h { |key, value| [key.to_s, value.respond_to?(:as_json) ? value.as_json(options) : value] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Defined explicitly because the generic Object#to_json would serialize the Data's
|
|
48
|
+
# inspect output, which looks like a response body until someone reads one.
|
|
49
|
+
#: (*untyped) -> String
|
|
50
|
+
def to_json(*) = JSON.generate(as_json)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "action_dispatch"
|
|
8
|
+
require "action_dispatch/middleware/exception_wrapper"
|
|
9
|
+
require "problem/detailable"
|
|
10
|
+
require "problem/details"
|
|
11
|
+
|
|
12
|
+
module Problem
|
|
13
|
+
# Answers an exception that escaped the controller — a routing error, an unreadable
|
|
14
|
+
# body, a failure in middleware — as application/problem+json. Nothing else sees those:
|
|
15
|
+
# they are raised before dispatch, so no controller rescue_from ever runs.
|
|
16
|
+
#
|
|
17
|
+
# config.exceptions_app = Problem::ExceptionsApp.new(
|
|
18
|
+
# ActionDispatch::PublicExceptions.new(Rails.public_path),
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# A wrapper rather than an ActionDispatch::PublicExceptions subclass, so each layer of a
|
|
22
|
+
# deployment's stack claims the requests it recognizes and passes on the rest; a
|
|
23
|
+
# subclass can only ever be the innermost one.
|
|
24
|
+
class ExceptionsApp
|
|
25
|
+
# Wraps the exceptions app to fall back to, which keeps whatever it already answers
|
|
26
|
+
# for the requests this one does not claim.
|
|
27
|
+
#: (untyped app) -> void
|
|
28
|
+
def initialize(app)
|
|
29
|
+
@app = app
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Answers with a problem document, or hands the request to the wrapped app.
|
|
33
|
+
#: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
34
|
+
def call(env)
|
|
35
|
+
exception = env["action_dispatch.exception"]
|
|
36
|
+
return @app.call(env) if exception.nil? || html?(env)
|
|
37
|
+
|
|
38
|
+
render_problem(problem_for(exception, env))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Browser traffic keeps getting the wrapped app's static error pages.
|
|
42
|
+
#: (Hash[String, untyped]) -> bool
|
|
43
|
+
private def html?(env)
|
|
44
|
+
ActionDispatch::Request.new(env).formats.first&.html? || false
|
|
45
|
+
rescue ActionDispatch::Http::MimeNegotiation::InvalidType
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The problem to send for an escaped exception. The title is the status's own text,
|
|
50
|
+
# never the exception's message, which can quote internals.
|
|
51
|
+
#
|
|
52
|
+
# Override to publish a type: a deployment with a catalogue of problems maps the
|
|
53
|
+
# status onto it here, so every identifier a client can see comes from one place.
|
|
54
|
+
#: (Exception, Hash[String, untyped]) -> Details
|
|
55
|
+
private def problem_for(exception, env)
|
|
56
|
+
return exception.to_problem if exception.is_a?(Detailable)
|
|
57
|
+
|
|
58
|
+
# Read the way ActionDispatch::PublicExceptions reads it, so `rescue_responses` stays
|
|
59
|
+
# the one place the application classifies an exception.
|
|
60
|
+
status = ActionDispatch::ExceptionWrapper.new(
|
|
61
|
+
env["action_dispatch.backtrace_cleaner"], exception
|
|
62
|
+
).status_code
|
|
63
|
+
|
|
64
|
+
Details.new(status: status, title: Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (Details) -> [Integer, Hash[String, String], Array[String]]
|
|
68
|
+
private def render_problem(problem)
|
|
69
|
+
body = problem.to_json
|
|
70
|
+
|
|
71
|
+
[
|
|
72
|
+
problem.status,
|
|
73
|
+
{"content-type" => Document::CONTENT_TYPE, "content-length" => body.bytesize.to_s},
|
|
74
|
+
[body],
|
|
75
|
+
]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "i18n"
|
|
8
|
+
require "active_support/concern"
|
|
9
|
+
require "problem/detailable"
|
|
10
|
+
|
|
11
|
+
module Problem
|
|
12
|
+
# Looks a problem's title up through I18n instead of declaring it in Ruby.
|
|
13
|
+
#
|
|
14
|
+
# class ApiError < StandardError
|
|
15
|
+
# include Problem::I18nable
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# class NotFound < ApiError
|
|
19
|
+
# type "not-found"
|
|
20
|
+
# status 404
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# en:
|
|
24
|
+
# problem_details:
|
|
25
|
+
# titles:
|
|
26
|
+
# not_found: "Not Found"
|
|
27
|
+
#
|
|
28
|
+
# Titles are keyed by the declared type, dashes replaced, so two classes publishing one
|
|
29
|
+
# type publish one title as well. That is what keeps a subclass written to be
|
|
30
|
+
# indistinguishable from its parent indistinguishable in the title too.
|
|
31
|
+
#
|
|
32
|
+
# A class that declares a literal title keeps it, so a codebase can move over gradually.
|
|
33
|
+
#
|
|
34
|
+
# i18n is not a declared dependency of this gem. Referencing this module is opting in,
|
|
35
|
+
# and it is autoloaded so that a host which never does pays nothing for it.
|
|
36
|
+
#
|
|
37
|
+
# @rbs module-self ::Exception
|
|
38
|
+
# @rbs module-self _DetailableSelf
|
|
39
|
+
module I18nable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
# Brings the DSL this overrides, and orders the two so `super` reaches it whichever
|
|
43
|
+
# way round a class mixes them in.
|
|
44
|
+
include Detailable
|
|
45
|
+
|
|
46
|
+
# Where titles live unless a class says otherwise.
|
|
47
|
+
DEFAULT_SCOPE = "problem_details.titles" #: String
|
|
48
|
+
|
|
49
|
+
# steep:ignore:start
|
|
50
|
+
included do
|
|
51
|
+
class_attribute :problem_title_scope, instance_accessor: false
|
|
52
|
+
class_attribute :problem_title_key, instance_accessor: false
|
|
53
|
+
end
|
|
54
|
+
# steep:ignore:end
|
|
55
|
+
|
|
56
|
+
# @rbs module-self _I18nableClass
|
|
57
|
+
module ClassMethods
|
|
58
|
+
# The I18n scope titles are looked up under.
|
|
59
|
+
#: (?String?) -> String
|
|
60
|
+
def title_scope(value = nil)
|
|
61
|
+
return self.problem_title_scope = value if value
|
|
62
|
+
|
|
63
|
+
problem_title_scope || DEFAULT_SCOPE
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The key within that scope, derived from the declared type unless it is set.
|
|
67
|
+
#: (?(String | Symbol)?) -> String?
|
|
68
|
+
def title_key(value = nil)
|
|
69
|
+
return self.problem_title_key = value.to_s if value
|
|
70
|
+
|
|
71
|
+
problem_title_key || type&.tr("-", "_")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Falls through to the literal DSL for a class that declares a title of its own, or
|
|
75
|
+
# that has no type to build a key from.
|
|
76
|
+
#: (?String?, ?interpolations: Hash[Symbol, untyped]) -> String?
|
|
77
|
+
def title(value = nil, interpolations: {})
|
|
78
|
+
return super if value || problem_title
|
|
79
|
+
|
|
80
|
+
key = title_key
|
|
81
|
+
return super if key.nil?
|
|
82
|
+
|
|
83
|
+
I18n.t(key, scope: title_scope, **interpolations)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "rails/railtie"
|
|
8
|
+
|
|
9
|
+
module Problem
|
|
10
|
+
# Wires the gem into a Rails application on boot.
|
|
11
|
+
#
|
|
12
|
+
# config.problem.type_prefix = "https://api-probs.example.com/"
|
|
13
|
+
#
|
|
14
|
+
# config.exceptions_app is deliberately left alone: replacing what an application set
|
|
15
|
+
# there is not an initializer's business, so Problem::ExceptionsApp is wired by hand.
|
|
16
|
+
class Railtie < ::Rails::Railtie
|
|
17
|
+
config.problem = ActiveSupport::OrderedOptions.new
|
|
18
|
+
|
|
19
|
+
# After config/initializers, so the prefix can be set there as well as in
|
|
20
|
+
# config/application.rb and the environment files.
|
|
21
|
+
initializer("problem.config", after: :load_config_initializers) do |app|
|
|
22
|
+
prefix = app.config.problem.type_prefix
|
|
23
|
+
Problem.config.type_prefix = prefix if prefix
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer("problem.renderer") { Problem.install! }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "action_controller"
|
|
8
|
+
require "problem/document"
|
|
9
|
+
|
|
10
|
+
module Problem
|
|
11
|
+
# Registers the application/problem+json media type and the `problem:` renderer.
|
|
12
|
+
#
|
|
13
|
+
# render problem: error.to_problem
|
|
14
|
+
#
|
|
15
|
+
# Installed by Problem::Railtie on boot; a Rack host or a spec that never boots Rails
|
|
16
|
+
# calls Problem.install! itself.
|
|
17
|
+
module Renderer
|
|
18
|
+
# Renders whatever it is handed, so a deployment can pass its own serializer's object
|
|
19
|
+
# rather than a Problem::Details as long as it answers #status and #to_json.
|
|
20
|
+
#: () -> void
|
|
21
|
+
def self.install!
|
|
22
|
+
Mime::Type.register(Document::CONTENT_TYPE, :problem) unless Mime[:problem]
|
|
23
|
+
|
|
24
|
+
# `self` in a renderer block is the controller instance, which RBS cannot name here.
|
|
25
|
+
# steep:ignore:start
|
|
26
|
+
ActionController::Renderers.add(:problem) do |problem, options|
|
|
27
|
+
self.content_type = Mime[:problem]
|
|
28
|
+
self.status = options[:status] || problem.status
|
|
29
|
+
problem.to_json
|
|
30
|
+
end
|
|
31
|
+
# steep:ignore:end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "active_support"
|
|
8
|
+
require "active_support/concern"
|
|
9
|
+
require "problem/detailable"
|
|
10
|
+
|
|
11
|
+
module Problem
|
|
12
|
+
# Renders any Problem::Detailable a controller raises as application/problem+json.
|
|
13
|
+
#
|
|
14
|
+
# class ApplicationController < ActionController::API
|
|
15
|
+
# include Problem::Rescuable
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# One registration covers every error class that mixed Problem::Detailable in, because
|
|
19
|
+
# rescue_from matches a Module with ===.
|
|
20
|
+
#
|
|
21
|
+
# Each step is its own method, so a deployment overrides the one it needs and a
|
|
22
|
+
# different transport — a Connect RPC error, say — replaces only #render_problem.
|
|
23
|
+
#
|
|
24
|
+
# @rbs module-self _RescuableSelf
|
|
25
|
+
module Rescuable
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
# steep:ignore:start
|
|
29
|
+
included do
|
|
30
|
+
rescue_from Problem::Detailable, with: :render_problem_detailable
|
|
31
|
+
end
|
|
32
|
+
# steep:ignore:end
|
|
33
|
+
|
|
34
|
+
#: (detailable_error) -> void
|
|
35
|
+
private def render_problem_detailable(error)
|
|
36
|
+
around_problem_render do
|
|
37
|
+
problem = problem_for(error)
|
|
38
|
+
report_problem(error, problem) if report_problem?(error, problem)
|
|
39
|
+
apply_problem_headers(error)
|
|
40
|
+
render_problem(problem, error)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Wraps building, reporting and rendering. Override to re-establish per-request state
|
|
45
|
+
# that has already unwound: ActionController::Rescue wraps
|
|
46
|
+
# AbstractController::Callbacks, so an around_action is gone by the time a rescue_from
|
|
47
|
+
# handler runs, and #problem_for may read a translation.
|
|
48
|
+
#
|
|
49
|
+
# private def around_problem_render(&) = I18n.with_locale(negotiated_locale, &)
|
|
50
|
+
#
|
|
51
|
+
#: () { () -> void } -> void
|
|
52
|
+
private def around_problem_render = yield
|
|
53
|
+
|
|
54
|
+
# Override to fill members only the request can supply, such as `instance`.
|
|
55
|
+
#: (detailable_error) -> Details
|
|
56
|
+
private def problem_for(error) = error.to_problem
|
|
57
|
+
|
|
58
|
+
# A client error the API expected is not an incident; a server-side failure is.
|
|
59
|
+
#: (detailable_error, Details) -> bool
|
|
60
|
+
private def report_problem?(_error, problem) = problem.status >= 500
|
|
61
|
+
|
|
62
|
+
# A rescue_from handler swallows the exception, so nothing logs it and no error
|
|
63
|
+
# reporter sees it. Reported through the registry Rails error reporters subscribe to,
|
|
64
|
+
# rather than to a particular one.
|
|
65
|
+
#: (detailable_error, Details) -> void
|
|
66
|
+
private def report_problem(error, _problem)
|
|
67
|
+
ActiveSupport.error_reporter.report(error, handled: true, severity: :error, source: "problem")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (detailable_error) -> void
|
|
71
|
+
private def apply_problem_headers(error)
|
|
72
|
+
headers = error.problem_headers
|
|
73
|
+
response.headers.merge!(headers) unless headers.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The only transport-specific step.
|
|
77
|
+
#: (Details, detailable_error) -> void
|
|
78
|
+
private def render_problem(problem, _error) = render(problem: problem)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "problem/detailable"
|
|
8
|
+
|
|
9
|
+
module Problem
|
|
10
|
+
# Publishes when a rejected request may be retried, as a Retry-After response header
|
|
11
|
+
# (RFC 9110 §10.2.3) and a `retry_after` extension member carrying the same seconds.
|
|
12
|
+
#
|
|
13
|
+
# Mixed in after Problem::Detailable, whose hooks it extends rather than replaces:
|
|
14
|
+
#
|
|
15
|
+
# class Throttled < StandardError
|
|
16
|
+
# include Problem::Detailable
|
|
17
|
+
# include Problem::RetryAfter
|
|
18
|
+
#
|
|
19
|
+
# status 429
|
|
20
|
+
# title "Try again in %{retry_after} seconds"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# raise Throttled.new(retry_after: 30)
|
|
24
|
+
#
|
|
25
|
+
# @rbs module-self Detailable
|
|
26
|
+
module RetryAfter
|
|
27
|
+
# The response header the wait is published in.
|
|
28
|
+
HEADER = "Retry-After" #: String
|
|
29
|
+
|
|
30
|
+
# Seconds to wait, or the Time to wait until.
|
|
31
|
+
attr_reader :retry_after #: (Integer | Time)
|
|
32
|
+
|
|
33
|
+
# Takes the wait as seconds or as the Time to wait until, and forwards the rest.
|
|
34
|
+
#: (*untyped, retry_after: (Integer | Time), **untyped) -> void
|
|
35
|
+
def initialize(*args, retry_after:, **kwargs)
|
|
36
|
+
@retry_after = retry_after #: (Integer | Time)
|
|
37
|
+
super(*args, **kwargs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A deadline that has already passed is published as 0 rather than as a negative wait.
|
|
41
|
+
#: () -> Integer
|
|
42
|
+
def retry_after_seconds
|
|
43
|
+
value = retry_after
|
|
44
|
+
value.is_a?(Time) ? [(value - Time.now).ceil, 0].max : value.to_i
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Adds the wait, so a title template can name it.
|
|
48
|
+
#: () -> Hash[Symbol, untyped]
|
|
49
|
+
def title_interpolations = super.merge(retry_after: retry_after_seconds)
|
|
50
|
+
|
|
51
|
+
# Publishes the wait in the document as a `retry_after` extension member.
|
|
52
|
+
#: () -> Hash[Symbol, untyped]
|
|
53
|
+
def problem_extensions = super.merge(retry_after: retry_after_seconds)
|
|
54
|
+
|
|
55
|
+
# Publishes the wait as a Retry-After header, which a generic HTTP client obeys.
|
|
56
|
+
#: () -> Hash[String, String]
|
|
57
|
+
def problem_headers = super.merge(HEADER => retry_after_seconds.to_s)
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/problem.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "problem/version"
|
|
8
|
+
require "problem/document"
|
|
9
|
+
require "problem/details"
|
|
10
|
+
require "problem/detailable"
|
|
11
|
+
require "problem/retry_after"
|
|
12
|
+
require "problem/rescuable"
|
|
13
|
+
require "problem/exceptions_app"
|
|
14
|
+
|
|
15
|
+
# RFC 9457 Problem Details for HTTP APIs.
|
|
16
|
+
module Problem
|
|
17
|
+
# Autoloaded because it is the one part of the gem that needs i18n, which is not a
|
|
18
|
+
# declared dependency.
|
|
19
|
+
autoload :I18nable, "problem/i18nable"
|
|
20
|
+
|
|
21
|
+
# Anything a deployment sets once, rather than per error class.
|
|
22
|
+
class Configuration
|
|
23
|
+
# Resolves a declared type slug into the URI a client dispatches on, so an error class
|
|
24
|
+
# names `"bad-request"` instead of repeating the deployment's authority. Left unset,
|
|
25
|
+
# a slug goes out as-is, which RFC 9457 allows: `type` is a URI reference.
|
|
26
|
+
attr_accessor :type_prefix #: String?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A URI reference carrying a scheme is already absolute (RFC 3986 §3.1).
|
|
30
|
+
ABSOLUTE_URI = /\A[a-zA-Z][a-zA-Z0-9+.\-]*:/ #: Regexp
|
|
31
|
+
|
|
32
|
+
# @rbs self.@config: Configuration?
|
|
33
|
+
|
|
34
|
+
# The settings in effect, created on first use.
|
|
35
|
+
#: () -> Configuration
|
|
36
|
+
def self.config
|
|
37
|
+
@config ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Yields the settings, for a host that is not a Rails application.
|
|
41
|
+
#
|
|
42
|
+
# Problem.configure { |c| c.type_prefix = "https://api-probs.example.com/" }
|
|
43
|
+
#
|
|
44
|
+
#: () { (Configuration) -> void } -> void
|
|
45
|
+
def self.configure
|
|
46
|
+
yield(config)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Registers the application/problem+json media type and the `problem:` renderer. Called
|
|
50
|
+
# by Problem::Railtie on boot; a Rack host or a spec that never boots Rails calls it.
|
|
51
|
+
#: () -> void
|
|
52
|
+
def self.install!
|
|
53
|
+
require "problem/renderer"
|
|
54
|
+
Renderer.install!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The type URI a declared value publishes. `about:blank` and anything already absolute
|
|
58
|
+
# are returned untouched — prefixing `about:blank` would produce a URI that looks valid
|
|
59
|
+
# and means nothing.
|
|
60
|
+
#: (String?, ?prefix: String?) -> String?
|
|
61
|
+
def self.type_uri(value, prefix: config.type_prefix)
|
|
62
|
+
return if value.nil?
|
|
63
|
+
return value if value == Document::ABOUT_BLANK || value.match?(ABSOLUTE_URI)
|
|
64
|
+
|
|
65
|
+
prefix ? "#{prefix}#{value}" : value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
require "problem/railtie" if defined?(Rails::Railtie)
|