rest_framework 0.1.0 → 0.2.2

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,26 @@
1
+ # Top-level class for all REST Framework errors.
2
+ class RESTFramework::Error < StandardError
3
+ end
4
+
5
+ class RESTFramework::NilPassedToAPIResponseError < RESTFramework::Error
6
+ def message
7
+ return <<~MSG.split("\n").join(' ')
8
+ Payload of `nil` was passed to `api_response`; this is unsupported. If you want a blank
9
+ response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
10
+ (or similar Active Record method) not finding a record, you should use the bang version (e.g.,
11
+ `find_by!`) to raise `ActiveRecord::RecordNotFound`, which the REST controller will catch and
12
+ return an appropriate error response.
13
+ MSG
14
+ end
15
+ end
16
+
17
+ class RESTFramework::UnserializableError < RESTFramework::Error
18
+ def initialize(object)
19
+ @object = object
20
+ return super
21
+ end
22
+
23
+ def message
24
+ return "Unable to serialize `#{@object.inspect}` (of type `#{@object.class}`)."
25
+ end
26
+ end
@@ -1,65 +1,68 @@
1
- module RESTFramework
2
- class BaseFilter
3
- def initialize(controller:)
4
- @controller = controller
5
- end
1
+ class RESTFramework::BaseFilter
2
+ def initialize(controller:)
3
+ @controller = controller
4
+ end
6
5
 
7
- def get_filtered_data(data)
8
- raise NotImplementedError
9
- end
6
+ def get_filtered_data(data)
7
+ raise NotImplementedError
10
8
  end
9
+ end
11
10
 
12
- # A simple filtering backend that supports filtering a recordset based on fields defined on the
13
- # controller class.
14
- class ModelFilter < BaseFilter
15
- # Filter params for keys allowed by the current action's filterset_fields/fields config.
16
- def _get_filter_params
17
- fields = @controller.class.filterset_fields || @controller.send(:get_fields)
18
- return @controller.request.query_parameters.select { |p|
19
- fields.include?(p.to_sym) || fields.include?(p.to_s)
20
- }.to_hash.symbolize_keys
21
- end
22
11
 
23
- def get_filtered_data(data)
24
- filter_params = self._get_filter_params
25
- unless filter_params.blank?
26
- return data.where(**filter_params)
27
- end
12
+ # A simple filtering backend that supports filtering a recordset based on fields defined on the
13
+ # controller class.
14
+ class RESTFramework::ModelFilter < RESTFramework::BaseFilter
15
+ # Filter params for keys allowed by the current action's filterset_fields/fields config.
16
+ def _get_filter_params
17
+ fields = @controller.class.filterset_fields&.map(&:to_s) || @controller.send(:get_fields)
18
+ return @controller.request.query_parameters.select { |p|
19
+ fields.include?(p)
20
+ }.to_h.symbolize_keys # convert from HashWithIndifferentAccess to Hash w/keys
21
+ end
28
22
 
29
- return data
23
+ # Filter data according to the request query parameters.
24
+ def get_filtered_data(data)
25
+ filter_params = self._get_filter_params
26
+ unless filter_params.blank?
27
+ return data.where(**filter_params)
30
28
  end
29
+
30
+ return data
31
31
  end
32
+ end
32
33
 
33
- # A filter backend which handles ordering of the recordset.
34
- class ModelOrderingFilter < BaseFilter
35
- # Convert ordering string to an ordering configuration.
36
- def _get_ordering
37
- return nil unless @controller.class.ordering_query_param
38
34
 
39
- order_string = @controller.params[@controller.class.ordering_query_param]
40
- unless order_string.blank?
41
- return order_string.split(',').map { |field|
42
- if field[0] == '-'
43
- [field[1..-1].to_sym, :desc]
44
- else
45
- [field.to_sym, :asc]
46
- end
47
- }.to_h
48
- end
35
+ # A filter backend which handles ordering of the recordset.
36
+ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
37
+ # Convert ordering string to an ordering configuration.
38
+ def _get_ordering
39
+ return nil unless @controller.class.ordering_query_param
49
40
 
