halitosis 0.1.0 → 0.2.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.
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"