praxis-blueprints 2.2 → 3.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,71 @@
1
+ module Praxis
2
+ class Renderer
3
+ attr_reader :include_nil
4
+ attr_reader :cache
5
+
6
+ def initialize(include_nil: false)
7
+ @cache = Hash.new do |hash,key|
8
+ hash[key] = Hash.new
9
+ end
10
+
11
+ @include_nil = include_nil
12
+ end
13
+
14
+ # Renders an a collection using a given list of per-member fields.
15
+ #
16
+ # @param [Object] object the object to render
17
+ # @param [Hash] fields the set of fields, as from FieldExpander, to apply to each member of the collection.
18
+ def render_collection(collection, member_fields, view=nil, context: Attributor::DEFAULT_ROOT_CONTEXT)
19
+ render(collection,[member_fields], view, context: context)
20
+ end
21
+
22
+ # Renders an object using a given list of fields.
23
+ #
24
+ # @param [Object] object the object to render
25
+ # @param [Hash] fields the correct set of fields, as from FieldExpander
26
+ def render(object, fields, view=nil, context: Attributor::DEFAULT_ROOT_CONTEXT)
27
+ if fields.kind_of? Array
28
+ sub_fields = fields[0]
29
+ object.each_with_index.collect do |sub_object, i|
30
+ sub_context = context + ["at(#{i})"]
31
+ render(sub_object, sub_fields, view, context: sub_context)
32
+ end
33
+ elsif object.kind_of? Praxis::Blueprint
34
+ @cache[object.object_id][fields.object_id] ||= _render(object,fields, view, context: context)
35
+ else
36
+ _render(object,fields, view, context: context)
37
+ end
38
+ end
39
+
40
+ def _render(object, fields, view=nil, context: Attributor::DEFAULT_ROOT_CONTEXT)
41
+ return object if fields == true
42
+
43
+ notification_payload = {
44
+ blueprint: object,
45
+ fields: fields,
46
+ view: view
47
+ }
48
+
49
+ ActiveSupport::Notifications.instrument 'praxis.blueprint.render'.freeze, notification_payload do
50
+ fields.each_with_object(Hash.new) do |(key, subfields), hash|
51
+ begin
52
+ value = object._get_attr(key)
53
+ rescue => e
54
+ raise Attributor::DumpError, context: context, name: key, type: object.class, original_exception: e
55
+ end
56
+
57
+ next if value.nil? && !self.include_nil
58
+
59
+ if subfields == true
60
+ hash[key] = value
61
+ else
62
+ new_context = context + [key]
63
+ hash[key] = self.render(value, subfields, context: new_context)
64
+ end
65
+
66
+ end
67
+ end
68
+ end
69
+
70
+ end
71
+ end
@@ -1,3 +1,3 @@
1
1
  module Praxis
2
- BLUEPRINTS_VERSION = "2.2"
2
+ BLUEPRINTS_VERSION = "3.0"
3
3
  end
@@ -6,18 +6,13 @@ module Praxis
6
6
  attr_reader :name
7
7
  attr_reader :options
8
8
 
9
- attr_reader :include_nil
10
-
11
9
  def initialize(name, schema, **options, &block)
12
10
  @name = name
13
11
  @schema = schema
14
12
  @contents = ::Hash.new
15
13
  @block = block
16
14
 
17
- @include_nil = options.fetch(:include_nil, false)
18
-
19
15
  @options = options
20
-
21
16
  end
22
17
 
23
18
  def contents
@@ -29,52 +24,21 @@ module Praxis
29
24
  @contents
30
25
  end
31
26
 
32
- def dump(object, context: Attributor::DEFAULT_ROOT_CONTEXT,**opts)
33
- fields = opts[:fields]
34
- # Restrict which attributes to output if we receive a fields parameter
35
- # Note: should we complain if any of the names in "fields" do not match an existing attribute name?
36
- attributes_to_render = self.contents.keys
37
- attributes_to_render &= fields.keys if fields
38
-
39
- attributes_to_render.each_with_object({}) do |name, hash|
40
- dumpable, dumpable_opts = self.contents[name]
41
- unless object.respond_to?(name)
42
- warn "#{object} does not respond to #{name} during rendering???"
43
- next
44
- end
45
-
46
- begin
47
- value = object.send(name)
48
- rescue => e
49
- raise Attributor::DumpError, context: context, name: name, type: object.class, original_exception: e
50
- end
27
+ def expanded_fields
28
+ @expanded_fields ||= begin
29
+ self.contents # force evaluation of the contents
30
+ FieldExpander.expand(self)
31
+ end
32
+ end
51
33
 