50
- return nil
41
+ order_string = @controller.params[@controller.class.ordering_query_param]
42
+ unless order_string.blank?
43
+ return order_string.split(',').map { |field|
44
+ if field[0] == '-'
45
+ [field[1..-1].to_sym, :desc]
46
+ else
47
+ [field.to_sym, :asc]
48
+ end
49
+ }.to_h
51
50
  end
52
51
 
53
- def get_filtered_data(data)
54
- ordering = self._get_ordering
55
- if ordering && !ordering.empty?
56
- return data.order(_get_ordering)
57
- end
58
- return data
59
- end
52
+ return nil
60
53
  end
61
54
 
62
- # TODO: implement searching within fields rather than exact match filtering (ModelFilter)
63
- # class ModelSearchFilter < BaseFilter
64
- # end
55
+ # Order data according to the request query parameters.
56
+ def get_filtered_data(data)
57
+ ordering = self._get_ordering
58
+ if ordering && !ordering.empty?
59
+ return data.order(_get_ordering)
60
+ end
61
+ return data
62
+ end
65
63
  end
64
+
65
+
66
+ # TODO: implement searching within fields rather than exact match filtering (ModelFilter)
67
+ # class RESTFramework::ModelSearchFilter < RESTFramework::BaseFilter
68
+ # end
@@ -0,0 +1,5 @@
1
+ module RESTFramework::Generators
2
+ end
3
+
4
+
5
+ require_relative 'generators/controller_generator'
@@ -0,0 +1,26 @@
1
+ require 'rails/generators'
2
+
3
+
4
+ class RESTFramework::Generators::ControllerGenerator < Rails::Generators::Base
5
+ desc <<~END
6
+ Description:
7
+ Stubs out a active_scaffolded controller. Pass the model name,
8
+ either CamelCased or under_scored.
9
+ The controller name is retrieved as a pluralized version of the model
10
+ name.
11
+ To create a controller within a module, specify the model name as a
12
+ path like 'parent_module/controller_name'.
13
+ This generates a controller class in app/controllers and invokes helper,
14
+ template engine and test framework generators.
15
+ Example:
16
+ `rails generate rest_framework:controller CreditCard`
17
+ Credit card controller with URLs like /credit_card/debit.
18
+ Controller: app/controllers/credit_cards_controller.rb
19
+ Functional Test: test/functional/credit_cards_controller_test.rb
20
+ Helper: app/helpers/credit_cards_helper.rb
21
+ END
22
+
23
+ def create_controller_file
24
+ create_file "app/controllers/some_controller.rb", "# Add initialization content here"
25
+ end
26
+ end
@@ -1,84 +1,95 @@
1
- module RESTFramework
2
-
3
- # A simple paginator based on page numbers.
4
- #
5
- # Example: http://example.com/api/users/?page=3&page_size=50
6
- class PageNumberPaginator
7
- def initialize(data:, controller:, **kwargs)
8
- @data = data
9
- @controller = controller
10
- @count = data.count
11
- @page_size = self._page_size
12
-
13
- @total_pages = @count / @page_size
14
- @total_pages += 1 if (@count % @page_size != 0)
15
- end
1
+ class RESTFramework::BasePaginator
2
+ def initialize(data:, controller:, **kwargs)
3
+ @data = data
4
+ @controller = controller
5
+ end
16
6
 
17
- def _page_size
18
- page_size = nil
7
+ # Get the page and return it so the caller can serialize it.
8
+ def get_page
9
+ raise NotImplementedError
10
+ end
19
11
 
20
- # Get from context, if allowed.
21
- if @controller.class.page_size_query_param
22
- page_size = @controller.params[@controller.class.page_size_query_param].presence
23
- if page_size
24
- page_size = page_size.to_i
25
- end
26
- end
12
+ # Wrap the serialized page with appropriate metadata.
13
+ def get_paginated_response(serialized_page)
14
+ raise NotImplementedError
15
+ end
16
+ end
27
17
 
