halitosis 0.1.0 → 0.2.0

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: 759e2f5eada1444024bd208f82706b0a30b40dbfe24f6c1d829bea49067d1b3d
4
- data.tar.gz: 72b52a2ccba027e2745fbcc245b7311b8aca6097afc77395c92f4274036c8d00
3
+ metadata.gz: 6dc2b6951bb551687107ecbf28dd84ef5d616b86a01f405bfd0578592e2ae572
4
+ data.tar.gz: a98c1b99cc9973958db53bd3ea5d6acd11de9b6f79fb9a5c9fcf07f97514de01
5
5
  SHA512:
6
- metadata.gz: b4384527d214837272668d24c9e0f4c7ad9578189d0c31bbadb8490b00a88f2cd92b236c7c36f3584f98abebf216980c626c6b617c72e4e217d11bac3df175ed
7
- data.tar.gz: 1f4d4c859877996961a9187397ab9913f0214542893d718f5fe8c9dc226ceaff5eb2d298f4b18d62f0a65ad5e1a32330268ff7c1eb57c25db25d0e032fecbad6
6
+ metadata.gz: dfed5ec625a1764322fa5ddc3df08b3fe0ee2dca6bcbe21da0c92daa00a168518f2f31e69ac0c7834ef4dded8ba3c62e93ba9349f9e68d63e7403ce98131d96b
7
+ data.tar.gz: bb36dc686ccecdefc51500b9cf5d8142a72d4e460ad562ea020de9b93b57f00f9a2ff4ddac12351afe3060c2494408dbefa3735002672bb80e39d97bb88e2f34
data/README.md CHANGED
@@ -21,7 +21,7 @@ Need something more standardized ([JSON:API](https://jsonapi.org/), or [HAL](htt
21
21
  Add this line to your application's Gemfile:
22
22
 
23
23
  ```ruby
24
- gem "`UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG`"
24
+ gem "halitosis"
25
25
  ```
26
26
 
27
27
  And then execute:
@@ -33,7 +33,7 @@ $ bundle install
33
33
  Or install it yourself as:
34
34
 
35
35
  ```bash
36
- $ gem install `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG`
36
+ $ gem install halitosis
37
37
  ```
38
38
 
39
39
  ### Basic usage
@@ -51,7 +51,7 @@ class DuckSerializer
51
51
 
52
52
  resource :duck
53
53
 
54
- property :name
54
+ attribute :name
55
55
 
56
56
  link :self do
57
57
  "/ducks/#{duck.code}"
@@ -68,11 +68,13 @@ serializer = DuckSerializer.new(duck)
68
68
 
69
69
  Then call `serializer.render`:
70
70
 
71
- ```json
71
+ ```ruby
72
72
  {
73
- name: 'Ferdi',
74
- _links: {
75
- self: { href: '/ducks/ferdi' }
73
+ duck: {
74
+ name: 'Ferdi',
75
+ _links: {
76
+ self: { href: '/ducks/ferdi' }
77
+ }
76
78
  }
77
79
  }
78
80
  ```
@@ -80,7 +82,7 @@ Then call `serializer.render`:
80
82
  Or `serializer.to_json`:
81
83
 
82
84
  ```ruby
83
- '{"name": "Ferdi", "_links": {"self": {"href": "/ducks/ferdi"}}}'
85
+ '{"duck": {"name": "Ferdi", "_links": {"self": {"href": "/ducks/ferdi"}}}}'
84
86
  ```
85
87
 
86
88
 
@@ -117,10 +119,10 @@ When a resource is declared, `#initialize` expects the resource as the first arg
117
119
  serializer = DuckSerializer.new(Duck.new, ...)
118
120
  ```
119
121
 
120
- This makes property definitions cleaner:
122
+ This makes attribute definitions cleaner:
121
123
 
122
124
  ```ruby
123
- property :name # now calls Duck#name by default
125
+ attribute :name # now calls Duck#name by default
124
126
  ```
125
127
 
126
128
  #### 3. Collection
@@ -139,39 +141,51 @@ end
139
141
 
140
142
  The block should return an array of Halitosis instances in order to be rendered.
141
143
 
142
- ### Defining properties, links, relationships, meta, and permissions
144
+ ### Defining attributes, links, relationships, meta, and permissions
143
145
 
144
- Properties can be defined in several ways:
146
+ Attributes can be defined in several ways:
145
147
 
146
148
  ```ruby
147
- property(:quacks) { "#{duck.quacks} per minute" }
149
+ attribute(:quacks) { "#{duck.quacks} per minute" }
150
+ ```
151
+
152
+ ```ruby
153
+ attribute :quacks # => Duck#quacks, if resource is declared
148
154
  ```
149
155
 
150
156
  ```ruby
151
- property :quacks # => Duck#quacks, if resource is declared
157
+ attribute :quacks, value: "many"
152
158
  ```
153
159
 
154
160
  ```ruby
155
- property :quacks do
161
+ attribute :quacks do
156
162
  duck.quacks.round
157
163
  end
158
164
  ```
159
165
 
160
166
  ```ruby
161
- property(:quacks) { calculate_quacks }
167
+ attribute(:quacks) { calculate_quacks }
162
168
 
163
169
  def calculate_quacks
164
170
  ...
165
171
  end
166
172
  ```
167
173
 
174
+ Attributes can also be implemented using the legacy `property` alias:
175
+
176
+ ```ruby
177
+ property(:quacks) { "#{duck.quacks} per minute" }
178
+ property :quacks # Duck#quacks
179
+ property :quacks, value: "many"
180
+ ```
181
+
168
182
  #### Conditionals
169
183
 
170
- The inclusion of properties can be determined by conditionals using `if` and
184
+ The inclusion of attributes can be determined by conditionals using `if` and
171
185
  `unless` options. For example, with a method name:
172
186
 
173
187
  ```ruby
174
- property :quacks, if: :include_quacks?
188
+ attribute :quacks, if: :include_quacks?
175
189
 
176
190
  def include_quacks?
177
191
  duck.quacks < 10
@@ -180,7 +194,7 @@ end
180
194
 
181
195
  With a proc:
182
196
  ```ruby
183
- property :quacks, unless: proc { duck.quacks.nil? }, value: ...
197
+ attribute :quacks, unless: proc { duck.quacks.nil? }, value: ...
184
198
  ```
185
199
 
186
200
  For links and relationships:
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Halitosis
4
- module Properties
4
+ module Attributes
5
5
  class Field < Halitosis::Field
6
6
  end
7
7
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Attributes
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ module ClassMethods
12
+ # Legacy alias for attribute
13
+ #
14
+ # @param name [Symbol, String]
15
+ # @param options [nil, Hash]
16
+ #
17
+ # @return [Halitosis::Attributes::Field]
18
+ def property(...)
19
+ attribute(...)
20
+ end
21
+
22
+ # Rails-style attribute definition
23
+ #
24
+ # @param name [Symbol, String]
25
+ # @param options [nil, Hash]
26
+ #
27
+ # @return [Halitosis::Attributes::Field]
28
+ #
29
+ def attribute(name, options = {}, &procedure)
30
+ fields.add(Field.new(name, options, procedure))
31
+ end
32
+ end
33
+
34
+ module InstanceMethods
35
+ # @return [Hash] the rendered hash with attributes, if any
36
+ #
37
+ def render_with_context(context)
38
+ super.merge(attributes(context))
39
+ end
40
+
41
+ # @return [Hash] attributes from fields
42
+ #
43
+ def attributes(context = build_context)
44
+ render_fields(Field, context) do |field, result|
45
+ result[field.name] = field.value(context)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ require "halitosis/attributes/field"
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Halitosis
4
+ # Base module for all serializer classes.
5
+ #
6
+ # Include this module in your serializer class, and include any additional field-type modules
4
7
  module Base
5
8
  def self.included(base)
6
9
  base.extend ClassMethods
7
10
 
8
11
  base.send :include, InstanceMethods
9
- base.send :include, Links
10
- base.send :include, Meta
11
- base.send :include, Permissions
12
- base.send :include, Properties
13
- base.send :include, Relationships
14
12
 
15
13
  base.send :attr_reader, :options
14
+
15
+ base.class.send :attr_accessor, :resource_type
16
16
  end
17
17
 
18
18
  module ClassMethods
@@ -32,7 +32,7 @@ module Halitosis
32
32
  # @return [Object] the serializer instance
33
33
  #
34
34
  def initialize(**options)
35
- @options = Halitosis::HashUtil.symbolize_params(options)
35
+ @options = Halitosis::HashUtil.symbolize_hash(options).freeze
36
36
  end
37
37
 
38
38
  # @return [Hash, Array] rendered JSON
@@ -49,20 +49,14 @@ module Halitosis
49
49
  # @return [Hash] rendered representation
50
50
  #
51
51
  def render
52
- {}
53
- end
54
-
55
- # @return [nil, Object] the parent serializer, if this instance is an
56
- # embedded child
57
- #
58
- def parent
59
- @parent ||= options.fetch(:parent, nil)
52
+ render_with_context(build_context)
60
53
  end
61
54
 
62
- # @return [Integer] the depth at which this serializer is embedded
55
+ # @param context [Halitosis::Context] the context instance
56
+ # @return [Hash] the rendered hash
63
57
  #
64
- def depth
65
- @depth ||= parent ? parent.depth + 1 : 0
58
+ def render_with_context(_context)
59
+ {}
66
60
  end
67
61
 
68
62
  def collection?
@@ -71,6 +65,13 @@ module Halitosis
71
65
 
72
66
  protected
73
67
 
68
+ # Build a new context instance using this serializer instance
69
+ #
70
+ # @return [Halitosis::Context] the context instance
71
+ def build_context(options = {})
72
+ Context.new(self, HashUtil.deep_merge(@options, options))
73
+ end
74
+
74
75
  # Allow included modules to decorate rendered hash
75
76
  #
76
77
  # @param key [Symbol] the key (e.g. `embedded`, `links`)
@@ -78,9 +79,9 @@ module Halitosis
78
79
  #
79
80
  # @return [Hash] the decorated hash
80
81
  #
81
- def decorate_render(key, result)
82
+ def decorate_render(key, context, result)
82
83
  result.tap do
83
- value = send(key)
84
+ value = send(key, context)
84
85
 
85
86
  result[:"_#{key}"] = value if value.any?
86
87
  end
@@ -93,11 +94,11 @@ module Halitosis
93
94
  #
94
95
  # @return [Hash] the result
95
96
  #
96
- def render_fields(type)
97
- fields = self.class.fields.fetch(type, [])
97
+ def render_fields(type, context)
98
+ fields = self.class.fields.for_type(type)
98
99
 
99
100
  fields.each_with_object({}) do |field, result|
100
- next unless field.enabled?(self)
101
+ next unless field.enabled?(context)
101
102
 
102
103
  yield field, result
103
104
  end
@@ -108,15 +109,10 @@ module Halitosis
108
109
  #
109
110
  # @return [nil, Hash] the rendered child
110
111
  #
111
- def render_child(child, opts)
112
- return unless child.class.included_modules.include?(Halitosis)
113
-
114
- child.options[:include] ||= {}
115
- child.options[:include] = child.options[:include].merge(opts)
116
-
117
- child.options[:parent] = self
112
+ def render_child(child, context, opts)
113
+ return unless child.class.included_modules.include?(Halitosis::Base)
118
114
 
119
- child.render
115
+ child.render_with_context child.build_context(parent: context, include: opts)
120
116
  end
121
117
  end
122
118
  end
@@ -3,6 +3,17 @@
3
3
  module Halitosis
4
4
  module Collection
5
5
  class Field < Halitosis::Field
6
+ # @return [true] if nothing is raised
7
+ #
8
+ # @raise [Halitosis::InvalidField] if the definition is invalid
9
+ #
10
+ def validate
11
+ super
12
+
13
+ return true if procedure
14
+
15
+ raise InvalidField, "Collection #{name} must be defined with a proc"
16
+ end
6
17
  end
7
18
  end
8
19
  end
@@ -15,8 +15,6 @@ module Halitosis
15
15
  base.send :include, InstanceMethods
16
16
 
17
17
  base.send :attr_reader, :collection
18
-
19
- base.class.send :attr_accessor, :collection_name
20
18
  end
21
19
 
22
20
  module ClassMethods
@@ -25,9 +23,9 @@ module Halitosis
25
23
  # @return [Module] self
26
24
  #
27
25
  def define_collection(name, options = {}, &procedure)
28
- raise InvalidCollection, "#{self.name} collection is already defined" if fields.key?(Field.name)
26
+ raise InvalidCollection, "#{self.name || Collection.name} collection is already defined" if fields.for_type(Field).any?
29
27
 
30
- self.collection_name = name.to_s
28
+ self.resource_type = name.to_s
31
29
 
32
30
  alias_method name, :collection
33
31
 
@@ -39,7 +37,7 @@ module Halitosis
39
37
  end
40
38
 
41
39
  def collection_field
42
- fields[Field.name].last || raise(InvalidCollection, "#{name} collection is not defined")
40
+ fields.for_type(Field).last || raise(InvalidCollection, "#{name || Collection.name} collection is not defined")
43
41
  end
44
42
  end
45
43
 
@@ -50,47 +48,47 @@ module Halitosis
50
48
  #
51
49
  def initialize(collection, **)
52
50
  @collection = collection
51
+ @collection_field = self.class.collection_field
53
52
 
54
53
  super(**)
55
54
  end
56
55
 
57
- # @return [Hash] the rendered hash with collection, if any
56
+ # @return [Hash, Array] the rendered hash with collection, as an array or a hash under a key
58
57
  #
59
- def render
60
- field = self.class.collection_field
61
- if depth.zero?
62
- super.merge(field.name => render_collection_field(field))
58
+ def render_with_context(context)
59
+ if (include_root = context.fetch(:include_root) { context.depth.zero? })
60
+ {
61
+ root_name(include_root) => render_collection_field(context)
62
+ }.merge(super)
63
63
  else
64
- render_collection_field(field)
64
+ render_collection_field(context)
65
65
  end
66
66
  end
67
67
 
68
- # @return [Hash] collection from fields
69
- #
70
- def render_collection_field(field)
71
- value = instance_eval(&field.procedure)
72
- value.map { |child| render_child(child, collection_opts) }
73
- end
74
-
75
68
  def collection?
76
69
  true
77
70
  end
78
71
 
79
72
  private
80
73
 
81
- # @param key [String]
82
- #
83
- # @return [Hash]
74
+ attr_reader :collection_field
75
+
76
+ # @return [Hash] collection from fields
84
77
  #
85
- def collection_opts
86
- return include_options if depth.positive?
78
+ def render_collection_field(context)
79
+ value = collection_field.value(context)
87
80
 
88
- opts = include_options.fetch(self.class.collection_field.name.to_s, {})
81
+ return render_child(value, context, context.include_options) if value.is_a?(Halitosis::Collection)
82
+
83
+ value.reject { |child| child.is_a?(Halitosis::Collection) } # Skip nested collections in array
84
+ .map { |child| render_child(child, context, context.include_options) }
85
+ .compact
86
+ end
89
87
 
90
- # Turn { :report => 1 } into { :report => {} } for child
91
- opts = {} unless opts.is_a?(Hash)
88
+ def root_name(include_root)
89
+ return include_root.to_sym if include_root.is_a?(String) || include_root.is_a?(Symbol)
92
90
 
93
- opts
91
+ self.class.resource_type.to_sym
94
92
  end
95
93
  end
96
94
  end
@@ -0,0 +1,54 @@
1
+ module Halitosis
2
+ class Context
3
+ # @param instance [Halitosis::Base] the serializer instance
4
+ # @param options [Hash] hash of options
5
+ def initialize(instance, options = {})
6
+ @instance = instance
7
+ @options = HashUtil.symbolize_hash(options).freeze
8
+ end
9
+
10
+ ### Instance ###
11
+
12
+ # Evaluate guard procedure or method on the serializer instance
13
+ #
14
+ def call_instance(guard)
15
+ case guard
16
+ when Proc
17
+ instance.instance_exec(self, &guard)
18
+ when Symbol, String
19
+ instance.send(guard)
20
+ else
21
+ guard
22
+ end
23
+ end
24
+
25
+ ### Options ###
26
+
27
+ def fetch(...)
28
+ options.fetch(...)
29
+ end
30
+
31
+ # @return [Hash] hash of options with top level string keys
32
+ #
33
+ def include_options
34
+ @include_options ||= HashUtil.hasherize_include_option(options[:include] || {})
35
+ end
36
+
37
+ # @return [nil, Halitosis::Context] the parent context, if this instance is an
38
+ # embedded child
39
+ #
40
+ def parent
41
+ options.fetch(:parent, nil)
42
+ end
43
+
44
+ # @return [Integer] the depth at which this serializer is embedded
45
+ #
46
+ def depth
47
+ @depth ||= parent ? parent.depth + 1 : 0
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :instance, :options
53
+ end
54
+ end
@@ -3,9 +3,22 @@
3
3
  module Halitosis
4
4
  class Error < StandardError; end
5
5
 
6
+ ### Configuration Errors ###
7
+
6
8
  class InvalidCollection < StandardError; end
7
9
 
8
10
  class InvalidField < StandardError; end
9
11
 
10
12
  class InvalidResource < StandardError; end
13
+
14
+ ### Rendering Errors ###
15
+
16
+ class InvalidQueryParameter < Error
17
+ def initialize(message, parameter)
18
+ @parameter = parameter
19
+ super(message)
20
+ end
21
+
22
+ attr_reader :parameter
23
+ end
11
24
  end
@@ -7,8 +7,6 @@ module Halitosis
7
7
  class Field
8
8
  attr_reader :name, :options
9
9
 
10
- attr_accessor :procedure
11
-
12
10
  # Construct a new Field instance
13
11
  #
14
12
  # @param name [Symbol, String] Field name
@@ -18,27 +16,25 @@ module Halitosis
18
16
  #
19
17
  def initialize(name, options, procedure)
20
18
  @name = name.to_sym
21
- @options = Halitosis::HashUtil.symbolize_params(options)
19
+ @options = Halitosis::HashUtil.symbolize_hash(options)
22
20
  @procedure = procedure
23
21
  end
24
22
 
25
- # @param instance [Object] the serializer instance with which to evaluate
23
+ # @param context [Halitosis::Context] the serializer instance with which to evaluate
26
24
  # the stored procedure
27
25
  #
28
- def value(instance)
29
- options.fetch(:value) do
30
- procedure ? instance.instance_eval(&procedure) : instance.send(name)
31
- end
26
+ def value(context)
27
+ options.fetch(:value) { context.call_instance(procedure || name) }
32
28
  end
33
29
 
34
30
  # @return [true, false] whether this Field should be included based on
35
31
  # its conditional guard, if any
36
32
  #
37
- def enabled?(instance)
33
+ def enabled?(context)
38
34
  if options.key?(:if)
39
- !!eval_guard(instance, options.fetch(:if))
35
+ !!context.call_instance(options.fetch(:if))
40
36
  elsif options.key?(:unless)
41
- !eval_guard(instance, options.fetch(:unless))
37
+ !context.call_instance(options.fetch(:unless))
42
38
  else
43
39
  true
44
40
  end
@@ -57,17 +53,6 @@ module Halitosis
57
53
 
58
54
  private
59
55
 
60
- # Evaluate guard procedure or method
61
- #
62
- def eval_guard(instance, guard)
63
- case guard
64
- when Proc
65
- instance.instance_eval(&guard)
66
- when Symbol, String
67
- instance.send(guard)
68
- else
69
- guard
70
- end
71
- end
56
+ attr_reader :procedure
72
57
  end
73
58
  end
@@ -9,10 +9,16 @@ module Halitosis
9
9
 
10
10
  field.validate
11
11
 
12
+ field.freeze
13
+
12
14
  self[type] ||= []
13
15
  self[type] << field
14
16
 
15
17
  field
16
18
  end
19
+
20
+ def for_type(type)
21
+ fetch(type.name, [])
22
+ end
17
23
  end
18
24
  end
@@ -4,23 +4,42 @@ module Halitosis
4
4
  module HashUtil
5
5
  module_function
6
6
 
7
- # Transform hash keys into strings if necessary
7
+ # Transform include params into a hash
8
8
  #
9
- # @param hash [Hash, Array, String]
9
+ # @param object [Hash, Array, String]
10
10
  #
11
11
  # @return [Hash]
12
- #
13
- def stringify_params(hash)
14
- case hash
15
- when String
16
- hash.split(",").inject({}) do |output, key|
12
+ def hasherize_include_option(object)
13
+ case object
14
+ when Hash
15
+ object.transform_keys(&:to_s)
16
+ when String, Symbol
17
+ object.to_s.split(",").inject({}) do |output, key|
17
18
  f, value = key.split(".", 2)
18
- output.merge(f => value ? stringify_params(value) : true)
19
+ deep_merge(output, f => value ? hasherize_include_option(value) : {})
19
20
  end
20
21
  when Array
21
- hash.map { |item| stringify_params(item) }.inject({}, &:merge)
22
+ object.inject({}) do |output, value|
23
+ deep_merge(output, hasherize_include_option(value))
24
+ end
22
25
  else
23
- hash.transform_keys(&:to_s)
26
+ object
27
+ end
28
+ end
29
+
30
+ # Deep merge two hashes
31
+ #
32
+ # @param hash [Hash]
33
+ # @param other_hash [Hash]
34
+ #
35
+ # @return [Hash]
36
+ def deep_merge(hash, other_hash)
37
+ hash.merge(other_hash) do |key, this_val, other_val|
38
+ if this_val.is_a?(Hash) && other_val.is_a?(Hash)
39
+ deep_merge(this_val, other_val)
40
+ else
41
+ other_val
42
+ end
24
43
  end
25
44
  end
26
45
 
@@ -30,9 +49,9 @@ module Halitosis
30
49
  #
31
50
  # @return [Hash]
32
51
  #
33
- def symbolize_params(hash)
52
+ def symbolize_hash(hash)
34
53
  if hash.respond_to?(:transform_keys)
35
- hash.transform_keys(&:to_sym).transform_values(&method(:symbolize_params))
54
+ hash.transform_keys(&:to_sym).transform_values(&method(:symbolize_hash))
36
55
  else
37
56
  hash
38
57
  end
@@ -25,7 +25,7 @@ module Halitosis
25
25
 
26
26
  # @return [nil, Hash]
27
27
  #
28
- def value(_instance)
28
+ def value(_context)
29
29
  hrefs = super
30
30
 
31
31
  attrs = options.fetch(:attrs, {})
@@ -19,9 +19,9 @@ module Halitosis
19
19
  module InstanceMethods
20
20
  # @return [Hash] the rendered hash with links, if any
21
21
  #
22
- def render
23
- if options.fetch(:include_links, true)
24
- decorate_render :links, super
22
+ def render_with_context(context)
23
+ if context.fetch(:include_links, true)
24
+ decorate_render :links, context, super
25
25
  else
26
26
  super
27
27
  end
@@ -29,9 +29,9 @@ module Halitosis
29
29
 
30
30
  # @return [Hash] links from fields
31
31
  #
32
- def links
33
- render_fields(Field.name) do |field, result|
34
- value = field.value(self)
32
+ def links(context = build_context)
33
+ render_fields(Field, context) do |field, result|
34
+ value = field.value(context)
35
35
 
36
36
  result[field.name] = value if value
37
37
  end
@@ -19,9 +19,9 @@ module Halitosis
19
19
  module InstanceMethods
20
20
  # @return [Hash] the rendered hash with meta, if any
21
21
  #
22
- def render
23
- if options.fetch(:include_meta, true)
24
- decorate_render :meta, super
22
+ def render_with_context(context)
23
+ if context.fetch(:include_meta, true)
24
+ decorate_render :meta, context, super
25
25
  else
26
26
  super
27
27
  end
@@ -29,11 +29,11 @@ module Halitosis
29
29
 
30
30
  # @return [Hash] meta from fields
31
31
  #
32
- def meta
33
- render_fields(Field.name) do |field, result|
34
- value = field.value(self)
32
+ def meta(context = build_context)
33
+ render_fields(Field, context) do |field, result|
34
+ value = field.value(context)
35
35
 
36
- result[field.name] = value if value
36
+ result[field.name] = value
37
37
  end
38
38
  end
39
39
  end
@@ -19,9 +19,9 @@ module Halitosis
19
19
  module InstanceMethods
20
20
  # @return [Hash] the rendered hash with permissions, if any
21
21
  #
22
- def render
23
- if options.fetch(:include_permissions, true)
24
- decorate_render :permissions, super
22
+ def render_with_context(context)
23
+ if context.fetch(:include_permissions, true)
24
+ decorate_render :permissions, context, super
25
25
  else
26
26
  super
27
27
  end
@@ -29,11 +29,11 @@ module Halitosis
29
29
 
30
30
  # @return [Hash] permissions from fields
31
31
  #
32
- def permissions
33
- render_fields(Field.name) do |field, result|
34
- value = field.value(self)
32
+ def permissions(context = build_context)
33
+ render_fields(Field, context) do |field, result|
34
+ value = field.value(context)
35
35
 
36
- result[field.name] = value if value
36
+ result[field.name] = value || false
37
37
  end
38
38
  end
39
39
  end
@@ -16,6 +16,10 @@ module Halitosis
16
16
  Halitosis.config.extensions << ::Rails.application.routes.url_helpers
17
17
  end
18
18
 
19
+ initializer "halitosis.error_response" do |app|
20
+ app.config.action_dispatch.rescue_responses[InvalidQueryParameter.name] ||= :bad_request
21
+ end
22
+
19
23
  initializer "halitosis.renderable" do |_app|
20
24
  Halitosis.config.extensions << Renderable
21
25
  end
@@ -21,10 +21,10 @@ module Halitosis
21
21
  #
22
22
  # @return [true, false]
23
23
  #
24
- def enabled?(instance)
24
+ def enabled?(context)
25
25
  return false unless super
26
26
 
27
- opts = instance.include_options
27
+ opts = context.include_options
28
28
 
29
29
  # Field name must appear in instance included option keys
30
30
  return false unless opts.include?(name.to_s)
@@ -24,17 +24,20 @@ module Halitosis
24
24
  module InstanceMethods
25
25
  # @return [Hash] the rendered hash with relationships resources, if any
26
26
  #
27
- def render
28
- decorate_render :relationships, super
27
+ def render_with_context(context)
28
+ decorate_render :relationships, context, super
29
29
  end
30
30
 
31
31
  # @return [Hash] hash of rendered resources to include
32
32
  #
33
- def relationships
34
- render_fields(Field.name) do |field, result|
35
- value = instance_eval(&field.procedure)
33
+ def relationships(context = build_context)
34
+ # Do not validation non-root collections (as they pass values directly to children)
35
+ validate_relationships!(context) unless collection?
36
36
 
37
- child = relationships_child(field.name.to_s, value)
37
+ render_fields(Field, context) do |field, result|
38
+ value = field.value(context)
39
+
40
+ child = relationships_child(field.name.to_s, context, value)
38
41
 
39
42
  result[field.name] = child if child
40
43
  end
@@ -43,15 +46,15 @@ module Halitosis
43
46
  # @return [nil, Hash, Array<Hash>] either a single rendered child
44
47
  # serializer or an array of them
45
48
  #
46
- def relationships_child(key, value)
49
+ def relationships_child(key, context, value)
47
50
  return unless value
48
51
 
49
- opts = child_relationship_opts(key)
52
+ opts = child_relationship_opts(key, context)
50
53
 
51
54
  if value.is_a?(Array)
52
- value.map { |item| render_child(item, opts) }.compact
55
+ value.map { |item| render_child(item, context, opts) }.compact
53
56
  else
54
- render_child(value, opts)
57
+ render_child(value, context, opts)
55
58
  end
56
59
  end
57
60
 
@@ -59,8 +62,8 @@ module Halitosis
59
62
  #
60
63
  # @return [Hash]
61
64
  #
62
- def child_relationship_opts(key)
63
- opts = include_options.fetch(key, {})
65
+ def child_relationship_opts(key, context)
66
+ opts = context.include_options.fetch(key, {})
64
67
 
65
68
  # Turn { :report => 1 } into { :report => {} } for child
66
69
  opts = {} unless opts.is_a?(Hash)
@@ -68,10 +71,17 @@ module Halitosis
68
71
  opts
69
72
  end
70
73
 
71
- # @return [Hash] hash of options with top level string keys
72
- #
73
- def include_options
74
- @include_options ||= Halitosis::HashUtil.stringify_params(options.fetch(:include, {}))
74
+ private
75
+
76
+ def validate_relationships!(context)
77
+ opts = context.include_options.keys.map(&:to_s)
78
+
79
+ opts -= self.class.fields.for_type(Field).map { |field| field.name.to_s }
80
+
81
+ return if opts.none?
82
+
83
+ resource_label = [self.class.resource_type, "resource"].compact.join(" ")
84
+ raise Halitosis::InvalidQueryParameter.new("The #{resource_label} does not have a `#{opts.first}` relationship path.", "include")
75
85
  end
76
86
  end
77
87
  end
@@ -14,8 +14,6 @@ module Halitosis
14
14
  base.send :include, InstanceMethods
15
15
 
16
16
  base.send :attr_reader, :resource
17
-
18
- base.class.send :attr_accessor, :resource_name
19
17
  end
20
18
 
21
19
  module ClassMethods
@@ -24,22 +22,21 @@ module Halitosis
24
22
  # @return [Module] self
25
23
  #
26
24
  def define_resource(name)
27
- self.resource_name = name.to_s
25
+ self.resource_type = name.to_s
28
26
 
29
27
  alias_method name, :resource
30
28
  end
31
29
 
32
- # Override standard property field for resource-based serializers
30
+ # Override standard attribute field for resource-based serializers
33
31
  #
34
- # @param name [Symbol, String] name of the property
35
- # @param options [nil, Hash] property options for field
32
+ # @param name [Symbol, String] name of the attribute
33
+ # @param options [nil, Hash] attribute options for field
36
34
  #
37
- def property(name, options = {}, &procedure)
38
- super.tap do |field|
39
- unless field.procedure || field.options.key?(:value)
40
- field.procedure = proc { resource.send(name) }
41
- end
35
+ def attribute(name, options = {}, &procedure)
36
+ unless procedure || options.key?(:value)
37
+ procedure = proc { resource.public_send(name) }
42
38
  end
39
+ super(name, options, &procedure)
43
40
  end
44
41
  end
45
42
 
@@ -53,6 +50,23 @@ module Halitosis
53
50
 
54
51
  super(**)
55
52
  end
53
+
54
+ # @return [Hash] the rendered hash with resource, as a hash
55
+ #
56
+ def render_with_context(context)
57
+ if (include_root = context.fetch(:include_root) { context.depth.zero? })
58
+ {root_name(include_root, self.class.resource_type) => super}
59
+ else
60
+ super
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def root_name(include_root, default)
67
+ return include_root.to_sym if include_root.is_a?(String) || include_root.is_a?(Symbol)
68
+ default.to_sym
69
+ end
56
70
  end
57
71
  end
58
72
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Halitosis
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/halitosis.rb CHANGED
@@ -15,9 +15,9 @@ require "json"
15
15
  #
16
16
  # resource :article
17
17
  #
18
- # property :id, required: true
18
+ # attribute :id, required: true
19
19
  #
20
- # property :title
20
+ # attribute :title
21
21
  #
22
22
  # link :self, -> { article_path(article) }
23
23
  #
@@ -33,6 +33,11 @@ module Halitosis
33
33
  base.extend ClassMethods
34
34
 
35
35
  base.include Base
36
+ base.include Links
37
+ base.include Meta
38
+ base.include Permissions
39
+ base.include Attributes
40
+ base.include Relationships
36
41
 
37
42
  config.extensions.each { |extension| base.send :include, extension }
38
43
  end
@@ -68,11 +73,12 @@ module Halitosis
68
73
  end
69
74
  end
70
75
 
76
+ require_relative "halitosis/context"
71
77
  require_relative "halitosis/base"
72
78
  require_relative "halitosis/errors"
73
79
  require_relative "halitosis/field"
74
80
  require_relative "halitosis/fields"
75
- require_relative "halitosis/properties"
81
+ require_relative "halitosis/attributes"
76
82
  require_relative "halitosis/links"
77
83
  require_relative "halitosis/meta"
78
84
  require_relative "halitosis/permissions"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: halitosis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Morrall
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-30 00:00:00.000000000 Z
11
+ date: 2024-10-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Provides an interface for serializing resources as JSON with HAL-like
14
14
  links and relationships.
@@ -26,10 +26,13 @@ files:
26
26
  - README.md
27
27
  - Rakefile
28
28
  - lib/halitosis.rb
29
+ - lib/halitosis/attributes.rb
30
+ - lib/halitosis/attributes/field.rb
29
31
  - lib/halitosis/base.rb
30
32
  - lib/halitosis/collection.rb
31
33
  - lib/halitosis/collection/field.rb
32
34
  - lib/halitosis/configuration.rb
35
+ - lib/halitosis/context.rb
33
36
  - lib/halitosis/errors.rb
34
37
  - lib/halitosis/field.rb
35
38
  - lib/halitosis/fields.rb
@@ -40,8 +43,6 @@ files:
40
43
  - lib/halitosis/meta/field.rb
41
44
  - lib/halitosis/permissions.rb
42
45
  - lib/halitosis/permissions/field.rb
43
- - lib/halitosis/properties.rb
44
- - lib/halitosis/properties/field.rb
45
46
  - lib/halitosis/railtie.rb
46
47
  - lib/halitosis/relationships.rb
47
48
  - lib/halitosis/relationships/field.rb
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Halitosis
4
- module Properties
5
- def self.included(base)
6
- base.extend ClassMethods
7
-
8
- base.send :include, InstanceMethods
9
- end
10
-
11
- module ClassMethods
12
- # @param name [Symbol, String]
13
- # @param options [nil, Hash]
14
- #
15
- # @return [Halitosis::Properties::Field]
16
- #
17
- def property(name, options = {}, &procedure)
18
- fields.add(Field.new(name, options, procedure))
19
- end
20
- end
21
-
22
- module InstanceMethods
23
- # @return [Hash] the rendered hash with properties, if any
24
- #
25
- def render
26
- super.merge(properties)
27
- end
28
-
29
- # @return [Hash] properties from fields
30
- #
31
- def properties
32
- render_fields(Field.name) do |field, result|
33
- result[field.name] = field.value(self)
34
- end
35
- end
36
- end
37
- end
38
- end
39
-
40
- require "halitosis/properties/field"