blueprinter-rb 1.0.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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class BlockExtractor < Extractor
6
+ def extract(field_name, object, local_options, options = {})
7
+ options[:block].call(object, local_options)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class HashExtractor < Extractor
6
+ def extract(field_name, object, _local_options, _options = {})
7
+ object[field_name]
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class PublicSendExtractor < Extractor
6
+ def extract(field_name, object, local_options, options = {})
7
+ object.public_send(field_name)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @api private
4
+ class Blueprinter::Field
5
+ attr_reader :method, :name, :extractor, :options, :blueprint
6
+ def initialize(method, name, extractor, blueprint, options = {})
7
+ @method = method
8
+ @name = name
9
+ @extractor = extractor
10
+ @blueprint = blueprint
11
+ @options = options
12
+ end
13
+
14
+ def extract(object, local_options)
15
+ extractor.extract(method, object, local_options, options)
16
+ end
17
+
18
+ def skip?(field_name, object, local_options)
19
+ return true if if_callable && !if_callable.call(field_name, object, local_options)
20
+ unless_callable && unless_callable.call(field_name, object, local_options)
21
+ end
22
+
23
+ private
24
+
25
+ def if_callable
26
+ return @if_callable if defined?(@if_callable)
27
+ @if_callable = callable_from(:if)
28
+ end
29
+
30
+ def unless_callable
31
+ return @unless_callable if defined?(@unless_callable)
32
+ @unless_callable = callable_from(:unless)
33
+ end
34
+
35
+ def callable_from(condition)
36
+ callable = old_callable_from(condition)
37
+
38
+ if callable && callable.arity == 2
39
+ Blueprinter::Deprecation.report("`:#{condition}` conditions now expects 3 arguments instead of 2.")
40
+ ->(_field_name, obj, options) { callable.call(obj, options) }
41
+ else
42
+ callable
43
+ end
44
+ end
45
+
46
+ def old_callable_from(condition)
47
+ config = Blueprinter.configuration
48
+
49
+ # Use field-level callable, or when not defined, try global callable
50
+ tmp = if options.key?(condition)
51
+ options.fetch(condition)
52
+ elsif config.valid_callable?(condition)
53
+ config.public_send(condition)
54
+ end
55
+
56
+ return false unless tmp
57
+
58
+ case tmp
59
+ when Proc then tmp
60
+ when Symbol then blueprint.method(tmp)
61
+ else
62
+ raise ArgumentError, "#{tmp.class} is passed to :#{condition}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ class DateTimeFormatter
5
+ InvalidDateTimeFormatterError = Class.new(BlueprinterError)
6
+
7
+ def format(value, options)
8
+ return value if value.nil?
9
+
10
+ field_format = options[:datetime_format]
11
+ if value.respond_to?(:strftime)
12
+ value = format_datetime(value, field_format)
13
+ elsif field_format
14
+ raise InvalidDateTimeFormatterError, 'Cannot format invalid DateTime object'
15
+ end
16
+ value
17
+ end
18
+
19
+ private
20
+
21
+ def format_datetime(value, field_format)
22
+ format = field_format || Blueprinter.configuration.datetime_format
23
+
24
+ case format
25
+ when NilClass then value
26
+ when Proc then format.call(value)
27
+ when String then value.strftime(format)
28
+ else
29
+ raise InvalidDateTimeFormatterError, 'Cannot format DateTime object with invalid formatter: #{format.class}'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module BaseHelpers
5
+ def self.included(base)
6
+ base.extend(SingletonMethods)
7
+ end
8
+
9
+ module SingletonMethods
10
+ include TypeHelpers
11
+
12
+ private
13
+
14
+ def prepare_for_render(object, options)
15
+ view_name = options.delete(:view) || :default
16
+ root = options.delete(:root)
17
+ meta = options.delete(:meta)
18
+ validate_root_and_meta!(root, meta)
19
+ prepare(object, view_name: view_name, local_options: options, root: root, meta: meta)
20
+ end
21
+
22
+ def prepare_data(object, view_name, local_options)
23
+ if array_like?(object)
24
+ object.map do |obj|
25
+ object_to_hash(obj,
26
+ view_name: view_name,
27
+ local_options: local_options)
28
+ end
29
+ else
30
+ object_to_hash(object,
31
+ view_name: view_name,
32
+ local_options: local_options)
33
+ end
34
+ end
35
+
36
+ def prepend_root_and_meta(data, root, meta)
37
+ return data unless root
38
+ ret = {root => data}
39
+ meta ? ret.merge!(meta: meta) : ret
40
+ end
41
+
42
+ def inherited(subclass)
43
+ subclass.send(:view_collection).inherit(view_collection)
44
+ end
45
+
46
+ def object_to_hash(object, view_name:, local_options:)
47
+ result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash|
48
+ next if field.skip?(field.name, object, local_options)
49
+ hash[field.name] = field.extract(object, local_options)
50
+ end
51
+ view_collection.transformers(view_name).each do |transformer|
52
+ transformer.transform(result_hash, object, local_options)
53
+ end
54
+ result_hash
55
+ end
56
+
57
+ def validate_root_and_meta!(root, meta)
58
+ case root
59
+ when String, Symbol
60
+ # no-op
61
+ when NilClass
62
+ raise BlueprinterError, "meta requires a root to be passed" if meta
63
+ else
64
+ raise BlueprinterError, "root should be one of String, Symbol, NilClass"
65
+ end
66
+ end
67
+
68
+ def dynamic_blueprint?(blueprint)
69
+ blueprint.is_a?(Proc)
70
+ end
71
+
72
+ def validate_blueprint!(blueprint, method)
73
+ validate_presence_of_blueprint!(blueprint)
74
+ unless dynamic_blueprint?(blueprint)
75
+ validate_blueprint_has_ancestors!(blueprint, method)
76
+ validate_blueprint_has_blueprinter_base_ancestor!(blueprint, method)
77
+ end
78
+ end
79
+
80
+ def validate_presence_of_blueprint!(blueprint)
81
+ raise BlueprinterError, 'Blueprint required' unless blueprint
82
+ end
83
+
84
+ def validate_blueprint_has_ancestors!(blueprint, association_name)
85
+ # If the class passed as a blueprint does not respond to ancestors
86
+ # it means it, at the very least, does not have Blueprinter::Base as
87
+ # one of its ancestor classes (e.g: Hash) and thus an error should
88
+ # be raised.
89
+ unless blueprint.respond_to?(:ancestors)
90
+ raise BlueprinterError, "Blueprint provided for #{association_name} "\
91
+ 'association is not valid.'
92
+ end
93
+ end
94
+
95
+ def validate_blueprint_has_blueprinter_base_ancestor!(blueprint, association_name)
96
+ # Guard clause in case Blueprinter::Base is present in the ancestor list
97
+ # for the blueprint class provided.
98
+ return if blueprint.ancestors.include? Blueprinter::Base
99
+
100
+ # Raise error describing what's wrong.
101
+ raise BlueprinterError, "Class #{blueprint.name} does not inherit from "\
102
+ 'Blueprinter::Base and is not a valid Blueprinter '\
103
+ "for #{association_name} association."
104
+ end
105
+
106
+ def jsonify(blob)
107
+ Blueprinter.configuration.jsonify(blob)
108
+ end
109
+
110
+ def current_view
111
+ @current_view ||= view_collection[:default]
112
+ end
113
+
114
+ def view_collection
115
+ @view_collection ||= ViewCollection.new
116
+ end
117
+
118
+ def associations(view_name = :default)
119
+ view_collection.fields_for(view_name).select { |f| f.options[:association] }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module TypeHelpers
5
+ private
6
+ def active_record_relation?(object)
7
+ !!(defined?(ActiveRecord::Relation) &&
8
+ object.is_a?(ActiveRecord::Relation))
9
+ end
10
+
11
+ def array_like?(object)
12
+ object.is_a?(Array) || active_record_relation?(object)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class Transformer
6
+ def transform(_result_hash, _primary_obj, _options = {})
7
+ fail NotImplementedError, "A Transformer must implement #transform"
8
+ end
9
+
10
+ def self.transform(result_hash, primary_obj, options = {})
11
+ self.new.transform(result_hash, primary_obj, options)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ DefinitionPlaceholder = Struct.new :name, :view?
6
+ class View
7
+ attr_reader :excluded_field_names, :fields, :included_view_names, :name, :view_transformers, :definition_order
8
+
9
+ def initialize(name, fields: {}, included_view_names: [], excluded_view_names: [], transformers: [])
10
+ @name = name
11
+ @fields = fields
12
+ @included_view_names = included_view_names
13
+ @excluded_field_names = excluded_view_names
14
+ @view_transformers = transformers
15
+ @definition_order = []
16
+ @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
17
+ end
18
+
19
+ def transformers
20
+ view_transformers.empty? ? Blueprinter.configuration.default_transformers : view_transformers
21
+ end
22
+
23
+ def track_definition_order(method, is_view = true)
24
+ if @sort_by_definition
25
+ @definition_order << DefinitionPlaceholder.new(method, is_view)
26
+ end
27
+ end
28
+
29
+ def inherit(view)
30
+ view.fields.values.each do |field|
31
+ self << field
32
+ end
33
+
34
+ view.included_view_names.each do |view_name|
35
+ include_view(view_name)
36
+ end
37
+
38
+ view.excluded_field_names.each do |field_name|
39
+ exclude_field(field_name)
40
+ end
41
+
42
+ view.view_transformers.each do |transformer|
43
+ add_transformer(transformer)
44
+ end
45
+ end
46
+
47
+ def include_view(view_name)
48
+ track_definition_order(view_name)
49
+ included_view_names << view_name
50
+ end
51
+
52
+ def include_views(view_names)
53
+ view_names.each do |view_name|
54
+ track_definition_order(view_name)
55
+ included_view_names << view_name
56
+ end
57
+ end
58
+
59
+ def exclude_field(field_name)
60
+ excluded_field_names << field_name
61
+ end
62
+
63
+ def exclude_fields(field_names)
64
+ field_names.each do |field_name|
65
+ excluded_field_names << field_name
66
+ end
67
+ end
68
+
69
+ def add_transformer(custom_transformer)
70
+ view_transformers << custom_transformer
71
+ end
72
+
73
+ def <<(field)
74
+ track_definition_order(field.name,false)
75
+ fields[field.name] = field
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ # @api private
5
+ class ViewCollection
6
+ attr_reader :views, :sort_by_definition
7
+ def initialize
8
+ @views = {
9
+ identifier: View.new(:identifier),
10
+ default: View.new(:default)
11
+ }
12
+ @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
13
+ end
14
+
15
+ def inherit(view_collection)
16
+ view_collection.views.each do |view_name, view|
17
+ self[view_name].inherit(view)
18
+ end
19
+ end
20
+
21
+ def has_view?(view_name)
22
+ views.has_key? view_name
23
+ end
24
+
25
+ def fields_for(view_name)
26
+ return identifier_fields if view_name == :identifier
27
+
28
+ fields, excluded_fields = sortable_fields(view_name)
29
+ sorted_fields = sort_by_definition ? sort_by_def(view_name, fields) : fields.values.sort_by(&:name)
30
+
31
+ (identifier_fields + sorted_fields).reject { |field| excluded_fields.include?(field.name) }
32
+ end
33
+
34
+ def transformers(view_name)
35
+ views[view_name].transformers
36
+ end
37
+
38
+ def [](view_name)
39
+ @views[view_name] ||= View.new(view_name)
40
+ end
41
+
42
+ private
43
+
44
+ def identifier_fields
45
+ views[:identifier].fields.values
46
+ end
47
+
48
+ # @param [String] view_name
49
+ # @return [Array<(Hash, Hash<String, NilClass>)>] fields, excluded_fields
50
+ def sortable_fields(view_name)
51
+ excluded_fields = {}
52
+ fields = views[:default].fields
53
+ views[view_name].included_view_names.each do |included_view_name|
54
+ next if view_name == included_view_name
55
+
56
+ view_fields, view_excluded_fields = sortable_fields(included_view_name)
57
+ fields = merge_fields(fields, view_fields)
58
+ excluded_fields.merge!(view_excluded_fields)
59
+ end
60
+ fields = merge_fields(fields, views[view_name].fields)
61
+
62
+ views[view_name].excluded_field_names.each { |name| excluded_fields[name] = nil }
63
+
64
+ [fields, excluded_fields]
65
+ end
66
+
67
+ # select and order members of fields according to traversal of the definition_orders
68
+ def sort_by_def(view_name, fields)
69
+ ordered_fields = {}
70
+ views[:default].definition_order.each { |definition| add_to_ordered_fields(ordered_fields, definition, fields, view_name) }
71
+ ordered_fields.values
72
+ end
73
+
74
+ # view_name_filter allows to follow definition order all the way down starting from the view_name given to sort_by_def()
75
+ # but include no others at the top-level
76
+ def add_to_ordered_fields(ordered_fields, definition, fields, view_name_filter = nil)
77
+ if definition.view?
78
+ if view_name_filter.nil? || view_name_filter == definition.name
79
+ views[definition.name].definition_order.each { |_definition| add_to_ordered_fields(ordered_fields, _definition, fields) }
80
+ end
81
+ else
82
+ ordered_fields[definition.name] = fields[definition.name]
83
+ end
84
+ end
85
+
86
+ def merge_fields(source_fields, included_fields)
87
+ source_fields.merge included_fields
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'blueprinter/base'
4
+
5
+ module Blueprinter
6
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blueprinter
4
+ module Generators
5
+ class BlueprintGenerator < ::Rails::Generators::NamedBase
6
+ desc "Generates blueprint for ActiveRecord model with the given NAME."
7
+
8
+ attr_accessor :options
9
+
10
+ source_root File.expand_path("../templates", __FILE__)
11
+
12
+
13
+
14
+ class_option :blueprints_dir, default: "app/blueprints", desc: "path to new blueprint", aliases: "-d"
15
+
16
+
17
+
18
+ class_option :identifier, default: nil, desc: "Add an identifer to the generated blueprint, either uses :id or your specified value", aliases: "-i", banner: "id"
19
+
20
+
21
+
22
+ class_option :fields, type: :array, default: [], desc: "Manually add specified fields"
23
+
24
+ class_option :detect_fields, type: :boolean, default: false, desc: "Introspect on the model to set fields in the generated blueprint. Will be merged with any manually specified"
25
+
26
+
27
+
28
+ class_option :associations, type: :array, default: [], desc: "Manually add specified associations", aliases: "-a"
29
+
30
+ class_option :detect_associations, type: :boolean, default: false, desc: "Introspect on the model to set associations in the generated blueprint. Will be merged with any manually specified"
31
+
32
+
33
+
34
+ class_option :wrap_at, type: :numeric, default: 80, desc: "Maximum length of generated fields line", aliases: "-w"
35
+
36
+ class_option :indentation, type: :string, default: "two", desc: "Indentation of generated file", banner: "two|four|tab"
37
+
38
+
39
+
40
+ remove_class_option :skip_namespace
41
+
42
+ def ensure_blueprint_dir
43
+ FileUtils.mkdir_p(path) unless File.directory?(path)
44
+ end
45
+
46
+ def create_blueprint
47
+ template "blueprint.rb", File.join(path, "#{file_path}_blueprint.rb")
48
+ end
49
+
50
+
51
+
52
+ private
53
+
54
+ def path
55
+ options["blueprints_dir"]
56
+ end
57
+
58
+ def identifier_symbol
59
+ if options['identifier']
60
+ options['identifier'] == "identifier" ? :id : options['identifier']
61
+ end
62
+ end
63
+
64
+ def fields
65
+ fs = if options["detect_fields"]
66
+ Array.new(options["fields"]).concat(introspected_fields)
67
+ else
68
+ options["fields"]
69
+ end
70
+ fs.reject {|f| f.blank? }.uniq
71
+ end
72
+
73
+ def introspected_fields
74
+ class_name.constantize.columns_hash.keys
75
+ end
76
+
77
+ # split at wrap_at chars, two indentations
78
+ def formatted_fields
79
+ two_indents = indent * 2
80
+ fields_string = fields.reduce([]) do |memo, f|
81
+ if !memo.last.nil?
82
+ now = "#{memo.last} :#{f},"
83
+ if now.length > options["wrap_at"].to_i
84
+ memo << ":#{f},"
85
+ else
86
+ memo[memo.length - 1] = now
87
+ end
88
+ else
89
+ memo << " :#{f},"
90
+ end
91
+ memo
92
+ end.join("\n#{two_indents}")
93
+
94
+ fields_string[0,fields_string.length - 1]
95
+ end
96
+
97
+ def associations
98
+ as = if options["detect_associations"]
99
+ Array.new(options["associations"]).concat(introspected_associations.keys)
100
+ else
101
+ options["associations"]
102
+ end
103
+ as.reject {|f| f.blank? }.uniq
104
+ end
105
+
106
+ def introspected_associations
107
+ class_name.constantize.reflections
108
+ end
109
+
110
+ def association_blueprint(association_name)
111
+ ", blueprint: #{association_class(association_name)}"
112
+ end
113
+
114
+ def association_class(association_name)
115
+ introspected_name = if introspected_associations[association_name].respond_to?(:klass)
116
+ introspected_associations[association_name].klass.to_s
117
+ else
118
+ nil
119
+ end
120
+ "#{introspected_name || association_name.camelcase}Blueprint"
121
+ end
122
+
123
+ def indent
124
+ user_intended = {two: " ", four: " ", tab:"\t"}[options["indentation"].intern]
125
+ user_intended.nil? ? " " : user_intended
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>Blueprint < Blueprinter::Base
4
+ <% if identifier_symbol -%>
5
+ <%= indent -%>identifier :<%= identifier_symbol %>
6
+
7
+ <% end -%>
8
+ <% if fields.any? -%>
9
+ <%= indent -%>fields<%= formatted_fields %>
10
+
11
+ <% end -%>
12
+ <% associations.each do |a| -%>
13
+ <%= indent -%>association :<%= a -%><%= association_blueprint(a) %>
14
+
15
+ <% end -%>
16
+ end