rest_framework 0.6.9 → 0.6.10

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.
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