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.
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ module Problem
8
+ # The gem's version.
9
+ VERSION = "0.1.0" #: String
10
+ 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)