52
- if value.nil?
53
- next unless @include_nil
54
- end
34
+ def render(object, context: Attributor::DEFAULT_ROOT_CONTEXT, renderer: Renderer.new)
35
+ renderer.render(object, self.expanded_fields, context: context)
36
+ end
55
37
 
56
- # FIXME: this is such an ugly way to do this. Need attributor#67.
57
- if dumpable.kind_of?(View) || dumpable.kind_of?(CollectionView)
58
- new_context = context + [name]
38
+ alias_method :to_hash, :render # Why did we need this again?
59
39
 
60
- sub_opts = add_subfield_options( fields, name, dumpable_opts )
61
- hash[name] = dumpable.dump(value, context: new_context ,**sub_opts)
62
- else
63
40
 
64
- type = dumpable.type
65
- if type.respond_to?(:attributes) || type.respond_to?(:member_attribute)
66
- new_context = context + [name]
67
- sub_opts = add_subfield_options( fields, name, dumpable_opts )
68
- hash[name] = dumpable.dump(value, context: new_context ,**sub_opts)
69
- else
70
- hash[name] = value
71
- end
72
- end
73
- end
74
- end
75
- alias_method :to_hash, :dump
76
-
77
- def attribute(name, opts={}, &block)
41
+ def attribute(name, **opts, &block)
78
42
  raise AttributorException, "Attribute names must be symbols, got: #{name.inspect}" unless name.kind_of? ::Symbol
79
43
 
80
44
  attribute = self.schema.attributes.fetch(name) do
@@ -82,42 +46,59 @@ module Praxis
82
46
  end
83
47
 
84
48
  if block_given?
85
- view = View.new(name, attribute, &block)
86
- @contents[name] = view
49
+ type = attribute.type
50
+ @contents[name] = if type < Attributor::Collection
51
+ CollectionView.new(name, type.member_attribute.type, &block)
52
+ else
53
+ View.new(name, attribute, &block)
54
+ end
87
55
  else
88
- raise "Invalid options (#{opts.inspect}) for #{name} while defining view #{@name}" unless opts.is_a?(Hash)
89
- @contents[name] = [attribute, opts]
56
+ type = attribute.type
57
+ if type < Attributor::Collection
58
+ is_collection = true
59
+ type = type.member_attribute.type
60
+ end
61
+
62
+
63
+ if type < Praxis::Blueprint
64
+ view_name = opts[:view] || :default
65
+ view = type.views.fetch(view_name) do
66
+ raise "view with name '#{view_name.inspect}' is not defined in #{type}"
67
+ end
68
+ if is_collection
69
+ @contents[name] = Praxis::CollectionView.new(view_name, type, view)
70
+ else
71
+ @contents[name] = view
72
+ end
73
+ else
74
+ @contents[name] = attribute #, opts]
75
+ end
90
76
  end
91
77
 
92
78
  end
93
79
 
94
- def example(context=nil)
80
+ def example(context=Attributor::DEFAULT_ROOT_CONTEXT)
95
81
  object = self.schema.example(context)
96
82
  opts = {}
97
83
  opts[:context] = context if context
98
- self.dump(object, opts)
84
+ self.render(object, opts)
99
85
  end
100
86
 
101
87
  def describe
102
88
  # TODO: for now we are just return the first level keys
103
89
  view_attributes = {}
104
90
 
105
- self.contents.each do |k,(dumpable,dumpable_opts)|
91
+ self.contents.each do |k,dumpable|
106
92
  inner_desc = {}
107
- inner_desc[:view] = dumpable_opts[:view] if dumpable_opts && dumpable_opts[:view]
93
+ if dumpable.kind_of?(Praxis::View)
94
+ inner_desc[:view] = dumpable.name if dumpable.name
95
+ end
108
96
  view_attributes[k] = inner_desc
