praxis 2.0.pre.21 → 2.0.pre.22

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22211954ff9fa77a0667e1daf3ae00b30d1d599cd5a25b65547321295e5a2fcb
4
- data.tar.gz: 3ec2f9c7f2cd9d261f09675a25ca5211131c65d58cc088a539f60f273d5ac207
3
+ metadata.gz: fcf3359dc0ac9062c61efc934673d9bf5dfdda43e5abfbc9abc41d3030a7a00e
4
+ data.tar.gz: 04ffe07b27222f714b8da5e756cc9e1733b76d066a9e1bf5a3d15358dc7e6662
5
5
  SHA512:
6
- metadata.gz: d46f5cd5b91786467262f2baf3e2e60d27431f3b01ac7d674508f66b33046cb26b6489541eb55324354762964f017dc0e448f69d895dfc7804149e87f57b3b0b
7
- data.tar.gz: 957d3f7d32a54f8990e1697a6e6c8182042f15879ef0f8689298ca751b5e0fc5eb64a9f530064066d8722f41b57ddf014889736c5959097c013759ba911beddd
6
+ metadata.gz: f90af4ae85f4170a850d7d7637758c0c2d878271befa2fe437da8c994ac78657eebd28831bb0945a74ca48aa93a91d42e8b734fd70f3996984e427ad6850a7ae
7
+ data.tar.gz: b8eaa60ce2ba599b94f4b844339df8501ca295f3e1efb9a0f5dd38b9c4551aa64f108ba3a28913d06a22748dab11fee5c4bd8f9cfb8d2e85b1c456fb3284a5b3
data/.travis.yml CHANGED
@@ -3,7 +3,7 @@ language: ruby
3
3
  rvm:
4
4
  - 2.6
5
5
  - 2.7
6
- # - 3.1 # Not available in TravisCI out of the box yet
6
+ - 3.1 # Not available in TravisCI out of the box yet
7
7
  script:
8
8
  - bundle exec rspec spec
9
9
  branches:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.22
6
+ * Introduced Resource callbacks (an includeable concern). Callbacks allow you to define methods or blocks to be executed `before`, `after` or `around` any existing method in the resource. Class-level callbacks are defined with `self.xxxxx`. These methods will be executed within the instance of the resource (i.e., in the same context of the original) and must be defined with the same parameter signature. For around methods, only blocks can be used, and to call the original (inner) one, one needs to yield.
7
+ * Introduced QueryMethods for resources (an includeable concern). QueryMethods expose handy querying methods (`.get`, `.get!`, `.all`, `.first` and `.last` ) which will reach into the underlying ORM (i.e., right now, only ActiveModelCompat is supported) to perform the desired loading of data (and subsequent wrapping of results in resource instances).
8
+ * For ActiveRecord `.get` takes a condition hash that will translate to `.find_by`, and `.all` gets a condition hash that will translate to `.where`.
9
+ * `.get!` is a `.get` but that will raise a `Praxis::Mapper::ResourceNotFound` exception if nothing was found.
10
+ * There is an `.including(<spec>)` function that can be use to preload the underlying associations. I.e., the `<spec>` argument will translate to `.includes(<spec>)` in ActiveRecord.
11
+ * Introduced method signatures and validations for resources.
12
+ * One can define a method signature with the `signature(<name>)` stanza, passing a block defining Attributor parameters. For instance method signatures, the `<name>` is just a symbol with the name of the method. For class level methods use a string, and prepend `self.` to it (i.e., `self.create`).
13
+ * Signatures can only work for methods that either have a single argument (taken as a whole hash), or that have only keyword arguments (i.e., no mixed args and kwargs). It would be basically impossible to validate that combo against an Attributor Struct.
14
+ * The calls to typed methods will be intercepted (using an around callback), and the incoming parameters will be validated against the Attributor Struct defined in the siguature, coerced if necessary and passed onto the original method. If the incoming parameters fail validation, a `IncompatibleTypeForMethodArguments` exception will be thrown.
15
+
5
16
  ## 2.0.pre.21
6
17
  * Fix nullable attribute in OpenApi generation
7
18
  ## 2.0.pre.20
@@ -80,6 +80,27 @@ module Praxis
80
80
  end
81
81
  end
82
82
 
83
+ # Compatible reader accessors
84
+ def _get(condition)
85
+ find_by(condition)
86
+ end
87
+
88
+ def _all(conditions = {})
89
+ where(conditions)
90
+ end
91
+
92
+ def _add_includes(base, includes)
93
+ base.includes(includes) # includes(nil) seems to have no effect
94
+ end
95
+
96
+ def _first
97
+ first
98
+ end
99
+
100
+ def _last
101
+ last
102
+ end
103
+
83
104
  private
84
105
 
85
106
  def local_columns_used_for_the_association(type, assoc_reflection)
@@ -4,6 +4,15 @@
4
4
  # Once that is complete, the data set is iterated and a resultant view is generated.
5
5
  module Praxis
6
6
  module Mapper
7
+ class ResourceNotFound < RuntimeError
8
+ attr_reader :type, :id
9
+
10
+ def initialize(type:, id: nil)
11
+ @type = type
12
+ @id = id
13
+ end
14
+ end
15
+
7
16
  class Resource
8
17
  extend Praxis::Finalizable
9
18
 
@@ -58,6 +67,7 @@ module Praxis
58
67
  finalize_resource_delegates
59
68
  define_model_accessors
60
69
 
70
+ hookup_callbacks
61
71
  super
62
72
  end
63
73
 
@@ -79,6 +89,39 @@ module Praxis
79
89
  end
80
90
  end
81
91
 
92
+ def self.hookup_callbacks
93
+ return unless ancestors.include?(Praxis::Mapper::Resources::Callbacks)
94
+
95
+ instance_module = nil
96
+ class_module = nil
97
+
98
+ affected_methods = (before_callbacks.keys + after_callbacks.keys + around_callbacks.keys).uniq
99
+ affected_methods&.each do |method|
100
+ calls = {}
101
+ calls[:before] = before_callbacks[method] if before_callbacks.key?(method)
102
+ calls[:around] = around_callbacks[method] if around_callbacks.key?(method)
103
+ calls[:after] = after_callbacks[method] if after_callbacks.key?(method)
104
+
105
+ if method.start_with?('self.')
106
+ # Look for a Class method
107
+ simple_name = method.to_s.gsub(/^self./, '').to_sym
108
+ raise "Error building callback: Class-level method #{method} is not defined in class #{name}" unless methods.include?(simple_name)
109
+
110
+ class_module ||= Module.new
111
+ create_override_module(mod: class_module, method: method(simple_name), calls: calls)
112
+ else
113
+ # Look for an instance method
114
+ raise "Error building callback: Instance method #{method} is not defined in class #{name}" unless method_defined?(method)
115
+
116
+ instance_module ||= Module.new
117
+ create_override_module(mod: instance_module, method: instance_method(method), calls: calls)
118
+ end
119
+ end
120
+ # Prepend the created instance and/or class modules if there were any functions in them
121
+ prepend instance_module if instance_module
122
+ singleton_class.send(:prepend, class_module) if class_module
123
+ end
124
+
82
125
  def self.for_record(record)
83
126
  return record._resource if record._resource
84
127
 
@@ -184,8 +227,11 @@ module Praxis
184
227
  end
185
228
 
186
229
  def self.define_accessor(name)
187
- ivar_name = if name.to_s =~ /\?/
230
+ ivar_name = case name.to_s
231
+ when /\?/
188
232
  "is_#{name.to_s[0..-2]}"
233
+ when /!/
234
+ "#{name.to_s[0..-2]}_bang"
189
235
  else
190
236
  name.to_s
191
237
  end
@@ -250,6 +296,7 @@ module Praxis
250
296
  def reload
251
297
  clear_memoization
252
298
  reload_record
299
+ self
253
300
  end
254
301
 
255
302
  def clear_memoization
@@ -275,6 +322,81 @@ module Praxis
275
322
  super
276
323
  end
277
324
  end
325
+
326
+ # Defines a 'proxy' method in the given module (mod), so it can then be prepended
327
+ # There are mostly 3 flavors, which dictate how to define the procs (to make sure we play nicely
328
+ # with ruby's arguments and all). Method with only args, with only kwords, and with both
329
+ # Note: if procs could be defined with the (...) syntax, this could be more DRY and simple...
330
+ def self.create_override_module(mod:, method:, calls:)
331
+ has_args = method.parameters.any? { |(type, _)| %i[req opt rest].include?(type) }
332
+ has_kwargs = method.parameters.any? { |(type, _)| %i[keyreq keyrest].include?(type) }
333
+
334
+ mod.class_eval do
335
+ if has_args && has_kwargs
336
+ # Setup the method to take both args and kwargs
337
+ define_method(method.name.to_sym) do |*args, **kwargs|
338
+ calls[:before]&.each do |target|
339
+ target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target)
340
+ end
341
+
342
+ orig_call = proc { |*a, **kw| super(*a, **kw) }
343
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
344
+ proc { |*a, **kw| send(target, *a, **kw, &inner) }
345
+ end
346
+ result = if calls[:around].presence
347
+ around_chain.call(*args, **kwargs)
348
+ else
349
+ super(*args, **kwargs)
350
+ end
351
+ calls[:after]&.each do |target|
352
+ target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target)
353
+ end
354
+ result
355
+ end
356
+ elsif has_kwargs && !has_args
357
+ # Setup the method to only take kwargs
358
+ define_method(method.name.to_sym) do |**kwargs|
359
+ calls[:before]&.each do |target|
360
+ target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target)
361
+ end
362
+ orig_call = proc { |**kw| super(**kw) }
363
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
364
+ proc { |**kw| send(target, **kw, &inner) }
365
+ end
366
+ result = if calls[:around].presence
367
+ around_chain.call(**kwargs)
368
+ else
369
+ super(**kwargs)
370
+ end
371
+ calls[:after]&.each do |target|
372
+ target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target)
373
+ end
374
+ result
375
+ end
376
+ else
377
+ # Setup the method to only take args
378
+ define_method(method.name.to_sym) do |*args|
379
+ calls[:before]&.each do |target|
380
+ target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target)
381
+ end
382
+ orig_call = proc { |*a| super(*a) }
383
+ around_chain = calls[:around].inject(orig_call) do |inner, target|
384
+ proc { |*a| send(target, *a, &inner) }
385
+ end
386
+ result = if calls[:around].presence
387
+ around_chain.call(*args)
388
+ else
389
+ super(*args)
390
+ end
391
+ calls[:after]&.each do |target|
392
+ target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target)
393
+ end
394
+ result
395
+ end
396
+ end
397
+ end
398
+ end
399
+ private_class_method :create_override_module
278
400
  end
279
401
  end
280
402
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxis
4
+ module Mapper
5
+ module Resources
6
+ module Callbacks
7
+ extend ::ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :before_callbacks, :after_callbacks, :around_callbacks
11
+ self.before_callbacks = Hash.new { |h, method| h[method] = [] }
12
+ self.after_callbacks = Hash.new { |h, method| h[method] = [] }
13
+ self.around_callbacks = Hash.new { |h, method| h[method] = [] }
14
+ end
15
+
16
+ module ClassMethods
17
+ def before(method, function = nil, &block)
18
+ target = function ? function.to_sym : block
19
+ before_callbacks[method] << target
20
+ end
21
+
22
+ def after(method, function = nil, &block)
23
+ target = function ? function.to_sym : block
24
+ after_callbacks[method] << target
25
+ end
26
+
27
+ def around(method, function = nil, &block)
28
+ target = function ? function.to_sym : block
29
+ around_callbacks[method] << target
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxis
4
+ module Mapper
5
+ module Resources
6
+ module QueryMethods
7
+ extend ::ActiveSupport::Concern
8
+
9
+ # Includes some limited, but handy query methods so we can transparently
10
+ # use them from the resource layer, and get wrapped resources from it
11
+ module ClassMethods
12
+ def including(args)
13
+ QueryProxy.new(klass: self).including(args)
14
+ end
15
+
16
+ def all(args = {})
17
+ QueryProxy.new(klass: self).all(args)
18
+ end
19
+
20
+ def get(args)
21
+ QueryProxy.new(klass: self).get(args)
22
+ end
23
+
24
+ def get!(args)
25
+ QueryProxy.new(klass: self).get!(args)
26
+ end
27
+
28
+ def first
29
+ QueryProxy.new(klass: self).first
30
+ end
31
+
32
+ def last
33
+ QueryProxy.new(klass: self).last
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxis
4
+ module Mapper
5
+ module Resources
6
+ class QueryProxy
7
+ attr_reader :klass
8
+
9
+ def initialize(klass:)
10
+ @klass = klass
11
+ end
12
+
13
+ def including(includes)
14
+ @_includes = includes
15
+ self
16
+ end
17
+
18
+ # Can pass extra includes through :_includes
19
+ # TODO: We should not use the AR includes, but first pass them through the properties, cause
20
+ # we need to expand based on the resource methods, not the model methods
21
+ def get(condition)
22
+ base = klass.model._add_includes(klass.model, @_includes) # includes(nil) seems to have no effect
23
+ record = base._get(condition)
24
+
25
+ record.nil? ? nil : klass.wrap(record)
26
+ end
27
+
28
+ def get!(condition)
29
+ resource = get(condition)
30
+ # TODO: passing the :id if there is one in the condition...but can be more complex...think if we want to expose more
31
+ raise Praxis::Mapper::ResourceNotFound.new(type: @klass, id: condition[:id]) unless resource
32
+
33
+ resource
34
+ end
35
+
36
+ # Retrieve all or many wrapped resources
37
+ # .all -> returns them all
38
+ # .all(name: 'foo') -> returns all that match the name
39
+ def all(condition = {})
40
+ base = klass.model._add_includes(klass.model, @_includes) # includes(nil) seems to have no effect
41
+ records = base._all(condition)
42
+
43
+ klass.wrap(records)
44
+ end
45
+
46
+ def first
47
+ record = klass.model._first
48
+ record.nil? ? nil : klass.wrap(record)
49
+ end
50
+
51
+ def last
52
+ record = klass.model._last
53
+ record.nil? ? nil : klass.wrap(record)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxis
4
+ module Mapper
5
+ module Resources
6
+ # Error raised when trying to call a typed method and the validation of arguments fails
7
+ class IncompatibleTypeForMethodArguments < ::StandardError
8
+ attr_reader :errors, :method
9
+
10
+ def initialize(errors:, method:, klass:)
11
+ @errors = errors
12
+ super "Error validating/coercing arguments for call to method #{method} of class #{klass}:\n#{errors}"
13
+ end
14
+ end
15
+
16
+ module TypedMethods
17
+ extend ::ActiveSupport::Concern
18
+
19
+ included do
20
+ include Praxis::Mapper::Resources::Callbacks
21
+
22
+ class << self
23
+ attr_reader :signatures
24
+
25
+ def _finalize!
26
+ if @signatures
27
+ # Build the around callbacks for coercing the params for the methods with types defined
28
+ # Also, this needs to be before, so that we hit the coercion code before any other around callback
29
+ const_set(:MethodSignatures, Module.new)
30
+ @signatures.each do |method, type|
31
+ # Also add a constant pointing to the signature type inside Signatures (and substitute ! for Bang, as that's not allowed in a constant)
32
+ # For class Methods, also substitute .self for Self
33
+ # This helps with debugging, as we won't get anonymous struct classes, but we'll see these better names
34
+ cleaned_name = method.to_s.gsub(/!/, '_bang').to_s.gsub(/^self./, 'self_')
35
+ self::MethodSignatures.const_set(cleaned_name.camelize.to_sym, type)
36
+ coerce_params_for method, type
37
+ end
38
+ end
39
+
40
+ super
41
+ end
42
+
43
+ def signature(method_name, &block)
44
+ method = method_name.to_sym
45
+ @signatures ||= {}
46
+ if block_given?
47
+ type =
48
+ Class.new(Attributor::Struct) do
49
+ attributes do
50
+ instance_eval(&block)
51
+ end
52
+ end
53
+ @signatures[method] = type
54
+ else
55
+ @signatures[method]
56
+ end
57
+ end
58
+
59
+ # Sets up a specific around callback to a given method, where it'd pass the loaded/coerced type from the input
60
+ def coerce_params_for(method, type)
61
+ raise "Argument type for #{method} could not be found. Did you define a `signature` stanza for it?" unless type
62
+
63
+ if method.start_with?('self.')
64
+ simple_name = method.to_s.gsub(/^self./, '').to_sym
65
+ # Look for a Class method
66
+ raise "Error building typed method signature: Method #{method} is not defined in class #{name}" unless methods.include?(simple_name)
67
+
68
+ coerce_params_for_class(method(simple_name), type)
69
+ else
70
+ # Look for an instance method
71
+ raise "Error building typed method signature: Method #{method} is not defined in class #{name}" unless method_defined?(method)
72
+
73
+ coerce_params_for_instance(instance_method(method), type)
74
+ end
75
+ end
76
+
77
+ def coerce_params_for_instance(method, type)
78
+ around_method_name = "_coerce_params_for_#{method.name}"
79
+ instance_exec around_method_name: around_method_name,
80
+ orig_method: method,
81
+ type: type,
82
+ ctx: [to_s, method.name].freeze,
83
+ &CREATE_LOADER_METHOD
84
+
85
+ # Set an around callback to call the defined method above
86
+ around method.name, around_method_name
87
+ end
88
+
89
+ def coerce_params_for_class(method, type)
90
+ around_method_name = "_coerce_params_for_class_#{method.name}"
91
+ # Define an instance method in the eigenclass
92
+ singleton_class.instance_exec around_method_name: around_method_name,
93
+ orig_method: method,
94
+ type: type,
95
+ ctx: [to_s, method.name].freeze,
96
+ &CREATE_LOADER_METHOD
97
+
98
+ # Set an around callback to call the defined method above (the callbacks need self. for class interceptors)
99
+ class_method_name = "self.#{method.name}"
100
+ around class_method_name.to_sym, around_method_name
101
+ end
102
+
103
+ CREATE_LOADER_METHOD = proc do |around_method_name:, orig_method:, type:, ctx:|
104
+ has_args = orig_method.parameters.any? { |(argtype, _)| %i[req opt rest].include?(argtype) }
105
+ has_kwargs = orig_method.parameters.any? { |(argtype, _)| %i[keyreq keyrest].include?(argtype) }
106
+ raise "Typed signatures aren't supported for methods that have both kwargs and normal args: #{orig_method.name} of #{self.class}" if has_args && has_kwargs
107
+
108
+ if has_args
109
+ define_method(around_method_name) do |arg, &block|
110
+ loaded = type.load(arg, ctx)
111
+ errors = type.validate(loaded, ctx, nil)
112
+ raise IncompatibleTypeForMethodArguments.new(errors: errors, method: orig_method.name, klass: self) unless errors.empty?
113
+
114
+ # pass the struct object as a single arg
115
+ block.yield(loaded)
116
+ end
117
+ else
118
+ define_method(around_method_name) do |**args, &block|
119
+ loaded = type.load(args, ctx)
120
+ errors = type.validate(loaded, ctx, nil)
121
+ raise IncompatibleTypeForMethodArguments.new(errors: errors, method: orig_method.name, klass: self) unless errors.empty?
122
+
123
+ # Splat the args if it's a kwarg type method
124
+ block.yield(**loaded)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Praxis
4
- VERSION = '2.0.pre.21'
4
+ VERSION = '2.0.pre.22'
5
5
  end
data/lib/praxis.rb CHANGED
@@ -136,6 +136,13 @@ module Praxis
136
136
  module Mapper
137
137
  autoload :Resource, 'praxis/mapper/resource'
138
138
  autoload :SelectorGenerator, 'praxis/mapper/selector_generator'
139
+
140
+ module Resources
141
+ autoload :Callbacks, 'praxis/mapper/resources/callbacks'
142
+ autoload :QueryMethods, 'praxis/mapper/resources/query_methods'
143
+ autoload :TypedMethods, 'praxis/mapper/resources/typed_methods'
144
+ autoload :QueryProxy, 'praxis/mapper/resources/query_proxy'
145
+ end
139
146
  end
140
147
 
141
148
  # Avoid loading responses (and templates) lazily as they need to be registered in time
data/praxis.gemspec CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |spec|
23
23
  spec.executables << 'praxis'
24
24
 
25
25
  spec.add_dependency 'activesupport', '>= 3'
26
- spec.add_dependency 'attributor', '>= 6.0'
26
+ spec.add_dependency 'attributor', '>= 6.2'
27
27
  spec.add_dependency 'mime', '~> 0'
28
28
  spec.add_dependency 'mustermann', '>=1.1', '<=2'
29
29
  spec.add_dependency 'rack', '>= 1'
@@ -32,7 +32,9 @@ describe Praxis::Mapper::Resource do
32
32
  context 'retrieving resources' do
33
33
  context 'getting a single resource' do
34
34
  before do
35
- expect(SimpleModel).to receive(:get).with(name: 'george xvi').and_return(record)
35
+ expect(SimpleModel).to receive(:get) do |args|
36
+ expect(**args).to match(name: 'george xvi')
37
+ end.and_return(record)
36
38
  end
37
39
 
38
40
  subject(:resource) { SimpleResource.get(name: 'george xvi') }
@@ -44,7 +46,9 @@ describe Praxis::Mapper::Resource do
44
46
 
45
47
  context 'getting multiple resources' do
46
48
  before do
47
- expect(SimpleModel).to receive(:all).with(name: ['george xvi']).and_return([record])
49
+ expect(SimpleModel).to receive(:all) do |args|
50
+ expect(**args).to eq(name: ['george xvi'])
51
+ end.and_return([record])
48
52
  end
49
53
 
50
54
  subject(:resource_collection) { SimpleResource.all(name: ['george xvi']) }
@@ -176,4 +180,38 @@ describe Praxis::Mapper::Resource do
176
180
  expect(SimpleResource.wrap(record)).to be(OtherResource.wrap(record))
177
181
  end
178
182
  end
