decent_exposure 2.3.3 → 3.0.0.beta1

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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new
5
+
6
+ task default: :spec
@@ -0,0 +1,30 @@
1
+ require File.expand_path("../lib/decent_exposure/version", __FILE__)
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "decent_exposure"
5
+ spec.version = DecentExposure::VERSION
6
+ spec.authors = ["Pavel Pravosud", "Stephen Caudill"]
7
+ spec.email = ["info@hashrocket.com"]
8
+ spec.summary = "A helper for creating declarative interfaces in controllers"
9
+ spec.description = %q{
10
+ DecentExposure helps you program to an interface, rather than an
11
+ implementation in your Rails controllers. The fact of the matter is that
12
+ sharing state via instance variables in controllers promotes close coupling
13
+ with views. DecentExposure gives you a declarative manner of exposing an
14
+ interface to the state that controllers contain and thereby decreasing
15
+ coupling and improving your testability and overall design.
16
+ }
17
+ spec.homepage = "https://github.com/hashrocket/decent_exposure"
18
+ spec.license = "MIT"
19
+ spec.files = `git ls-files -z`.split("\x0")
20
+ spec.test_files = spec.files.grep(/\Aspec\//)
21
+ spec.require_path = "lib"
22
+
23
+ spec.required_ruby_version = "~> 2.0"
24
+
25
+ spec.add_dependency "railties", "~> 5.x"
26
+ spec.add_dependency "activesupport", "~> 5.x"
27
+ spec.add_development_dependency "rspec-rails", "~> 3.0"
28
+ spec.add_development_dependency "rake", "~> 10.3"
29
+ spec.add_development_dependency "pry"
30
+ end
Binary file
Binary file
@@ -1,6 +1,15 @@
1
- require 'decent_exposure/expose'
2
- require 'decent_exposure/error'
1
+ require "decent_exposure/version"
2
+ require "active_support/all"
3
3
 
4
- ActiveSupport.on_load(:action_controller) do
5
- extend DecentExposure::Expose
4
+ module DecentExposure
5
+ autoload :Controller, "decent_exposure/controller"
6
+ autoload :Exposure, "decent_exposure/exposure"
7
+ autoload :Attribute, "decent_exposure/attribute"
8
+ autoload :Context, "decent_exposure/context"
9
+ autoload :Behavior, "decent_exposure/behavior"
10
+ autoload :Flow, "decent_exposure/flow"
11
+
12
+ ActiveSupport.on_load :action_controller do
13
+ include Controller
14
+ end
6
15
  end
@@ -0,0 +1,55 @@
1
+ module DecentExposure
2
+ class Attribute
3
+ attr_reader :name, :fetch, :ivar_name
4
+
5
+ # Public: Initialize an Attribute
6
+ #
7
+ # options - Hash of options for the Attribute
8
+ # :name - The String name of the Attribute instance
9
+ # :fetch - The Proc fetch to calculate
10
+ # the value of the Attribute instance.
11
+ # This is only called if the attribute's
12
+ # instance variable is not defined.
13
+ # :ivar_name - The String instance variable name that
14
+ # is associated with the attribute.
15
+ def initialize(options)
16
+ @name = options.fetch(:name)
17
+ @fetch = options.fetch(:fetch)
18
+ @ivar_name = options.fetch(:ivar_name)
19
+ end
20
+
21
+ # Public: The getter method for the Attribute.
22
+ #
23
+ # Returns the name of the Attribute as a Symbol.
24
+ def getter_method_name
25
+ name.to_sym
26
+ end
27
+
28
+ # Public: The setter method for the Attribute.
29
+ #
30
+ # Returns the name of the attribute as a Symbol with an appended '='.
31
+ def setter_method_name
32
+ "#{name}=".to_sym
33
+ end
34
+
35
+
36
+ # Public: Expose a getter and setter method for the Attribute
37
+ # on the passed in Controller class.
38
+ #
39
+ # klass - The Controller class where the Attribute getter and setter
40
+ # methods will be exposed.
41
+ def expose!(klass)
42
+ attribute = self
43
+
44
+ klass.instance_eval do
45
+ define_method attribute.getter_method_name do
46
+ Context.new(self, attribute).get
47
+ end
48
+
49
+ define_method attribute.setter_method_name do |value|
50
+ Context.new(self, attribute).set(value)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,100 @@
1
+ module DecentExposure
2
+ module Behavior
3
+ # Public: Fetches a scope.
4
+ #
5
+ # Finds an object. If it isn't found, the object gets instantiated.
6
+ #
7
+ # Returns the decorated object.
8
+ def fetch
9
+ instance = id ? find(id, computed_scope) : build(build_params, computed_scope)
10
+ decorate(instance)
11
+ end
12
+
13
+ # Public: Checks a params hash for an id attribute.
14
+ #
15
+ # Checks a hash of parameters for keys that represent an object's id.
16
+ #
17
+ # Returns the value of the id parameter, if it exists. Otherwise nil.
18
+ def id
19
+ params_id_key_candidates.each do |key|
20
+ value = params[key]
21
+ return value if value.present?
22
+ end
23
+
24
+ nil
25
+ end
26
+
27
+ # Public: An object query. Essentially, this method is designed to be
28
+ # overridden.
29
+ #
30
+ # model - The Class to be scoped or queried.
31
+ #
32
+ # Returns the object scope.
33
+ def scope(model)
34
+ model
35
+ end
36
+
37
+ # Public: Converts a name into a standard Class name.
38
+ #
39
+ # Examples
40
+ # 'egg_and_hams'.model # => EggAndHam
41
+ #
42
+ # Returns a standard Class name.
43
+ def model
44
+ name.to_s.classify.constantize
45
+ end
46
+
47
+ # Public: Find an object on the supplied scope.
48
+ #
49
+ # id - The Integer id attribute of the desired object
50
+ # scope - The collection that will be searched.
51
+ #
52
+ # Returns the found object.
53
+ def find(id, scope)
54
+ scope.find(id)
55
+ end
56
+
57
+ # Public: Builds a new object on the passed-in scope.
58
+ #
59
+ # params - A Hash of attributes for the object to-be built.
60
+ # scope - The collection that will be searched.
61
+ #
62
+ # Returns the new object.
63
+ def build(params, scope)
64
+ scope.new(params)
65
+ end
66
+
67
+ # Public: Returns a decorated object. This method is designed to be
68
+ # overridden.
69
+ #
70
+ # Returns the decorated object.
71
+ def decorate(instance)
72
+ instance
73
+ end
74
+
75
+ # Public: Get all the parameters of the current request.
76
+ #
77
+ # Returns the controller's parameters for the current request.
78
+ def build_params
79
+ if controller.respond_to?(params_method_name, true) && !get_request?
80
+ controller.send(params_method_name)
81
+ else
82
+ {}
83
+ end
84
+ end
85
+
86
+ protected
87
+
88
+ def params_id_key_candidates
89
+ [ "#{model_param_key}_id", "#{name}_id", "id" ].uniq
90
+ end
91
+
92
+ def model_param_key
93
+ model.name.underscore
94
+ end
95
+
96
+ def computed_scope
97
+ scope(model)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,61 @@
1
+ module DecentExposure
2
+ class Context
3
+ attr_reader :context, :attribute
4
+
5
+ # Public: Initialize a context.
6
+ #
7
+ # context - The Class where the attribute is defined.
8
+ # attribute - The attribute that will be accessed by a getter
9
+ # and setter.
10
+ def initialize(context, attribute)
11
+ @context, @attribute = context, attribute
12
+ end
13
+
14
+ # Public: Read an attribute on the context Class.
15
+ #
16
+ # Get an attribute's value. If the attribute's instance
17
+ # variable is not defined, it will create one,
18
+ # execute attribute#fetch, and assign the result
19
+ # to the instance variable.
20
+ #
21
+ # Returns the attribute's value.
22
+ def get
23
+ ivar_defined?? ivar_get : set(fetch_value)
24
+ end
25
+
26
+ # Public: Write to an attribute on the context Class.
27
+ #
28
+ # value - The value that will be set to the attribute's
29
+ # instance variable.
30
+ #
31
+ # Returns the attribute's value.
32
+ def set(value)
33
+ ivar_set(value)
34
+ end
35
+
36
+ private
37
+
38
+ delegate :instance_variable_set, :instance_variable_get,
39
+ :instance_variable_defined?, to: :context
40
+
41
+ def ivar_defined?
42
+ instance_variable_defined?(ivar_name)
43
+ end
44
+
45
+ def ivar_get
46
+ instance_variable_get(ivar_name)
47
+ end
48
+
49
+ def ivar_set(value)
50
+ instance_variable_set(ivar_name, value)
51
+ end
52
+
53
+ def ivar_name
54
+ "@#{attribute.ivar_name}"
55
+ end
56
+
57
+ def fetch_value
58
+ context.instance_exec(&attribute.fetch)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,53 @@
1
+ module DecentExposure
2
+ module Controller
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :exposure_configuration,
7
+ instance_accessor: false, instance_predicate: false
8
+ end
9
+
10
+ module ClassMethods
11
+ # Public: Exposes an attribute to a controller Class.
12
+ #
13
+ # *args - An Array of attributes for the new exposure. See
14
+ # Exposure#initialize for attribute details.
15
+ # block - If supplied, the exposed attribute method executes
16
+ # the Proc when accessed.
17
+ #
18
+ # Returns the helper methods that are now defined on the class
19
+ # where this method is included.
20
+ def expose(*args, &block)
21
+ Exposure.expose! self, *args, &block
22
+ end
23
+
24
+ # Public: Exposes an attribute to a controller Class.
25
+ # The exposed methods are then set to a before_action
26
+ # callback.
27
+ #
28
+ # name - The String name of the Exposure instance.
29
+ # *args - An Array of attributes for the new exposure. See
30
+ # Exposure#initialize for attribute details.
31
+ # block - If supplied, the exposed attribute method executes
32
+ # the Proc when accessed.
33
+ #
34
+ # Sets the exposed attribute to a before_action callback in the
35
+ # controller.
36
+ def expose!(name, *args, &block)
37
+ expose name, *args, &block
38
+ before_action name
39
+ end
40
+
41
+ # Public: Configures an Exposure instance for a controller Class.
42
+ #
43
+ # name - The String name of the Exposure instance.
44
+ # options - The Hash of options to configure the Exposure instance.
45
+ #
46
+ # Returns the exposure configuration Hash.
47
+ def exposure_config(name, options)
48
+ store = self.exposure_configuration ||= {}
49
+ self.exposure_configuration = store.merge(name => options)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,17 +1,207 @@
1
- require 'decent_exposure/inflector'
2
-
3
1
  module DecentExposure
4
2
  class Exposure
5
- attr_accessor :name, :strategy, :options
3
+ attr_reader :controller, :options
4
+
5
+ # Public: Initializes an Exposure and makes it accessible to a controller.
6
+ # For each Exposure, a getter and setter is defined.
7
+ # Those getters and setters are made available to
8
+ # the controller as helper methods.
9
+ #
10
+ # *args - An Array of all parameters for the new Exposure. See
11
+ # #initialize.
12
+ # block - If supplied, the exposed attribute method executes
13
+ # the Proc when called.
14
+ #
15
+ # Returns a collection of exposed helper methods.
16
+ def self.expose!(*args, &block)
17
+ new(*args, &block).expose!
18
+ end
19
+
20
+ # Public: Initalize an Exposure with a hash of options.
21
+ #
22
+ # If a block is given, the Proc is assigned to value
23
+ # of options[name].
24
+ #
25
+ # The `asserts_*` section raise errors if the controller
26
+ # was initialized with an unacceptable options Hash.
27
+ #
28
+ # controller - The Controller class where methods will be exposed.
29
+ # name - The String name of the Exposure instance.
30
+ # fetch_block - Proc that will be executed if the exposed
31
+ # attribute has no value (default: nil).
32
+ # options - Hash of options for the Behavior of the exposed methods.
33
+ # block - If supplied, the exposed attribute method executes
34
+ # the Proc.
35
+ #
36
+ # Returns a normalized options Hash.
37
+ def initialize(controller, name, fetch_block=nil, **options, &block)
38
+ @controller = controller
39
+ @options = options.with_indifferent_access.merge(name: name)
40
+
41
+ merge_lambda_option :fetch, fetch_block if fetch_block
42
+ merge_lambda_option :fetch, block if block_given?
43
+
44
+ assert_singleton_option :fetch
45
+ assert_singleton_option :from
46
+ assert_incompatible_options_pair :parent, :model
47
+ assert_incompatible_options_pair :parent, :scope
48
+ assert_incompatible_options_pair :find_by, :find
49
+
50
+ normalize_options
51
+ end
52
+
53
+ # Public: Creates a getter and setter methods for the attribute.
54
+ # Those methods are made avaiable to the controller as
55
+ # helper methods.
56
+ #
57
+ # Returns a collection of exposed helper methods.
58
+ def expose!
59
+ expose_attribute!
60
+ expose_helper_methods!
61
+ end
62
+
63
+ private
64
+
65
+ def expose_attribute!
66
+ attribute.expose! controller
67
+ end
68
+
69
+ def expose_helper_methods!
70
+ helper_methods = [ attribute.getter_method_name, attribute.setter_method_name ]
71
+ controller.helper_method *helper_methods
72
+ end
73
+
74
+ def normalize_options
75
+ normalize_fetch_option
76
+ normalize_with_option
77
+ normalize_id_option
78
+ normalize_model_option
79
+ normalize_build_params_option
80
+ normalize_scope_options
81
+ normalize_parent_option
82
+ normalize_from_option
83
+ normalize_find_by_option
84
+ end
85
+
86
+ def normalize_fetch_option
87
+ normalize_non_proc_option :fetch do |method_name|
88
+ ->{ send(method_name) }
89
+ end
90
+ end
91
+
92
+ def normalize_find_by_option
93
+ if find_by = options.delete(:find_by)
94
+ merge_lambda_option :find, ->(id, scope){ scope.find_by!(find_by => id) }
95
+ end
96
+ end
97
+
98
+ def normalize_parent_option
99
+ exposure_name = options.fetch(:name)
100
+
101
+ if parent = options.delete(:parent)
102
+ merge_lambda_option :scope, ->{ send(parent).send(exposure_name.to_s.pluralize) }
103
+ end
104
+ end
105
+
106
+ def normalize_from_option
107
+ exposure_name = options.fetch(:name)
108
+
109
+ if from = options.delete(:from)
110
+ merge_lambda_option :build, ->{ send(from).send(exposure_name) }
111
+ merge_lambda_option :model, ->{ nil }
112
+ merge_lambda_option :id, ->{ nil }
113
+ end
114
+ end
115
+
116
+ def normalize_with_option
117
+ if configs = options.delete(:with)
118
+ Array.wrap(configs).each{ |config| reverse_merge_config! config }
119
+ end
120
+ end
121
+
122
+ def normalize_id_option
123
+ normalize_non_proc_option :id do |ids|
124
+ ->{ Array.wrap(ids).map{ |id| params[id] }.find(&:present?) }
125
+ end
126
+ end
127
+
128
+ def normalize_model_option
129
+ normalize_non_proc_option :model do |value|
130
+ model = if [String, Symbol].include?(value.class)
131
+ value.to_s.classify.constantize
132
+ else
133
+ value
134
+ end
135
+
136
+ ->{ model }
137
+ end
138
+ end
139
+
140
+ def normalize_build_params_option
141
+ normalize_non_proc_option :build_params do |value|
142
+ options[:build_params_method] = value
143
+ nil
144
+ end
145
+ end
146
+
147
+ def normalize_scope_options
148
+ normalize_non_proc_option :scope do |custom_scope|
149
+ ->(model){ model.send(custom_scope) }
150
+ end
151
+ end
152
+
153
+ def normalize_non_proc_option(name)
154
+ option_value = options[name]
155
+ return if Proc === option_value
156
+ if option_value.present?
157
+ normalized_value = yield(option_value)
158
+ if normalized_value
159
+ merge_lambda_option name, normalized_value
160
+ else
161
+ options.delete name
162
+ end
163
+ end
164
+ end
165
+
166
+ def merge_lambda_option(name, body)
167
+ if previous_value = options[name] and Proc === previous_value
168
+ fail ArgumentError, "#{name.to_s.titleize} block is already defined"
169
+ end
170
+
171
+ options[name] = body
172
+ end
173
+
174
+ def attribute
175
+ @attribute ||= begin
176
+ local_options = options
177
+
178
+ name = options.fetch(:name)
179
+ ivar_name = "exposed_#{name}"
180
+ fetch = ->{ Flow.new(self, local_options).fetch }
181
+
182
+ Attribute.new(
183
+ name: name,
184
+ ivar_name: ivar_name,
185
+ fetch: fetch
186
+ )
187
+ end
188
+ end
189
+
190
+ def assert_incompatible_options_pair(key1, key2)
191
+ if options.key?(key1) && options.key?(key2)
192
+ fail ArgumentError, "Using #{key1.inspect} option with #{key2.inspect} doesn't make sense"
193
+ end
194
+ end
6
195
 
7
- def initialize(name, strategy, options)
8
- self.name = name.to_s
9
- self.strategy = strategy
10
- self.options = options
196
+ def assert_singleton_option(name)
197
+ if options.except(name, :name, :decorate).any? && options.key?(name)
198
+ fail ArgumentError, "Using #{name.inspect} option with other options doesn't make sense"
199
+ end
11
200
  end
12
201
 
13
- def call(controller)
14
- strategy.new(controller, name, options).resource
202
+ def reverse_merge_config!(name)
203
+ config = controller.exposure_configuration.fetch(name)
204
+ options.reverse_merge! config
15
205
  end
16
206
  end
17
207
  end