28
- # Otherwise, get from config.
29
- if !page_size && @controller.class.page_size
30
- page_size = @controller.class.page_size
31
- end
32
18
 
33
- # Fallback to a page size of 15.
34
- page_size = 15 unless page_size
19
+ # A simple paginator based on page numbers.
20
+ #
21
+ # Example: http://example.com/api/users/?page=3&page_size=50
22
+ class RESTFramework::PageNumberPaginator < RESTFramework::BasePaginator
23
+ def initialize(**kwargs)
24
+ super
25
+ @count = @data.count
26
+ @page_size = self._page_size
27
+
28
+ @total_pages = @count / @page_size
29
+ @total_pages += 1 if (@count % @page_size != 0)
30
+ end
31
+
32
+ def _page_size
33
+ page_size = nil
35
34
 
36
- # Ensure we don't exceed the max page size.
37
- if @controller.class.max_page_size && page_size > @controller.class.max_page_size
38
- page_size = @controller.class.max_page_size
35
+ # Get from context, if allowed.
36
+ if @controller.class.page_size_query_param
37
+ if page_size = @controller.params[@controller.class.page_size_query_param].presence
38
+ page_size = page_size.to_i
39
39
  end
40
+ end
40
41
 
41
- # Ensure we return at least 1.
42
- return page_size.zero? ? 1 : page_size
42
+ # Otherwise, get from config.
43
+ if !page_size && @controller.class.page_size
44
+ page_size = @controller.class.page_size
43
45
  end
44
46
 
45
- def _page_query_param
46
- return @controller.class.page_query_param&.to_sym
47
+ # Ensure we don't exceed the max page size.
48
+ if @controller.class.max_page_size && page_size > @controller.class.max_page_size
49
+ page_size = @controller.class.max_page_size
47
50
  end
48
51
 
49
- def get_page(page_number=nil)
50
- # If page number isn't provided, infer from the params or use 1 as a fallback value.
51
- if !page_number
52
- page_number = @controller&.params&.[](self._page_query_param)
53
- if page_number.blank?
52
+ # Ensure we return at least 1.
53
+ return page_size.zero? ? 1 : page_size
54
+ end
55
+
56
+ def _page_query_param
57
+ return @controller.class.page_query_param&.to_sym
58
+ end
59
+
60
+ # Get the page and return it so the caller can serialize it.
61
+ def get_page(page_number=nil)
62
+ # If page number isn't provided, infer from the params or use 1 as a fallback value.
63
+ if !page_number
64
+ page_number = @controller&.params&.[](self._page_query_param)
65
+ if page_number.blank?
66
+ page_number = 1
67
+ else
68
+ page_number = page_number.to_i
69
+ if page_number.zero?
54
70
  page_number = 1
55
- else
56
- page_number = page_number.to_i
57
- if page_number.zero?
58
- page_number = 1
59
- end
60
71
  end
61
72
  end
62
- @page_number = page_number
63
-
64
- # Get the data page and return it so the caller can serialize the data in the proper format.
65
- page_index = @page_number - 1
66
- return @data.limit(@page_size).offset(page_index * @page_size)
67
73
  end
74
+ @page_number = page_number
68
75
 
69
- # Wrap the serialized page with appripriate metadata.
70
- def get_paginated_response(serialized_page)
71
- return {
72
- count: @count,
73
- page: @page_number,
74
- total_pages: @total_pages,
75
- results: serialized_page,
76
- }
77
- end
76
+ # Get the data page and return it so the caller can serialize the data in the proper format.
77
+ page_index = @page_number - 1
78
+ return @data.limit(@page_size).offset(page_index * @page_size)
78
79
  end
79
80
 
80
- # TODO: implement this
81
- # class CountOffsetPaginator
82
- # end
83
-
81
+ # Wrap the serialized page with appropriate metadata. TODO: include links.
82
+ def get_paginated_response(serialized_page)
83
+ return {
84
+ count: @count,
85
+ page: @page_number,
86
+ total_pages: @total_pages,
87
+ results: serialized_page,
88
+ }
89
+ end
84
90
  end
