rest_framework 0.6.9 → 0.6.10

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b74a4553ac1297795e4bb614e395e6b21a8c3ba18ef11ca36594e76bc51c3aac
4
- data.tar.gz: 948044b9b604fe754aad64db509351478d869b298a87d382e19dd3a95eba7087
3
+ metadata.gz: f2111609983018e956218533467140d0d8fb268cf3830cc722daeb502d1a0fda
4
+ data.tar.gz: a1c4b5a0255d37afff40e8f4c94f69891cfd50658e9cbd9abee06f915a35e4fc
5
5
  SHA512:
6
- metadata.gz: 42780202ca53a1e52a6c866e01c0ae428c2f54bafe3c4f2e0f7be599139f3159cade939346f8018ddc4e3b2a4ad37dbbedef5377c97e1df1e84c2f09dbd6a31d
7
- data.tar.gz: 6e50a220a25ed28f1f1b0d71fdaf73a1f8fe6caf5c3130779621cb4528a5774008e6c379ad0a98c12132740d962f3dac786f0a17e9691c6b5a7e09586f96bf6e
6
+ metadata.gz: 5a86179680989af8aa7e6d09886ac8f6d6a8cc295949678d0346411cf73ec22547266b3870c6d489d06bcca641f519edc47ff3b507e37212d8f1a700d18b9126
7
+ data.tar.gz: 33ef69ba052e03c5da388bbbb2f8ee401d1e925542f4b7e7e87253df466e0150f76d7eadceb64508ffe2cd1361311208e113f6153957dcb79de940831c1cfcd2
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.9
1
+ 0.6.10
@@ -51,6 +51,9 @@
51
51
  <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "DELETE" } %>
52
52
  <button type="button" class="btn btn-danger" onclick="rrfDelete(this)">DELETE</button>
53
53
  <% end %>
54
+ <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "OPTIONS" } %>
55
+ <button type="button" class="btn btn-primary" onclick="rrfOptions(this)">OPTIONS</button>
56
+ <% end %>
54
57
  <button type="button" class="btn btn-primary" onclick="rrfRefresh(this)">GET</button>
55
58
  </div>
56
59
  </div>
@@ -95,7 +98,10 @@
95
98
  <div class="tab-pane fade show active" id="tab-json" role="tab">
96
99
  <% if @json_payload.present? %>
97
100
  <div>
98
- <pre class="rrf-copy"><code class="language-json"><%= JSON.pretty_generate(JSON.parse(@json_payload)) unless @json_payload == '' %></code></pre>
101
+ <pre class="rrf-copy"><code class="language-json"><%=
102
+ JSON.pretty_generate(
103
+ JSON.parse(@json_payload)
104
+ ) unless @json_payload == '' %></code></pre>
99
105
  </div>
100
106
  <% end %>
101
107
  </div>
@@ -57,12 +57,34 @@ code {
57
57
  </style>
58
58
 
59
59
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ" crossorigin="anonymous"></script>
60
+ <script src="https://cdn.jsdelivr.net/npm/neatjson@0.10.5/javascript/neatjson.min.js"></script>
60
61
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/highlight.min.js" integrity="sha512-TDKKr+IvoqZnPzc3l35hdjpHD0m+b2EC2SrLEgKDRWpxf2rFCxemkgvJ5kfU48ip+Y+m2XVKyOCD85ybtlZDmw==" crossorigin="anonymous"></script>
61
62
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/json.min.js" integrity="sha512-FoN8JE+WWCdIGXAIT8KQXwpiavz0Mvjtfk7Rku3MDUNO0BDCiRMXAsSX+e+COFyZTcDb9HDgP+pM2RX12d4j+A==" crossorigin="anonymous"></script>
62
63
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/languages/xml.min.js" integrity="sha512-dICltIgnUP+QSJrnYGCV8943p3qSDgvcg2NU4W8IcOZP4tdrvxlXjbhIznhtVQEcXow0mOjLM0Q6/NvZsmUH4g==" crossorigin="anonymous"></script>
63
- <script>hljs.initHighlightingOnLoad()</script>
64
64
  <script>
65
- // Helper to replace the document when doing form submission (mainly to support PUT/PATCH/DELETE).
65
+ hljs.initHighlightingOnLoad()
66
+
67
+ // What to do when document loads.
68
+ document.addEventListener("DOMContentLoaded", (event) => {
69
+ // Pretty-print JSON.
70
+ [...document.getElementsByClassName("language-json")].forEach((element, index) => {
71
+ element.innerHTML = neatJSON(JSON.parse(element.innerHTML), {
72
+ wrap: 80,
73
+ afterComma: 1,
74
+ afterColon: 1,
75
+ })
76
+ });
77
+
78
+ // Insert copy link and callback to copy contents of `<code>` element.
79
+ [...document.getElementsByClassName("rrf-copy")].forEach((element, index) => {
80
+ element.insertAdjacentHTML(
81
+ "afterbegin",
82
+ "<a class=\"rrf-copy-link\" onclick=\"return rrfCopyToClipboard(this)\" href=\"#\">Copy to Clipboard</a>",
83
+ )
84
+ })
85
+ })
86
+
87
+ // Replace the document when doing form submission (mainly to support PUT/PATCH/DELETE).
66
88
  function rrfReplaceDocument(content) {
67
89
  // Replace the document with provided content.
68
90
  document.open()
@@ -73,7 +95,7 @@ function rrfReplaceDocument(content) {
73
95
  document.dispatchEvent(new Event("DOMContentLoaded", {bubbles: true, cancelable: true}))
74
96
  }
75
97
 
76
- // Helper to copy the element's next `<code>` sibling's content to the clipboard.
98
+ // Copy the element's next `<code>` sibling's content to the clipboard.
77
99
  function rrfCopyToClipboard(element) {
78
100
  let range = document.createRange()
79
101
  range.selectNode(element.nextSibling)
@@ -93,29 +115,25 @@ function rrfCopyToClipboard(element) {
93
115
  return false
94
116
  }
95
117
 
96
- // Insert copy link and callback to copy contents of `<code>` element.
97
- document.addEventListener("DOMContentLoaded", (event) => {
98
- [...document.getElementsByClassName("rrf-copy")].forEach((element, index) => {
99
- element.insertAdjacentHTML(
100
- "afterbegin",
101
- "<a class=\"rrf-copy-link\" onclick=\"return rrfCopyToClipboard(this)\" href=\"#\">Copy to Clipboard</a>",
102
- )
103
- })
104
- })
105
-
106
- // Helper to refresh the window.
118
+ // Refresh the window.
107
119
  function rrfRefresh(button) {
108
120
  button.disabled = true
109
121
  window.location.reload()
110
122
  }
111
123
 
112
- // Helper to call `DELETE` on the current path.
124
+ // Call `DELETE` on the current path.
113
125
  function rrfDelete(button) {
114
126
  button.disabled = true
115
127
  rrfAPICall(window.location.pathname, "DELETE")
116
128
  }
117
129
 
118
- // Helper to submit the raw form.
130
+ // Call `OPTIONS` on the current path.
131
+ function rrfOptions(button) {
132
+ button.disabled = true
133
+ rrfAPICall(window.location.pathname, "OPTIONS")
134
+ }
135
+
136
+ // Submit the raw form.
119
137
  function rrfSubmitRawForm(button) {
120
138
  button.disabled = true
121
139
 
@@ -128,7 +146,7 @@ function rrfSubmitRawForm(button) {
128
146
  rrfAPICall(path, method, {body, headers: {"Content-Type": media_type}})
129
147
  }
130
148
 
131
- // Helper to make an HTML API call and replace the document with the response.
149
+ // Make an HTML API call and replace the document with the response.
132
150
  function rrfAPICall(path, method, kwargs={}) {
133
151
  const headers = kwargs.headers || {}
134
152
  delete kwargs.headers
@@ -12,87 +12,123 @@ module RESTFramework::BaseControllerMixin
12
12
  end
13
13
 
14
14
  module ClassMethods
15
- # Helper to get the actions that should be skipped.
16
- def get_skip_actions(skip_undefined: true)
17
- # First, skip explicitly skipped actions.
18
- skip = self.skip_actions || []
19
-
20
- # Now add methods which don't exist, since we don't want to route those.
21
- if skip_undefined
22
- [:index, :new, :create, :show, :edit, :update, :destroy].each do |a|
23
- skip << a unless self.method_defined?(a)
24
- end
15
+ # Collect actions (including extra actions) metadata for this controller.
16
+ def get_actions_metadata
17
+ actions = {}
18
+
19
+ # Start with builtin actions.
20
+ RESTFramework::BUILTIN_ACTIONS.merge(
21
+ RESTFramework::RRF_BUILTIN_ACTIONS,
22
+ ).each do |action, methods|
23
+ actions[action] = {path: "", methods: methods}
24
+ end
25
+
26
+ # Add extra actions.
27
+ if extra_actions = self.try(:extra_actions)
28
+ actions.merge!(RESTFramework::Utils.parse_extra_actions(extra_actions))
25
29
  end
26
30
 
27
- return skip
31
+ return actions
28
32
  end
29
- end
30
33
 
31
- def self.included(base)
32
- if base.is_a?(Class)
33
- base.extend(ClassMethods)
34
+ # Collect member actions (including extra member actions) metadata for this controller.
35
+ def get_member_actions_metadata
36
+ actions = {}
34
37
 
35
- # Add class attributes (with defaults) unless they already exist.
36
- {
37
- filter_pk_from_request_body: true,
38
- exclude_body_fields: [:created_at, :created_by, :updated_at, :updated_by],
39
- accept_generic_params_as_body_params: false,
40
- show_backtrace: false,
41
- extra_actions: nil,
42
- extra_member_actions: nil,
43
- filter_backends: nil,
44
- singleton_controller: nil,
45
- skip_actions: nil,
46
-
47
- # Options related to serialization.
48
- rescue_unknown_format_with: :json,
49
- serializer_class: nil,
50
- serialize_to_json: true,
51
- serialize_to_xml: true,
52
-
53
- # Options related to pagination.
54
- paginator_class: nil,
55
- page_size: 20,
56
- page_query_param: "page",
57
- page_size_query_param: "page_size",
58
- max_page_size: nil,
59
-
60
- # Option to disable serializer adapters by default, mainly introduced because Active Model
61
- # Serializers will do things like serialize `[]` into `{"":[]}`.
62
- disable_adapters_by_default: true,
63
- }.each do |a, default|
64
- next if base.respond_to?(a)
65
-
66
- base.class_attribute(a)
67
-
68
- # Set default manually so we can still support Rails 4. Maybe later we can use the default
69
- # parameter on `class_attribute`.
70
- base.send(:"#{a}=", default)
38
+ # Start with builtin actions.
39
+ RESTFramework::BUILTIN_MEMBER_ACTIONS.each do |action, methods|
40
+ actions[action] = {path: "", methods: methods}
71
41
  end
72
42
 
73
- # Alias `extra_actions` to `extra_collection_actions`.
74
- unless base.respond_to?(:extra_collection_actions)
75
- base.alias_method(:extra_collection_actions, :extra_actions)
76
- base.alias_method(:extra_collection_actions=, :extra_actions=)
43
+ # Add extra actions.
44
+ if extra_actions = self.try(:extra_member_actions)
45
+ actions.merge!(RESTFramework::Utils.parse_extra_actions(extra_actions))
77
46
  end
78
47
 
79
- # Skip csrf since this is an API.
80
- begin
81
- base.skip_before_action(:verify_authenticity_token)
82
- rescue
83
- nil
84
- end
48
+ return actions
49
+ end
50
+
51
+ # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
52
+ def get_options_metadata
53
+ return @_base_options_metadata ||= {
54
+ name: self.metadata&.name || self.controller_name.titleize,
55
+ description: self.metadata&.description,
56
+ renders: [
57
+ "text/html",
58
+ self.serialize_to_json ? "application/json" : nil,
59
+ self.serialize_to_xml ? "application/xml" : nil,
60
+ ].compact,
61
+ actions: self.get_actions_metadata,
62
+ member_actions: self.get_member_actions_metadata,
63
+ }.compact
64
+ end
65
+ end
85
66
 
86
- # Handle some common exceptions.
87
- base.rescue_from(ActiveRecord::RecordNotFound, with: :record_not_found)
88
- base.rescue_from(ActiveRecord::RecordInvalid, with: :record_invalid)
89
- base.rescue_from(ActiveRecord::RecordNotSaved, with: :record_not_saved)
90
- base.rescue_from(ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed)
91
- base.rescue_from(ActiveModel::UnknownAttributeError, with: :unknown_attribute_error)
67
+ def self.included(base)
68
+ return unless base.is_a?(Class)
69
+
70
+ base.extend(ClassMethods)
71
+
72
+ # Add class attributes (with defaults) unless they already exist.
73
+ {
74
+ filter_pk_from_request_body: true,
75
+ exclude_body_fields: [:created_at, :created_by, :updated_at, :updated_by],
76
+ accept_generic_params_as_body_params: false,
77
+ show_backtrace: false,
78
+ extra_actions: nil,
79
+ extra_member_actions: nil,
80
+ filter_backends: nil,
81
+ singleton_controller: nil,
82
+ metadata: nil,
83
+
84
+ # Options related to serialization.
85
+ rescue_unknown_format_with: :json,
86
+ serializer_class: nil,
87
+ serialize_to_json: true,
88
+ serialize_to_xml: true,
89
+
90
+ # Options related to pagination.
91
+ paginator_class: nil,
92
+ page_size: 20,
93
+ page_query_param: "page",
94
+ page_size_query_param: "page_size",
95
+ max_page_size: nil,
96
+
97
+ # Option to disable serializer adapters by default, mainly introduced because Active Model
98
+ # Serializers will do things like serialize `[]` into `{"":[]}`.
99
+ disable_adapters_by_default: true,
100
+ }.each do |a, default|
101
+ next if base.respond_to?(a)
102
+
103
+ base.class_attribute(a)
104
+
105
+ # Set default manually so we can still support Rails 4. Maybe later we can use the default
106
+ # parameter on `class_attribute`.
107
+ base.send(:"#{a}=", default)
108
+ end
109
+
110
+ # Alias `extra_actions` to `extra_collection_actions`.
111
+ unless base.respond_to?(:extra_collection_actions)
112
+ base.alias_method(:extra_collection_actions, :extra_actions)
113
+ base.alias_method(:extra_collection_actions=, :extra_actions=)
114
+ end
115
+
116
+ # Skip CSRF since this is an API.
117
+ begin
118
+ base.skip_before_action(:verify_authenticity_token)
119
+ rescue
120
+ nil
92
121
  end
122
+
123
+ # Handle some common exceptions.
124
+ base.rescue_from(ActiveRecord::RecordNotFound, with: :record_not_found)
125
+ base.rescue_from(ActiveRecord::RecordInvalid, with: :record_invalid)
126
+ base.rescue_from(ActiveRecord::RecordNotSaved, with: :record_not_saved)
127
+ base.rescue_from(ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed)
128
+ base.rescue_from(ActiveModel::UnknownAttributeError, with: :unknown_attribute_error)
93
129
  end
94
130
 
95
- # Helper to get the configured serializer class.
131
+ # Get the configured serializer class.
96
132
  def get_serializer_class
97
133
  return nil unless serializer_class = self.class.serializer_class
98
134
 
@@ -110,17 +146,17 @@ module RESTFramework::BaseControllerMixin
110
146
  return serializer_class
111
147
  end
112
148
 
113
- # Helper to serialize data using the `serializer_class`.
149
+ # Serialize the given data using the `serializer_class`.
114
150
  def serialize(data, **kwargs)
115
151
  return self.get_serializer_class.new(data, controller: self, **kwargs).serialize
116
152
  end
117
153
 
118
- # Helper to get filtering backends, defaulting to no backends.
154
+ # Get filtering backends, defaulting to no backends.
119
155
  def get_filter_backends
120
156
  return self.class.filter_backends || []
121
157
  end
122
158
 
123
- # Helper to filter an arbitrary data set over all configured filter backends.
159
+ # Filter an arbitrary data set over all configured filter backends.
124
160
  def get_filtered_data(data)
125
161
  self.get_filter_backends.each do |filter_class|
126
162
  filter = filter_class.new(controller: self)
@@ -130,6 +166,10 @@ module RESTFramework::BaseControllerMixin
130
166
  return data
131
167
  end
132
168
 
169
+ def get_options_metadata
170
+ return self.class.get_options_metadata
171
+ end
172
+
133
173
  def record_invalid(e)
134
174
  return api_response(
135
175
  {
@@ -229,7 +269,7 @@ module RESTFramework::BaseControllerMixin
229
269
  @xml_payload = payload.to_xml if self.class.serialize_to_xml
230
270
  end
231
271
  @template_logo_text ||= "Rails REST Framework"
232
- @title ||= self.controller_name.camelize
272
+ @title ||= self.controller_name.titleize
233
273
  @route_props, @route_groups = RESTFramework::Utils.get_routes(
234
274
  Rails.application.routes, request
235
275
  )
@@ -253,4 +293,9 @@ module RESTFramework::BaseControllerMixin
253
293
  end
254
294
  end
255
295
  end
296
+
297
+ # Provide a generic `OPTIONS` response with metadata such as available actions.
298
+ def options
299
+ return api_response(self.get_options_metadata)
300
+ end
256
301
  end
@@ -5,56 +5,212 @@ require_relative "../filters"
5
5
  module RESTFramework::BaseModelControllerMixin
6
6
  include RESTFramework::BaseControllerMixin
7
7
 
8
+ module ClassMethods
9
+ IGNORE_VALIDATORS_WITH_KEYS = [:if, :unless]
10
+
11
+ # Get the model for this controller.
12
+ def get_model(from_get_recordset: false)
13
+ return @model if @model
14
+ return (@model = self.model) if self.model
15
+
16
+ # Delegate to the recordset's model, if it's defined.
17
+ unless from_get_recordset # Prevent infinite recursion.
18
+ if (recordset = self.new.get_recordset)
19
+ return @model = recordset.klass
20
+ end
21
+ end
22
+
23
+ # Try to determine model from controller name.
24
+ begin
25
+ return @model = self.name.demodulize.match(/(.*)Controller/)[1].singularize.constantize
26
+ rescue NameError
27
+ end
28
+
29
+ return nil
30
+ end
31
+
32
+ # Get metadata about the resource's fields.
33
+ def get_fields_metadata(fields: nil)
34
+ # Get metadata sources.
35
+ model = self.get_model
36
+ fields ||= self.fields || model&.column_names || []
37
+ fields = fields.map(&:to_s)
38
+ columns = model&.columns_hash
39
+ column_defaults = model&.column_defaults
40
+ attributes = model&._default_attributes
41
+
42
+ return fields.map { |f|
43
+ # Initialize metadata to make the order consistent.
44
+ metadata = {
45
+ type: nil, kind: nil, label: nil, primary_key: nil, required: nil, read_only: nil
46
+ }
47
+
48
+ # Determine `primary_key` based on model.
49
+ if model&.primary_key == f
50
+ metadata[:primary_key] = true
51
+ end
52
+
53
+ # Determine `type`, `required`, `label`, and `kind` based on schema.
54
+ if column = columns[f]
55
+ metadata[:type] = column.type
56
+ metadata[:required] = true unless column.null
57
+ metadata[:label] = column.human_name.instance_eval { |n| n == "Id" ? "ID" : n }
58
+ metadata[:kind] = "column"
59
+ end
60
+
61
+ # Determine `default` based on schema; we use `column_defaults` rather than `columns_hash`
62
+ # because these are casted to the proper type.
63
+ column_default = column_defaults[f]
64
+ unless column_default.nil?
65
+ metadata[:default] = column_default
66
+ end
67
+
68
+ # Determine `default` and `kind` based on attribute only if not determined by the DB.
69
+ if attributes.key?(f) && attribute = attributes[f]
70
+ unless metadata.key?(:default)
71
+ default = attribute.value_before_type_cast
72
+ metadata[:default] = default unless default.nil?
73
+ end
74
+
75
+ unless metadata[:kind]
76
+ metadata[:kind] = "attribute"
77
+ end
78
+ end
79
+
80
+ # Determine if `kind` is a association or method if not determined already.
81
+ unless metadata[:kind]
82
+ if association = model.reflections[f]
83
+ metadata[:kind] = "association.#{association.macro}"
84
+ elsif model.method_defined?(f)
85
+ metadata[:kind] = "method"
86
+ end
87
+ end
88
+
89
+ # Collect validator options into a hash on their type, while also updating `required` based
90
+ # on any presence validators.
91
+ model.validators_on(f).each do |validator|
92
+ kind = validator.kind
93
+ options = validator.options
94
+
95
+ # Reject validator if it includes keys like `:if` and `:unless` because those are
96
+ # conditionally applied in a way that is not feasible to communicate via the API.
97
+ next if IGNORE_VALIDATORS_WITH_KEYS.any? { |k| options.key?(k) }
98
+
99
+ # Update `required` if we find a presence validator.
100
+ metadata[:required] = true if kind == :presence
101
+
102
+ metadata[:validators] ||= {}
103
+ metadata[:validators][kind] ||= []
104
+ metadata[:validators][kind] << options
105
+ end
106
+
107
+ next [f, metadata.compact]
108
+ }.to_h
109
+ end
110
+
111
+ # Get a hash of metadata to be rendered in the `OPTIONS` response. Cache the result.
112
+ def get_options_metadata(fields: nil)
113
+ return super().merge(
114
+ {
115
+ fields: self.get_fields_metadata(fields: fields),
116
+ },
117
+ )
118
+ end
119
+ end
120
+
8
121
  def self.included(base)
9
- if base.is_a?(Class)
10
- RESTFramework::BaseControllerMixin.included(base)
11
-
12
- # Add class attributes (with defaults) unless they already exist.
13
- {
14
- # Core attributes related to models.
15
- model: nil,
16
- recordset: nil,
17
-
18
- # Attributes for configuring record fields.
19
- fields: nil,
20
- action_fields: nil,
21
-
22
- # Attributes for finding records.
23
- find_by_fields: nil,
24
- find_by_query_param: "find_by",
25
-
26
- # Attributes for create/update parameters.
27
- allowed_parameters: nil,
28
- allowed_action_parameters: nil,
29
-
30
- # Attributes for the default native serializer.
31
- native_serializer_config: nil,
32
- native_serializer_singular_config: nil,
33
- native_serializer_plural_config: nil,
34
- native_serializer_only_query_param: "only",
35
- native_serializer_except_query_param: "except",
36
-
37
- # Attributes for default model filtering (and ordering).
38
- filterset_fields: nil,
39
- ordering_fields: nil,
40
- ordering_query_param: "ordering",
41
- ordering_no_reorder: false,
42
- search_fields: nil,
43
- search_query_param: "search",
44
- search_ilike: false,
45
-
46
- # Other misc attributes.
47
- create_from_recordset: true, # Option for `recordset.create` vs `Model.create` behavior.
48
- filter_recordset_before_find: true, # Option to control if filtering is done before find.
49
- }.each do |a, default|
50
- next if base.respond_to?(a)
51
-
52
- base.class_attribute(a)
53
-
54
- # Set default manually so we can still support Rails 4. Maybe later we can use the default
55
- # parameter on `class_attribute`.
56
- base.send(:"#{a}=", default)
122
+ return unless base.is_a?(Class)
123
+
124
+ RESTFramework::BaseControllerMixin.included(base)
125
+ base.extend(ClassMethods)
126
+
127
+ # Add class attributes (with defaults) unless they already exist.
128
+ {
129
+ # Core attributes related to models.
130
+ model: nil,
131
+ recordset: nil,
132
+
133
+ # Attributes for configuring record fields.
134
+ fields: nil,
135
+ action_fields: nil,
136
+ metadata_fields: nil,
137
+
138
+ # Attributes for finding records.
139
+ find_by_fields: nil,
140
+ find_by_query_param: "find_by",
141
+
142
+ # Attributes for create/update parameters.
143
+ allowed_parameters: nil,
144
+ allowed_action_parameters: nil,
145
+
146
+ # Attributes for the default native serializer.
147
+ native_serializer_config: nil,
148
+ native_serializer_singular_config: nil,
149
+ native_serializer_plural_config: nil,
150
+ native_serializer_only_query_param: "only",
151
+ native_serializer_except_query_param: "except",
152
+
153
+ # Attributes for default model filtering, ordering, and searching.
154
+ filterset_fields: nil,
155
+ ordering_fields: nil,
156
+ ordering_query_param: "ordering",
157
+ ordering_no_reorder: false,
158
+ search_fields: nil,
159
+ search_query_param: "search",
160
+ search_ilike: false,
161
+
162
+ # Other misc attributes.
163
+ create_from_recordset: true, # Option for `recordset.create` vs `Model.create` behavior.
164
+ filter_recordset_before_find: true, # Control if filtering is done before find.
165
+ }.each do |a, default|
166
+ next if base.respond_to?(a)
167
+
168
+ base.class_attribute(a)
169
+
170
+ # Set default manually so we can still support Rails 4. Maybe later we can use the default
171
+ # parameter on `class_attribute`.
172
+ base.send(:"#{a}=", default)
173
+ end
174
+
175
+ # Actions to run at the end of the class definition.
176
+ TracePoint.trace(:end) do |t|
177
+ next if base != t.self
178
+
179
+ # Delegate extra actions.
180
+ base.extra_actions&.each do |action, config|
181
+ next unless config.is_a?(Hash) && config[:delegate]
182
+
183
+ base.define_method(action) do
184
+ model = self.class.get_model
185
+ next unless model.respond_to?(action)
186
+
187
+ if model.method(action).parameters.last&.first == :keyrest
188
+ return api_response(model.send(action, **params))
189
+ else
190
+ return api_response(model.send(action))
191
+ end
192
+ end
57
193
  end
194
+
195
+ # Delegate extra member actions.
196
+ base.extra_member_actions&.each do |action, config|
197
+ next unless config.is_a?(Hash) && config[:delegate]
198
+
199
+ base.define_method(action) do
200
+ record = self.get_record
201
+ next unless record.respond_to?(action)
202
+
203
+ if record.method(action).parameters.last&.first == :keyrest
204
+ return api_response(record.send(action, **params))
205
+ else
206
+ return api_response(record.send(action))
207
+ end
208
+ end
209
+ end
210
+
211
+ # It's important to disable the trace once we've found the end of the base class definition,
212
+ # for performance.
213
+ t.disable
58
214
  end
59
215
  end
60
216
 
@@ -75,33 +231,25 @@ module RESTFramework::BaseModelControllerMixin
75
231
  fields = _get_specific_action_config(:action_fields, :fields)
76
232
 
77
233
  if fallback
78
- fields ||= self.get_model&.column_names || []
234
+ fields ||= self.class.get_model&.column_names || []
79
235
  end
80
236
 
81
237
  return fields
82
238
  end
83
239
 
84
- # Get a list of find_by fields for the current action.
85
- def get_find_by_fields
86
- return self.class.find_by_fields || self.get_fields
87
- end
88
-
89
- # Get a list of find_by fields for the current action. Default to the model column names.
90
- def get_filterset_fields
91
- return self.class.filterset_fields || self.get_fields(fallback: true)
92
- end
93
-
94
- # Get a list of ordering fields for the current action.
95
- def get_ordering_fields
96
- return self.class.ordering_fields || self.get_fields
240
+ # Pass fields to get dynamic metadata based on which fields are available.
241
+ def get_options_metadata
242
+ return self.class.get_options_metadata(fields: self.get_fields(fallback: true))
97
243
  end
98
244
 
99
- # Get a list of search fields for the current action. Default to the model column names.
100
- def get_search_fields
101
- return self.class.search_fields || self.get_fields(fallback: true)
245
+ # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
246
+ # wants to find by virtual columns.
247
+ def get_find_by_fields
248
+ return self.class.find_by_fields || self.get_fields
102
249
  end
103
250
 
104
- # Get a list of parameters allowed for the current action.
251
+ # Get a list of parameters allowed for the current action. By default we do not fallback to
252
+ # columns so arbitrary fields can be submitted if no fields are defined.
105
253
  def get_allowed_parameters
106
254
  return _get_specific_action_config(
107
255
  :allowed_action_parameters,
@@ -143,7 +291,7 @@ module RESTFramework::BaseModelControllerMixin
143
291
 
144
292
  # Filter primary key if configured.
145
293
  if self.class.filter_pk_from_request_body
146
- body_params.delete(self.get_model&.primary_key)
294
+ body_params.delete(self.class.get_model&.primary_key)
147
295
  end
148
296
 
149
297
  # Filter fields in exclude_body_fields.
@@ -154,27 +302,6 @@ module RESTFramework::BaseModelControllerMixin
154
302
  alias_method :get_create_params, :get_body_params
155
303
  alias_method :get_update_params, :get_body_params
156
304
 
157
- # Get the model for this controller.
158
- def get_model(from_get_recordset: false)
159
- return @model if instance_variable_defined?(:@model) && @model
160
- return (@model = self.class.model) if self.class.model
161
-
162
- # Delegate to the recordset's model, if it's defined.
163
- unless from_get_recordset # prevent infinite recursion
164
- if (recordset = self.get_recordset)
165
- return @model = recordset.klass
166
- end
167
- end
168
-
169
- # Try to determine model from controller name.
170
- begin
171
- return @model = self.class.name.demodulize.match(/(.*)Controller/)[1].singularize.constantize
172
- rescue NameError
173
- end
174
-
175
- return nil
176
- end
177
-
178
305
  # Get the set of records this controller has access to. The return value is cached and exposed to
179
306
  # the view as the `@recordset` instance variable.
180
307
  def get_recordset
@@ -182,7 +309,7 @@ module RESTFramework::BaseModelControllerMixin
182
309
  return (@recordset = self.class.recordset) if self.class.recordset
183
310
 
184
311
  # If there is a model, return that model's default scope (all records by default).
185
- if (model = self.get_model(from_get_recordset: true))
312
+ if (model = self.class.get_model(from_get_recordset: true))
186
313
  return @recordset = model.all
187
314
  end
188
315
 
@@ -203,7 +330,7 @@ module RESTFramework::BaseModelControllerMixin
203
330
  return @record if instance_variable_defined?(:@record)
204
331
 
205
332
  recordset = self.get_recordset
206
- find_by_key = self.get_model.primary_key
333
+ find_by_key = self.class.get_model.primary_key
207
334
 
208
335
  # Find by another column if it's permitted.
209
336
  if find_by_param = self.class.find_by_query_param.presence
@@ -276,7 +403,7 @@ module RESTFramework::CreateModelMixin
276
403
  return self.get_recordset.except(:select).create!(self.get_create_params)
277
404
  else
278
405
  # Otherwise, perform a "bare" create.
279
- return self.get_model.create!(self.get_create_params)
406
+ return self.class.get_model.create!(self.get_create_params)
280
407
  end
281
408
  end
282
409
  end
@@ -313,9 +440,9 @@ module RESTFramework::ReadOnlyModelControllerMixin
313
440
  include RESTFramework::BaseModelControllerMixin
314
441
 
315
442
  def self.included(base)
316
- if base.is_a?(Class)
317
- RESTFramework::BaseModelControllerMixin.included(base)
318
- end
443
+ return unless base.is_a?(Class)
444
+
445
+ RESTFramework::BaseModelControllerMixin.included(base)
319
446
  end
320
447
 
321
448
  include RESTFramework::ListModelMixin
@@ -327,9 +454,9 @@ module RESTFramework::ModelControllerMixin
327
454
  include RESTFramework::BaseModelControllerMixin
328
455
 
329
456
  def self.included(base)
330
- if base.is_a?(Class)
331
- RESTFramework::BaseModelControllerMixin.included(base)
332
- end
457
+ return unless base.is_a?(Class)
458
+
459
+ RESTFramework::BaseModelControllerMixin.included(base)
333
460
  end
334
461
 
335
462
  include RESTFramework::ListModelMixin
@@ -11,10 +11,16 @@ end
11
11
  # A simple filtering backend that supports filtering a recordset based on fields defined on the
12
12
  # controller class.
13
13
  class RESTFramework::ModelFilter < RESTFramework::BaseFilter
14
+ # Get a list of filterset fields for the current action. Fallback to columns because we don't want
15
+ # to try filtering by any query parameter because that could clash with other query parameters.
16
+ def _get_fields
17
+ return @controller.class.filterset_fields || @controller.get_fields(fallback: true)
18
+ end
19
+
14
20
  # Filter params for keys allowed by the current action's filterset_fields/fields config.
15
21
  def _get_filter_params
16
22
  # Map filterset fields to strings because query parameter keys are strings.
17
- if fields = @controller.get_filterset_fields&.map(&:to_s)
23
+ if fields = self._get_fields.map(&:to_s)
18
24
  return @controller.request.query_parameters.select { |p, _| fields.include?(p) }
19
25
  end
20
26
 
@@ -34,15 +40,21 @@ end
34
40
 
35
41
  # A filter backend which handles ordering of the recordset.
36
42
  class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
43
+ # Get a list of ordering fields for the current action. Do not fallback to columns in case the
44
+ # user wants to order by a virtual column.
45
+ def _get_fields
46
+ return @controller.class.ordering_fields || @controller.get_fields
47
+ end
48
+
37
49
  # Convert ordering string to an ordering configuration.
38
50
  def _get_ordering
39
51
  return nil if @controller.class.ordering_query_param.blank?
40
52
 
41
53
  # Ensure ordering_fields are strings since the split param will be strings.
42
- ordering_fields = @controller.get_ordering_fields&.map(&:to_s)
54
+ fields = self._get_fields&.map(&:to_s)
43
55
  order_string = @controller.params[@controller.class.ordering_query_param]
44
56
 
45
- unless order_string.blank?
57
+ if order_string.present? && fields
46
58
  ordering = {}.with_indifferent_access
47
59
  order_string.split(",").each do |field|
48
60
  if field[0] == "-"
@@ -52,7 +64,7 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
52
64
  column = field
53
65
  direction = :asc
54
66
  end
55
- if !ordering_fields || column.in?(ordering_fields)
67
+ if !fields || column.in?(fields)
56
68
  ordering[column] = direction
57
69
  end
58
70
  end
@@ -77,13 +89,19 @@ end
77
89
 
78
90
  # Multi-field text searching on models.
79
91
  class RESTFramework::ModelSearchFilter < RESTFramework::BaseFilter
92
+ # Get a list of search fields for the current action. Fallback to columns because we need an
93
+ # explicit list of columns to search on, so `nil` is useless in this context.
94
+ def _get_fields
95
+ return @controller.class.search_fields || @controller.get_fields(fallback: true)
96
+ end
97
+
80
98
  # Filter data according to the request query parameters.
81
99
  def get_filtered_data(data)
82
- fields = @controller.get_search_fields
100
+ fields = self._get_fields
83
101
  search = @controller.request.query_parameters[@controller.class.search_query_param]
84
102
 
85
103
  # Ensure we use array conditions to prevent SQL injection.
86
- unless search.blank?
104
+ if search.present? && !fields.empty?
87
105
  return data.where(
88
106
  fields.map { |f|
89
107
  "CAST(#{f} AS VARCHAR) #{@controller.class.search_ilike ? "ILIKE" : "LIKE"} ?"
@@ -39,9 +39,9 @@ module ActionDispatch::Routing
39
39
 
40
40
  # Interal interface for routing extra actions.
41
41
  def _route_extra_actions(actions, &block)
42
- actions.each do |action_config|
43
- action_config[:methods].each do |m|
44
- public_send(m, action_config[:path], **action_config[:kwargs])
42
+ actions.each do |action, config|
43
+ config[:methods].each do |m|
44
+ public_send(m, config[:path], action: action, **(config[:kwargs] || {}))
45
45
  end
46
46
  yield if block_given?
47
47
  end
@@ -51,8 +51,7 @@ module ActionDispatch::Routing
51
51
  # @param default_singular [Boolean] the default plurality of the resource if the plurality is
52
52
  # not otherwise defined by the controller
53
53
  # @param name [Symbol] the resource name, from which path and controller are deduced by default
54
- # @param skip_undefined [Boolean] whether we should skip routing undefined resourceful actions
55
- def _rest_resources(default_singular, name, skip_undefined: true, **kwargs, &block)
54
+ def _rest_resources(default_singular, name, **kwargs, &block)
56
55
  controller = kwargs.delete(:controller) || name
57
56
  if controller.is_a?(Class)
58
57
  controller_class = controller
@@ -63,6 +62,9 @@ module ActionDispatch::Routing
63
62
  # Set controller if it's not explicitly set.
64
63
  kwargs[:controller] = name unless kwargs[:controller]
65
64
 
65
+ # Passing `unscoped: true` will prevent a nested resource from being scoped.
66
+ unscoped = kwargs.delete(:unscoped)
67
+
66
68
  # Determine plural/singular resource.
67
69
  force_singular = kwargs.delete(:force_singular)
68
70
  force_plural = kwargs.delete(:force_plural)
@@ -78,24 +80,39 @@ module ActionDispatch::Routing
78
80
  resource_method = singular ? :resource : :resources
79
81
 
80
82
  # Call either `resource` or `resources`, passing appropriate modifiers.
81
- skip_undefined = kwargs.delete(:skip_undefined) || true
82
- skip = controller_class.get_skip_actions(skip_undefined: skip_undefined)
83
+ skip = RESTFramework::Utils.get_skipped_builtin_actions(controller_class)
83
84
  public_send(resource_method, name, except: skip, **kwargs) do
84
85
  if controller_class.respond_to?(:extra_member_actions)
85
86
  member do
86
- actions = RESTFramework::Utils.parse_extra_actions(
87
- controller_class.extra_member_actions,
87
+ self._route_extra_actions(
88
+ RESTFramework::Utils.parse_extra_actions(controller_class.extra_member_actions),
88
89
  )
89
- self._route_extra_actions(actions)
90
90
  end
91
91
  end
92
92
 
93
93
  collection do
94
- actions = RESTFramework::Utils.parse_extra_actions(controller_class.extra_actions)
95
- self._route_extra_actions(actions)
94
+ # Route extra controller-defined actions.
95
+ self._route_extra_actions(
96
+ RESTFramework::Utils.parse_extra_actions(controller_class.extra_actions),
97
+ )
98
+
99
+ # Route extra RRF-defined actions.
100
+ RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
101
+ next unless controller_class.method_defined?(action)
102
+
103
+ [methods].flatten.each do |m|
104
+ public_send(m, "", action: action) if self.respond_to?(m)
105
+ end
106
+ end
96
107
  end
97
108
 
98
- yield if block_given?
109
+ if unscoped
110
+ yield if block_given?
111
+ else
112
+ scope(module: name, as: name) do
113
+ yield if block_given?
114
+ end
115
+ end
99
116
  end
100
117
  end
101
118
 
@@ -126,15 +143,39 @@ module ActionDispatch::Routing
126
143
  # Set controller if it's not explicitly set.
127
144
  kwargs[:controller] = name unless kwargs[:controller]
128
145
 
146
+ # Passing `unscoped: true` will prevent a nested resource from being scoped.
147
+ unscoped = kwargs.delete(:unscoped)
148
+
129
149
  # Route actions using the resourceful router, but skip all builtin actions.
130
- actions = RESTFramework::Utils.parse_extra_actions(controller_class.extra_actions)
131
150
  public_send(:resource, name, only: [], **kwargs) do
132
151
  # Route a root for this resource.
133
152
  if route_root_to
134
153
  get("", action: route_root_to)
135
154
  end
136
155
 
137
- self._route_extra_actions(actions, &block)
156
+ collection do
157
+ # Route extra controller-defined actions.
158
+ self._route_extra_actions(
159
+ RESTFramework::Utils.parse_extra_actions(controller_class.extra_actions),
160
+ )
161
+
162
+ # Route extra RRF-defined actions.
163
+ RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
164
+ next unless controller_class.method_defined?(action)
165
+
166
+ [methods].flatten.each do |m|
167
+ public_send(m, "", action: action) if self.respond_to?(m)
168
+ end
169
+ end
170
+ end
171
+
172
+ if unscoped
173
+ yield if block_given?
174
+ else
175
+ scope(module: name, as: name) do
176
+ yield if block_given?
177
+ end
178
+ end
138
179
  end
139
180
  end
140
181
 
@@ -61,7 +61,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
61
61
  @model ||= @object[0].class if
62
62
  @many && @object.is_a?(Enumerable) && @object.is_a?(ActiveRecord::Base)
63
63
 
64
- @model ||= @controller.get_model if @controller
64
+ @model ||= @controller.class.get_model if @controller
65
65
  end
66
66
 
67
67
  # Get controller action, if possible.
@@ -182,7 +182,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
182
182
  cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: only, only: true)
183
183
  elsif cfg[:except]
184
184
  # For the `except` part of the serializer, we need to append any columns not in `only`.
185
- model = @controller.get_model
185
+ model = @controller.class.get_model
186
186
  except_cols = model&.column_names&.map(&:to_sym)&.reject { |c| c.in?(only) }
187
187
  cfg[:except] = self.class.filter_subcfg(cfg[:except], fields: except_cols, add: true)
188
188
  else
@@ -216,14 +216,24 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
216
216
  if fields = @controller&.get_fields
217
217
  fields = fields.deep_dup
218
218
 
219
+ columns = []
220
+ includes = []
221
+ methods = []
219
222
  if @model
220
- columns, methods = fields.partition { |f| f.in?(@model.column_names) }
223
+ fields.each do |f|
224
+ if f.in?(@model.column_names)
225
+ columns << f
226
+ elsif @model.reflections.key?(f)
227
+ includes << f
228
+ elsif @model.method_defined?(f)
229
+ methods << f
230
+ end
231
+ end
221
232
  else
222
233
  columns = fields
223
- methods = []
224
234
  end
225
235
 
226
- return {only: columns, methods: methods}
236
+ return {only: columns, include: includes, methods: methods}
227
237
  end
228
238
 
229
239
  # By default, pass an empty configuration, allowing the serialization of all columns.
@@ -1,11 +1,9 @@
1
1
  module RESTFramework::Utils
2
- HTTP_METHOD_ORDERING = %w(GET POST PUT PATCH DELETE)
2
+ HTTP_METHOD_ORDERING = %w(GET POST PUT PATCH DELETE OPTIONS HEAD)
3
3
 
4
- # Helper to take extra_actions hash and convert to a consistent format:
5
- # `{paths:, methods:, kwargs:}`.
4
+ # Convert `extra_actions` hash to a consistent format: `{path:, methods:, kwargs:}`.
6
5
  def self.parse_extra_actions(extra_actions)
7
- return (extra_actions || {}).map do |k, v|
8
- kwargs = {action: k}
6
+ return (extra_actions || {}).map { |k, v|
9
7
  path = k
10
8
 
11
9
  # Convert structure to path/methods/kwargs.
@@ -25,30 +23,38 @@ module RESTFramework::Utils
25
23
  end
26
24
 
27
25
  # Pass any further kwargs to the underlying Rails interface.
28
- kwargs = kwargs.merge(v)
26
+ kwargs = v.presence&.except(:delegate)
29
27
  elsif v.is_a?(Symbol) || v.is_a?(String)
30
28
  methods = [v]
31
29
  else
32
30
  methods = v
33
31
  end
34
32
 
35
- # Return a hash with keys: :path, :methods, :kwargs.
36
- {path: path, methods: methods, kwargs: kwargs}
33
+ [k, {path: path, methods: methods, kwargs: kwargs}.compact]
34
+ }.to_h
35
+ end
36
+
37
+ # Get actions which should be skipped for a given controller.
38
+ def self.get_skipped_builtin_actions(controller_class)
39
+ return (
40
+ RESTFramework::BUILTIN_ACTIONS.keys + RESTFramework::BUILTIN_MEMBER_ACTIONS.keys
41
+ ).reject do |action|
42
+ controller_class.method_defined?(action)
37
43
  end
38
44
  end
39
45
 
40
- # Helper to get the first route pattern which matches the given request.
46
+ # Get the first route pattern which matches the given request.
41
47
  def self.get_request_route(application_routes, request)
42
48
  application_routes.router.recognize(request) { |route, _| return route }
43
49
  end
44
50
 
45
- # Helper to normalize a path pattern by replacing URL params with generic placeholder, and
46
- # removing the `(.:format)` at the end.
51
+ # Normalize a path pattern by replacing URL params with generic placeholder, and removing the
52
+ # `(.:format)` at the end.
47
53
  def self.comparable_path(path)
48
54
  return path.gsub("(.:format)", "").gsub(/:[0-9A-Za-z_-]+/, ":x")
49
55
  end
50
56
 
51
- # Helper for showing routes under a controller action; used for the browsable API.
57
+ # Show routes under a controller action; used for the browsable API.
52
58
  def self.get_routes(application_routes, request, current_route: nil)
53
59
  current_route ||= self.get_request_route(application_routes, request)
54
60
  current_path = current_route.path.spec.to_s.gsub("(.:format)", "")
@@ -1,12 +1,27 @@
1
1
  module RESTFramework
2
+ BUILTIN_ACTIONS = {
3
+ index: :get,
4
+ new: :get,
5
+ create: :post,
6
+ }.freeze
7
+ BUILTIN_MEMBER_ACTIONS = {
8
+ show: :get,
9
+ edit: :get,
10
+ update: [:put, :patch],
11
+ destroy: :delete,
12
+ }.freeze
13
+ RRF_BUILTIN_ACTIONS = {
14
+ options: :options,
15
+ }.freeze
2
16
  end
3
17
 
4
18
  require_relative "rest_framework/controller_mixins"
5
19
  require_relative "rest_framework/engine"
6
20
  require_relative "rest_framework/errors"
7
21
  require_relative "rest_framework/filters"
22
+ require_relative "rest_framework/generators"
8
23
  require_relative "rest_framework/paginators"
9
24
  require_relative "rest_framework/routers"
10
25
  require_relative "rest_framework/serializers"
26
+ require_relative "rest_framework/utils"
11
27
  require_relative "rest_framework/version"
12
- require_relative "rest_framework/generators"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rest_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.9
4
+ version: 0.6.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory N. Schmit
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-20 00:00:00.000000000 Z
11
+ date: 2022-12-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails