halitosis 0.1.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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ # Behavior for serializers with a primary collection resource.
5
+ #
6
+ # The main reason to declare a collection is that the resource with that name
7
+ # will always be included during rendering.
8
+ #
9
+ module Collection
10
+ def self.included(base)
11
+ raise InvalidCollection, "#{base.name} has already defined a resource" if base.included_modules.include?(Resource)
12
+
13
+ base.extend ClassMethods
14
+
15
+ base.send :include, InstanceMethods
16
+
17
+ base.send :attr_reader, :collection
18
+
19
+ base.class.send :attr_accessor, :collection_name
20
+ end
21
+
22
+ module ClassMethods
23
+ # @param name [Symbol, String] name of the collection
24
+ #
25
+ # @return [Module] self
26
+ #
27
+ def define_collection(name, options = {}, &procedure)
28
+ raise InvalidCollection, "#{self.name} collection is already defined" if fields.key?(Field.name)
29
+
30
+ self.collection_name = name.to_s
31
+
32
+ alias_method name, :collection
33
+
34
+ fields.add Field.new(name, options, procedure)
35
+ end
36
+
37
+ def collection?
38
+ true
39
+ end
40
+
41
+ def collection_field
42
+ fields[Field.name].last || raise(InvalidCollection, "#{name} collection is not defined")
43
+ end
44
+ end
45
+
46
+ module InstanceMethods
47
+ # Override standard initializer to assign primary collection
48
+ #
49
+ # @param collection [Object] the primary collection
50
+ #
51
+ def initialize(collection, **)
52
+ @collection = collection
53
+
54
+ super(**)
55
+ end
56
+
57
+ # @return [Hash] the rendered hash with collection, if any
58
+ #
59
+ def render
60
+ field = self.class.collection_field
61
+ if depth.zero?
62
+ super.merge(field.name => render_collection_field(field))
63
+ else
64
+ render_collection_field(field)
65
+ end
66
+ end
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
+ def collection?
76
+ true
77
+ end
78
+
79
+ private
80
+
81
+ # @param key [String]
82
+ #
83
+ # @return [Hash]
84
+ #
85
+ def collection_opts
86
+ return include_options if depth.positive?
87
+
88
+ opts = include_options.fetch(self.class.collection_field.name.to_s, {})
89
+
90
+ # Turn { :report => 1 } into { :report => {} } for child
91
+ opts = {} unless opts.is_a?(Hash)
92
+
93
+ opts
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ require "halitosis/collection/field"
@@ -0,0 +1,13 @@
1
+ module Halitosis
2
+ # Simple configuration class
3
+ #
4
+ class Configuration
5
+ # Array of extension modules to be included in all serializers
6
+ #
7
+ # @return [Array<Module>]
8
+ #
9
+ def extensions
10
+ @extensions ||= []
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ class Error < StandardError; end
5
+
6
+ class InvalidCollection < StandardError; end
7
+
8
+ class InvalidField < StandardError; end
9
+
10
+ class InvalidResource < StandardError; end
11
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ # Stores instructions for how to render a value for a given serializer
5
+ # instance
6
+ #
7
+ class Field
8
+ attr_reader :name, :options
9
+
10
+ attr_accessor :procedure
11
+
12
+ # Construct a new Field instance
13
+ #
14
+ # @param name [Symbol, String] Field name
15
+ # @param options [Hash] hash of options
16
+ #
17
+ # @return [Halitosis::Field] the instance
18
+ #
19
+ def initialize(name, options, procedure)
20
+ @name = name.to_sym
21
+ @options = Halitosis::HashUtil.symbolize_params(options)
22
+ @procedure = procedure
23
+ end
24
+
25
+ # @param instance [Object] the serializer instance with which to evaluate
26
+ # the stored procedure
27
+ #
28
+ def value(instance)
29
+ options.fetch(:value) do
30
+ procedure ? instance.instance_eval(&procedure) : instance.send(name)
31
+ end
32
+ end
33
+
34
+ # @return [true, false] whether this Field should be included based on
35
+ # its conditional guard, if any
36
+ #
37
+ def enabled?(instance)
38
+ if options.key?(:if)
39
+ !!eval_guard(instance, options.fetch(:if))
40
+ elsif options.key?(:unless)
41
+ !eval_guard(instance, options.fetch(:unless))
42
+ else
43
+ true
44
+ end
45
+ end
46
+
47
+ # @return [true] if nothing is raised
48
+ #
49
+ # @raise [Halitosis::InvalidField] if the Field is invalid
50
+ #
51
+ def validate
52
+ return true unless options.key?(:value) && procedure
53
+
54
+ raise InvalidField,
55
+ "Cannot specify both value and procedure for #{name}"
56
+ end
57
+
58
+ private
59
+
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
72
+ end
73
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ # Each serializer class has a Fields object that stores the fields that have been defined on it.
5
+ #
6
+ class Fields < Hash
7
+ def add(field)
8
+ type = field.class.name
9
+
10
+ field.validate
11
+
12
+ self[type] ||= []
13
+ self[type] << field
14
+
15
+ field
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module HashUtil
5
+ module_function
6
+
7
+ # Transform hash keys into strings if necessary
8
+ #
9
+ # @param hash [Hash, Array, String]
10
+ #
11
+ # @return [Hash]
12
+ #
13
+ def stringify_params(hash)
14
+ case hash
15
+ when String
16
+ hash.split(",").inject({}) do |output, key|
17
+ f, value = key.split(".", 2)
18
+ output.merge(f => value ? stringify_params(value) : true)
19
+ end
20
+ when Array
21
+ hash.map { |item| stringify_params(item) }.inject({}, &:merge)
22
+ else
23
+ hash.transform_keys(&:to_s)
24
+ end
25
+ end
26
+
27
+ # Transform hash keys into symbols if necessary
28
+ #
29
+ # @param hash [Hash]
30
+ #
31
+ # @return [Hash]
32
+ #
33
+ def symbolize_params(hash)
34
+ if hash.respond_to?(:transform_keys)
35
+ hash.transform_keys(&:to_sym).transform_values(&method(:symbolize_params))
36
+ else
37
+ hash
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Links
5
+ class Field < Halitosis::Field
6
+ # Links have special keywords that other fields don't, so override
7
+ # the standard initializer to build options from keywords
8
+ #
9
+ def initialize(name, *args, procedure)
10
+ super(name, self.class.build_options(args), procedure)
11
+ end
12
+
13
+ # @return [true] if nothing is raised
14
+ #
15
+ # @raise [Halitosis::InvalidField] if the field is invalid
16
+ #
17
+ def validate
18
+ super
19
+
20
+ return true if procedure || options.key?(:value)
21
+
22
+ raise InvalidField,
23
+ "Link #{name} requires either procedure or explicit value"
24
+ end
25
+
26
+ # @return [nil, Hash]
27
+ #
28
+ def value(_instance)
29
+ hrefs = super
30
+
31
+ attrs = options.fetch(:attrs, {})
32
+
33
+ case hrefs
34
+ when Array
35
+ hrefs.map { |href| attrs.merge(href:) }
36
+ when nil
37
+ # no-op
38
+ else
39
+ attrs.merge(href: hrefs)
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Build hash of options from flexible field arguments
45
+ #
46
+ # @param args [Array] the raw field arguments
47
+ #
48
+ # @return [Hash] standardized hash of options
49
+ #
50
+ def build_options(args)
51
+ {}.tap do |options|
52
+ options.merge!(args.pop) if args.last.is_a?(Hash)
53
+
54
+ options[:attrs] ||= {}
55
+ options[:attrs].merge!(build_attrs(args))
56
+ end
57
+ end
58
+
59
+ # @param keywords [Array] array of special keywords
60
+ #
61
+ # @raise [Halitosis::InvalidField] if a keyword is unrecognized
62
+ #
63
+ def build_attrs(keywords)
64
+ keywords.each_with_object({}) do |keyword, attrs|
65
+ case keyword
66
+ when :templated, "templated"
67
+ attrs[:templated] = true
68
+ else
69
+ raise InvalidField, "Unrecognized link keyword: #{keyword}"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Links
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ module ClassMethods
12
+ # @return [Halitosis::Links::Field]
13
+ #
14
+ def link(name, *, &procedure)
15
+ fields.add(Field.new(name, *, procedure))
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ # @return [Hash] the rendered hash with links, if any
21
+ #
22
+ def render
23
+ if options.fetch(:include_links, true)
24
+ decorate_render :links, super
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ # @return [Hash] links from fields
31
+ #
32
+ def links
33
+ render_fields(Field.name) do |field, result|
34
+ value = field.value(self)
35
+
36
+ result[field.name] = value if value
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ require "halitosis/links/field"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Meta
5
+ class Field < Halitosis::Field
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Meta
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ module ClassMethods
12
+ # @return [Halitosis::Meta::Field]
13
+ #
14
+ def meta(name, **options, &procedure)
15
+ fields.add(Field.new(name, options, procedure))
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ # @return [Hash] the rendered hash with meta, if any
21
+ #
22
+ def render
23
+ if options.fetch(:include_meta, true)
24
+ decorate_render :meta, super
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ # @return [Hash] meta from fields
31
+ #
32
+ def meta
33
+ render_fields(Field.name) do |field, result|
34
+ value = field.value(self)
35
+
36
+ result[field.name] = value if value
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ require "halitosis/meta/field"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Permissions
5
+ class Field < Halitosis::Field
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Permissions
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+
8
+ base.send :include, InstanceMethods
9
+ end
10
+
11
+ module ClassMethods
12
+ # @return [Halitosis::Permissions::Field]
13
+ #
14
+ def permission(name, **options, &procedure)
15
+ fields.add(Field.new(name, options, procedure))
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ # @return [Hash] the rendered hash with permissions, if any
21
+ #
22
+ def render
23
+ if options.fetch(:include_permissions, true)
24
+ decorate_render :permissions, super
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ # @return [Hash] permissions from fields
31
+ #
32
+ def permissions
33
+ render_fields(Field.name) do |field, result|
34
+ value = field.value(self)
35
+
36
+ result[field.name] = value if value
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ require "halitosis/permissions/field"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Properties
5
+ class Field < Halitosis::Field
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,40 @@
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"
@@ -0,0 +1,23 @@
1
+ module Halitosis
2
+ # Provide Rails-specific extensions if loaded in a Rails application
3
+ #
4
+ class Railtie < ::Rails::Railtie
5
+ module Renderable
6
+ def render_in(view_context)
7
+ view_context.render json: self
8
+ end
9
+
10
+ def format
11
+ :json
12
+ end
13
+ end
14
+
15
+ initializer "halitosis.url_helpers" do |_app|
16
+ Halitosis.config.extensions << ::Rails.application.routes.url_helpers
17
+ end
18
+
19
+ initializer "halitosis.renderable" do |_app|
20
+ Halitosis.config.extensions << Renderable
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Relationships
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, "Relationship #{name} must be defined with a proc"
16
+ end
17
+
18
+ # Check whether this definition should be included for the given instance
19
+ #
20
+ # @param instance [Object]
21
+ #
22
+ # @return [true, false]
23
+ #
24
+ def enabled?(instance)
25
+ return false unless super
26
+
27
+ opts = instance.include_options
28
+
29
+ # Field name must appear in instance included option keys
30
+ return false unless opts.include?(name.to_s)
31
+
32
+ # Check value of included option for definition name
33
+ !%w[0 false].include?(opts.fetch(name.to_s).to_s)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Halitosis
4
+ module Relationships
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::Relationships::Field]
16
+ #
17
+ def relationship(name, options = {}, &procedure)
18
+ fields.add(Field.new(name, options, procedure))
19
+ end
20
+
21
+ alias_method :rel, :relationship
22
+ end
23
+
24
+ module InstanceMethods
25
+ # @return [Hash] the rendered hash with relationships resources, if any
26
+ #
27
+ def render
28
+ decorate_render :relationships, super
29
+ end
30
+
31
+ # @return [Hash] hash of rendered resources to include
32
+ #
33
+ def relationships
34
+ render_fields(Field.name) do |field, result|
35
+ value = instance_eval(&field.procedure)
36
+
37
+ child = relationships_child(field.name.to_s, value)
38
+
39
+ result[field.name] = child if child
40
+ end
41
+ end
42
+
43
+ # @return [nil, Hash, Array<Hash>] either a single rendered child
44
+ # serializer or an array of them
45
+ #
46
+ def relationships_child(key, value)
47
+ return unless value
48
+
49
+ opts = child_relationship_opts(key)
50
+
51
+ if value.is_a?(Array)
52
+ value.map { |item| render_child(item, opts) }.compact
53
+ else
54
+ render_child(value, opts)
55
+ end
56
+ end
57
+
58
+ # @param key [String]
59
+ #
60
+ # @return [Hash]
61
+ #
62
+ def child_relationship_opts(key)
63
+ opts = include_options.fetch(key, {})
64
+
65
+ # Turn { :report => 1 } into { :report => {} } for child
66
+ opts = {} unless opts.is_a?(Hash)
67
+
68
+ opts
69
+ end
70
+
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, {}))
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ require "halitosis/relationships/field"