109
97
  end
110
98
 
111
99
  { attributes: view_attributes, type: :standard }
112
100
  end
113
101
 
114
- private
115
- def add_subfield_options(fields, name, existing_options)
116
- sub_opts = if fields && fields[name]
117
- {fields: fields[name] }
118
- else
119
- {}
120
- end.merge(existing_options || {})
121
- end
102
+
122
103
  end
123
104
  end
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = "Praxis Blueprints is a library that allows for defining a reusable class structures that has a set of typed attributes and a set of views with which to render them. Instantiations of Blueprints resemble ruby Structs which respond to methods of the attribute names. Rendering is format-agnostic in that
11
11
  it results in a structured hash instead of an encoded string. Blueprints can automatically generate object structures that follow the attribute definitions."
12
12
  spec.email = ["blanquer@gmail.com","dane.jensen@gmail.com"]
13
-
13
+
14
14
  spec.homepage = "https://github.com/rightscale/praxis-blueprints"
15
15
  spec.license = "MIT"
16
16
  spec.required_ruby_version = ">=2.1"
@@ -20,7 +20,7 @@ it results in a structured hash instead of an encoded string. Blueprints can aut
20
20
  spec.require_paths = ["lib"]
21
21
 
22
22
  spec.add_runtime_dependency(%q<randexp>, ["~> 0"])
23
- spec.add_runtime_dependency(%q<attributor>, [">= 4.0.1"])
23
+ spec.add_runtime_dependency(%q<attributor>, [">= 4.1"])
24
24
  spec.add_runtime_dependency(%q<activesupport>, [">= 3"])
25
25
 
26
26
  spec.add_development_dependency "bundler", "~> 1.6"
@@ -25,14 +25,15 @@ describe Praxis::Blueprint do
25
25
  end
26
26
 
27
27
  it 'uses :master view for rendering blueprint sub-attributes' do
28
- dumpable, dumpable_opts = master_view.contents[:address]
29
- dumpable_opts[:view].should == :master
28
+ subview = master_view.contents[:address]
29
+ subview.should be Address.views[:default]
30
30
  end
31
31
  end
32
32
 
33
33
  context 'creating a new Blueprint class' do
34
34
  subject!(:blueprint_class) do
35
35
  Class.new(Praxis::Blueprint) do
36
+ domain_model Hash
36
37
  attributes do
37
38
  attribute :id, Integer
38
39
  end
@@ -40,6 +41,7 @@ describe Praxis::Blueprint do
40
41
  end
41
42
 
42
43
  its(:finalized?) { should be(false) }
44
+ its(:domain_model) { should be(Hash) }
43
45
 
44
46
  context '.finalize on Praxis::Blueprint' do
45
47
  before do
@@ -101,19 +103,6 @@ describe Praxis::Blueprint do
101
103
  it 'has the right values' do
102
104
  subject[:name].should eq(expected_name)
103
105
  end
104
-
105
- it 'sends the correct ActiveSupport::Notification' do
106
- notification_payload = {
107
- blueprint: blueprint_instance,
108
- view: blueprint_class.views[:name_only],
109
- fields: nil
110
- }
111
- ActiveSupport::Notifications.should_receive(:instrument).
112
- with('praxis.blueprint.render',notification_payload).
113
- and_call_original
114
-
115
- blueprint_instance.render(view: :name_only)
116
- end
117
106
  end
118
107
 
119
108
  context 'validation' do
@@ -140,7 +129,7 @@ describe Praxis::Blueprint do
140
129
  email: "bob@example.com",
141
130
  aliases: [],
142
131
  prior_addresses: [],
143
- parents: { father: /[:first_name:]/.gen, mother: /[:first_name:]/.gen},
132
+ parents: { father: Randgen.first_name, mother: Randgen.first_name},
144
133
  href: "www.example.com",
145
134
  alive: true
146
135
  }
@@ -245,217 +234,178 @@ describe Praxis::Blueprint do
245
234
 
246
235
  it { should have(1).item }
247
236
  its(:first) { should =~ /Attribute \$.address.state/ }
248
- end
237
+ end
249
238
 
