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.
@@ -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 UnprocessableContent(errors:)
29
- { json: { data: nil, errors: errors, status: "error" }, status: :unprocessable_content }
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 assert_Ok(result, msg = nil)
14
- assert_status(:ok, result, msg)
15
- end
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
- def assert_Created(result, msg = nil)
18
- assert_status(:created, result, msg)
24
+ define_method(:"refute_#{name}") do |result, msg = nil|
25
+ refute_status(status, result, msg)
26
+ end
19
27
  end
20
28
 
21
- def assert_Accepted(result, msg = nil)
22
- assert_status(:accepted, result, msg)
23
- end
29
+ Testing.statuses.each { |name, status| define_status_assertions(name, status) }
24
30
 
25
- def assert_NoContent(result, msg = nil)
26
- assert_status(:no_content, result, msg)
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 assert_UnprocessableContent(result, msg = nil)
30
- assert_status(:unprocessable_content, result, msg)
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
- def assert_NotFound(result, msg = nil)
34
- assert_status(:not_found, result, msg)
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 assert_Forbidden(result, msg = nil)
38
- assert_status(:forbidden, result, msg)
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
- def assert_Conflict(result, msg = nil)
42
- assert_status(:conflict, result, msg)
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 assert_PaymentRequired(result, msg = nil)
46
- assert_status(:payment_required, result, msg)
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
- private
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
- MATCHERS = {
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionFigure
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.0"
5
5
  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 ||= Concurrent::Map.new
82
+ @format_modules
82
83
  end
83
84
  private_class_method :format_modules
84
85
  end
@@ -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 UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
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 UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
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 UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
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 UnprocessableContent: (errors: ActionFigure::error_hash) -> ActionFigure::response
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
- # Minitest assertions for action response results
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, be_Created, etc.)
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
- MATCHERS: Hash[Symbol, Symbol]
231
+ def self.define_status_matcher: (Symbol name, Symbol status) -> void
177
232
  end
178
233
  end
179
234
  end