action_figure 0.5.0 → 0.6.2

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.
@@ -6,13 +6,11 @@ module ActionFigure
6
6
  # Simple resource serialization
7
7
  class Resource
8
8
  def self.serialize(resource)
9
- if resource.is_a?(Hash)
10
- resource
11
- elsif resource.respond_to?(:attributes)
9
+ if resource.respond_to?(:attributes)
12
10
  serialize_one(resource)
13
- elsif resource.respond_to?(:each)
11
+ elsif !resource.is_a?(Hash) && resource.respond_to?(:each)
14
12
  resource.map { |r| serialize(r) }
15
- else # rubocop:disable Lint/DuplicateBranch
13
+ else
16
14
  resource
17
15
  end
18
16
  end
@@ -20,7 +18,7 @@ module ActionFigure
20
18
  def self.serialize_one(resource)
21
19
  {
22
20
  type: resource.class.model_name.element,
23
- id: resource.id.to_s,
21
+ id: resource.id&.to_s,
24
22
  attributes: resource.attributes.except("id")
25
23
  }
26
24
  end
@@ -38,17 +38,30 @@ module ActionFigure
38
38
  { json: { errors: convert_errors(errors, "403") }, status: :forbidden }
39
39
  end
40
40
 
41
+ def Conflict(errors:)
42
+ { json: { errors: convert_errors(errors, "409") }, status: :conflict }
43
+ end
44
+
45
+ def PaymentRequired(errors:)
46
+ { json: { errors: convert_errors(errors, "402") }, status: :payment_required }
47
+ end
48
+
41
49
  private
42
50
 
43
- def convert_errors(errors, status)
51
+ def convert_errors(errors, status, prefix = "/data/attributes")
44
52
  errors.flat_map do |field, messages|
45
- pointer = field.to_sym == :base ? "/data" : "/data/attributes/#{field}"
46
- messages.map do |message|
47
- {
48
- status: status,
49
- detail: message,
50
- source: { pointer: pointer }
51
- }
53
+ pointer = field.to_sym == :base ? "/data" : "#{prefix}/#{field}"
54
+
55
+ if messages.is_a?(Hash)
56
+ convert_errors(messages, status, pointer)
57
+ else
58
+ messages.map do |message|
59
+ {
60
+ status: status,
61
+ detail: message,
62
+ source: { pointer: pointer }
63
+ }
64
+ end
52
65
  end
53
66
  end
54
67
  end
@@ -36,6 +36,14 @@ module ActionFigure
36
36
  def Forbidden(errors:)
37
37
  { json: { data: nil, errors: errors, status: "error" }, status: :forbidden }
38
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 }
46
+ end
39
47
  end
40
48
  end
41
49
  end
@@ -38,6 +38,14 @@ module ActionFigure
38
38
  assert_status(:forbidden, result, msg)
39
39
  end
40
40
 
41
+ def assert_Conflict(result, msg = nil)
42
+ assert_status(:conflict, result, msg)
43
+ end
44
+
45
+ def assert_PaymentRequired(result, msg = nil)
46
+ assert_status(:payment_required, result, msg)
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def assert_status(expected, result, msg)
@@ -13,6 +13,7 @@ module ActionFigure
13
13
  # RSpec.describe Users::Create do
14
14
  # it "returns ok" do
15
15
  # expect(Users::Create.call(params: ...)).to be_Ok
16
+ # expect(Users::Create.call(params: ...)).to have_action_json(status: "success")
16
17
  # end
17
18
  # end
18
19
  module RSpec
@@ -23,7 +24,9 @@ module ActionFigure
23
24
  NoContent: :no_content,
24
25
  UnprocessableContent: :unprocessable_content,
25
26
  NotFound: :not_found,
26
- Forbidden: :forbidden
27
+ Forbidden: :forbidden,
28
+ Conflict: :conflict,
29
+ PaymentRequired: :payment_required
27
30
  }.freeze
28
31
 
29
32
  MATCHERS.each do |name, status|
@@ -32,11 +35,38 @@ module ActionFigure
32
35
  failure_message do |result|
33
36
  "expected result status to be #{status.inspect}, but got #{result[:status].inspect}"
34
37
  end
35
- failure_message_when_negated do |_result|
36
- "expected result status not to be #{status.inspect}"
38
+ failure_message_when_negated do
39
+ "expected result not to have status #{status.inspect}"
37
40
  end
38
41
  end
39
42
  end
43
+
44
+ # Asserts against +result[:json]+ using +a_hash_including+ (nested matchers allowed).
45
+ ::RSpec::Matchers.define :have_action_json do |expected_fragment|
46
+ include ::RSpec::Matchers
47
+
48
+ match do |result|
49
+ @inner_matcher ||= a_hash_including(expected_fragment)
50
+ next false unless result.is_a?(Hash) && result.key?(:json)
51
+
52
+ @inner_matcher.matches?(result[:json])
53
+ end
54
+
55
+ failure_message do |result|
56
+ if !result.is_a?(Hash)
57
+ "expected an ActionFigure result hash, got #{result.inspect}"
58
+ elsif !result.key?(:json)
59
+ "expected #{result.inspect} to include key :json (ActionFigure render hash)"
60
+ else
61
+ "expected result[:json] to #{@inner_matcher.description}"
62
+ end
63
+ end
64
+
65
+ failure_message_when_negated do |result|
66
+ @inner_matcher ||= a_hash_including(expected_fragment)
67
+ "#{result.inspect} was expected not to match #{@inner_matcher.description}"
68
+ end
69
+ end
40
70
  end
41
71
  end
42
72
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionFigure
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.2"
5
5
  end
data/lib/action_figure.rb CHANGED
@@ -9,13 +9,24 @@ require_relative "action_figure/formatters/jsend"
9
9
  require_relative "action_figure/formatters/json_api"
10
10
  require_relative "action_figure/formatters/default"
11
11
  require_relative "action_figure/formatters/wrapped"
12
- require "concurrent/map"
13
12
 
14
13
  # ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions.
15
14
  module ActionFigure
16
15
  extend Configuration
17
16
  extend FormatRegistry
18
17
 
18
+ class IndeterminateEntryPointError < StandardError; end
19
+
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
+ # Raised when an action class defines +initialize+. ActionFigure builds instances with
26
+ # +new+ and passes no constructor arguments; use keyword arguments on the entry method
27
+ # or class-level state instead of custom initializers.
28
+ class InitializationNotSupportedError < StandardError; end
29
+
19
30
  register_formatter(jsend: Formatters::Jsend)
20
31
  register_formatter(jsonapi: Formatters::JsonApi)
21
32
  register_formatter(default: Formatters::Default)
@@ -1,4 +1,179 @@
1
+ # Type definitions for the ActionFigure gem
2
+
3
+ # Response hash returned by formatter methods
4
+ type ActionFigure::response = { json: Hash[Symbol, untyped], status: Symbol } | { status: Symbol }
5
+
6
+ # Error hash mapping field names to arrays of error messages
7
+ type ActionFigure::error_hash = Hash[Symbol, Array[String]]
8
+
1
9
  module ActionFigure
2
10
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
11
+
12
+ class IndeterminateEntryPointError < StandardError
13
+ end
14
+
15
+ # Deprecated alias for IndeterminateEntryPointError (misspelled constant from <= 0.6.0).
16
+ IndeterminantEntryPointError: Class
17
+
18
+ class InitializationNotSupportedError < StandardError
19
+ end
20
+
21
+ extend Configuration
22
+ extend FormatRegistry
23
+
24
+ def self.[]: (?Symbol format) -> Module
25
+ def self.included: (Module base) -> void
26
+ def self.register_formatter: (**Module formatters) -> void
27
+ def self.clear_format_module_cache: (Symbol name) -> void
28
+
29
+ # Provides global configuration via ActionFigure.configure
30
+ module Configuration
31
+ class Settings
32
+ attr_accessor format: Symbol
33
+ attr_accessor whiny_extra_params: bool
34
+ attr_accessor api_version: untyped
35
+ attr_accessor activesupport_notifications: bool
36
+
37
+ def initialize: () -> void
38
+ def configure: () { (Settings) -> void } -> void
39
+ def register: (**Module formatters) -> void
40
+ end
41
+
42
+ def configure: () { (Settings) -> void } -> void
43
+ def configuration: () -> Settings
44
+ end
45
+
46
+ # Provides formatter registration and lookup
47
+ module FormatRegistry
48
+ class Formats
49
+ def initialize: () -> void
50
+ def register_formatter: (**Module formatters) -> void
51
+ def fetch: (Symbol name) -> Module
52
+ end
53
+
54
+ def register_formatter: (**Module formatters) -> void
55
+ def fetch: (Symbol name) -> Module
56
+ end
57
+
58
+ # Base module for response formatters
59
+ module Formatter
60
+ REQUIRED_METHODS: Array[Symbol]
61
+
62
+ def NoContent: () -> ActionFigure::response
63
+ end
64
+
65
+ # Validation pipeline and DSL mixed into action classes
66
+ module Core
67
+ # Cross-parameter rule helpers for dry-validation contracts
68
+ module CrossParamRuleHelpers
69
+ def exclusive_rule: (*Symbol fields, String message) -> void
70
+ def any_rule: (*Symbol fields, String message) -> void
71
+ def one_rule: (*Symbol fields, String message) -> void
72
+ def all_rule: (*Symbol fields, String message) -> void
73
+ end
74
+
75
+ # Class-level DSL extended into action classes
76
+ module ClassMethods
77
+ def params_schema: () { () -> void } -> void
78
+ def rules: () { () -> void } -> void
79
+ def entry_point: (Symbol name) -> void
80
+ def entry_point_name: () -> Symbol?
81
+ def api_version: (?untyped value) -> untyped
82
+ def contract: () -> untyped
83
+ end
84
+
85
+ def entry_point_name: () -> Symbol?
86
+ def contract: () -> untyped
87
+ def validated_call: (**untyped kwargs) -> ActionFigure::response
88
+
89
+ # ActiveSupport::Notifications instrumentation
90
+ module Notifications
91
+ private
92
+
93
+ def notify: () { () -> ActionFigure::response } -> ActionFigure::response
94
+ end
95
+ end
96
+
97
+ module Formatters
98
+ # Rails-style responses with { data: } envelope
99
+ module Default
100
+ include ActionFigure::Formatter
101
+
102
+ def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
103
+ def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
104
+ 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
110
+ end
111
+
112
+ # JSend-formatted responses
113
+ module Jsend
114
+ include ActionFigure::Formatter
115
+
116
+ def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
117
+ def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
118
+ 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
124
+ end
125
+
126
+ # JSON:API-formatted responses
127
+ module JsonApi
128
+ include ActionFigure::Formatter
129
+
130
+ def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
131
+ def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
132
+ 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
138
+
139
+ # Simple resource serialization for JSON:API
140
+ class Resource
141
+ def self.serialize: (untyped resource) -> untyped
142
+ end
143
+ end
144
+
145
+ # Uniform { data:, errors:, status: } envelope
146
+ module Wrapped
147
+ include ActionFigure::Formatter
148
+
149
+ def Ok: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
150
+ def Created: (resource: untyped, ?meta: untyped?) -> ActionFigure::response
151
+ 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
157
+ end
158
+ end
159
+
160
+ module Testing
161
+ # Minitest assertions for action response results
162
+ module Minitest
163
+ def assert_Ok: (ActionFigure::response result, ?String? msg) -> void
164
+ def assert_Created: (ActionFigure::response result, ?String? msg) -> void
165
+ def assert_Accepted: (ActionFigure::response result, ?String? msg) -> void
166
+ def assert_NoContent: (ActionFigure::response result, ?String? msg) -> void
167
+ def assert_UnprocessableContent: (ActionFigure::response result, ?String? msg) -> void
168
+ def assert_NotFound: (ActionFigure::response result, ?String? msg) -> void
169
+ def assert_Forbidden: (ActionFigure::response result, ?String? msg) -> void
170
+ def assert_Conflict: (ActionFigure::response result, ?String? msg) -> void
171
+ def assert_PaymentRequired: (ActionFigure::response result, ?String? msg) -> void
172
+ end
173
+
174
+ # RSpec custom matchers (be_Ok, be_Created, etc.)
175
+ module RSpec
176
+ MATCHERS: Hash[Symbol, Symbol]
177
+ end
178
+ end
4
179
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_figure
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: dry-validation
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -29,6 +43,7 @@ executables: []
29
43
  extensions: []
30
44
  extra_rdoc_files: []
31
45
  files:
46
+ - CHANGELOG.md
32
47
  - LICENSE.txt
33
48
  - README.md
34
49
  - Rakefile
@@ -38,6 +53,7 @@ files:
38
53
  - docs/custom-formatters.md
39
54
  - docs/integration-patterns.md
40
55
  - docs/response-formatters.md
56
+ - docs/status-codes.md
41
57
  - docs/testing.md
42
58
  - docs/validation.md
43
59
  - lib/action_figure.rb
@@ -60,6 +76,7 @@ licenses:
60
76
  metadata:
61
77
  homepage_uri: https://github.com/phaedryx/action_figure
62
78
  source_code_uri: https://github.com/phaedryx/action_figure
79
+ changelog_uri: https://github.com/phaedryx/action_figure/blob/main/CHANGELOG.md
63
80
  rubygems_mfa_required: 'true'
64
81
  rdoc_options: []
65
82
  require_paths:
@@ -75,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
92
  - !ruby/object:Gem::Version
76
93
  version: '0'
77
94
  requirements: []
78
- rubygems_version: 4.0.8
95
+ rubygems_version: 4.0.3
79
96
  specification_version: 4
80
97
  summary: Fully-articulated controller actions
81
98
  test_files: []