250
- context 'for objects of the wrong type' do
251
- it 'raises an error' do
252
- expect {
253
- Person.validate(Object.new)
254
- }.to raise_error(ArgumentError, /Error validating .* as Person for an object of type Object/)
255
- end
256
- end
257
- end
239
+ context 'for objects of the wrong type' do
240
+ it 'raises an error' do
241
+ expect {
242
+ Person.validate(Object.new)
243
+ }.to raise_error(ArgumentError, /Error validating .* as Person for an object of type Object/)
244
+ end
245
+ end
246
+ end
258
247
 
259
- context '.load' do
260
- let(:hash) do
261
- {
262
- :name => 'Bob',
263
- :full_name => {:first => 'Robert', :last => 'Robertson'},
264
- :address => {:street => 'main', :state => 'OR'}
265
- }
266
- end
267
- subject(:person) { Person.load(hash) }
268
-
269
- it { should be_kind_of(Person) }
270
-
271
- context 'recursively loading sub-attributes' do
272
- context 'for a Blueprint' do
273
- subject(:address) { person.address }
274
- it { should be_kind_of(Address) }
275
- end
276
- context 'for an Attributor::Model' do
277
- subject(:full_name) { person.full_name }
278
- it { should be_kind_of(FullName) }
279
- end
280
- end
248
+ context '.load' do
249
+ let(:hash) do
250
+ {
251
+ :name => 'Bob',
252
+ :full_name => {:first => 'Robert', :last => 'Robertson'},
253
+ :address => {:street => 'main', :state => 'OR'}
254
+ }
255
+ end
256
+ subject(:person) { Person.load(hash) }
281
257
 
282
- end
258
+ it { should be_kind_of(Person) }
283
259
 
260
+ context 'recursively loading sub-attributes' do
261
+ context 'for a Blueprint' do
262
+ subject(:address) { person.address }
263
+ it { should be_kind_of(Address) }
264
+ end
265
+ context 'for an Attributor::Model' do
266
+ subject(:full_name) { person.full_name }
267
+ it { should be_kind_of(FullName) }
268
+ end
269
+ end
284
270
 
285
- context 'decorators' do
286
- let(:name) { 'Soren II' }
271
+ end
287
272
 
288
- let(:object) { Person.example.object }
289
- subject(:person) { Person.new(object, decorators) }
290
273
 
274
+ context 'decorators' do
275
+ let(:name) { 'Soren II' }
291
276
 
292
- context 'as a hash' do
293
- let(:decorators) { {name: name} }
294
- it do
295
- pers = person
296
- # binding.pry
297
- pers.name.should eq('Soren II')
298
- end
277
+ let(:object) { Person.example.object }
278
+ subject(:person) { Person.new(object, decorators) }
299
279
 
300
- its(:name) { should be(name) }
301
280
 
302
- context 'an additional instance with the equivalent hash' do
303
- subject(:additional_person) { Person.new(object, {name: name}) }
304
- it { should_not be person }
305
- end
281
+ context 'as a hash' do
282
+ let(:decorators) { {name: name} }
283
+ it do
284
+ person.name.should eq('Soren II')
285
+ end
306
286
 
307
- context 'an additional instance with the same hash object' do
308
- subject(:additional_person) { Person.new(object, decorators) }
309
- it { should_not be person }
310
- end
287
+ its(:name) { should be(name) }
311
288
 
312
- context 'an instance of the same object without decorators' do
313
- subject(:additional_person) { Person.new(object) }
314
- it { should_not be person }
315
- end
316
- end
289
+ context 'an additional instance with the equivalent hash' do
290
+ subject(:additional_person) { Person.new(object, {name: name}) }
291
+ it { should_not be person }
292
+ end
317
293
 
318
- context 'as an object' do
319
- let(:decorators) { double("decorators", name: name) }
320
- its(:name) { should be(name) }
294
+ context 'an additional instance with the same hash object' do
295
+ subject(:additional_person) { Person.new(object, decorators) }
296
+ it { should_not be person }
297
+ end
321
298
 
322
- context 'an additional instance with the same object' do
323
- subject(:additional_person) { Person.new(object, decorators) }
324
- it { should_not be person }
325
- end
326
- end
299
+ context 'an instance of the same object without decorators' do
300
+ subject(:additional_person) { Person.new(object) }
301
+ it { should_not be person }
302
+ end
303
+ end
327
304
 
