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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionFigure
4
- VERSION = "0.1.0"
4
+ VERSION = "0.6.0"
5
5
  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