rails-surrender 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (29) hide show
  1. checksums.yaml +7 -0
  2. data/lib/rails/surrender/config/initializers/active_record_associations_patch.rb +17 -0
  3. data/lib/rails/surrender/config/initializers/active_record_preloader_patch.rb +20 -0
  4. data/lib/rails/surrender/config/locales/en.yml +12 -0
  5. data/lib/rails/surrender/controller_additions.rb +109 -0
  6. data/lib/rails/surrender/default_ability.rb +12 -0
  7. data/lib/rails/surrender/exceptions.rb +9 -0
  8. data/lib/rails/surrender/helpers/filter_builder.rb +50 -0
  9. data/lib/rails/surrender/helpers/pagination_builder.rb +25 -0
  10. data/lib/rails/surrender/helpers/query_param_parser.rb +98 -0
  11. data/lib/rails/surrender/helpers/sort_builder.rb +56 -0
  12. data/lib/rails/surrender/model_additions.rb +59 -0
  13. data/lib/rails/surrender/model_filter_scopes.rb +56 -0
  14. data/lib/rails/surrender/railtie.rb +22 -0
  15. data/lib/rails/surrender/render/configuration/inclusion_mapper_logic.rb +69 -0
  16. data/lib/rails/surrender/render/configuration/instance_logic.rb +88 -0
  17. data/lib/rails/surrender/render/configuration.rb +93 -0
  18. data/lib/rails/surrender/render/count.rb +21 -0
  19. data/lib/rails/surrender/render/ids.rb +21 -0
  20. data/lib/rails/surrender/render/resource/collection.rb +26 -0
  21. data/lib/rails/surrender/render/resource/inclusion_mapper.rb +48 -0
  22. data/lib/rails/surrender/render/resource/instance.rb +75 -0
  23. data/lib/rails/surrender/render/resource.rb +54 -0
  24. data/lib/rails/surrender/response.rb +18 -0
  25. data/lib/rails/surrender/version.rb +7 -0
  26. data/lib/rails/surrender.rb +31 -0
  27. data/test/surrender_test.rb +9 -0
  28. data/test/test_helper.rb +21 -0
  29. metadata +98 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 06b60d3e2d458b2bc4d683990a6542e73291f3a3b97be987a0bd069732a885d8
