halitosis 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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"