183
+
184
+ context 'calling typed methods' do
185
+ let(:resource) { TypedResource }
186
+ context 'class level ones' do
187
+ it 'kwarg methods get their args splatted in the top level' do
188
+ arg = resource.create(name: 'Praxis-licious', payload: { struct_param: { id: 1 } })
189
+ # Top level args are a hash (cause the typed methods will splat them before calling)
190
+ expect(arg).to be_kind_of Hash
191
+ # But structs beyond that are just the loaded types (which we can splat if we want to keep calling)
192
+ expect(arg[:payload]).to be_kind_of Attributor::Struct
193
+ end
194
+
195
+ it 'non-kwarg methods get a single arg' do
196
+ arg = resource.singlearg_create({ name: 'Praxis-licious', payload: { struct_param: { id: 1 } } })
197
+ # Single argument, instance of an Attributor Struct
198
+ expect(arg).to be_kind_of Attributor::Struct
199
+ end
200
+ end
201
+ context 'instance level ones' do
202
+ it 'kwarg methods get their args splatted in the top level' do
203
+ arg = resource.new({}).update!(string_param: 'Stringer', struct_param: { id: 1 })
204
+ # Top level args are a hash (cause the typed methods will splat them before calling)
205
+ expect(arg).to be_kind_of Hash
206
+ # But structs beyond that are just the loaded types (which we can splat if we want to keep calling)
207
+ expect(arg[:struct_param]).to be_kind_of Attributor::Struct
208
+ end
209
+
210
+ it 'non-kwarg methods get a single arg' do
211
+ arg = resource.new({}).singlearg_update!({ string_param: 'Stringer', struct_param: { id: 1 } })
212
+ # Single argument, instance of an Attributor Struct
213
+ expect(arg).to be_kind_of Attributor::Struct
214
+ end
215
+ end
216
+ end
179
217
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Praxis::Mapper::Resources::Callbacks do
6
+ context 'callbacks' do
7
+ let(:double_model) do
8
+ SimpleModel.new(before_count: 0, after_count: 0, around_count: 0, name: '', force: false)
9
+ end
10
+ let(:resource) { SimpleResource.new(double_model) }
11
+ context 'using functions with args and kwargs' do
12
+ subject { resource.change_name('hi', force: true) }
13
+ it 'before' do
14
+ expect(subject.record.before_count).to eq(1) # 1 before hook
15
+ end
16
+ it 'after' do
17
+ expect(subject.record.after_count).to eq(1) # 1 after hook
18
+ end
19
+ it 'around' do
20
+ # 50, just for the only filter
21
+ expect(subject.record.around_count).to eq(50)
22
+ end
23
+ after do
24
+ # one for the before, the around filter, the actual method and the after filter
25
+ expect(subject.record.name).to eq('hi-hi-hi-hi')
26
+ expect(subject.record.force).to be_truthy
27
+ end
28
+ end
29
+
30
+ context 'using functions with only kwargs' do
31
+ subject { resource.update!(number: 1000) }
32
+ it 'before' do
33
+ expect(subject.record.before_count).to eq(1) # 1 before hook
34
+ end
35
+ it 'after' do
36
+ expect(subject.record.after_count).to eq(1) # 1 after hook
37
+ end
38
+ it 'around' do
39
+ # 1000 for the orig update+1 from before_count + 50+100 for the 2 around filters
40
+ expect(subject.record.around_count).to eq(1151)
41
+ end
42
+ end
43
+
44
+ context 'using functions only args' do
45
+ subject { resource.argsonly('hi') }
46
+ it 'around' do
47
+ # 50, just for the only filter
48
+ expect(subject.record.around_count).to eq(50)
49
+ end
50
+ after do
51
+ # one for the the around filter and one for the actual methodr
52
+ expect(subject.record.name).to eq('hi-hi')
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Praxis::Mapper::Resources::QueryProxy do
6
+ let(:instance) { described_class.new(klass: resource_class) }
7
+ let(:resource_class) { SimpleResource }
8
+ let(:the_includes) { nil }
9
+
10
+ context 'including' do
11
+ let(:the_includes) { %i[one two] }
12
+ it 'saves the includes' do
13
+ expect(instance.instance_variable_get(:@_includes)).to be_nil
14
+ result = instance.including(the_includes)
15
+ expect(instance.instance_variable_get(:@_includes)).to be(the_includes)
16
+ expect(result).to be instance
17
+ end
18
+ end
19
+
20
+ context 'get' do
21
+ subject { instance.get(id: 39) }
22
+ # Base responds to the underlying ORM method _get to retrieve a record given a condition
23
+ let(:base) { double('ORM Base', _get: model_instance) }
24
+
25
+ before do
26
+ expect(resource_class.model).to receive(:_add_includes) do |model, includes|
27
+ expect(model).to be SimpleModel
28
+ expect(includes).to be(the_includes)
29
+ end.and_return(base)
30
+ end
31
+ context 'when a model is not found' do
32
+ let(:model_instance) { nil }
33
+ it 'returns nil' do
34
+ expect(subject).to be nil
35
+ end
36
+ end
37
+
38
+ context 'with a found model' do
39
+ let(:model_instance) { SimpleModel.new }
40
+ it 'returns an instance of the resource, wrapping the record' do
41
+ expect(subject).to be_kind_of(SimpleResource)
42
+ expect(subject.record).to be(model_instance)
43
+ end
44
+ end
45
+ end
46
+
47
+ context 'all' do
48
+ subject { instance.all(id: 39, name: 'foo') }
49
+ # Base responds to the underlying ORM method _all to retrieve a list of records given a condition
50
+ let(:base) { double('ORM Base', _all: model_instances) }
51
+
52
+ before do
53
+ expect(resource_class.model).to receive(:_add_includes) do |model, includes|
54
+ expect(model).to be SimpleModel
55
+ expect(includes).to be(the_includes)
56
+ end.and_return(base)
57
+ end
58
+ context 'when no records found' do
59
+ let(:model_instances) { nil }
60
+ it 'returns nil' do
61
+ expect(subject).to be_empty
62
+ end
63
+ end
64
+
65
+ context 'with found records' do
66
+ let(:model_instances) { [SimpleModel.new(id: 1), SimpleModel.new(id: 2)] }
67
+ it 'returns an array of resource instances, each wrapping their record' do
68
+ expect(subject).to be_kind_of(Array)
69
+ expect(subject.map(&:record)).to eq(model_instances)
70
+ end
71
+ end
72
+ end
73
+
74
+ context 'first' do
75
+ subject { instance.first }
76
+ before do
77
+ expect(resource_class.model).to receive(:_first).and_return(model_instance)
78
+ end
79
+ context 'when a model is not found' do
80
+ let(:model_instance) { nil }
81
+ it 'returns nil' do
82
+ expect(subject).to be nil
83
+ end
84
+ end
85
+
86
+ context 'with a found model' do
87
+ let(:model_instance) { SimpleModel.new }
88
+ it 'returns an instance of the resource, wrapping the record' do
89
+ expect(subject).to be_kind_of(SimpleResource)
90
+ expect(subject.record).to be(model_instance)
91
+ end
92
+ end
93
+ end
94
+
95
+ context 'last' do
96
+ subject { instance.last }
97
+ before do
98
+ expect(resource_class.model).to receive(:_last).and_return(model_instance)
99
+ end
100
+ context 'when a model is not found' do
101
+ let(:model_instance) { nil }
102
+ it 'returns nil' do
103
+ expect(subject).to be nil
104
+ end
105
+ end
106
+
107
+ context 'with a found model' do
108
+ let(:model_instance) { SimpleModel.new }
109
+ it 'returns an instance of the resource, wrapping the record' do
110
+ expect(subject).to be_kind_of(SimpleResource)
111
+ expect(subject.record).to be(model_instance)
112
+ end
113
+ end
114
+ end
115
+
116
+ context 'get!' do
117
+ subject { instance.get!(id: 39) }
118
+ before do
119
+ # Expects to always call the normal get function for the result
120
+ expect(instance).to receive(:get) do |args|
121
+ expect(args[:id]).to eq(39)
122
+ end.and_return(resource_instance)
123
+ end
124
+ context 'when no record is found' do
125
+ let(:resource_instance) { nil }
126
+ it 'raises ResourceNotFound' do
127
+ expect { subject }.to raise_error(Praxis::Mapper::ResourceNotFound)
128
+ end
129
+ end
130
+ context 'with a record found' do
131
+ let(:resource_instance) { double('ResourceInstance') }
132
+ it 'simply returns the result of get' do
133
+ expect(subject).to be(resource_instance)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Praxis::Mapper::Resources::TypedMethods do
6
+ let(:resource_class) { TypedResource }
7
+
8
+ context '._finalize!' do
9
+ # The class is already finalized by loading the TypedResource from the spec_resources
10
+ # So we will simply check that all the right things are created
11
+ it 'builds the MethodSignatures constant within the class' do
12
+ expect(TypedResource::MethodSignatures).to_not be_nil
13
+ end
14
+
15
+ it 'builds the inner class type for the defined signatures' do
16
+ expect(TypedResource::MethodSignatures::UpdateBang).to_not be_nil
17
+ expect(TypedResource::MethodSignatures::UpdateBang).to be TypedResource.signature(:update!)
18
+
19
+ expect(TypedResource::MethodSignatures::SelfCreate).to_not be_nil
20
+ expect(TypedResource::MethodSignatures::SelfCreate).to be TypedResource.signature(:'self.create')
21
+ end
22
+
23
+ it 'Subsitutes a ! for a Bang when creating the constant' do
24
+ expect(TypedResource::MethodSignatures::UpdateBang).to be TypedResource.signature(:update!)
25
+ end
26
+
27
+ it 'defines the coercing methods' do
28
+ expect(TypedResource.methods).to include(:_coerce_params_for_class_create)
29
+ expect(TypedResource.instance_methods).to include(:_coerce_params_for_update!)
30
+ end
31
+ end
32
+
33
+ context '.signature' do
34
+ # We are not creating more classes and signatures, simply checking that the ones created
35
+ # for TypedResource in the spec_resource_files are correctly processed
36
+ it 'defines it in the @signatures hash' do
37
+ expect(TypedResource.signatures.keys).to match_array(
38
+ %i[
39
+ self.create
40
+ self.singlearg_create
41
+ create
42
+ update!
43
+ singlearg_update!
44
+ ]
45
+ )
46
+ expect(TypedResource.signature(:'self.create')).to be < Attributor::Struct
47
+ expect(TypedResource.signature(:create)).to be < Attributor::Struct
48
+ expect(TypedResource.signature(:update!)).to be < Attributor::Struct
49
+ end
50
+
51
+ it 'holds the right definition for class create' do
52
+ definition = TypedResource.signature(:'self.create')
53
+ expect(definition.attributes.keys).to eq %i[name payload]
54
+ expect(definition.attributes[:payload].attributes.keys).to eq %i[string_param struct_param]
55
+ end
56
+
57
+ it 'holds the right definition for instance create' do
58
+ definition = TypedResource.signature(:create)
59
+ expect(definition.attributes.keys).to eq %i[id]
60
+ end
61
+ it 'holds the right definition for update!' do
62
+ definition = TypedResource.signature(:update!)
63
+ expect(definition.attributes.keys).to eq %i[string_param struct_param]
64
+ expect(definition.attributes[:struct_param].attributes.keys).to eq %i[id]
65
+ end
66
+ end
67
+
68
+ context 'coerce_params_for' do
69
+ let(:resource_class) do
70
+ Class.new(Praxis::Mapper::Resource) do
71
+ include Praxis::Mapper::Resources::TypedMethods
72
+ def imethod_args(args)
73
+ args
74
+ end
75
+
76
+ def self.cmethod_args(args)
77
+ args
78
+ end
79
+
80
+ def imethod_kwargs(**args)
81
+ args
82
+ end
83
+
84
+ def self.cmethod_kwargs(**args)
85
+ args
86
+ end
87
+ end
88
+ end
89
+
90
+ let(:hook_coercer) { methods.each { |method| resource_class.coerce_params_for(method, type) } }
91
+ # Note, we're associating the same type signature for both imethod and cmethod signatures!
92
+ let(:type) do
93
+ Class.new(Attributor::Struct) do
94
+ attributes do
95
+ attribute :id, Integer, required: true
96
+ attribute :name, String, null: false
97
+ end
98
+ end
99
+ end
100
+
101
+ before do
102
+ # None of our wrappers before invoking the function
103
+ our_wrappers = resource_class.methods.select { |m| m.to_s =~ /^_coerce_params_for_class_/ }
104
+ our_wrappers += resource_class.instance_methods.select { |m| m.to_s =~ /^_coerce_params_for_/ }
105
+ expect(our_wrappers).to be_empty
106
+ end
107
+ context 'instance methods' do
108
+ let(:methods) { %i[imethod_args imethod_kwargs] }
109
+ it 'creates the wrapper methods' do
110
+ hook_coercer
111
+ iwrappers = resource_class.instance_methods.select { |m| m.to_s =~ /^_coerce_params_for_/ }
112
+ expect(iwrappers).to match_array %i[_coerce_params_for_imethod_args _coerce_params_for_imethod_kwargs]
113
+ end
114
+
115
+ it 'sets an around callback for them' do
116
+ hook_coercer
117
+ expect(resource_class.around_callbacks[:imethod_args]).to eq([:_coerce_params_for_imethod_args])
118
+ expect(resource_class.around_callbacks[:imethod_kwargs]).to eq([:_coerce_params_for_imethod_kwargs])
119
+ end
120
+
121
+ context 'when hooking in the callbacks' do
122
+ before do
123
+ hook_coercer
124
+ resource_class._finalize!
125
+ end
126
+ context 'calls the wrapper to validate and load' do
127
+ it 'fails if invalid (id is required)' do
128
+ expect do
129
+ resource_class.new(nil).imethod_args({ name: 'Praxis' })
130
+ end.to raise_error(
131
+ Praxis::Mapper::Resources::IncompatibleTypeForMethodArguments,
132
+ /.imethod_args.id is required/
133
+ )
134
+ expect do
135
+ resource_class.new(nil).imethod_kwargs(name: 'Praxis')
136
+ end.to raise_error(
137
+ Praxis::Mapper::Resources::IncompatibleTypeForMethodArguments,
138
+ /.imethod_kwargs.id is required/
139
+ )
140
+ end
141
+
142
+ it 'succeeds and returns the coerced struct if compatible' do
143
+ result = resource_class.new(nil).imethod_args({ id: '1', name: 'Praxis' })
144
+ expect(result[:id]).to eq(1) # Coerces to Integer!
145
+ expect(result[:name]).to eq('Praxis')
146
+ result = resource_class.new(nil).imethod_kwargs(id: '1', name: 'Praxis')
147
+ expect(result[:id]).to eq(1) # Coerces to Integer!
148
+ expect(result[:name]).to eq('Praxis')
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ context 'class methods' do
155
+ let(:methods) { %i[self.cmethod_args self.cmethod_kwargs] }
156
+ it 'creates the wrapper methods' do
157
+ hook_coercer
158
+ cwrappers = resource_class.methods.select { |m| m.to_s =~ /^_coerce_params_for_class_/ }
159
+ expect(cwrappers).to match_array %i[_coerce_params_for_class_cmethod_args _coerce_params_for_class_cmethod_kwargs]
160
+ end
161
+
162
+ it 'sets an around callback for them' do
163
+ hook_coercer
164
+ expect(resource_class.around_callbacks[:'self.cmethod_args']).to eq([:_coerce_params_for_class_cmethod_args])
165
+ expect(resource_class.around_callbacks[:'self.cmethod_kwargs']).to eq([:_coerce_params_for_class_cmethod_kwargs])
166
+ end
167
+
168
+ context 'when hooking in the callbacks' do
169
+ before do
170
+ hook_coercer
171
+ resource_class._finalize!
172
+ end
173
+ context 'calls the wrapper to validate and load' do
174
+ it 'fails if invalid (id is required)' do
175
+ expect do
176
+ resource_class.cmethod_args({ name: 'Praxis' })
177
+ end.to raise_error(
178
+ Praxis::Mapper::Resources::IncompatibleTypeForMethodArguments,
179
+ /.cmethod_args.id is required/
180
+ )
181
+ expect do
182
+ resource_class.cmethod_kwargs(name: 'Praxis')
183
+ end.to raise_error(
184
+ Praxis::Mapper::Resources::IncompatibleTypeForMethodArguments,
185
+ /.cmethod_kwargs.id is required/
186
+ )
187
+ end
188
+
189
+ it 'succeeds and returns the coerced struct if compatible' do
190
+ result = resource_class.cmethod_args({ id: '1', name: 'Praxis' })
191
+ expect(result[:id]).to eq(1) # Coerces to Integer!
192
+ expect(result[:name]).to eq('Praxis')
193
+ result = resource_class.cmethod_kwargs(id: '1', name: 'Praxis')
194
+ expect(result[:id]).to eq(1) # Coerces to Integer!
195
+ expect(result[:name]).to eq('Praxis')
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -77,6 +77,12 @@ class YamlArrayModel < OpenStruct
77
77
  end
78
78
  end
79
79
 
80
+ class TypedModel < OpenStruct
81
+ def self._praxis_associations
82
+ {}
83
+ end
84
+ end
85
+
80
86
  # A set of resource classes for use in specs
81
87
  class BaseResource < Praxis::Mapper::Resource
82
88
  def href
@@ -100,6 +106,8 @@ class ParentResource < BaseResource
100
106
  end
101
107
 
102
108
  class SimpleResource < BaseResource
109
+ include Praxis::Mapper::Resources::Callbacks
110
+
103
111
  model SimpleModel
104
112
 
105
113
  resource_delegate other_model: [:other_attribute]
@@ -123,8 +131,132 @@ class SimpleResource < BaseResource
123
131
  property :no_deps, dependencies: []
124
132
 
125
133
  property :deep_nested_deps, dependencies: ['parent.simple_children.other_model.parent.display_name']
134
+
135
+ before(:update!, :do_before_update)
136
+ around(:update!, :do_around_update_nested)
137
+ around(:update!, :do_around_update)
138
+ # Define an after as a proc
139
+ after(:update!) do |number:|
140
+ _unused = number
141
+ record.after_count += 1
142
+ end
143
+
144
+ def do_before_update(number:)
145
+ _unused = number
146
+ record.before_count += 1
147
+ end
148
+
149
+ def do_around_update_nested(number:)
150
+ record.around_count += 100
151
+ yield(number: number)
152
+ end
153
+
154
+ def do_around_update(number:)
155
+ record.around_count += 50
156
+ yield(number: number)
157
+ end
158
+
159
+ around(:change_name, :do_around_change_name)
160
+ after(:change_name, :do_after_change_name)
161
+ # Define a before as a proc
162
+ before(:change_name) do |name, force:|
163
+ _unused = force
164
+ record.before_count += 1
165
+ record.name = name
166
+ record.force = false # Force always false in before
167
+ end
168
+
169
+ def do_after_change_name(name, force:)
170
+ _unused = force
171
+ record.after_count += 1
172
+ record.name += "-#{name}"
173
+ end
174
+
175
+ def do_around_change_name(name, force:)
176
+ record.around_count += 50
177
+
178
+ record.name += "-#{name}"
179
+ yield(name, force: force)
180
+ end
181
+
182
+ # Appends the name and overrides the force
183
+ def change_name(name, force:)
184
+ record.name += "-#{name}"
185
+ record.force = force
186
+ self
187
+ end
188
+
189
+ around(:argsonly, :do_around_argsonly)
190
+ def do_around_argsonly(name)
191
+ record.around_count += 50
192
+ record.name += name.to_s
193
+ yield(name)
194
+ end
195
+
196
+ def argsonly(name)
197
+ record.name += "-#{name}"
198
+ self
199
+ end
200
+
201
+ # Adds 1000 to the around count, plus whatever has been accumulated in before_count
202
+ def update!(number:)
203
+ record.around_count += number + record.before_count
204
+ self
205
+ end
126
206
  end
127
207
 
128
208
  class YamlArrayResource < BaseResource
129
209
  model YamlArrayModel
130
210
  end
211
+
212
+ class TypedResource < BaseResource
213
+ include Praxis::Mapper::Resources::TypedMethods
214
+
215
+ model TypedModel
216
+
217
+ signature(:update!) do
218
+ attribute :string_param, String, null: false
219
+ attribute :struct_param do
220
+ attribute :id, Integer
221
+ end
222
+ end
223
+ def update!(**payload)
224
+ payload
225
+ end
226
+
227
+ signature(:singlearg_update!) do
228
+ attribute :string_param, String, null: false
229
+ attribute :struct_param do
230
+ attribute :id, Integer
231
+ end
232
+ end
233
+ def singlearg_update!(payload)
234
+ payload
235
+ end
236
+
237
+ # Instance method: create, to make sure we support both an instance and a class method signature
238
+ signature(:create) do
239
+ attribute :id, String
240
+ end
241
+ def create(id:)
242
+ id
243
+ end
244
+
245
+ signature('self.create') do
246
+ attribute :name, String, regexp: /Praxis/
247
+ attribute :payload, TypedResource.signature(:update!), required: true
248
+ end
249
+
250
+ def self.create(**payload)
251
+ payload
252
+ end
253
+
254
+ signature('self.singlearg_create') do
255
+ attribute :name, String, regexp: /Praxis/
256
+ attribute :payload, TypedResource.signature(:update!), required: true
257
+ end
258
+
259
+ def self.singlearg_create(payload)
260
+ payload
261
+ end
262
+ end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: praxis
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.pre.21
4
+ version: 2.0.pre.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josep M. Blanquer
8
8
  - Dane Jensen
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-02-15 00:00:00.000000000 Z
12
+ date: 2022-05-16 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -31,14 +31,14 @@ dependencies:
31
31
  requirements:
32
32
  - - ">="
33
33
  - !ruby/object:Gem::Version
34
- version: '6.0'
34
+ version: '6.2'
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - ">="
40
40
  - !ruby/object:Gem::Version
41
- version: '6.0'
41
+ version: '6.2'
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: mime
44
44
  requirement: !ruby/object:Gem::Requirement
@@ -359,7 +359,7 @@ dependencies:
359
359
  - - "~>"
360
360
  - !ruby/object:Gem::Version
361
361
  version: '5'
362
- description:
362
+ description:
363
363
  email:
364
364
  - blanquer@gmail.com
365
365
  - dane.jensen@gmail.com
@@ -463,6 +463,10 @@ files:
463
463
  - lib/praxis/handlers/xml_sample.rb
464
464
  - lib/praxis/mapper/active_model_compat.rb
465
465
  - lib/praxis/mapper/resource.rb
466
+ - lib/praxis/mapper/resources/callbacks.rb
467
+ - lib/praxis/mapper/resources/query_methods.rb
468
+ - lib/praxis/mapper/resources/query_proxy.rb
469
+ - lib/praxis/mapper/resources/typed_methods.rb
466
470
  - lib/praxis/mapper/selector_generator.rb
467
471
  - lib/praxis/mapper/sequel_compat.rb
468
472
  - lib/praxis/media_type.rb
@@ -543,6 +547,9 @@ files:
543
547
  - spec/praxis/file_group_spec.rb
544
548
  - spec/praxis/handlers/json_spec.rb
545
549
  - spec/praxis/mapper/resource_spec.rb
550
+ - spec/praxis/mapper/resources/callbacks_spec.rb
551
+ - spec/praxis/mapper/resources/query_proxy_spec.rb
552
+ - spec/praxis/mapper/resources/typed_methods_spec.rb
546
553
  - spec/praxis/mapper/selector_generator_spec.rb
547
554
  - spec/praxis/media_type_identifier_spec.rb
548
555
  - spec/praxis/media_type_spec.rb
@@ -660,7 +667,7 @@ homepage: https://github.com/praxis/praxis
660
667
  licenses:
661
668
  - MIT
662
669
  metadata: {}
663
- post_install_message:
670
+ post_install_message:
664
671
  rdoc_options: []
665
672
  require_paths:
666
673
  - lib
@@ -675,8 +682,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
675
682
  - !ruby/object:Gem::Version
676
683
  version: 1.3.1
677
684
  requirements: []
678
- rubygems_version: 3.1.2
679
- signing_key:
685
+ rubygems_version: 3.3.7
686
+ signing_key:
680
687
  specification_version: 4
681
688
  summary: Building APIs the way you want it.
682
689
  test_files: []