rails-surrender 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.
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: []