action_figure 0.6.2 → 0.7.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 +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +4 -3
- data/docs/custom-formatters.md +34 -34
- data/docs/integration-patterns.md +20 -18
- data/docs/problem-details.md +113 -0
- data/docs/response-formatters.md +183 -17
- data/docs/status-codes.md +5 -3
- data/docs/testing.md +65 -0
- data/lib/action_figure/error_registry.rb +108 -0
- data/lib/action_figure/formatter.rb +12 -5
- data/lib/action_figure/formatters/default.rb +2 -18
- data/lib/action_figure/formatters/jsend.rb +2 -18
- data/lib/action_figure/formatters/json_api.rb +3 -18
- data/lib/action_figure/formatters/rfc_9457.rb +143 -0
- data/lib/action_figure/formatters/wrapped.rb +2 -18
- data/lib/action_figure/testing/minitest.rb +75 -22
- data/lib/action_figure/testing/rspec.rb +53 -14
- data/lib/action_figure/testing/statuses.rb +46 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +7 -6
- data/sig/action_figure.rbs +81 -26
- metadata +19 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
module Formatters
|
|
7
|
+
# Implements RFC 9457 (Problem Details for HTTP APIs) response helpers.
|
|
8
|
+
# Errors render as application/problem+json documents. Success responses
|
|
9
|
+
# mirror the same type/title vocabulary.
|
|
10
|
+
#
|
|
11
|
+
# Defaults for type and title are derived mechanically from class names and
|
|
12
|
+
# status symbols — deliberately unattractive. Pass type: and title: kwargs
|
|
13
|
+
# to provide stable, documented values your clients can rely on.
|
|
14
|
+
module Rfc9457
|
|
15
|
+
include ActionFigure::Formatter
|
|
16
|
+
|
|
17
|
+
def Ok(resource:, meta: nil, as: nil, type: nil, title: nil)
|
|
18
|
+
build_success(resource: resource, meta: meta, as: as,
|
|
19
|
+
type: type, title: title, status: :ok)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def Created(resource:, meta: nil, as: nil, type: nil, title: nil)
|
|
23
|
+
build_success(resource: resource, meta: meta, as: as,
|
|
24
|
+
type: type, title: title, status: :created)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def Accepted(resource: nil, meta: nil, as: nil, type: nil, title: nil)
|
|
28
|
+
build_success(resource: resource, meta: meta, as: as,
|
|
29
|
+
type: type, title: title, status: :accepted)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def UnprocessableContent(errors: nil, type: "unprocessable-content-error", **extras)
|
|
33
|
+
error_response(status: :unprocessable_content, errors: errors, type: type, **extras)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
|
|
37
|
+
def error_response(status:, errors: nil, type: nil, title: nil, detail: nil, instance: nil, **extras)
|
|
38
|
+
name = action_name_for_error
|
|
39
|
+
word = status_word(status)
|
|
40
|
+
body = {}
|
|
41
|
+
body[:type] = type || derive_type(name, "#{word}-error")
|
|
42
|
+
body[:title] = title || rack_status_title(status)
|
|
43
|
+
body[:status] = ActionFigure.status_code_for(status)
|
|
44
|
+
body[:detail] = detail if detail
|
|
45
|
+
body[:instance] = instance if instance
|
|
46
|
+
body[:errors] = errors unless errors.nil?
|
|
47
|
+
extras.each { |k, v| body[k] = v }
|
|
48
|
+
{ json: body, status: status, content_type: "application/problem+json" }
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
|
|
51
|
+
|
|
52
|
+
PRIMITIVE_CLASSES = [Hash, Array, String, Numeric, Symbol,
|
|
53
|
+
TrueClass, FalseClass, NilClass].freeze
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# rubocop:disable Metrics/ParameterLists
|
|
58
|
+
def build_success(resource:, meta:, as:, type:, title:, status:)
|
|
59
|
+
name, key = resource_info(resource, as)
|
|
60
|
+
word = status_word(status)
|
|
61
|
+
body = {}
|
|
62
|
+
body[:type] = type || derive_type(name, word)
|
|
63
|
+
body[:title] = title || derive_title(name, word)
|
|
64
|
+
body[key] = resource unless resource.nil? && status == :accepted
|
|
65
|
+
body[:meta] = meta if meta
|
|
66
|
+
{ json: body, status: status }
|
|
67
|
+
end
|
|
68
|
+
# rubocop:enable Metrics/ParameterLists
|
|
69
|
+
|
|
70
|
+
# Returns [name, key] where name drives type/title derivation and key
|
|
71
|
+
# is the JSON member. Primitives and anonymous classes use name "resource"
|
|
72
|
+
# and key :data; model-like objects use their dasherized class name for both.
|
|
73
|
+
def resource_info(resource, as_kwarg)
|
|
74
|
+
if as_kwarg
|
|
75
|
+
name = dasherize_class_name(as_kwarg.to_s)
|
|
76
|
+
return [name, as_kwarg.to_sym]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
klass = resource.class
|
|
80
|
+
return ["resource", :data] if primitive_class?(klass) || klass.name.nil?
|
|
81
|
+
|
|
82
|
+
name = dasherize_class_name(klass.name)
|
|
83
|
+
[name, name.gsub("-", "_").to_sym]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the action class's dasherized name (Action suffix stripped)
|
|
87
|
+
# for use as the error type prefix.
|
|
88
|
+
def action_name_for_error
|
|
89
|
+
klass_name = self.class.name
|
|
90
|
+
return "action" if klass_name.nil?
|
|
91
|
+
|
|
92
|
+
dasherize_class_name(klass_name)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Converts a Ruby class name string (or an as: symbol) to a dasherized,
|
|
96
|
+
# Action-stripped slug.
|
|
97
|
+
# Examples:
|
|
98
|
+
# "User" -> "user"
|
|
99
|
+
# "Admin::UserProfile" -> "admin-user-profile"
|
|
100
|
+
# "Projects::CreateAction" -> "projects-create"
|
|
101
|
+
# "user_profile" -> "user-profile"
|
|
102
|
+
def dasherize_class_name(name)
|
|
103
|
+
name
|
|
104
|
+
.sub(/Action\z/, "") # strip trailing "Action"
|
|
105
|
+
.gsub("::", "-") # namespace separator -> dash
|
|
106
|
+
.tr("_", "-") # snake_case as: symbols -> dashes
|
|
107
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2') # e.g. XMLParser -> XML-Parser
|
|
108
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2') # camelCase -> camel-Case
|
|
109
|
+
.downcase
|
|
110
|
+
.squeeze("-") # collapse any double-dashes from empty namespace parts
|
|
111
|
+
.sub(/\A-/, "") # remove leading dash if Action was the only segment
|
|
112
|
+
.sub(/-\z/, "") # remove trailing dash
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Status word derived from the Rack status symbol (underscores -> dashes).
|
|
116
|
+
# Examples: :ok -> "ok", :not_found -> "not-found", :created -> "created"
|
|
117
|
+
def status_word(status_symbol)
|
|
118
|
+
status_symbol.to_s.tr("_", "-")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def derive_type(name, word)
|
|
122
|
+
"#{name}-#{word}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Builds a human title from a dasherized name and a status word.
|
|
126
|
+
# Examples: ("user", "created") -> "User created"
|
|
127
|
+
# ("admin-user-profile", "created") -> "Admin user profile created"
|
|
128
|
+
def derive_title(name, word)
|
|
129
|
+
phrase = "#{name.gsub("-", " ")} #{word.tr("-", " ")}"
|
|
130
|
+
phrase[0].upcase + phrase[1..]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def rack_status_title(status_symbol)
|
|
134
|
+
code = ActionFigure.status_code_for(status_symbol)
|
|
135
|
+
Rack::Utils::HTTP_STATUS_CODES[code] || status_symbol.to_s.tr("_", " ").capitalize
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def primitive_class?(klass)
|
|
139
|
+
PRIMITIVE_CLASSES.any? { |p| klass <= p }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -25,24 +25,8 @@ module ActionFigure
|
|
|
25
25
|
{ json: body, status: :accepted }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
{ json: { data: nil, errors: errors, status: "error" }, status:
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def NotFound(errors:)
|
|
33
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :not_found }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def Forbidden(errors:)
|
|
37
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def Conflict(errors:)
|
|
41
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :conflict }
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def PaymentRequired(errors:)
|
|
45
|
-
{ json: { data: nil, errors: errors, status: "error" }, status: :payment_required }
|
|
28
|
+
def error_response(errors:, status:)
|
|
29
|
+
{ json: { data: nil, errors: errors, status: "error" }, status: status }
|
|
46
30
|
end
|
|
47
31
|
end
|
|
48
32
|
end
|
|
@@ -1,58 +1,111 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "statuses"
|
|
4
|
+
|
|
3
5
|
module ActionFigure
|
|
4
6
|
module Testing
|
|
5
7
|
# Minitest assertions for ActionFigure response results.
|
|
6
8
|
#
|
|
7
|
-
# Include in your test class to get assert_Ok, assert_Created, etc
|
|
9
|
+
# Include in your test class to get assert_Ok, assert_Created, etc.
|
|
10
|
+
# (and the negated refute_Ok, refute_Created, ...):
|
|
8
11
|
#
|
|
9
12
|
# class Users::CreateTest < Minitest::Test
|
|
10
13
|
# include ActionFigure::Testing::Minitest
|
|
11
14
|
# end
|
|
15
|
+
#
|
|
16
|
+
# The status helpers are generated from ActionFigure::Testing.statuses so the
|
|
17
|
+
# Minitest and RSpec adapters never drift.
|
|
12
18
|
module Minitest
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
def self.define_status_assertions(name, status)
|
|
20
|
+
define_method(:"assert_#{name}") do |result, msg = nil|
|
|
21
|
+
assert_status(status, result, msg)
|
|
22
|
+
end
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
define_method(:"refute_#{name}") do |result, msg = nil|
|
|
25
|
+
refute_status(status, result, msg)
|
|
26
|
+
end
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
assert_status(:accepted, result, msg)
|
|
23
|
-
end
|
|
29
|
+
Testing.statuses.each { |name, status| define_status_assertions(name, status) }
|
|
24
30
|
|
|
25
|
-
def
|
|
26
|
-
|
|
31
|
+
def assert_valid_params(action_class, params, msg = nil)
|
|
32
|
+
result = action_contract(action_class).call(params)
|
|
33
|
+
message = msg || "Expected params to be valid, but got errors: #{result.errors.to_h.inspect}"
|
|
34
|
+
assert result.success?, message
|
|
27
35
|
end
|
|
28
36
|
|
|
29
|
-
def
|
|
30
|
-
|
|
37
|
+
def assert_invalid_params(action_class, params, on: nil, msg: nil)
|
|
38
|
+
result = action_contract(action_class).call(params)
|
|
39
|
+
|
|
40
|
+
if on.nil?
|
|
41
|
+
message = msg || "Expected params to be invalid, but the contract accepted them"
|
|
42
|
+
assert result.failure?, message
|
|
43
|
+
else
|
|
44
|
+
errored = result.errors.to_h.key?(on)
|
|
45
|
+
message = msg || "Expected params to be invalid on #{on.inspect}, " \
|
|
46
|
+
"but errors were: #{result.errors.to_h.inspect}"
|
|
47
|
+
assert errored, message
|
|
48
|
+
end
|
|
31
49
|
end
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
# Asserts +result[:json]+ contains the given fragment as a (possibly nested) subset.
|
|
52
|
+
# Mirrors the RSpec +have_action_json+ matcher: nested Hashes match as subsets and
|
|
53
|
+
# Regexp values match against strings.
|
|
54
|
+
#
|
|
55
|
+
# assert_action_json(result, status: "success", data: { name: "Jane" })
|
|
56
|
+
def assert_action_json(result, fragment, msg = nil)
|
|
57
|
+
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)
|
|
58
|
+
unless result.key?(:json)
|
|
59
|
+
flunk(msg || "Expected #{result.inspect} to include key :json (ActionFigure render hash)")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
message = msg || "Expected result[:json] to include #{fragment.inspect}, got #{result[:json].inspect}"
|
|
63
|
+
assert json_subset?(result[:json], fragment), message
|
|
35
64
|
end
|
|
36
65
|
|
|
37
|
-
def
|
|
38
|
-
|
|
66
|
+
def refute_action_json(result, fragment, msg = nil)
|
|
67
|
+
json = result.is_a?(Hash) ? result[:json] : nil
|
|
68
|
+
message = msg || "Expected result[:json] not to include #{fragment.inspect}"
|
|
69
|
+
refute(json.is_a?(Hash) && json_subset?(json, fragment), message)
|
|
39
70
|
end
|
|
40
71
|
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def json_subset?(actual, expected)
|
|
75
|
+
return false unless actual.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
expected.all? do |key, value|
|
|
78
|
+
actual.key?(key) && json_value_match?(actual[key], value)
|
|
79
|
+
end
|
|
43
80
|
end
|
|
44
81
|
|
|
45
|
-
def
|
|
46
|
-
|
|
82
|
+
def json_value_match?(actual, expected)
|
|
83
|
+
case expected
|
|
84
|
+
when Hash then json_subset?(actual, expected)
|
|
85
|
+
when Regexp then expected.match?(actual.to_s)
|
|
86
|
+
else actual == expected
|
|
87
|
+
end
|
|
47
88
|
end
|
|
48
89
|
|
|
49
|
-
|
|
90
|
+
def action_contract(action_class)
|
|
91
|
+
ActionFigure::Testing.contract_for(action_class)
|
|
92
|
+
end
|
|
50
93
|
|
|
51
94
|
def assert_status(expected, result, msg)
|
|
95
|
+
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)
|
|
96
|
+
|
|
52
97
|
actual = result[:status]
|
|
53
98
|
message = msg || "Expected result status to be #{expected.inspect}, but got #{actual.inspect}"
|
|
54
99
|
assert actual == expected, message
|
|
55
100
|
end
|
|
101
|
+
|
|
102
|
+
def refute_status(expected, result, msg)
|
|
103
|
+
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
actual = result[:status]
|
|
106
|
+
message = msg || "Expected result status not to be #{expected.inspect}"
|
|
107
|
+
refute actual == expected, message
|
|
108
|
+
end
|
|
56
109
|
end
|
|
57
110
|
end
|
|
58
111
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rspec/matchers"
|
|
4
|
+
require_relative "statuses"
|
|
4
5
|
|
|
5
6
|
module ActionFigure
|
|
6
7
|
module Testing
|
|
@@ -17,22 +18,12 @@ module ActionFigure
|
|
|
17
18
|
# end
|
|
18
19
|
# end
|
|
19
20
|
module RSpec
|
|
20
|
-
|
|
21
|
-
Ok: :ok,
|
|
22
|
-
Created: :created,
|
|
23
|
-
Accepted: :accepted,
|
|
24
|
-
NoContent: :no_content,
|
|
25
|
-
UnprocessableContent: :unprocessable_content,
|
|
26
|
-
NotFound: :not_found,
|
|
27
|
-
Forbidden: :forbidden,
|
|
28
|
-
Conflict: :conflict,
|
|
29
|
-
PaymentRequired: :payment_required
|
|
30
|
-
}.freeze
|
|
31
|
-
|
|
32
|
-
MATCHERS.each do |name, status|
|
|
21
|
+
def self.define_status_matcher(name, status)
|
|
33
22
|
::RSpec::Matchers.define :"be_#{name}" do
|
|
34
|
-
match { |result| result[:status] == status }
|
|
23
|
+
match { |result| result.is_a?(Hash) && result[:status] == status }
|
|
35
24
|
failure_message do |result|
|
|
25
|
+
next "expected an ActionFigure result hash, got #{result.inspect}" unless result.is_a?(Hash)
|
|
26
|
+
|
|
36
27
|
"expected result status to be #{status.inspect}, but got #{result[:status].inspect}"
|
|
37
28
|
end
|
|
38
29
|
failure_message_when_negated do
|
|
@@ -41,6 +32,8 @@ module ActionFigure
|
|
|
41
32
|
end
|
|
42
33
|
end
|
|
43
34
|
|
|
35
|
+
Testing.statuses.each { |name, status| define_status_matcher(name, status) }
|
|
36
|
+
|
|
44
37
|
# Asserts against +result[:json]+ using +a_hash_including+ (nested matchers allowed).
|
|
45
38
|
::RSpec::Matchers.define :have_action_json do |expected_fragment|
|
|
46
39
|
include ::RSpec::Matchers
|
|
@@ -67,6 +60,52 @@ module ActionFigure
|
|
|
67
60
|
"#{result.inspect} was expected not to match #{@inner_matcher.description}"
|
|
68
61
|
end
|
|
69
62
|
end
|
|
63
|
+
|
|
64
|
+
# Asserts an action class's params_schema/rules accept the given params.
|
|
65
|
+
# Subject is the action class, not a result hash:
|
|
66
|
+
#
|
|
67
|
+
# expect(Users::Create).to accept_params(email: "jane@example.com")
|
|
68
|
+
::RSpec::Matchers.define :accept_params do |params|
|
|
69
|
+
match { |action_class| ActionFigure::Testing.contract_for(action_class).call(params).success? }
|
|
70
|
+
|
|
71
|
+
failure_message do |action_class|
|
|
72
|
+
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
|
|
73
|
+
"expected #{action_class} to accept params, but got errors: #{errors.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
failure_message_when_negated do |action_class|
|
|
77
|
+
"expected #{action_class} not to accept params #{params.inspect}, but it did"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Asserts an action class's params_schema/rules reject the given params.
|
|
82
|
+
# Chain +with_error_on+ to require the failure to land on a specific field:
|
|
83
|
+
#
|
|
84
|
+
# expect(Users::Create).to reject_params(name: "Jane").with_error_on(:email)
|
|
85
|
+
::RSpec::Matchers.define :reject_params do |params|
|
|
86
|
+
chain(:with_error_on) { |field| @field = field }
|
|
87
|
+
|
|
88
|
+
match do |action_class|
|
|
89
|
+
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
|
|
90
|
+
next false if errors.empty?
|
|
91
|
+
|
|
92
|
+
@field.nil? || errors.key?(@field)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
failure_message do |action_class|
|
|
96
|
+
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
|
|
97
|
+
if @field
|
|
98
|
+
"expected #{action_class} to reject params with an error on #{@field.inspect}, " \
|
|
99
|
+
"but errors were: #{errors.inspect}"
|
|
100
|
+
else
|
|
101
|
+
"expected #{action_class} to reject params #{params.inspect}, but it accepted them"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
failure_message_when_negated do |action_class|
|
|
106
|
+
"expected #{action_class} not to reject params #{params.inspect}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
70
109
|
end
|
|
71
110
|
end
|
|
72
111
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_figure"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
# Helpers shared by the Minitest and RSpec testing adapters.
|
|
7
|
+
module Testing
|
|
8
|
+
# Success/no-body statuses with bespoke formatter bodies. These are not part of
|
|
9
|
+
# the error registry (their bodies differ per outcome, not just per status).
|
|
10
|
+
SUCCESS_STATUSES = {
|
|
11
|
+
Ok: :ok,
|
|
12
|
+
Created: :created,
|
|
13
|
+
Accepted: :accepted,
|
|
14
|
+
NoContent: :no_content
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Live full name→status map driving both adapters: success statuses plus
|
|
18
|
+
# every error status registered so far (built-in and user-registered).
|
|
19
|
+
# Each adapter iterates this at its own load time, so an adapter loaded
|
|
20
|
+
# after a +register_error+ call still sees the full registry; registrations
|
|
21
|
+
# made after an adapter loads are patched in via +define_error_helper+.
|
|
22
|
+
def self.statuses
|
|
23
|
+
SUCCESS_STATUSES.merge(ActionFigure.error_statuses)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resolves an action class's validation contract, raising a clear error when
|
|
27
|
+
# the class declares no +params_schema+ (and therefore has no contract).
|
|
28
|
+
def self.contract_for(action_class)
|
|
29
|
+
contract = action_class.contract
|
|
30
|
+
return contract if contract
|
|
31
|
+
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"#{action_class} defines no params_schema, so it has no contract to validate against"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Patches whichever adapters are loaded with one status's assertion/matcher.
|
|
37
|
+
# Called by ActionFigure.register_error so a status registered after the
|
|
38
|
+
# adapters have loaded still gets its assert_/refute_/be_ helpers.
|
|
39
|
+
# +const_defined?(..., false)+ checks only this namespace — a bare
|
|
40
|
+
# +defined?(Minitest)+ would find the top-level framework constant.
|
|
41
|
+
def self.define_error_helper(name, status)
|
|
42
|
+
Minitest.define_status_assertions(name, status) if const_defined?(:Minitest, false)
|
|
43
|
+
RSpec.define_status_matcher(name, status) if const_defined?(:RSpec, false)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/action_figure.rb
CHANGED
|
@@ -3,25 +3,25 @@
|
|
|
3
3
|
require_relative "action_figure/version"
|
|
4
4
|
require_relative "action_figure/configuration"
|
|
5
5
|
require_relative "action_figure/format_registry"
|
|
6
|
+
require_relative "action_figure/error_registry"
|
|
6
7
|
require_relative "action_figure/formatter"
|
|
7
8
|
require_relative "action_figure/core"
|
|
8
9
|
require_relative "action_figure/formatters/jsend"
|
|
9
10
|
require_relative "action_figure/formatters/json_api"
|
|
10
11
|
require_relative "action_figure/formatters/default"
|
|
11
12
|
require_relative "action_figure/formatters/wrapped"
|
|
13
|
+
require_relative "action_figure/formatters/rfc_9457"
|
|
12
14
|
|
|
13
15
|
# ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions.
|
|
14
16
|
module ActionFigure
|
|
17
|
+
@format_modules = Concurrent::Map.new
|
|
18
|
+
|
|
15
19
|
extend Configuration
|
|
16
20
|
extend FormatRegistry
|
|
21
|
+
extend ErrorRegistry
|
|
17
22
|
|
|
18
23
|
class IndeterminateEntryPointError < StandardError; end
|
|
19
24
|
|
|
20
|
-
# Backwards-compatible alias for the misspelled constant shipped through 0.6.0.
|
|
21
|
-
# Remove in the next minor release after Unreleased.
|
|
22
|
-
IndeterminantEntryPointError = IndeterminateEntryPointError
|
|
23
|
-
deprecate_constant :IndeterminantEntryPointError
|
|
24
|
-
|
|
25
25
|
# Raised when an action class defines +initialize+. ActionFigure builds instances with
|
|
26
26
|
# +new+ and passes no constructor arguments; use keyword arguments on the entry method
|
|
27
27
|
# or class-level state instead of custom initializers.
|
|
@@ -31,6 +31,7 @@ module ActionFigure
|
|
|
31
31
|
register_formatter(jsonapi: Formatters::JsonApi)
|
|
32
32
|
register_formatter(default: Formatters::Default)
|
|
33
33
|
register_formatter(wrapped: Formatters::Wrapped)
|
|
34
|
+
register_formatter(rfc_9457: Formatters::Rfc9457) # rubocop:disable Naming/VariableNumber
|
|
34
35
|
|
|
35
36
|
def self.[](format = configuration.format)
|
|
36
37
|
format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) }
|
|
@@ -78,7 +79,7 @@ module ActionFigure
|
|
|
78
79
|
private_class_method :new_format_module
|
|
79
80
|
|
|
80
81
|
def self.format_modules
|
|
81
|
-
@format_modules
|
|
82
|
+
@format_modules
|
|
82
83
|
end
|
|
83
84
|
private_class_method :format_modules
|
|
84
85
|
end
|
data/sig/action_figure.rbs
CHANGED
|
@@ -12,19 +12,20 @@ module ActionFigure
|
|
|
12
12
|
class IndeterminateEntryPointError < StandardError
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Deprecated alias for IndeterminateEntryPointError (misspelled constant from <= 0.6.0).
|
|
16
|
-
IndeterminantEntryPointError: Class
|
|
17
|
-
|
|
18
15
|
class InitializationNotSupportedError < StandardError
|
|
19
16
|
end
|
|
20
17
|
|
|
21
18
|
extend Configuration
|
|
22
19
|
extend FormatRegistry
|
|
20
|
+
extend ErrorRegistry
|
|
23
21
|
|
|
24
22
|
def self.[]: (?Symbol format) -> Module
|
|
25
23
|
def self.included: (Module base) -> void
|
|
26
24
|
def self.register_formatter: (**Module formatters) -> void
|
|
27
25
|
def self.clear_format_module_cache: (Symbol name) -> void
|
|
26
|
+
def self.error_statuses: () -> Hash[Symbol, Symbol]
|
|
27
|
+
def self.register_error: (Symbol | String name, Symbol | String status_symbol) -> Symbol
|
|
28
|
+
def self.status_code_for: (Symbol status_symbol) -> Integer
|
|
28
29
|
|
|
29
30
|
# Provides global configuration via ActionFigure.configure
|
|
30
31
|
module Configuration
|
|
@@ -55,8 +56,36 @@ module ActionFigure
|
|
|
55
56
|
def fetch: (Symbol name) -> Module
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Central registry of error-helper name → status symbol mappings.
|
|
60
|
+
module ErrorRegistry
|
|
61
|
+
DEFAULTS: Hash[Symbol, Symbol]
|
|
62
|
+
STATUS_CODE_FALLBACKS: Hash[Symbol, Integer]
|
|
63
|
+
|
|
64
|
+
# Live module carrying one generated error helper per registered status.
|
|
65
|
+
# Helpers for user-registered statuses are defined at runtime; the
|
|
66
|
+
# built-in ones are declared explicitly so the signatures stay visible.
|
|
67
|
+
module Helpers
|
|
68
|
+
def UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
69
|
+
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
70
|
+
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
71
|
+
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
72
|
+
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
73
|
+
def Gone: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
74
|
+
def Locked: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
75
|
+
def UnavailableForLegalReasons: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.define_helper: (Symbol name, Symbol status_symbol) -> void
|
|
79
|
+
|
|
80
|
+
def error_statuses: () -> Hash[Symbol, Symbol]
|
|
81
|
+
def register_error: (Symbol | String name, Symbol | String status_symbol) -> Symbol
|
|
82
|
+
def status_code_for: (Symbol status_symbol) -> Integer
|
|
83
|
+
end
|
|
84
|
+
|
|
58
85
|
# Base module for response formatters
|
|
59
86
|
module Formatter
|
|
87
|
+
include ErrorRegistry::Helpers
|
|
88
|
+
|
|
60
89
|
REQUIRED_METHODS: Array[Symbol]
|
|
61
90
|
|
|
62
91
|
def NoContent: () -> ActionFigure::response
|
|
@@ -102,11 +131,7 @@ module ActionFigure
|
|
|
102
131
|
def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
103
132
|
def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
104
133
|
def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response
|
|
105
|
-
def
|
|
106
|
-
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
107
|
-
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
108
|
-
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
109
|
-
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
134
|
+
def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response
|
|
110
135
|
end
|
|
111
136
|
|
|
112
137
|
# JSend-formatted responses
|
|
@@ -116,11 +141,7 @@ module ActionFigure
|
|
|
116
141
|
def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
117
142
|
def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
118
143
|
def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response
|
|
119
|
-
def
|
|
120
|
-
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
121
|
-
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
122
|
-
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
123
|
-
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
144
|
+
def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response
|
|
124
145
|
end
|
|
125
146
|
|
|
126
147
|
# JSON:API-formatted responses
|
|
@@ -130,11 +151,7 @@ module ActionFigure
|
|
|
130
151
|
def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
131
152
|
def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
132
153
|
def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response
|
|
133
|
-
def
|
|
134
|
-
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
135
|
-
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
136
|
-
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
137
|
-
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
154
|
+
def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response
|
|
138
155
|
|
|
139
156
|
# Simple resource serialization for JSON:API
|
|
140
157
|
class Resource
|
|
@@ -149,17 +166,31 @@ module ActionFigure
|
|
|
149
166
|
def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
150
167
|
def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
|
|
151
168
|
def Accepted: (?resource: untyped?, ?meta: untyped?) -> ActionFigure::response
|
|
152
|
-
def
|
|
153
|
-
def NotFound: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
154
|
-
def Forbidden: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
155
|
-
def Conflict: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
156
|
-
def PaymentRequired: (errors: ActionFigure::error_hash) -> ActionFigure::response
|
|
169
|
+
def error_response: (errors: ActionFigure::error_hash, status: Symbol) -> ActionFigure::response
|
|
157
170
|
end
|
|
158
171
|
end
|
|
159
172
|
|
|
160
173
|
module Testing
|
|
161
|
-
#
|
|
174
|
+
# Success-only statuses (Ok, Created, Accepted, NoContent).
|
|
175
|
+
SUCCESS_STATUSES: Hash[Symbol, Symbol]
|
|
176
|
+
|
|
177
|
+
# Live map of status-helper names to status symbols: success statuses plus
|
|
178
|
+
# every registered error status.
|
|
179
|
+
def self.statuses: () -> Hash[Symbol, Symbol]
|
|
180
|
+
|
|
181
|
+
# Resolves an action class's validation contract (RSpec adapter helper).
|
|
182
|
+
def self.contract_for: (untyped action_class) -> untyped
|
|
183
|
+
|
|
184
|
+
# Defines assert_/refute_ and be_ helpers for a dynamically registered status.
|
|
185
|
+
def self.define_error_helper: (Symbol name, Symbol status) -> void
|
|
186
|
+
|
|
187
|
+
# Minitest assertions for action response results.
|
|
188
|
+
#
|
|
189
|
+
# assert_* / refute_* status helpers are generated from Testing.statuses at
|
|
190
|
+
# load time; they are declared explicitly here so the signatures stay visible.
|
|
162
191
|
module Minitest
|
|
192
|
+
def self.define_status_assertions: (Symbol name, Symbol status) -> void
|
|
193
|
+
|
|
163
194
|
def assert_Ok: (ActionFigure::response result, ?String? msg) -> void
|
|
164
195
|
def assert_Created: (ActionFigure::response result, ?String? msg) -> void
|
|
165
196
|
def assert_Accepted: (ActionFigure::response result, ?String? msg) -> void
|
|
@@ -169,11 +200,35 @@ module ActionFigure
|
|
|
169
200
|
def assert_Forbidden: (ActionFigure::response result, ?String? msg) -> void
|
|
170
201
|
def assert_Conflict: (ActionFigure::response result, ?String? msg) -> void
|
|
171
202
|
def assert_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void
|
|
203
|
+
def assert_Gone: (ActionFigure::response result, ?String? msg) -> void
|
|
204
|
+
def assert_Locked: (ActionFigure::response result, ?String? msg) -> void
|
|
205
|
+
def assert_UnavailableForLegalReasons: (ActionFigure::response result, ?String? msg) -> void
|
|
206
|
+
|
|
207
|
+
def refute_Ok: (ActionFigure::response result, ?String? msg) -> void
|
|
208
|
+
def refute_Created: (ActionFigure::response result, ?String? msg) -> void
|
|
209
|
+
def refute_Accepted: (ActionFigure::response result, ?String? msg) -> void
|
|
210
|
+
def refute_NoContent: (ActionFigure::response result, ?String? msg) -> void
|
|
211
|
+
def refute_UnprocessableContent: (ActionFigure::response result, ?String? msg) -> void
|
|
212
|
+
def refute_NotFound: (ActionFigure::response result, ?String? msg) -> void
|
|
213
|
+
def refute_Forbidden: (ActionFigure::response result, ?String? msg) -> void
|
|
214
|
+
def refute_Conflict: (ActionFigure::response result, ?String? msg) -> void
|
|
215
|
+
def refute_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void
|
|
216
|
+
def refute_Gone: (ActionFigure::response result, ?String? msg) -> void
|
|
217
|
+
def refute_Locked: (ActionFigure::response result, ?String? msg) -> void
|
|
218
|
+
def refute_UnavailableForLegalReasons: (ActionFigure::response result, ?String? msg) -> void
|
|
219
|
+
|
|
220
|
+
def assert_valid_params: (untyped action_class, untyped params, ?String? msg) -> void
|
|
221
|
+
def assert_invalid_params: (untyped action_class, untyped params, ?on: Symbol?, ?msg: String?) -> void
|
|
222
|
+
|
|
223
|
+
def assert_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void
|
|
224
|
+
def refute_action_json: (ActionFigure::response result, Hash[Symbol, untyped] fragment, ?String? msg) -> void
|
|
172
225
|
end
|
|
173
226
|
|
|
174
|
-
# RSpec custom matchers (be_Ok,
|
|
227
|
+
# RSpec custom matchers: status matchers (be_Ok, ...), have_action_json,
|
|
228
|
+
# and the contract matchers accept_params / reject_params are registered
|
|
229
|
+
# globally via RSpec::Matchers.define and so are not module methods.
|
|
175
230
|
module RSpec
|
|
176
|
-
|
|
231
|
+
def self.define_status_matcher: (Symbol name, Symbol status) -> void
|
|
177
232
|
end
|
|
178
233
|
end
|
|
179
234
|
end
|