91
+
92
+
93
+ # TODO: implement this
94
+ # class RESTFramework::CountOffsetPaginator
95
+ # end
@@ -1,8 +1,9 @@
1
1
  require 'action_dispatch/routing/mapper'
2
2
 
3
+
3
4
  module ActionDispatch::Routing
4
5
  class Mapper
5
- # Helper to take extra_actions hash and convert to a consistent format.
6
+ # Internal helper to take extra_actions hash and convert to a consistent format.
6
7
  protected def _parse_extra_actions(extra_actions)
7
8
  return (extra_actions || {}).map do |k,v|
8
9
  kwargs = {}
@@ -24,6 +25,11 @@ module ActionDispatch::Routing
24
25
  path = v.delete(:path)
25
26
  end
26
27
 
28
+ # Set the action to be the action key unless it's already defined.
29
+ if !kwargs[:action]
30
+ kwargs[:action] = k
31
+ end
32
+
27
33
  # Pass any further kwargs to the underlying Rails interface.
28
34
  kwargs = kwargs.merge(v)
29
35
  elsif v.is_a?(Symbol) || v.is_a?(String)
@@ -37,7 +43,7 @@ module ActionDispatch::Routing
37
43
  end
38
44
  end
39
45
 
40
- # Private interface to get the controller class from the name and current scope.
46
+ # Internal interface to get the controller class from the name and current scope.
41
47
  protected def _get_controller_class(name, pluralize: true, fallback_reverse_pluralization: true)
42
48
  # get class name
43
49
  name = name.to_s.camelize # camelize to leave plural names plural
@@ -71,7 +77,7 @@ module ActionDispatch::Routing
71
77
  return controller
72
78
  end
73
79
 
74
- # Core implementation of the `rest_resource(s)` router, both singular and plural.
80
+ # Internal core implementation of the `rest_resource(s)` router, both singular and plural.
75
81
  # @param default_singular [Boolean] the default plurality of the resource if the plurality is
76
82
  # not otherwise defined by the controller
77
83
  # @param name [Symbol] the resource name, from which path and controller are deduced by default
@@ -166,7 +172,7 @@ module ActionDispatch::Routing
166
172
  end
167
173
 
168
174
  # Route a controller's `#root` to '/' in the current scope/namespace, along with other actions.
169
- # @param label [Symbol] the snake_case name of the controller
175
+ # @param name [Symbol] the snake_case name of the controller
170
176
  def rest_root(name=nil, **kwargs, &block)
171
177
  # By default, use RootController#root.
172
178
  root_action = kwargs.delete(:action) || :root
@@ -1,116 +1,165 @@
1
- module RESTFramework
2
- class BaseSerializer
3
- attr_reader :errors
1
+ class RESTFramework::BaseSerializer
2
+ def initialize(object: nil, controller: nil, **kwargs)
3
+ @object = object
4
+ @controller = controller
5
+ end
4
6
 
5
- def initialize(object: nil, controller: nil, **kwargs)
6
- @object = object
7
- @controller = controller
8
- end
7
+ def serialize
8
+ raise NotImplementedError
9
9
  end
10
+ end
10
11
 
11
- # This serializer uses `.as_json` to serialize objects. Despite the name, `.as_json` is an
12
- # `ActiveModel` method which converts objects to Ruby primitives (with the top-level being either
13
- # an array or a hash).
14
- class NativeModelSerializer < BaseSerializer
15
- class_attribute :config
16
- class_attribute :singular_config
17
- class_attribute :plural_config
18
- class_attribute :action_config
19
12
 
20
- def initialize(many: nil, model: nil, **kwargs)
21
- super(**kwargs)
13
+ # This serializer uses `.serializable_hash` to convert objects to Ruby primitives (with the
14
+ # top-level being either an array or a hash).
15
+ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
16
+ class_attribute :config
17
+ class_attribute :singular_config
18
+ class_attribute :plural_config
19
+ class_attribute :action_config
22
20
 
23
- if many.nil?
24
- @many = @object.respond_to?(:count) ? @object.count : nil
25
- else
26
- @many = many
27
- end
21
+ def initialize(many: nil, model: nil, **kwargs)
22
+ super(**kwargs)
28
23
 
