halitosis 0.1.0 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 759e2f5eada1444024bd208f82706b0a30b40dbfe24f6c1d829bea49067d1b3d
4
- data.tar.gz: 72b52a2ccba027e2745fbcc245b7311b8aca6097afc77395c92f4274036c8d00
3
+ metadata.gz: 0576b2d1e7ce15303f1bdc4a845bc7e81d1f2e497d3e49b220f1dd68920938cb
4
+ data.tar.gz: 79a5d29e8ec21cf3f035719dab8ef719216424a8514c2ff045d8f0f0d1cbe552
5
5
  SHA512:
6
- metadata.gz: b4384527d214837272668d24c9e0f4c7ad9578189d0c31bbadb8490b00a88f2cd92b236c7c36f3584f98abebf216980c626c6b617c72e4e217d11bac3df175ed
7
- data.tar.gz: 1f4d4c859877996961a9187397ab9913f0214542893d718f5fe8c9dc226ceaff5eb2d298f4b18d62f0a65ad5e1a32330268ff7c1eb57c25db25d0e032fecbad6
6
+ metadata.gz: '02779ecf3fd87d66cab25c245c1ca82a2eee370241088d1af6fcc72bde1f3e7917687119260e4d5d04f22dfb363c1d8d9b54730caece6a82825d2ca65f6d9ef5'
7
+ data.tar.gz: 2e77dae400e7f5ece72aa1604898444b90c43cca04694b495f23dda4aaf38e4b62b05bc1cdd67b1f32d1e5c1014924756e2b242bd4c5a648df944bee18d61520
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:
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Identifiers
5
+ class Field < Halitosis::Field
6
+ end
7
+ end
8
+ 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
@@ -48,21 +48,15 @@ module Halitosis
48
48
 
49
49
  # @return [Hash] rendered representation
50
50
  #
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)
51
+ def render(**options)
52
+ render_with_context(build_context(options))
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,22 @@ 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")
41
+ end
42
+
43
+ # Provide an alias for root_link
44
+ def link(*, **, &)
45
+ root_link(*, **, &)
46
+ end
47
+
48
+ # Provide an alias for root_meta
49
+ def meta(*, **, &)
50
+ root_meta(*, **, &)
51
+ end
52
+
53
+ # Provide an alias for root_permission
54
+ def permission(*, **, &)
55
+ root_permission(*, **, &)
43
56
  end
44
57
  end
45
58
 
@@ -50,47 +63,47 @@ module Halitosis
50
63
  #
51
64
  def initialize(collection, **)
52
65
  @collection = collection
66
+ @collection_field = self.class.collection_field
53
67
 
54
68
  super(**)
55
69
  end
56
70
 
57
- # @return [Hash] the rendered hash with collection, if any
71
+ # @return [Hash, Array] the rendered hash with collection, as an array or a hash under a key
58
72
  #
59
- def render
60
- field = self.class.collection_field
61
- if depth.zero?
62
- super.merge(field.name => render_collection_field(field))
73
+ def render_with_context(context)
74
+ if (include_root = context.fetch(:include_root) { context.depth.zero? })
75
+ {
76
+ root_name(include_root) => render_collection_field(context)
77
+ }.merge(super)
63
78
  else
64
- render_collection_field(field)
79
+ render_collection_field(context)
65
80
  end
66
81
  end
67
82
 
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
83
  def collection?
76
84
  true
77
85
  end
78
86
 
79
87
  private
80
88
 
81
- # @param key [String]
82
- #
83
- # @return [Hash]
89
+ attr_reader :collection_field
90
+
91
+ # @return [Hash] collection from fields
84
92
  #
85
- def collection_opts
86
- return include_options if depth.positive?
93
+ def render_collection_field(context)
94
+ value = collection_field.value(context)
87
95
 
88
- opts = include_options.fetch(self.class.collection_field.name.to_s, {})
96
+ return render_child(value, context, context.include_options) if value.is_a?(Halitosis::Collection)
97
+
98
+ value.reject { |child| child.is_a?(Halitosis::Collection) } # Skip nested collections in array
99
+ .map { |child| render_child(child, context, context.include_options) }
100
+ .compact
101
+ end
89
102
 
90
- # Turn { :report => 1 } into { :report => {} } for child
91
- opts = {} unless opts.is_a?(Hash)
103
+ def root_name(include_root)
104
+ return include_root.to_sym if include_root.is_a?(String) || include_root.is_a?(Symbol)
92
105
 
93
- opts
106
+ self.class.resource_type.to_sym
94
107
  end
95
108
  end
96
109
  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
@@ -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