action_figure 0.1.0 → 0.5.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,180 @@
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, call.
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
+ @params_schema_block = block
47
+ @contract = nil
48
+ end
49
+
50
+ def rules(&block)
51
+ raise ArgumentError, "rules requires params_schema to be defined" unless @params_schema_block
52
+
53
+ @rules_block = block
54
+ @contract = nil
55
+ end
56
+
57
+ # Declares an alternative entry point method name (e.g. +entry_point :search+).
58
+ # May only be called once per class. Inheritance of action classes is not supported —
59
+ # +params_schema+, +rules+, and +entry_point+ are not inherited by subclasses.
60
+ def entry_point(name)
61
+ if @entry_point_name
62
+ raise ArgumentError,
63
+ "entry_point already defined as '#{@entry_point_name}' — " \
64
+ "each action class may declare only one entry point"
65
+ end
66
+
67
+ @entry_point_name = name
68
+ singleton_class.define_method(name) do |**kwargs|
69
+ notify { new.validated_call(**kwargs) }
70
+ end
71
+ end
72
+
73
+ def entry_point_name
74
+ @entry_point_name
75
+ end
76
+
77
+ def api_version(value = :_unset)
78
+ value == :_unset ? @api_version : (@api_version = value)
79
+ end
80
+
81
+ def call(**)
82
+ if @entry_point_name
83
+ raise NoMethodError, "undefined method 'call' for #{self} (use '#{@entry_point_name}' instead)"
84
+ end
85
+
86
+ notify { new.validated_call(**) }
87
+ end
88
+
89
+ def contract
90
+ return nil unless @params_schema_block
91
+
92
+ @contract ||= build_contract
93
+ end
94
+
95
+ private
96
+
97
+ # no-op when notifications aren't turned on
98
+ def notify
99
+ yield
100
+ end
101
+
102
+ def build_contract
103
+ schema_block = @params_schema_block
104
+ rules_block = @rules_block
105
+
106
+ contract_class = Class.new(Dry::Validation::Contract) do
107
+ extend ActionFigure::Core::CrossParamRuleHelpers
108
+
109
+ params(&schema_block)
110
+ class_eval(&rules_block) if rules_block
111
+ end
112
+
113
+ contract_class.new
114
+ end
115
+ end
116
+
117
+ def entry_point_name
118
+ self.class.entry_point_name || :call
119
+ end
120
+
121
+ def contract
122
+ self.class.contract
123
+ end
124
+
125
+ def validated_call(**kwargs)
126
+ raise ArgumentError, "params: passed but no params_schema defined" if kwargs.key?(:params) && !contract
127
+
128
+ if kwargs.key?(:params)
129
+ call_with_params(**kwargs)
130
+ else
131
+ call_without_params(**kwargs)
132
+ end
133
+ end
134
+
135
+ # Overrides ClassMethods#notify with ActiveSupport::Notifications when available.
136
+ # Extended onto action classes at include-time so the check happens once, not per call.
137
+ module Notifications
138
+ private
139
+
140
+ def notify
141
+ payload = { action: name }
142
+ ActiveSupport::Notifications.instrument("process.action_figure", payload) do
143
+ result = yield
144
+ payload[:status] = result[:status]
145
+ result
146
+ end
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def call_with_params(**kwargs)
153
+ raw_params = kwargs[:params]
154
+ raw_params = raw_params.to_unsafe_h if raw_params.respond_to?(:to_unsafe_h)
155
+
156
+ result = contract.call(raw_params)
157
+
158
+ return UnprocessableContent(errors: result.errors.to_h) if result.failure?
159
+
160
+ extra_params_error = check_extra_params(raw_params, result)
161
+ return extra_params_error if extra_params_error
162
+
163
+ public_send(entry_point_name, **kwargs, params: result.to_h)
164
+ end
165
+
166
+ def check_extra_params(raw_params, result)
167
+ return unless ActionFigure.configuration.whiny_extra_params
168
+
169
+ extra_keys = raw_params.keys.map(&:to_sym) - result.to_h.keys
170
+ return if extra_keys.empty?
171
+
172
+ errors = extra_keys.to_h { |k| [k, ["is not allowed"]] }
173
+ UnprocessableContent(errors: errors)
174
+ end
175
+
176
+ def call_without_params(**)
177
+ public_send(entry_point_name, **)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionFigure
4
+ # Provides formatter registration and lookup for ActionFigure.
5
+ module FormatRegistry
6
+ # Stores the mapping of format names to formatter modules.
7
+ class Formats
8
+ def initialize
9
+ @formats = {}
10
+ end
11
+
12
+ def register_formatter(**formatters)
13
+ @formats.merge!(formatters)
14
+ end
15
+
16
+ def fetch(name)
17
+ @formats.fetch(name) do
18
+ raise ArgumentError,
19
+ "Unknown formatter: #{name}. Register it with ActionFigure.register_formatter(#{name}: MyFormatter)."
20
+ end
21
+ end
22
+ end
23
+
24
+ def register_formatter(**formatters)
25
+ format_registry.register_formatter(**formatters)
26
+ end
27
+
28
+ def fetch(name)
29
+ format_registry.fetch(name)
30
+ end
31
+
32
+ private
33
+
34
+ def format_registry
35
+ @format_registry ||= Formats.new
36
+ end
37
+ end
38
+ 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].freeze
9
+
10
+ def NoContent
11
+ { status: :no_content }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionFigure
4
+ module Formatters
5
+ # Implements Rails-style response helpers for use in action classes.
6
+ # Resource is the top-level JSON on success; errors live under an "errors" key on failure.
7
+ module Default
8
+ include ActionFigure::Formatter
9
+
10
+ def Ok(resource:, meta: nil)
11
+ body = meta ? { data: resource, meta: meta } : resource
12
+ { json: body, status: :ok }
13
+ end
14
+
15
+ def Created(resource:, meta: nil)
16
+ body = meta ? { data: resource, meta: meta } : resource
17
+ { json: body, status: :created }
18
+ end
19
+
20
+ def Accepted(resource: nil, meta: nil)
21
+ body = resource.nil? ? {} : resource
22
+ body = { data: body, meta: meta } if meta
23
+ { json: body, status: :accepted }
24
+ end
25
+
26
+ def UnprocessableContent(errors:)
27
+ { json: { errors: errors }, status: :unprocessable_content }
28
+ end
29
+
30
+ def NotFound(errors:)
31
+ { json: { errors: errors }, status: :not_found }
32
+ end
33
+
34
+ def Forbidden(errors:)
35
+ { json: { errors: errors }, status: :forbidden }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
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
+ end
40
+ end
41
+ end
@@ -0,0 +1,32 @@
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.is_a?(Hash)
10
+ resource
11
+ elsif resource.respond_to?(:attributes)
12
+ serialize_one(resource)
13
+ elsif resource.respond_to?(:each)
14
+ resource.map { |r| serialize(r) }
15
+ else # rubocop:disable Lint/DuplicateBranch
16
+ resource
17
+ end
18
+ end
19
+
20
+ def self.serialize_one(resource)
21
+ {
22
+ type: resource.class.model_name.element,
23
+ id: resource.id.to_s,
24
+ attributes: resource.attributes.except("id")
25
+ }
26
+ end
27
+
28
+ private_class_method :serialize_one
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
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
+ private
42
+
43
+ def convert_errors(errors, status)
44
+ 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
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
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
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
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
+ private
42
+
43
+ def assert_status(expected, result, msg)
44
+ actual = result[:status]
45
+ message = msg || "Expected result status to be #{expected.inspect}, but got #{actual.inspect}"
46
+ assert actual == expected, message
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,42 @@
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
+ }.freeze
28
+
29
+ MATCHERS.each do |name, status|
30
+ ::RSpec::Matchers.define :"be_#{name}" do
31
+ match { |result| result[:status] == status }
32
+ failure_message do |result|
33
+ "expected result status to be #{status.inspect}, but got #{result[:status].inspect}"
34
+ end
35
+ failure_message_when_negated do |_result|
36
+ "expected result status not to be #{status.inspect}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ 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.5.0"
5
5
  end