29
- @model = model || (@controller ? @controller.send(:get_model) : nil)
24
+ if many.nil?
25
+ # Determine if we are dealing with many objects or just one.
26
+ @many = @object.is_a?(Enumerable)
27
+ else
28
+ @many = many
30
29
  end
31
30
 
32
- # Get controller action, if possible.
33
- def get_action
34
- return @controller&.action_name&.to_sym
35
- end
31
+ # Determine model either explicitly, or by inspecting @object or @controller.
32
+ @model = model
33
+ @model ||= @object.class if @object.is_a?(ActiveRecord::Base)
34
+ @model ||= @object[0].class if (
35
+ @many && @object.is_a?(Enumerable) && @object.is_a?(ActiveRecord::Base)
36
+ )
37
+ @model ||= @controller.send(:get_model) if @controller
38
+ end
36
39
 
37
- # Get a locally defined configuration, if one is defined.
38
- def get_local_serializer_config
39
- action = self.get_action
40
+ # Get controller action, if possible.
41
+ def get_action
42
+ return @controller&.action_name&.to_sym
43
+ end
40
44
 
41
- if action && self.action_config
42
- # Index action should use :list serializer config if :index is not provided.
43
- action = :list if action == :index && !self.action_config.key?(:index)
45
+ # Get a locally defined native serializer configuration, if one is defined.
46
+ def get_local_native_serializer_config
47
+ action = self.get_action
44
48
 
45
- return self.action_config[action] if self.action_config[action]
46
- end
49
+ if action && self.action_config
50
+ # Index action should use :list serializer config if :index is not provided.
51
+ action = :list if action == :index && !self.action_config.key?(:index)
47
52
 
48
- # No action_config, so try singular/plural config.
49
- return self.plural_config if @many && self.plural_config
50
- return self.singular_config if !@many && self.singular_config
53
+ return self.action_config[action] if self.action_config[action]
54
+ end
55
+
56
+ # No action_config, so try singular/plural config if explicitly instructed to via @many.
57
+ return self.plural_config if @many == true && self.plural_config
58
+ return self.singular_config if @many == false && self.singular_config
59
+
60
+ # Lastly, try returning the default config, or singular/plural config in that order.
61
+ return self.config || self.singular_config || self.plural_config
62
+ end
63
+
64
+ # Helper to get a native serializer configuration from the controller.
65
+ def get_controller_native_serializer_config
66
+ return nil unless @controller
51
67
 
52
- # Lastly, try returning the default config.
53
- return self.config
68
+ if @many == true
69
+ controller_serializer = @controller.try(:native_serializer_plural_config)
70
+ elsif @many == false
71
+ controller_serializer = @controller.try(:native_serializer_singular_config)
54
72
  end
55
73
 
56
- # Get a configuration passable to `as_json` for the object.
57
- def get_serializer_config
58
- # Return a locally defined serializer config if one is defined.
59
- if local_config = self.get_local_serializer_config
60
- return local_config
61
- end
74
+ return controller_serializer || @controller.try(:native_serializer_config)
75
+ end
62
76
 
63
- # Return a serializer config if one is defined.
64
- if serializer_config = @controller.send(:get_native_serializer_config)
65
- return serializer_config
66
- end
77
+ # Get a configuration passable to `serializable_hash` for the object.
78
+ def get_serializer_config
79
+ # Return a locally defined serializer config if one is defined.
80
+ if local_config = self.get_local_native_serializer_config
81
+ return local_config
82
+ end
67
83
 
68
- # If the config wasn't determined, build a serializer config from model fields.
69
- fields = @controller.try(:get_fields) if @controller
70
- unless fields.blank?
71
- columns, methods = fields.partition { |f| f.to_s.in?(@model.column_names) }
72
- return {only: columns, methods: methods}
84
+ # Return a serializer config if one is defined on the controller.
85
+ if serializer_config = get_controller_native_serializer_config
86
+ return serializer_config
87
+ end
88
+
89
+ # If the config wasn't determined, build a serializer config from model fields.
90
+ fields = @controller.send(:get_fields) if @controller
91
+ if fields
92
+ if @model
93
+ columns, methods = fields.partition { |f| f.in?(@model.column_names) }
94
+ else
95
+ columns = fields
96
+ methods = []
73
97
  end