328
- end
305
+ context 'as an object' do
306
+ let(:decorators) { double("decorators", name: name) }
307
+ its(:name) { should be(name) }
329
308
 
309
+ context 'an additional instance with the same object' do
310
+ subject(:additional_person) { Person.new(object, decorators) }
311
+ it { should_not be person }
312
+ end
313
+ end
330
314
 
331
- context 'with a provided :reference option on attributes' do
332
- context 'that does not match the value set on the class' do
315
+ end
333
316
 
334
- subject(:mismatched_reference) do
335
- Class.new(Praxis::Blueprint) do
336
- self.reference = Class.new(Praxis::Blueprint)
337
- attributes(reference: Class.new(Praxis::Blueprint)) {}
338
- end
339
- end
340
317
 
341
- it 'should raise an error' do
342
- expect {
343
- mismatched_reference.attributes
344
- }.to raise_error
345
- end
318
+ context 'with a provided :reference option on attributes' do
319
+ context 'that does not match the value set on the class' do
346
320
 
347
- end
348
- end
321
+ subject(:mismatched_reference) do
322
+ Class.new(Praxis::Blueprint) do
323
+ self.reference = Class.new(Praxis::Blueprint)
324
+ attributes(reference: Class.new(Praxis::Blueprint)) {}
325
+ end
326
+ end
349
327
 
328
+ it 'should raise an error' do
329
+ expect {
330
+ mismatched_reference.attributes
331
+ }.to raise_error
332
+ end
350
333
 
351
- context '.example' do
352
- context 'with some attribute values provided' do
353
- let(:name) { 'Sir Bobbert' }
354
- subject(:person) { Person.example(name: name) }
355
- its(:name) { should eq(name) }
356
- end
357
- end
334
+ end
335
+ end
358
336
 
359
- context '.render' do
360
- let(:person) { Person.example }
361
- it 'is an alias to dump' do
362
- rendered = Person.render(person, view: :default)
363
- dumped = Person.dump(person, view: :default)
364
- expect(rendered).to eq(dumped)
365
- end
366
- end
367
337
 
368
- context '#render' do
369
- let(:person) { Person.example }
370
- let(:view_name) { :default }
371
- let(:render_opts) { {} }
372
- subject(:output) { person.render(view: view_name, **render_opts) }
373
-
374
- context 'caches rendered views' do
375
- it 'in the instance, by view name' do
376
- person.instance_variable_get(:@rendered_views)[view_name].should be_nil
377
- person.render(view: view_name)
378
- cached = person.instance_variable_get(:@rendered_views)[view_name]
379
- cached.should_not be_nil
380
- end
381
-
382
- it 'and does not re-render a view if one is already cached' do
383
- rendered1 = person.render(view: view_name)
384
- rendered2 = person.render(view: view_name)
385
- rendered1.should be(rendered2)
386
- end
387
-
388
- context 'even when :fields are specified' do
389
- let(:render_opts) { {fields: {email: nil, age: nil, address: {street: nil, state: nil}}} }
390
-
391
- it 'caches the output in a different key than just the view_name' do
392
- plain_view_render = person.render(view: view_name)
393
- fields_render = person.render(view: view_name, **render_opts)
394
- plain_view_render.should_not be(fields_render)
395
- end
396
-
397
- it 'it still caches the object if rendered for the same fields' do
398
- rendered1 = person.render(view: view_name, **render_opts)
399
- rendered2 = person.render(view: view_name, **render_opts)
400
- rendered1.should be(rendered2)
401
- end
402
-
403
- it 'it still caches the object if rendered for the same fields (even from an "equivalent" hash)' do
404
- rendered1 = person.render(view: view_name, **render_opts)
405
-
406
- equivalent_render_opts = { fields: {age: nil, address: {state: nil, street: nil}, email: nil} }
407
- rendered2 = person.render(view: view_name, **equivalent_render_opts)
408
-
409
- rendered1.should be(rendered2)
410
- end
411
- end
412
-
413
- end
414
-
415
- context 'with a sub-attribute that is a blueprint' do
416
-
417
- it { should have_key(:name) }
418
- it { should have_key(:address) }
419
- it 'renders the sub-attribute correctly' do
420
- output[:address].should have_key(:street)
421
- output[:address].should have_key(:state)
422
- end
423
-
424
- it 'reports a dump error with the appropriate context' do
425
- person.address.should_receive(:state).and_raise("Kaboom")
426
- expect {
427
- person.render(view: view_name, context: ['special_root'])
428
- }.to raise_error(/Error while dumping attribute state of type Address for context special_root.address. Reason: .*Kaboom/)
429
- end
430
- end
431
-
432
-
433
- context 'with sub-attribute that is an Attributor::Model' do
434
- it { should have_key(:full_name) }
435
- it 'renders the model correctly' do
436
- output[:full_name].should be_kind_of(Hash)
437
- output[:full_name].should have_key(:first)
438
- output[:full_name].should have_key(:last)
439
- end
440
- end
441
-
442
- context 'using the `fields` option' do
443
- context 'as a hash' do
444
- subject(:output) { person.render(view: view_name, fields: {address: { state: nil} } ) }
445
- it 'should only have the address rendered' do
446
- output.keys.should == [:address]
447
- end
448
- it 'address should only have state' do
449
- output[:address].keys.should == [:state]
450
- end
451
- end
452
- context 'as a simple array' do
453
- subject(:output) { person.render(view: view_name, fields: [:address] ) }
454
- it 'accepts it as the list of top-level attributes to be rendered' do
455
- output.keys.should == [:address]
456
- end
457
- end
458
- end
459
- end
338
+ context '.example' do
339
+ context 'with some attribute values provided' do
340
+ let(:name) { 'Sir Bobbert' }
341
+ subject(:person) { Person.example(name: name) }
342
+ its(:name) { should eq(name) }
343
+ end
344
+ end
460
345
 