4
+ data.tar.gz: 56a20555312a69afbd2f8e27dfa5f52d1e76adc8033dd1b0fd994dd20e0d2f27
5
+ SHA512:
6
+ metadata.gz: 1bf619637f8644ba084faf242a70b8f71a44e210b2f52477988b651bca5598eb7aa860b3d203b749039d6da2483e5e84bc6ee277a84ef52350a17e0de83efe58
7
+ data.tar.gz: 12eef2b6c7587faedb212037ea786d4a056650be3faf3583b9e73625e1cb5b58a8d11de069c1248604e3bac320bffd123b4237ec059cf9ef68e4d4e777a3b05f
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # ActiveRecord::Associations.class_eval do
4
+ # # patching to prevent AssociationNotFoundError.
5
+ # # if there is no association just return nil instead
6
+ # def association(name) # :nodoc:
7
+ # super
8
+ # rescue AssociationNotFoundError
9
+ # return nil
10
+ # end
11
+ # end
12
+ #
13
+ # ActiveRecord::Relation.class_eval do
14
+ # def eager_loading?
15
+ # false
16
+ # end
17
+ # end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # ActiveRecord::Associations::Preloader.class_eval do
4
+ # # patching to ignore nil response from association method (also patched)
5
+ #
6
+ # # if there is no association just return nil instead
7
+ # def grouped_records(association, records)
8
+ # h = {}
9
+ # records.each do |record|
10
+ # next unless record
11
+ #
12
+ # assoc = record.association(association)
13
+ # next if assoc.nil?
14
+ #
15
+ # klasses = h[assoc.reflection] ||= {}
16
+ # (klasses[assoc.klass] ||= []) << record
17
+ # end
18
+ # h
19
+ # end
20
+ # end
@@ -0,0 +1,12 @@
1
+ ---
2
+ en:
3
+ surrender:
4
+ error:
5
+ query_string:
6
+ incorrect_format: "The %{param} parameter was improperly formatted."
7
+ filter:
8
+ not_available: "%{param} is not a valid filter parameter."
9
+ include:
10
+ not_available: "%{param} is not a valid include parameter."
11
+ sort:
12
+ invalid_column: "%{param} is not a valid sort parameter."
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/query_param_parser'
4
+ require_relative 'helpers/filter_builder'
5
+ require_relative 'helpers/pagination_builder'
6
+ require_relative 'helpers/sort_builder'
7
+
8
+ module Rails
9
+ module Surrender
10
+ # Additions to the Rails ActionController to allow surrender's rendering.
11
+ module ControllerAdditions
12
+ def initialize(*args)
13
+ @will_paginate = true
14
+ super
15
+ end
16
+
17
+ def surrender(resource, status: 200, reload: true, include: [], exclude: [])
18
+ resource = filter resource if parsed_query_params.filter?
19
+
20
+ if parsed_query_params.sort?
21
+ resource = sort resource
22
+ response.headers['X-Sort'] = parsed_query_params.sort.request
23
+ end
24
+
25
+ if paginate?(resource)
26
+ resource = paginate resource
27
+ response.headers['X-Pagination'] = pagination_headers(resource)
28
+ end
29
+
30
+ surrender_response = if parsed_query_params.count?
31
+ Render::Count.new(resource).parse
32
+ elsif parsed_query_params.ids?
33
+ Render::Ids.new(resource).parse
34
+ else
35
+ config = Render::Configuration.new(
36
+ resource_class: resource_class(resource),
37
+ reload_resource: reload,
38
+ user_exclude: parsed_query_params.exclude,
39
+ user_include: parsed_query_params.include,
40
+ ctrl_exclude: exclude,
41
+ ctrl_include: include
42
+ )
43
+
44
+ Render::Resource.new(resource: resource,
45
+ ability: ability,
46
+ config: config).parse
47
+ end
48
+
49
+ # Allows the calling method to decorate the response data before returning the result
50
+ surrender_response.data = yield surrender_response.data if block_given?
51
+
52
+ render(json: surrender_response.json_data, status: status)
53
+ end
54
+
55
+ private
56
+
57
+ def ability
58
+ return current_ability if respond_to?(:current_ability)
59
+
60
+ DefaultAbility.new
61
+ end
62
+
63
+ def resource_class(resource)
64
+ resource.try(:klass) || resource.class
65
+ end
66
+
67
+ def skip_pagination
68
+ @will_paginate = false
69
+ end
70
+
71
+ def filter(resource)
72
+ FilterBuilder.new(resource: resource, filter: parsed_query_params.filter).build!
73
+ end
74
+
75
+ def paginate?(resource)
76
+ pagination(resource).paginatable? && @will_paginate
77
+ end
78
+
79
+ def paginate(resource)
80
+ pagination(resource).build!
81
+ end
82
+
83
+ def pagination(resource)
84
+ PaginationBuilder.new(resource: resource, pagination: parsed_query_params.pagination)
85
+ end
86
+
87
+ def pagination_headers(resource)
88
+ {
89
+ total: resource.total_count,
90
+ page_total: resource.count,
91
+ page: resource.current_page,
92
+ previous_page: resource.prev_page,
93
+ next_page: resource.next_page,
94
+ last_page: resource.total_pages,
95
+ per_page: parsed_query_params.pagination.per,
96
+ offset: resource.offset_value
97
+ }.to_json
98
+ end
99
+
100
+ def sort(resource)
101
+ SortBuilder.new(resource: resource, sort: parsed_query_params.sort).build!
102
+ end
103
+
104
+ def parsed_query_params
105
+ @parsed_query_params ||= QueryParamParser.new(request.query_parameters.symbolize_keys)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # If cancancan is not installed, this class will be instantiated to allow all models to render.
6
+ class DefaultAbility
7
+ def can?(*)
8
+ true
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # Surrender's main error class.
6
+ class Error < StandardError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # apply filtering directives to the given resource, based on the given filter controls
6
+ class FilterBuilder
7
+ attr_reader :resource, :filter
8
+
9
+ def initialize(resource:, filter:)
10
+ @resource = resource
11
+ @filter = filter
12
+ end
13
+
14
+ def build!
15
+ return resource unless resource.is_a?(ActiveRecord::Relation)
16
+
17
+ filter.each do |term|
18
+ scope, value = term.first
19
+
20
+ send_filter_for(scope, value)
21
+ end
22
+
23
+ resource
24
+ end
25
+
26
+ private
27
+
28
+ def send_filter_for(scope, value)
29
+ if resource.respond_to?(filter_method(scope))
30
+ # filter exists on model?
31
+ @resource = @resource.send(filter_method(scope), value)
32
+ elsif resource.respond_to?(filter_method_id(scope))
33
+ # resolved it by appending _id?
34
+ @resource = @resource.send(filter_method_id(scope), value)
35
+ else
36
+ raise Error, I18n.t('surrender.error.query_string.filter.not_available', param: scope)
37
+ end
38
+ end
39
+
40
+ # prepend filter_by so that only filter_by scope methods are reachable.
41
+ def filter_method(scope)
42
+ "filter_by_#{scope}".gsub('.', '_').to_sym
43
+ end
44
+
45
+ def filter_method_id(scope)
46
+ "#{filter_method(scope)}_id".to_sym
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # apply pagination directives to the given resource, based on the given pagination controls
6
+ class PaginationBuilder
7
+ attr_reader :resource, :pagination
8
+
9
+ def initialize(resource:, pagination:)
10
+ @resource = resource
11
+ @pagination = pagination
12
+ end
13
+
14
+ def build!
15
+ return resource unless paginatable?
16
+
17
+ resource.page(pagination.page).per(pagination.per)
18
+ end
19
+
20
+ def paginatable?
21
+ resource.respond_to?(:page)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # parse the requests query_params for surrender's controls and validate the formatting
6
+ class QueryParamParser
7
+ attr_reader :query_params
8
+
9
+ COUNT_PARAM = :count
10
+ EXCLUDE_PARAM = :exclude
11
+ FILTER_PARAM = :filter
12
+ IDS_PARAM = :ids
13
+ INCLUDE_PARAM = :include
14
+ SORT_PARAM = :sort
15
+ PAGE_PARAM = :page
16
+ PER_PARAM = :per
17
+
18
+ PER_PAGE_DEFAULT = 50
19
+ PAGE_DEFAULT = 1
20
+
21
+ Sort = Struct.new(:request, :direction, :association, :attribute, :scope_method, keyword_init: true)
22
+ Pagination = Struct.new(:page, :per, keyword_init: true)
23
+
24
+ def initialize(query_params)
25
+ @query_params = query_params
26
+ end
27
+
28
+ def include
29
+ @include ||= parse_yml(query_params[INCLUDE_PARAM], :include)
30
+ end
31
+
32
+ def exclude
33
+ @exclude ||= parse_yml(query_params[EXCLUDE_PARAM], :exclude)
34
+ end
35
+
36
+ def sort?
37
+ query_params.key? SORT_PARAM
38
+ end
39
+
40
+ def sort
41
+ @sort ||= begin
42
+ sort = String.new(query_params[SORT_PARAM] || '')
43
+
44
+ direction_flag = ['+', '-'].include?(sort[0, 1]) ? sort.slice!(0) : '+'
45
+ direction = direction_flag == '-' ? 'DESC' : 'ASC'
46
+
47
+ scope_method = "sort_by_#{sort}".gsub('.', '_').to_sym
48
+ association, attribute = sort_attributes(sort)
49
+
50
+ Sort.new(request: query_params[SORT_PARAM], direction: direction, attribute: attribute,
51
+ association: association, scope_method: scope_method)
52
+ end
53
+ end
54
+
55
+ def filter?
56
+ filter.present?
57
+ end
58
+
59
+ def filter
60
+ @filter ||= parse_yml(query_params[FILTER_PARAM], :filter)
61
+ end
62
+
63
+ def ids?
64
+ query_params.key?(IDS_PARAM)
65
+ end
66
+
67
+ def count?
68
+ query_params.key?(COUNT_PARAM)
69
+ end
70
+
71
+ def paginate?
72
+ query_params.key? PAGE_PARAM
73
+ end
74
+
75
+ def pagination
76
+ @pagination ||= Pagination.new(
77
+ page: query_params[PAGE_PARAM]&.to_i || PAGE_DEFAULT,
78
+ per: query_params[PER_PARAM]&.to_i || PER_PAGE_DEFAULT
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def sort_attributes(sort)
85
+ return sort.split('.') if sort.include? '.'
86
+
87
+ ['', sort]
88
+ end
89
+
90
+ def parse_yml(query_string, action)
91
+ query_string ||= '' # empty string in case nil is passed
92
+ Psych.safe_load("[#{query_string.gsub(/(,|:)/, '\1 ')}]")
93
+ rescue StandardError
94
+ raise Error, I18n.t('surrender.error.query_string.incorrect_format', param: action)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # apply sort directives to the given resource, based on the given sort controls
6
+ class SortBuilder
7
+ attr_reader :resource, :sort
8
+
9
+ def initialize(resource:, sort:)
10
+ @resource = resource
11
+ @sort = sort
12
+ end
13
+
14
+ def build!
15
+ return resource unless resource.is_a? ActiveRecord::Relation
16
+
17
+ return resource_attribute_order if resource_has_attribute?
18
+
19
+ return scope_method_order if resource_has_scope_method?
20
+
21
+ return association_attribute_order if resource_has_association_with_attribute?
22
+
23
+ raise Error, I18n.t('surrender.error.query_string.sort.invalid_column', param: sort.request)
24
+ end
25
+
26
+ private
27
+
28
+ def resource_has_attribute?
29
+ resource.klass.attribute_names.include?(sort.attribute)
30
+ end
31
+
32
+ def resource_attribute_order
33
+ resource.order(sort.attribute => sort.direction)
34
+ end
35
+
36
+ def resource_has_scope_method?
37
+ resource.respond_to?(sort.scope_method)
38
+ end
39
+
40
+ def scope_method_order
41
+ resource.send(sort.scope_method, sort.direction)
42
+ end
43
+
44
+ def resource_has_association_with_attribute?
45
+ resource.reflections.keys.include?(sort.association) &&
46
+ resource.reflect_on_association(sort.association).klass.attribute_names.include?(sort.attribute)
47
+ end
48
+
49
+ def association_attribute_order
50
+ table_name = resource.reflect_on_association(sort.association).klass.table_name
51
+ resource.joins(sort.association.to_sym)
52
+ .order("#{table_name}.#{sort.attribute} #{sort.direction}")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # Aad methods to the model class to describe how the model renders.
6
+ module ModelAdditions
7
+ def self.included(base)
8
+ attr_accessor :surrender_attributes
9
+ attr_accessor :surrender_expands
10
+ attr_accessor :surrender_available_attributes
11
+ attr_accessor :surrender_available_expands
12
+
13
+ base.extend(ClassMethods)
14
+ end
15
+
16
+ module ClassMethods
17
+ def surrenders(*args)
18
+ directives = args.extract_options!
19
+
20
+ # Run through the various lists of attributes and assign them to the rendering context
21
+ # If the superclass has attributes then consume those as well.
22
+ %w[attributes expands available_attributes available_expands].each do |directive|
23
+ surrender_directive = "surrender_#{directive}"
24
+ list = []
25
+ if superclass.instance_variable_defined?("@#{surrender_directive}")
26
+ list << superclass.instance_variable_get("@#{surrender_directive}")
27
+ end
28
+ list << directives[directive.to_sym] if directives.key?(directive.to_sym)
29
+ instance_variable_set("@#{surrender_directive}", list.flatten.uniq)
30
+ end
31
+ end
32
+
33
+ def surrender_attributes
34
+ @surrender_attributes ||= %i[id created_at]
35
+ end
36
+
37
+ def surrender_available_attributes
38
+ @surrender_available_attributes ||= []
39
+ end
40
+
41
+ def surrender_expands
42
+ @surrender_expands ||= []
43
+ end
44
+
45
+ def surrender_available_expands
46
+ @surrender_available_expands ||= []
47
+ end
48
+
49
+ def surrender_callable_attributes
50
+ @surrender_callable_attributes ||= (surrender_attributes + surrender_available_attributes).flatten
51
+ end
52
+
53
+ def surrender_callable_expands
54
+ @surrender_callable_expands ||= (surrender_expands + surrender_available_expands).flatten
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add filter_by_date_(to/from/before/after) methods for all *_at columns
4
+ module Rails
5
+ module Surrender
6
+ # apply filter scopes to the model
7
+ module ModelFilterScopes
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def inherited(child)
14
+ super
15
+
16
+ # Bad things happen if you ask table_exists? to ApplicationRecord while it's loading!
17
+ # TODO: Add configuration in case ApplicationRecord is _not_ the primary abstract class
18
+ return if child.name == 'ApplicationRecord'
19
+
20
+ return unless child.table_exists?
21
+
22
+ child.instance_eval do
23
+ # scope to filter by every column name
24
+ apply_surrender_column_name_scopes(child)
25
+
26
+ # scope to filter by date or time column names
27
+ apply_surrender_column_datetime_scopes(child)
28
+ rescue StandardError
29
+ # TODO: why are tests failing here!!!
30
+ end
31
+ end
32
+
33
+ def apply_surrender_column_name_scopes(child)
34
+ child.column_names.each do |column|
35
+ scope "filter_by_#{column}".to_sym, ->(val) { where({ column.to_sym => val }) }
36
+ end
37
+ end
38
+
39
+ def apply_surrender_column_datetime_scopes(child)
40
+ with_surrender_datetime_columns(child) do |column|
41
+ base = column.split(/_at$/).first
42
+
43
+ scope "filter_by_#{base}_to".to_sym, ->(time) { where("#{child.table_name}.#{column} <= ?", time) }
44
+ scope "filter_by_#{base}_from".to_sym, ->(time) { where("#{child.table_name}.#{column} >= ?", time) }
45
+ scope "filter_by_#{base}_before".to_sym, ->(time) { where("#{child.table_name}.#{column} < ?", time) }
46
+ scope "filter_by_#{base}_after".to_sym, ->(time) { where("#{child.table_name}.#{column} > ?", time) }
47
+ end
48
+ end
49
+
50
+ def with_surrender_datetime_columns(child, &block)
51
+ child.columns.select { |c| c.type.in? %i[date datetime] }.map(&:name).each(&block)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Railtie < ::Rails::Railtie
6
+ config.after_initialize do
7
+ ActionController::Base.class_eval do
8
+ include Rails::Surrender::ControllerAdditions
9
+ end
10
+
11
+ ActionController::API.class_eval do
12
+ include Rails::Surrender::ControllerAdditions
13
+ end
14
+
15
+ ActiveRecord::Base.class_eval do
16
+ include Rails::Surrender::ModelAdditions
17
+ include Rails::Surrender::ModelFilterScopes
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ # Container for config structure when rendering or generating the inclusion object.
7
+ class Configuration
8
+ module InclusionMapperLogic
9
+ def expanding_elements
10
+ list = resource_class_surrender_attributes_that_expand +
11
+ resource_class_surrender_expands +
12
+ resource_class_subclass_surrender_attributes_that_expand +
13
+ resource_class_subclass_surrender_expands +
14
+ user_included_joins_required +
15
+ ctrl_included_joins_required
16
+ .flatten.uniq
17
+ list
18
+ .map { |e| element_from(e) }
19
+ .reject do |element|
20
+ element.klass.in?(history) ||
21
+ element.name.in?(local_user_excludes) ||
22
+ (element.name.in?(local_ctrl_excludes) && !element.name.in?(local_user_includes))
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def resource_class_surrender_attributes_that_expand
29
+ resource_class.surrender_attributes
30
+ .select { |attr| attr.match /_ids$/ }
31
+ .map { |attr| attr.to_s.sub('_ids', '').pluralize }
32
+ .select { |include| attribute_type(include).in? %i[expand associate] }
33
+ end
34
+
35
+ def resource_class_surrender_expands
36
+ resource_class.surrender_expands
37
+ end
38
+
39
+ def resource_class_subclass_surrender_attributes_that_expand
40
+ resource_class.subclasses.map do |subclass|
41
+ subclass.surrender_attributes
42
+ .select { |attr| attr.match /_ids$/ }
43
+ .map { |attr| attr.to_s.sub('_ids', '').pluralize }
44
+ .select { |include| attribute_type(include, resource_class: sc).in? %i[expand associate] }
45
+ end
46
+ end
47
+
48
+ def resource_class_subclass_surrender_expands
49
+ resource_class.subclasses.map(&:surrender_expands)
50
+ end
51
+
52
+ def user_included_joins_required
53
+ top_level_keys_from(user_include).select { |include| attribute_type(include).in? %i[expand associate] }
54
+ end
55
+
56
+ def ctrl_included_joins_required
57
+ top_level_keys_from(ctrl_include).select { |include| attribute_type(include).in? %i[expand associate] }
58
+ end
59
+
60
+ def element_from(item)
61
+ item_name = resource_class.reflections.key?(item.to_s) ? item.to_s : item.to_s.sub('_ids', '').pluralize
62
+ item_klass = resource_class.reflections[item_name].klass
63
+ Element.new name: item_name, klass: item_klass, continue: (item.to_s == item_name)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ # Container for config structure when rendering or generating the inclusion object.
7
+ class Configuration
8
+ module InstanceLogic
9
+ def nested_user_includes
10
+ next_level_asks_from(user_include)
11
+ end
12
+
13
+ def nested_ctrl_includes
14
+ next_level_asks_from(ctrl_include)
15
+ end
16
+
17
+ def nested_user_excludes
18
+ next_level_asks_from(user_exclude)
19
+ end
20
+
21
+ def nested_ctrl_excludes
22
+ next_level_asks_from(ctrl_exclude)
23
+ end
24
+
25
+ def locally_included_attributes
26
+ [].push(resource_class.surrender_attributes)
27
+ .push(ctrl_included_attributes)
28
+ .push(user_included_attributes_to_render)
29
+ .flatten.uniq
30
+ .reject { |attr| exclude_locally?(attr) }
31
+ end
32
+
33
+ def locally_included_expands
34
+ [].push(user_included_local_expansions_to_render)
35
+ .push(ctrl_included_expansions)
36
+ .push(resource_class.surrender_expands)
37
+ .flatten.uniq
38
+ .each_with_object({}) { |key, result| result[key.to_sym] = [] }
39
+ .deep_merge(nested_user_includes)
40
+ .deep_merge(nested_ctrl_includes)
41
+ end
42
+
43
+ def exclude_locally?(key)
44
+ local_excludes.include?(key) && !local_user_includes.include?(key)
45
+ end
46
+
47
+ private
48
+
49
+ def local_excludes
50
+ local_ctrl_excludes.dup.push(local_user_excludes).flatten.uniq
51
+ end
52
+
53
+ def user_included_attributes
54
+ top_level_keys_from(user_include).select { |include| attribute_type(include).in? %i[include associate] }
55
+ end
56
+
57
+ def user_included_attributes_to_render
58
+ attrs = user_included_attributes
59
+ unavailable_attrs = (attrs - resource_class.surrender_callable_attributes)
60
+ return attrs if unavailable_attrs.empty?
61
+
62
+ raise Error, I18n.t('surrender.error.query_string.include.not_available', param: unavailable_attrs)
63
+ end
64
+
65
+ def user_included_expansions
66
+ local_user_includes.select { |i| attribute_type(i) == :expand }
67
+ end
68
+
69
+ def user_included_local_expansions_to_render
70
+ expansions = user_included_expansions
71
+ unavailable_expansions = (expansions - resource_class.surrender_callable_expands)
72
+ return expansions if unavailable_expansions.empty?
73
+
74
+ raise Error, I18n.t('surrender.error.query_string.include.not_available', param: unavailable_expansions)
75
+ end
76
+
77
+ def ctrl_included_attributes
78
+ top_level_keys_from(ctrl_include).select { |include| attribute_type(include).in? %i[include associate] }
79
+ end
80
+
81
+ def ctrl_included_expansions
82
+ local_ctrl_includes.select { |i| attribute_type(i) == :expand }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'configuration/inclusion_mapper_logic'
4
+ require_relative 'configuration/instance_logic'
5
+
6
+ module Rails
7
+ module Surrender
8
+ module Render
9
+ # Container for config structure when rendering or generating the inclusion object.
10
+ class Configuration
11
+ include InclusionMapperLogic
12
+ include InstanceLogic
13
+
14
+ attr_accessor :resource_class,
15
+ :reload_resource,
16
+ :user_exclude,
17
+ :user_include,
18
+ :ctrl_exclude,
19
+ :ctrl_include,
20
+ :history
21
+
22
+ alias reload_resource? reload_resource
23
+
24
+ Element = Struct.new(:name, :klass, :continue, keyword_init: true)
25
+
26
+ def initialize(
27
+ resource_class: nil,
28
+ reload_resource: false,
29
+ user_exclude: [],
30
+ user_include: [],
31
+ ctrl_exclude: [],
32
+ ctrl_include: [],
33
+ history: []
34
+ )
35
+ @resource_class = resource_class
36
+ @reload_resource = reload_resource
37
+ @user_exclude = user_exclude.compact
38
+ @user_include = user_include
39
+ @ctrl_exclude = ctrl_exclude.compact
40
+ @ctrl_include = ctrl_include
41
+ @history = history
42
+
43
+ validate_user_includes!
44
+ end
45
+
46
+ private
47
+
48
+ def validate_user_includes!
49
+ return if invalid_local_user_includes.empty?
50
+
51
+ raise Error, I18n.t('surrender.error.query_string.include.not_available', param: invalid_local_user_includes)
52
+ end
53
+
54
+ def invalid_local_user_includes
55
+ local_user_includes.select { |include| attribute_type(include) == :none }
56
+ end
57
+
58
+ def local_ctrl_excludes
59
+ top_level_keys_from(ctrl_exclude)
60
+ end
61
+
62
+ def local_ctrl_includes
63
+ top_level_keys_from(ctrl_include)
64
+ end
65
+
66
+ def local_user_excludes
67
+ top_level_keys_from(user_exclude)
68
+ end
69
+
70
+ def local_user_includes
71
+ top_level_keys_from(user_include)
72
+ end
73
+
74
+ def attribute_type(attr, klass: resource_class)
75
+ return :expand if klass.reflections.keys.include?(attr.to_s)
76
+ return :associate if klass.reflections.keys.include?(attr.to_s.sub('_ids', '').pluralize)
77
+ return :include if klass.attribute_names.include?(attr.to_s)
78
+ return :include if klass.instance_methods.include?(attr)
79
+
80
+ :none
81
+ end
82
+
83
+ def top_level_keys_from(list)
84
+ list.map { |x| x.is_a?(Hash) ? x.keys : x }.flatten.map(&:to_sym).uniq
85
+ end
86
+
87
+ def next_level_asks_from(list)
88
+ list.select { |x| x.is_a? Hash }.reduce({}, :merge).symbolize_keys
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ # Rendering a count of resources.
7
+ class Count
8
+ attr_reader :resource
9
+
10
+ def initialize(resource)
11
+ @resource = resource
12
+ end
13
+
14
+ def parse
15
+ count = resource.respond_to?(:count) ? resource.count : 1
16
+ Response.new(data: { count: count })
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ # Rendering the IDs of the requested resources.
7
+ class Ids
8
+ attr_reader :resource
9
+
10
+ def initialize(resource)
11
+ @resource = resource
12
+ end
13
+
14
+ def parse
15
+ ids = resource.respond_to?(:ids) ? resource.ids : [resource.id]
16
+ Response.new(data: { ids: ids })
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ class Resource
7
+ # Renders a collection resource
8
+ class Collection
9
+ attr_reader :resource, :config, :ability
10
+
11
+ def initialize(resource:, config:, ability:)
12
+ @resource = resource
13
+ @config = config
14
+ @ability = ability
15
+ end
16
+
17
+ def render
18
+ return nil if resource.nil?
19
+
20
+ resource.map { |data| Instance.new(resource: data, config: config, ability: ability).render }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ class Resource
7
+ # Builds a complete map of the resources needed to fulfill the request, for supplying to ActiveRecord.includes
8
+ class InclusionMapper
9
+ attr_reader :resource_class, :config
10
+
11
+ Element = Struct.new(:name, :klass, keyword_init: true)
12
+ # InclusionMapper is designed to recursively crawl through the model rendering structure and build a hash
13
+ # that ActiveRecord can use to eager load ALL of the data we're going to render, to prevent N+1 queries
14
+ def initialize(resource_class:, config:)
15
+ @resource_class = resource_class
16
+ @config = config
17
+ end
18
+
19
+ def parse
20
+ config.history.push resource_class
21
+ includes = []
22
+
23
+ config.expanding_elements.each do |element|
24
+ item_config = Configuration.new(
25
+ resource_class: element.klass,
26
+ user_include: config.nested_user_includes[element.name] || [],
27
+ ctrl_include: config.nested_ctrl_includes[element.name] || [],
28
+ user_exclude: config.nested_user_excludes[element.name] || [],
29
+ ctrl_exclude: config.nested_ctrl_excludes[element.name] || [],
30
+ history: config.history.dup.push(element.klass)
31
+ )
32
+
33
+ nested = if element.continue
34
+ InclusionMapper.new(resource_class: element.klass, config: item_config).parse
35
+ else
36
+ []
37
+ end
38
+
39
+ includes << (nested.size.zero? ? element.name : { element.name => nested })
40
+ end
41
+
42
+ includes.sort_by { |x| x.is_a?(Symbol) ? 0 : 1 }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Render
6
+ class Resource
7
+ # Renders an instance resource
8
+ class Instance
9
+ attr_reader :resource, :config, :ability
10
+
11
+ def initialize(resource:, config:, ability:)
12
+ @resource = resource
13
+ @config = config
14
+ @ability = ability
15
+ end
16
+
17
+ def render
18
+ config.history.push history_class
19
+
20
+ result = {}
21
+ config.locally_included_attributes.each { |attr| result[attr.to_sym] = resource.send(attr) }
22
+
23
+ config.locally_included_expands.each_key do |key|
24
+ next if config.exclude_locally?(key)
25
+
26
+ nested_resource_class = nested_class_for(resource, key)
27
+ next if config.history.include? nested_resource_class
28
+
29
+ nested_config = nested_config_for(nested_resource_class, key)
30
+
31
+ if resource.class.reflections[key.to_s].try(:collection?)
32
+ collection = resource.send(key.to_sym).select { |i| ability.can? :read, i }
33
+ result[key] = Collection.new(resource: collection, config: nested_config, ability: ability).render
34
+ else
35
+ instance = resource.send(key)
36
+ next if config.history.include? instance.class
37
+
38
+ if ability.can?(:read, instance)
39
+ result[key] = Instance.new(resource: instance, config: nested_config, ability: ability).render
40
+ elsif instance.nil?
41
+ result[key] = nil # represent an associated element as null if it's missing
42
+ end
43
+ end
44
+ end
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def nested_class_for(resource, key)
51
+ resource.class.reflections[key.to_s].klass
52
+ rescue NoMethodError
53
+ resource.send(key).class
54
+ end
55
+
56
+ def nested_config_for(nested_resource_class, key)
57
+ Configuration.new(
58
+ resource_class: nested_resource_class,
59
+ user_include: config.nested_user_includes[key] || [],
60
+ ctrl_include: config.nested_ctrl_includes[key] || [],
61
+ user_exclude: config.nested_user_excludes[key] || [],
62
+ ctrl_exclude: config.nested_ctrl_excludes[key] || [],
63
+ history: config.history
64
+ )
65
+ end
66
+
67
+ # get to the root subclass for sti models and store that as history
68
+ def history_class
69
+ resource.class.superclass until resource.class.superclass.in? [ActiveRecord::Base, ApplicationRecord]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource/inclusion_mapper'
4
+ require_relative 'resource/collection'
5
+ require_relative 'resource/instance'
6
+
7
+ module Rails
8
+ module Surrender
9
+ module Render
10
+ # Rendering a resource, and it's various nested components, according to the given config params.
11
+ class Resource
12
+ attr_reader :resource, :ability, :config
13
+
14
+ def initialize(resource:, ability:, config:)
15
+ @resource = resource
16
+ @ability = ability
17
+ @config = config
18
+ end
19
+
20
+ def parse
21
+ data = case resource
22
+ when nil? then {}
23
+ when Hash || Array then resource
24
+ when ActiveRecord::Relation then collection_data
25
+ else instance_data
26
+ end
27
+ Response.new(data: data)
28
+ end
29
+
30
+ private
31
+
32
+ def collection_data
33
+ includes = InclusionMapper.new(resource_class: resource.klass, config: config).parse
34
+ data = @resource.includes(includes)
35
+ Collection.new(resource: data, config: config, ability: ability).render
36
+ end
37
+
38
+ def instance_data
39
+ # Reloading the instance here allows us to take advantage of the eager loading
40
+ # capabilities of ActiveRecord with our 'includes' hash to prevent N+1 queries.
41
+ # This can save a TON of response time when the data sets begin to get large.
42
+ data = if config.reload_resource?
43
+ includes = InclusionMapper.new(resource_class: resource.class, config: config).parse
44
+ @resource = resource.class.includes(includes).find_by_id(resource.id)
45
+ else
46
+ resource
47
+ end
48
+
49
+ Instance.new(resource: data, config: config, ability: ability).render
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ # Generate a Response object from the given data
6
+ class Response
7
+ attr_accessor :data
8
+
9
+ def initialize(data:)
10
+ @data = data
11
+ end
12
+
13
+ def json_data
14
+ ::Oj.dump(data, mode: :compat)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ # Base namespace for the entire project
5
+ module Surrender
6
+ end
7
+ end
8
+
9
+ require 'kaminari'
10
+ require 'oj'
11
+
12
+ require 'rails/surrender/default_ability'
13
+ require 'rails/surrender/controller_additions'
14
+ require 'rails/surrender/exceptions'
15
+ require 'rails/surrender/model_additions'
16
+ require 'rails/surrender/model_filter_scopes'
17
+ require 'rails/surrender/railtie'
18
+ require 'rails/surrender/render/ids'
19
+ require 'rails/surrender/render/configuration'
20
+ require 'rails/surrender/render/count'
21
+ require 'rails/surrender/render/resource'
22
+ require 'rails/surrender/response'
23
+ require 'rails/surrender/version'
24
+
25
+ # Load Surrender specific error messages
26
+ locale_path = Dir.glob("#{__dir__}/surrender/config/locales/*.{rb,yml}")
27
+ I18n.load_path += locale_path unless I18n.load_path.include?(locale_path)
28
+
29
+ # ActiveRecord Patches required for proper functionality
30
+ require 'rails/surrender/config/initializers/active_record_associations_patch'
31
+ require 'rails/surrender/config/initializers/active_record_preloader_patch'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class SurrenderTest < ActiveSupport::TestCase
6
+ test 'truth' do
7
+ assert_kind_of Module, Surrender
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configure Rails Environment
4
+ ENV['RAILS_ENV'] = 'test'
5
+
6
+ require File.expand_path('../test/dummy/config/environment.rb', __dir__)
7
+ ActiveRecord::Migrator.migrations_paths = [File.expand_path('../test/dummy/db/migrate', __dir__)]
8
+ require 'rails/test_help'
9
+
10
+ # Filter out Minitest backtrace while allowing backtrace from other libraries
11
+ # to be shown.
12
+ Minitest.backtrace_filter = Minitest::BacktraceFilter.new
13
+
14
+ # Load support files
15
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
16
+
17
+ # Load fixtures from the engine
18
+ if ActiveSupport::TestCase.respond_to?(:fixture_path=)
19
+ ActiveSupport::TestCase.fixture_path = File.expand_path('fixtures', __dir__)
20
+ ActiveSupport::TestCase.fixtures :all
21
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-surrender
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shawn McBride
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: kaminari
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 1.2.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 1.2.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: oj
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 3.13.10
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 3.13.10
41
+ description:
42
+ email: smmcbride@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/rails/surrender.rb
48
+ - lib/rails/surrender/config/initializers/active_record_associations_patch.rb
49
+ - lib/rails/surrender/config/initializers/active_record_preloader_patch.rb
50
+ - lib/rails/surrender/config/locales/en.yml
51
+ - lib/rails/surrender/controller_additions.rb
52
+ - lib/rails/surrender/default_ability.rb
53
+ - lib/rails/surrender/exceptions.rb
54
+ - lib/rails/surrender/helpers/filter_builder.rb
55
+ - lib/rails/surrender/helpers/pagination_builder.rb
56
+ - lib/rails/surrender/helpers/query_param_parser.rb
57
+ - lib/rails/surrender/helpers/sort_builder.rb
58
+ - lib/rails/surrender/model_additions.rb
59
+ - lib/rails/surrender/model_filter_scopes.rb
60
+ - lib/rails/surrender/railtie.rb
61
+ - lib/rails/surrender/render/configuration.rb
62
+ - lib/rails/surrender/render/configuration/inclusion_mapper_logic.rb
63
+ - lib/rails/surrender/render/configuration/instance_logic.rb
64
+ - lib/rails/surrender/render/count.rb
65
+ - lib/rails/surrender/render/ids.rb
66
+ - lib/rails/surrender/render/resource.rb
67
+ - lib/rails/surrender/render/resource/collection.rb
68
+ - lib/rails/surrender/render/resource/inclusion_mapper.rb
69
+ - lib/rails/surrender/render/resource/instance.rb
70
+ - lib/rails/surrender/response.rb
71
+ - lib/rails/surrender/version.rb
72
+ - test/surrender_test.rb
73
+ - test/test_helper.rb
74
+ homepage: https://github.com/smmcbride/rails-surrender
75
+ licenses:
76
+ - GPL-3.0
77
+ metadata:
78
+ rubygems_mfa_required: 'true'
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.2.15
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: JSON rendering for Rails API
98
+ test_files: []