74
98
 
75
- return {}
99
+ return {only: columns, methods: methods}
76
100
  end
77
101
 
78
- # Convert the object (record or recordset) to Ruby primitives.
79
- def serialize
80
- if @object
81
- @many = @object.respond_to?(:each) if @many.nil?
82
- return @object.as_json(self.get_serializer_config)
102
+ # By default, pass an empty configuration, allowing the serialization of all columns.
103
+ return {}
104
+ end
105
+
106
+ # Convert the object (record or recordset) to Ruby primitives.
107
+ def serialize
108
+ if @object
109
+ begin
110
+ if @object.is_a?(Enumerable)
111
+ return @object.map { |r| r.serializable_hash(self.get_serializer_config) }
112
+ end
113
+ return @object.serializable_hash(self.get_serializer_config)
114
+ rescue NoMethodError
83
115
  end
84
- return nil
85
116
  end
86
117
 
87
- # Allow a serializer instance to be used as a hash directly in a nested serializer config.
88
- def [](key)
89
- unless instance_variable_defined?(:@_nested_config)
90
- @_nested_config = self.get_serializer_config
91
- end
92
- return @_nested_config[key]
118
+ # Raise an error if we cannot serialize the object.
119
+ raise RESTFramework::UnserializableError.new(@object)
120
+ end
121
+
122
+ # Allow a serializer instance to be used as a hash directly in a nested serializer config.
123
+ def [](key)
124
+ unless instance_variable_defined?(:@_nested_config)
125
+ @_nested_config = self.get_serializer_config
93
126
  end
94
- def []=(key, value)
95
- unless instance_variable_defined?(:@_nested_config)
96
- @_nested_config = self.get_serializer_config
97
- end
98
- return @_nested_config[key] = value
127
+ return @_nested_config[key]
128
+ end
129
+ def []=(key, value)
130
+ unless instance_variable_defined?(:@_nested_config)
131
+ @_nested_config = self.get_serializer_config
99
132
  end
133
+ return @_nested_config[key] = value
134
+ end
100
135
 
101
- # Allow a serializer class to be used as a hash directly in a nested serializer config.
102
- def self.[](key)
103
- unless instance_variable_defined?(:@_nested_config)
104
- @_nested_config = self.new.get_serializer_config
105
- end
106
- return @_nested_config[key]
136
+ # Allow a serializer class to be used as a hash directly in a nested serializer config.
137
+ def self.[](key)
138
+ unless instance_variable_defined?(:@_nested_config)
139
+ @_nested_config = self.new.get_serializer_config
107
140
  end
108
- def self.[]=(key, value)
109
- unless instance_variable_defined?(:@_nested_config)
110
- @_nested_config = self.new.get_serializer_config
111
- end
112
- return @_nested_config[key] = value
141
+ return @_nested_config[key]
142
+ end
143
+ def self.[]=(key, value)
144
+ unless instance_variable_defined?(:@_nested_config)
145
+ @_nested_config = self.new.get_serializer_config
113
146
  end
147
+ return @_nested_config[key] = value
114
148
  end
149
+ end
115
150
 
151
+
152
+ # :nocov:
153
+ # Alias NativeModelSerializer -> NativeSerializer.
154
+ class RESTFramework::NativeModelSerializer < RESTFramework::NativeSerializer
155
+ def initialize(**kwargs)
156
+ super
157
+ ActiveSupport::Deprecation.warn(
158
+ <<~MSG.split("\n").join(' ')
159
+ RESTFramework::NativeModelSerializer is deprecated and will be removed in future versions of
160
+ REST Framework; you should use RESTFramework::NativeSerializer instead.
161
+ MSG
162
+ )
163
+ end
116
164
  end
165
+ # :nocov: