action_figure 0.1.0 → 0.6.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/LICENSE.txt +1 -1
- data/README.md +219 -16
- data/docs/actions.md +495 -0
- data/docs/activesupport-notifications.md +113 -0
- data/docs/configuration.md +88 -0
- data/docs/custom-formatters.md +185 -0
- data/docs/integration-patterns.md +331 -0
- data/docs/response-formatters.md +1088 -0
- data/docs/status-codes.md +35 -0
- data/docs/testing.md +272 -0
- data/docs/validation.md +294 -0
- data/lib/action_figure/configuration.rb +33 -0
- data/lib/action_figure/core.rb +200 -0
- data/lib/action_figure/format_registry.rb +40 -0
- data/lib/action_figure/formatter.rb +14 -0
- data/lib/action_figure/formatters/default.rb +49 -0
- data/lib/action_figure/formatters/jsend.rb +49 -0
- data/lib/action_figure/formatters/json_api/resource.rb +30 -0
- data/lib/action_figure/formatters/json_api.rb +65 -0
- data/lib/action_figure/formatters/wrapped.rb +49 -0
- data/lib/action_figure/testing/minitest.rb +58 -0
- data/lib/action_figure/testing/rspec.rb +44 -0
- data/lib/action_figure/version.rb +1 -1
- data/lib/action_figure.rb +68 -0
- data/sig/action_figure.rbs +157 -1
- metadata +26 -5
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
# Provides the validation pipeline and DSL mixed into action classes via ActionFigure.[].
|
|
7
|
+
module Core
|
|
8
|
+
# Extended into dry-validation contract classes to provide cross-param rule helpers.
|
|
9
|
+
module CrossParamRuleHelpers
|
|
10
|
+
def exclusive_rule(*fields, message)
|
|
11
|
+
rule(*fields) do
|
|
12
|
+
present = fields.select { |f| values.key?(f) && !values[f].nil? }
|
|
13
|
+
present.each { |f| key(f).failure(message) } if present.size > 1
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def any_rule(*fields, message)
|
|
18
|
+
rule(*fields) do
|
|
19
|
+
present = fields.select { |f| values.key?(f) && !values[f].nil? }
|
|
20
|
+
fields.each { |f| key(f).failure(message) } if present.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def one_rule(*fields, message)
|
|
25
|
+
rule(*fields) do
|
|
26
|
+
present = fields.select { |f| values.key?(f) && !values[f].nil? }
|
|
27
|
+
fields.each { |f| key(f).failure(message) } unless present.size == 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def all_rule(*fields, message)
|
|
32
|
+
rule(*fields) do
|
|
33
|
+
present = fields.select { |f| values.key?(f) && !values[f].nil? }
|
|
34
|
+
fields.each { |f| key(f).failure(message) } unless present.empty? || present.size == fields.size
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# DSL class methods extended into action classes: params_schema, rules, entry_point.
|
|
40
|
+
#
|
|
41
|
+
# Note: ActionFigure does not support class inheritance. +params_schema+, +rules+, and
|
|
42
|
+
# +entry_point+ store state in class-level instance variables that are not inherited by
|
|
43
|
+
# subclasses. Define each action class independently.
|
|
44
|
+
module ClassMethods
|
|
45
|
+
def params_schema(&block)
|
|
46
|
+
if @params_schema_block && @rules_block
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"params_schema already defined with rules — " \
|
|
49
|
+
"redefining it would silently drop the existing rules block"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@params_schema_block = block
|
|
53
|
+
@contract = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rules(&block)
|
|
57
|
+
raise ArgumentError, "rules requires params_schema to be defined" unless @params_schema_block
|
|
58
|
+
|
|
59
|
+
@rules_block = block
|
|
60
|
+
@contract = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Declares an alternative entry point method name (e.g. +entry_point :search+).
|
|
64
|
+
# May only be called once per class. Inheritance of action classes is not supported —
|
|
65
|
+
# +params_schema+, +rules+, and +entry_point+ are not inherited by subclasses.
|
|
66
|
+
def entry_point(name)
|
|
67
|
+
if @entry_point_name
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"entry_point already defined as '#{@entry_point_name}' — " \
|
|
70
|
+
"each action class may declare only one entry point"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@explicit_entry_point = true
|
|
74
|
+
@entry_point_name = name
|
|
75
|
+
singleton_class.define_method(name) do |**kwargs|
|
|
76
|
+
notify { new.validated_call(**kwargs) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def entry_point_name
|
|
81
|
+
@entry_point_name
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def api_version(value = :_unset)
|
|
85
|
+
value == :_unset ? @api_version : (@api_version = value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def contract
|
|
89
|
+
return nil unless @params_schema_block
|
|
90
|
+
|
|
91
|
+
@contract ||= build_contract
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# no-op when notifications aren't turned on
|
|
97
|
+
def notify
|
|
98
|
+
yield
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def method_added(name)
|
|
102
|
+
return if @explicit_entry_point
|
|
103
|
+
return unless public_method_defined?(name)
|
|
104
|
+
return unless instance_method(name).owner == self
|
|
105
|
+
|
|
106
|
+
if @entry_point_name
|
|
107
|
+
raise IndeterminantEntryPointError,
|
|
108
|
+
"Multiple public methods defined in #{self}: " \
|
|
109
|
+
":#{@entry_point_name} and :#{name}. " \
|
|
110
|
+
"Either make one private or declare " \
|
|
111
|
+
"`entry_point :#{@entry_point_name}` to disambiguate."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@entry_point_name = name
|
|
115
|
+
singleton_class.define_method(name) do |**kwargs|
|
|
116
|
+
notify { new.validated_call(**kwargs) }
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
super
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_contract
|
|
123
|
+
schema_block = @params_schema_block
|
|
124
|
+
rules_block = @rules_block
|
|
125
|
+
|
|
126
|
+
contract_class = Class.new(Dry::Validation::Contract) do
|
|
127
|
+
extend ActionFigure::Core::CrossParamRuleHelpers
|
|
128
|
+
|
|
129
|
+
params(&schema_block)
|
|
130
|
+
class_eval(&rules_block) if rules_block
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
contract_class.new
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def entry_point_name
|
|
138
|
+
self.class.entry_point_name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def contract
|
|
142
|
+
self.class.contract
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validated_call(**kwargs)
|
|
146
|
+
kwargs = normalize_params(kwargs)
|
|
147
|
+
|
|
148
|
+
if contract && kwargs.key?(:params)
|
|
149
|
+
validate_and_call(**kwargs)
|
|
150
|
+
else
|
|
151
|
+
public_send(entry_point_name, **kwargs)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Overrides ClassMethods#notify with ActiveSupport::Notifications when available.
|
|
156
|
+
# Extended onto action classes at include-time so the check happens once, not per call.
|
|
157
|
+
module Notifications
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def notify
|
|
161
|
+
payload = { action: name }
|
|
162
|
+
ActiveSupport::Notifications.instrument("process.action_figure", payload) do
|
|
163
|
+
result = yield
|
|
164
|
+
payload[:status] = result[:status]
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def normalize_params(kwargs)
|
|
173
|
+
raw = kwargs[:params]
|
|
174
|
+
return kwargs unless raw.respond_to?(:to_unsafe_h)
|
|
175
|
+
|
|
176
|
+
kwargs.merge(params: raw.to_unsafe_h)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_and_call(**kwargs)
|
|
180
|
+
result = contract.call(kwargs[:params])
|
|
181
|
+
|
|
182
|
+
return UnprocessableContent(errors: result.errors.to_h) if result.failure?
|
|
183
|
+
|
|
184
|
+
extra_params_error = check_extra_params(kwargs[:params], result)
|
|
185
|
+
return extra_params_error if extra_params_error
|
|
186
|
+
|
|
187
|
+
public_send(entry_point_name, **kwargs.except(:params), params: result.to_h)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def check_extra_params(raw_params, result)
|
|
191
|
+
return unless ActionFigure.configuration.whiny_extra_params
|
|
192
|
+
|
|
193
|
+
extra_keys = raw_params.keys.map(&:to_sym) - result.to_h.keys
|
|
194
|
+
return if extra_keys.empty?
|
|
195
|
+
|
|
196
|
+
errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
|
|
197
|
+
UnprocessableContent(errors: errors)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
# Provides formatter registration and lookup for ActionFigure.
|
|
7
|
+
module FormatRegistry
|
|
8
|
+
# Stores the mapping of format names to formatter modules.
|
|
9
|
+
class Formats
|
|
10
|
+
def initialize
|
|
11
|
+
@formats = Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register_formatter(**formatters)
|
|
15
|
+
formatters.each { |name, mod| @formats[name] = mod }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(name)
|
|
19
|
+
@formats.fetch(name) do
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"Unknown formatter: #{name}. Register it with ActionFigure.register_formatter(#{name}: MyFormatter)."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def register_formatter(**formatters)
|
|
27
|
+
format_registry.register_formatter(**formatters)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(name)
|
|
31
|
+
format_registry.fetch(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def format_registry
|
|
37
|
+
@format_registry ||= Formats.new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
# Base module for ActionFigure response formatters.
|
|
5
|
+
# Include this in your formatter module to get a NoContent default
|
|
6
|
+
# and to signal that your module implements the formatter interface.
|
|
7
|
+
module Formatter
|
|
8
|
+
REQUIRED_METHODS = %i[Ok Created Accepted UnprocessableContent NotFound Forbidden Conflict PaymentRequired].freeze
|
|
9
|
+
|
|
10
|
+
def NoContent
|
|
11
|
+
{ status: :no_content }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
module Formatters
|
|
5
|
+
# Implements Rails-style response helpers for use in action classes.
|
|
6
|
+
# Success responses use a { data: } envelope; errors live under an "errors" key on failure.
|
|
7
|
+
module Default
|
|
8
|
+
include ActionFigure::Formatter
|
|
9
|
+
|
|
10
|
+
def Ok(resource:, meta: nil)
|
|
11
|
+
body = { data: resource }
|
|
12
|
+
body[:meta] = meta if meta
|
|
13
|
+
{ json: body, status: :ok }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def Created(resource:, meta: nil)
|
|
17
|
+
body = { data: resource }
|
|
18
|
+
body[:meta] = meta if meta
|
|
19
|
+
{ json: body, status: :created }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def Accepted(resource: nil, meta: nil)
|
|
23
|
+
body = { data: resource }
|
|
24
|
+
body[:meta] = meta if meta
|
|
25
|
+
{ json: body, status: :accepted }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def UnprocessableContent(errors:)
|
|
29
|
+
{ json: { errors: errors }, status: :unprocessable_content }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def NotFound(errors:)
|
|
33
|
+
{ json: { errors: errors }, status: :not_found }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def Forbidden(errors:)
|
|
37
|
+
{ json: { errors: errors }, status: :forbidden }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def Conflict(errors:)
|
|
41
|
+
{ json: { errors: errors }, status: :conflict }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def PaymentRequired(errors:)
|
|
45
|
+
{ json: { errors: errors }, status: :payment_required }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
module Formatters
|
|
5
|
+
# Implements JSend response helpers for use in action classes.
|
|
6
|
+
module Jsend
|
|
7
|
+
include ActionFigure::Formatter
|
|
8
|
+
|
|
9
|
+
def Ok(resource:, meta: nil)
|
|
10
|
+
body = { status: "success", data: resource }
|
|
11
|
+
body[:meta] = meta if meta
|
|
12
|
+
{ json: body, status: :ok }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def Created(resource:, meta: nil)
|
|
16
|
+
body = { status: "success", data: resource }
|
|
17
|
+
body[:meta] = meta if meta
|
|
18
|
+
{ json: body, status: :created }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def Accepted(resource: nil, meta: nil)
|
|
22
|
+
body = { status: "success" }
|
|
23
|
+
body[:data] = resource unless resource.nil?
|
|
24
|
+
body[:meta] = meta if meta
|
|
25
|
+
{ json: body, status: :accepted }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def UnprocessableContent(errors:)
|
|
29
|
+
{ json: { status: "fail", data: errors }, status: :unprocessable_content }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def NotFound(errors:)
|
|
33
|
+
{ json: { status: "fail", data: errors }, status: :not_found }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def Forbidden(errors:)
|
|
37
|
+
{ json: { status: "fail", data: errors }, status: :forbidden }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def Conflict(errors:)
|
|
41
|
+
{ json: { status: "fail", data: errors }, status: :conflict }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def PaymentRequired(errors:)
|
|
45
|
+
{ json: { status: "fail", data: errors }, status: :payment_required }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
module Formatters
|
|
5
|
+
module JsonApi
|
|
6
|
+
# Simple resource serialization
|
|
7
|
+
class Resource
|
|
8
|
+
def self.serialize(resource)
|
|
9
|
+
if resource.respond_to?(:attributes)
|
|
10
|
+
serialize_one(resource)
|
|
11
|
+
elsif !resource.is_a?(Hash) && resource.respond_to?(:each)
|
|
12
|
+
resource.map { |r| serialize(r) }
|
|
13
|
+
else
|
|
14
|
+
resource
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.serialize_one(resource)
|
|
19
|
+
{
|
|
20
|
+
type: resource.class.model_name.element,
|
|
21
|
+
id: resource.id.to_s,
|
|
22
|
+
attributes: resource.attributes.except("id")
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method :serialize_one
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "json_api/resource"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
module Formatters
|
|
7
|
+
# Implements JSON:API response helpers for use in action classes.
|
|
8
|
+
module JsonApi
|
|
9
|
+
include ActionFigure::Formatter
|
|
10
|
+
|
|
11
|
+
def Ok(resource:, meta: nil)
|
|
12
|
+
body = { data: Resource.serialize(resource) }
|
|
13
|
+
body[:meta] = meta if meta
|
|
14
|
+
{ json: body, status: :ok }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def Created(resource:, meta: nil)
|
|
18
|
+
body = { data: Resource.serialize(resource) }
|
|
19
|
+
body[:meta] = meta if meta
|
|
20
|
+
{ json: body, status: :created }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def Accepted(resource: nil, meta: nil)
|
|
24
|
+
body = resource.nil? ? {} : { data: Resource.serialize(resource) }
|
|
25
|
+
body[:meta] = meta if meta
|
|
26
|
+
{ json: body, status: :accepted }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def UnprocessableContent(errors:)
|
|
30
|
+
{ json: { errors: convert_errors(errors, "422") }, status: :unprocessable_content }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def NotFound(errors:)
|
|
34
|
+
{ json: { errors: convert_errors(errors, "404") }, status: :not_found }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def Forbidden(errors:)
|
|
38
|
+
{ json: { errors: convert_errors(errors, "403") }, status: :forbidden }
|
|
39
|
+
end
|
|
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
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def convert_errors(errors, status)
|
|
52
|
+
errors.flat_map do |field, messages|
|
|
53
|
+
pointer = field.to_sym == :base ? "/data" : "/data/attributes/#{field}"
|
|
54
|
+
messages.map do |message|
|
|
55
|
+
{
|
|
56
|
+
status: status,
|
|
57
|
+
detail: message,
|
|
58
|
+
source: { pointer: pointer }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
module Formatters
|
|
5
|
+
# Implements uniform envelope response helpers for use in action classes.
|
|
6
|
+
# Every response uses the same { data:, errors:, status: } shape.
|
|
7
|
+
module Wrapped
|
|
8
|
+
include ActionFigure::Formatter
|
|
9
|
+
|
|
10
|
+
def Ok(resource:, meta: nil)
|
|
11
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
12
|
+
body[:meta] = meta if meta
|
|
13
|
+
{ json: body, status: :ok }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def Created(resource:, meta: nil)
|
|
17
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
18
|
+
body[:meta] = meta if meta
|
|
19
|
+
{ json: body, status: :created }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def Accepted(resource: nil, meta: nil)
|
|
23
|
+
body = { data: resource, errors: nil, status: "success" }
|
|
24
|
+
body[:meta] = meta if meta
|
|
25
|
+
{ json: body, status: :accepted }
|
|
26
|
+
end
|
|
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 }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionFigure
|
|
4
|
+
module Testing
|
|
5
|
+
# Minitest assertions for ActionFigure response results.
|
|
6
|
+
#
|
|
7
|
+
# Include in your test class to get assert_Ok, assert_Created, etc.:
|
|
8
|
+
#
|
|
9
|
+
# class Users::CreateTest < Minitest::Test
|
|
10
|
+
# include ActionFigure::Testing::Minitest
|
|
11
|
+
# end
|
|
12
|
+
module Minitest
|
|
13
|
+
def assert_Ok(result, msg = nil)
|
|
14
|
+
assert_status(:ok, result, msg)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assert_Created(result, msg = nil)
|
|
18
|
+
assert_status(:created, result, msg)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def assert_Accepted(result, msg = nil)
|
|
22
|
+
assert_status(:accepted, result, msg)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def assert_NoContent(result, msg = nil)
|
|
26
|
+
assert_status(:no_content, result, msg)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assert_UnprocessableContent(result, msg = nil)
|
|
30
|
+
assert_status(:unprocessable_content, result, msg)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def assert_NotFound(result, msg = nil)
|
|
34
|
+
assert_status(:not_found, result, msg)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def assert_Forbidden(result, msg = nil)
|
|
38
|
+
assert_status(:forbidden, result, msg)
|
|
39
|
+
end
|
|
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
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def assert_status(expected, result, msg)
|
|
52
|
+
actual = result[:status]
|
|
53
|
+
message = msg || "Expected result status to be #{expected.inspect}, but got #{actual.inspect}"
|
|
54
|
+
assert actual == expected, message
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/matchers"
|
|
4
|
+
|
|
5
|
+
module ActionFigure
|
|
6
|
+
module Testing
|
|
7
|
+
# RSpec custom matchers for ActionFigure response results.
|
|
8
|
+
#
|
|
9
|
+
# Require in your spec_helper.rb to get be_Ok, be_Created, etc.:
|
|
10
|
+
#
|
|
11
|
+
# require 'action_figure/testing/rspec'
|
|
12
|
+
#
|
|
13
|
+
# RSpec.describe Users::Create do
|
|
14
|
+
# it "returns ok" do
|
|
15
|
+
# expect(Users::Create.call(params: ...)).to be_Ok
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
module RSpec
|
|
19
|
+
MATCHERS = {
|
|
20
|
+
Ok: :ok,
|
|
21
|
+
Created: :created,
|
|
22
|
+
Accepted: :accepted,
|
|
23
|
+
NoContent: :no_content,
|
|
24
|
+
UnprocessableContent: :unprocessable_content,
|
|
25
|
+
NotFound: :not_found,
|
|
26
|
+
Forbidden: :forbidden,
|
|
27
|
+
Conflict: :conflict,
|
|
28
|
+
PaymentRequired: :payment_required
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
MATCHERS.each do |name, status|
|
|
32
|
+
::RSpec::Matchers.define :"be_#{name}" do
|
|
33
|
+
match { |result| result[:status] == status }
|
|
34
|
+
failure_message do |result|
|
|
35
|
+
"expected result status to be #{status.inspect}, but got #{result[:status].inspect}"
|
|
36
|
+
end
|
|
37
|
+
failure_message_when_negated do
|
|
38
|
+
"expected result not to have status #{status.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/action_figure.rb
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "action_figure/version"
|
|
4
|
+
require_relative "action_figure/configuration"
|
|
5
|
+
require_relative "action_figure/format_registry"
|
|
6
|
+
require_relative "action_figure/formatter"
|
|
7
|
+
require_relative "action_figure/core"
|
|
8
|
+
require_relative "action_figure/formatters/jsend"
|
|
9
|
+
require_relative "action_figure/formatters/json_api"
|
|
10
|
+
require_relative "action_figure/formatters/default"
|
|
11
|
+
require_relative "action_figure/formatters/wrapped"
|
|
4
12
|
|
|
13
|
+
# ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions.
|
|
5
14
|
module ActionFigure
|
|
15
|
+
extend Configuration
|
|
16
|
+
extend FormatRegistry
|
|
17
|
+
|
|
18
|
+
class IndeterminantEntryPointError < StandardError; end
|
|
19
|
+
|
|
20
|
+
register_formatter(jsend: Formatters::Jsend)
|
|
21
|
+
register_formatter(jsonapi: Formatters::JsonApi)
|
|
22
|
+
register_formatter(default: Formatters::Default)
|
|
23
|
+
register_formatter(wrapped: Formatters::Wrapped)
|
|
24
|
+
|
|
25
|
+
def self.[](format = configuration.format)
|
|
26
|
+
format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.included(base)
|
|
30
|
+
base.include(self[])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.register_formatter(**formatters)
|
|
34
|
+
formatters.each_value do |mod|
|
|
35
|
+
missing = Formatter::REQUIRED_METHODS.reject { |m| mod.method_defined?(m) }
|
|
36
|
+
raise ArgumentError, "#{mod} is missing formatter methods: #{missing.join(", ")}" if missing.any?
|
|
37
|
+
end
|
|
38
|
+
formatters.each_key { |name| clear_format_module_cache(name) }
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.clear_format_module_cache(name)
|
|
43
|
+
format_modules.delete(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.build_format_module(name, formatter)
|
|
47
|
+
mod = new_format_module(formatter)
|
|
48
|
+
const_name = :"Format_#{name}"
|
|
49
|
+
remove_const(const_name) if const_defined?(const_name, false)
|
|
50
|
+
const_set(const_name, mod)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :build_format_module
|
|
53
|
+
|
|
54
|
+
def self.new_format_module(formatter)
|
|
55
|
+
Module.new do
|
|
56
|
+
def self.included(base)
|
|
57
|
+
base.extend(ActionFigure::Core::ClassMethods)
|
|
58
|
+
return unless defined?(ActiveSupport::Notifications) &&
|
|
59
|
+
ActionFigure.configuration.activesupport_notifications
|
|
60
|
+
|
|
61
|
+
base.extend(ActionFigure::Core::Notifications)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
include ActionFigure::Core
|
|
65
|
+
include formatter
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_class_method :new_format_module
|
|
69
|
+
|
|
70
|
+
def self.format_modules
|
|
71
|
+
@format_modules ||= Concurrent::Map.new
|
|
72
|
+
end
|
|
73
|
+
private_class_method :format_modules
|
|
6
74
|
end
|