data/lib/action_figure.rb CHANGED
@@ -1,6 +1,73 @@
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"
12
+ require "concurrent/map"
4
13
 
14
+ # ActionFigure provides explicit, purpose-driven operation classes for Rails controller actions.
5
15
  module ActionFigure
16
+ extend Configuration
17
+ extend FormatRegistry
18
+
19
+ register_formatter(jsend: Formatters::Jsend)
20
+ register_formatter(jsonapi: Formatters::JsonApi)
21
+ register_formatter(default: Formatters::Default)
22
+ register_formatter(wrapped: Formatters::Wrapped)
23
+
24
+ def self.[](format = configuration.format)
25
+ format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) }
26
+ end
27
+
28
+ def self.included(base)
29
+ base.include(self[])
30
+ end
31
+
32
+ def self.register_formatter(**formatters)
33
+ formatters.each_value do |mod|
34
+ missing = Formatter::REQUIRED_METHODS.reject { |m| mod.method_defined?(m) }
35
+ raise ArgumentError, "#{mod} is missing formatter methods: #{missing.join(", ")}" if missing.any?
36
+ end
37
+ formatters.each_key { |name| clear_format_module_cache(name) }
38
+ super
39
+ end
40
+
41
+ def self.clear_format_module_cache(name)
42
+ format_modules.delete(name)
43
+ end
44
+
45
+ def self.build_format_module(name, formatter)
46
+ mod = new_format_module(formatter)
47
+ const_name = :"Format_#{name}"
48
+ remove_const(const_name) if const_defined?(const_name, false)
49
+ const_set(const_name, mod)
50
+ end
51
+ private_class_method :build_format_module
52
+
53
+ def self.new_format_module(formatter)
54
+ Module.new do
55
+ def self.included(base)
56
+ base.extend(ActionFigure::Core::ClassMethods)
57
+ return unless defined?(ActiveSupport::Notifications) &&
58
+ ActionFigure.configuration.activesupport_notifications
59
+
60
+ base.extend(ActionFigure::Core::Notifications)
61
+ end
62
+
63
+ include ActionFigure::Core
64
+ include formatter
65
+ end
66
+ end
67
+ private_class_method :new_format_module
68
+
69
+ def self.format_modules
70
+ @format_modules ||= Concurrent::Map.new
71
+ end
72
+ private_class_method :format_modules
6
73
  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.1.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley
@@ -23,8 +23,8 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.10'
26
- description: Replaces general service objects with classes specifically for use in
27
- Rails controller action methods
26
+ description: Replaces service objects with explicit, purpose-driven classes for Rails
27
+ controller actions
28
28
  executables: []
29
29
  extensions: []
30
30
  extra_rdoc_files: []
@@ -32,7 +32,26 @@ files:
32
32
  - LICENSE.txt
33
33
  - README.md
34
34
  - Rakefile
35
+ - docs/actions.md
36
+ - docs/activesupport-notifications.md
37
+ - docs/configuration.md
38
+ - docs/custom-formatters.md
39
+ - docs/integration-patterns.md
40
+ - docs/response-formatters.md
41
+ - docs/testing.md
42
+ - docs/validation.md
35
43
  - lib/action_figure.rb
44
+ - lib/action_figure/configuration.rb
45
+ - lib/action_figure/core.rb
46
+ - lib/action_figure/format_registry.rb
47
+ - lib/action_figure/formatter.rb
48
+ - lib/action_figure/formatters/default.rb
49
+ - lib/action_figure/formatters/jsend.rb
50
+ - lib/action_figure/formatters/json_api.rb
51
+ - lib/action_figure/formatters/json_api/resource.rb
52
+ - lib/action_figure/formatters/wrapped.rb
53
+ - lib/action_figure/testing/minitest.rb
54
+ - lib/action_figure/testing/rspec.rb
36
55
  - lib/action_figure/version.rb
37
56
  - sig/action_figure.rbs
38
57
  homepage: https://github.com/phaedryx/action_figure
@@ -41,6 +60,7 @@ licenses:
41
60
  metadata:
42
61
  homepage_uri: https://github.com/phaedryx/action_figure
43
62
  source_code_uri: https://github.com/phaedryx/action_figure
63
+ rubygems_mfa_required: 'true'
44
64
  rdoc_options: []
45
65
  require_paths:
46
66
  - lib
@@ -48,7 +68,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
48
68
  requirements:
49
69
  - - ">="
50
70
  - !ruby/object:Gem::Version
51
- version: 3.1.0
71
+ version: 3.2.0
52
72
  required_rubygems_version: !ruby/object:Gem::Requirement
53
73
  requirements:
54
74
  - - ">="
@@ -57,5 +77,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
77
  requirements: []
58
78
  rubygems_version: 4.0.8
59
79
  specification_version: 4
60
- summary: Explicit, purpose-driven operation classes for Rails controller actions
80
+ summary: Fully-articulated controller actions
61
81
  test_files: []