461
- end
346
+ context '.render' do
347
+ let(:person) { Person.example('1') }
348
+ it 'is an alias to dump' do
349
+
350
+ person.object.contents
351
+ rendered = Person.render(person, view: :default)
352
+ dumped = Person.dump(person, view: :default)
353
+ expect(rendered).to eq(dumped)
354
+ end
355
+ end
356
+
357
+ context '#render' do
358
+ let(:person) { Person.example }
359
+ let(:view_name) { :default }
360
+ let(:render_opts) { {} }
361
+ subject(:output) { person.render(view: view_name, **render_opts) }
362
+
363
+
364
+
365
+ context 'with a sub-attribute that is a blueprint' do
366
+
367
+ it { should have_key(:name) }
368
+ it { should have_key(:address) }
369
+ it 'renders the sub-attribute correctly' do
370
+ output[:address].should have_key(:street)
371
+ output[:address].should have_key(:state)
372
+ end
373
+
374
+ it 'reports a dump error with the appropriate context' do
375
+ person.address.should_receive(:state).and_raise("Kaboom")
376
+ expect {
377
+ person.render(view: view_name, context: ['special_root'])
378
+ }.to raise_error(/Error while dumping attribute state of type Address for context special_root.address. Reason: .*Kaboom/)
379
+ end
380
+ end
381
+
382
+
383
+ context 'with sub-attribute that is an Attributor::Model' do
384
+ it { should have_key(:full_name) }
385
+ it 'renders the model correctly' do
386
+ output[:full_name].should be_kind_of(Hash)
387
+ output[:full_name].should have_key(:first)
388
+ output[:full_name].should have_key(:last)
389
+ end
390
+ end
391
+
392
+ context 'using the `fields` option' do
393
+ context 'as a hash' do
394
+ subject(:output) { person.render(fields: {address: {state: true}}) }
395
+ it 'should only have the address rendered' do
396
+ output.keys.should eq [:address]
397
+ end
398
+ it 'address should only have state' do
399
+ output[:address].keys.should eq [:state]
400
+ end
401
+ end
402
+ context 'as a simple array' do
403
+ subject(:output) { person.render(fields: [:full_name]) }
404
+ it 'accepts it as the list of top-level attributes to be rendered' do
405
+ output.keys.should == [:full_name]
406
+ end
407
+ end
408
+ end
409
+ end
410
+
411
+ end