rest_framework 0.6.5 → 0.6.6

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: 68ebf30c14f9f70796c6963505e7b45421ee838d076784073505feb9b05c3ddb
4
- data.tar.gz: 71918c4b2b2eeaed10833d84cb54f6bbb860ea3205895bc75abf9a543d0bedc9
3
+ metadata.gz: 88e17bbb8855d22fd7f73d637059d35bd12cc3fc9d982fbde73e11097b66af17
4
+ data.tar.gz: 8fe97e8557a9c957b3a31b51b89c4a7623e97c2fbc75c9c8b80bb23b54dc8429
5
5
  SHA512:
6
- metadata.gz: d909e86567107d2565a65e9c99f67b771e462a2bd9dae04289c9846cc0ba68bfe3728b63d1b8a154c532e7a5cb1aeac1f593401a98bbc0b1f405f474aabb8527
7
- data.tar.gz: abe06a65bb999c4926e6a54295afc4c55e254fba1276b0257eb2af765bbc3f367fe5ebc5d9fe1d6181c45157369440de13a7f4aed331a49271426d2468110a99
6
+ metadata.gz: 5362740a21ef0ef6248c36783da74ff5ad0b142ea9d2de7e228d5052d9badc7af3f78e259dba1015112c04379fd17c16265b0843c26cc2654e89a1742f53e051
7
+ data.tar.gz: 61d6ac5dca5113be086b9e3232e1e7c2e3fa26d45634764993c474cffcc62403f5285b9da0ebdcfd69b4dbe0b9fedaaa5f9d30d2366286a71c458fdbc9fe4ef7
data/README.md ADDED
@@ -0,0 +1 @@
1
+ docs/index.md
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.5
1
+ 0.6.6
@@ -19,22 +19,70 @@
19
19
  <div class="container py-3">
20
20
  <div class="container">
21
21
  <div class="row">
22
- <h1><%= (@header_text if defined? @header_text) || @title %></h1>
22
+ <nav>
23
+ <ol class="breadcrumb">
24
+ <%
25
+ breadcrumbs = request.path.split("/").inject([["/", "(root)"]]) { |breadcrumbs, part|
26
+ # Ignore blank parts of the path (leading slash or double-slashes).
27
+ next breadcrumbs if part.blank?
28
+
29
+ last_path = breadcrumbs[-1][0]
30
+ breadcrumbs << [
31
+ [last_path, part].join(last_path[-1] == "/" ? "" : "/"),
32
+ part,
33
+ ]
34
+ breadcrumbs
35
+ }
36
+ %>
37
+ <% breadcrumbs.each_with_index do |(path, label), i| %>
38
+ <% if i != breadcrumbs.size - 1 %>
39
+ <li class="breadcrumb-item"><a href="<%= path %>"><%= label %></a></li>
40
+ <% else %>
41
+ <li class="breadcrumb-item active"><%= label %></li>
42
+ <% end %>
43
+ <% end %>
44
+ </ol>
45
+ </nav>
46
+ </div>
47
+ <div class="row">
48
+ <div>
49
+ <h1><%= (@header_text if defined? @header_text) || @title %></h1>
50
+ <div style="float: right">
51
+ <% if @route_groups.values[0].any? { |r| r[:matches_path] && r[:verb] == "DELETE" } %>
52
+ <button type="button" class="btn btn-danger" onclick="rrfDelete(this)">DELETE</button>
53
+ <% end %>
54
+ <button type="button" class="btn btn-primary" onclick="rrfRefresh(this)">GET</button>
55
+ </div>
56
+ </div>
23
57
  </div>
24
58
  <hr/>
25
- <% if @json_payload || @xml_payload %>
59
+ <div class="row">
60
+ <div>
61
+ <pre style="white-space: normal">
62
+ <code class="language-plaintext">
63
+ <strong><%= request.method %></strong> <%= request.path %><br>
64
+ </code>
65
+ </pre>
66
+ <pre style="white-space: normal">
67
+ <code class="language-plaintext">
68
+ <strong>HTTP <%= response.status %> <%= response.message %></strong><br>
69
+ <strong>Content-Type:</strong> <%= response.content_type %>
70
+ </code>
71
+ </pre>
72
+ </div>
73
+ </div>
74
+ <% if @json_payload.present? || @xml_payload.present? %>
26
75
  <div class="row">
27
- <h2>Payload</h2>
28
76
  <div class="w-100">
29
77
  <ul class="nav nav-tabs">
30
- <% if @json_payload %>
78
+ <% if @json_payload.present? %>
31
79
  <li class="nav-item">
32
80
  <a class="nav-link active" href="#tab-json" data-bs-toggle="tab" role="tab">
33
81
  .json
34
82
  </a>
35
83
  </li>
36
84
  <% end %>
37
- <% if @xml_payload %>
85
+ <% if @xml_payload.present? %>
38
86
  <li class="nav-item">
39
87
  <a class="nav-link" href="#tab-xml" data-bs-toggle="tab" role="tab">
40
88
  .xml
@@ -45,22 +93,51 @@
45
93
  </div>
46
94
  <div class="tab-content w-100 pt-3">
47
95
  <div class="tab-pane fade show active" id="tab-json" role="tab">
48
- <% if @json_payload %>
49
- <div><pre><code class="language-json"><%= JSON.pretty_generate(JSON.parse(@json_payload)) unless @json_payload == '' %></code></pre></div>
96
+ <% if @json_payload.present? %>
97
+ <div>
98
+ <pre class="rrf-copy"><code class="language-json"><%= JSON.pretty_generate(JSON.parse(@json_payload)) unless @json_payload == '' %></code></pre>
99
+ </div>
50
100
  <% end %>
51
101
  </div>
52
102
  <div class="tab-pane fade" id="tab-xml" role="tab">
53
- <% if @xml_payload %>
54
- <div><pre><code class="language-xml"><%= @xml_payload %></code></pre></div>
103
+ <% if @xml_payload.present? %>
104
+ <div><pre class="rrf-copy"><code class="language-xml"><%= @xml_payload %></code></pre></div>
55
105
  <% end %>
56
106
  </div>
57
107
  </div>
58
108
  </div>
59
109
  <% end %>
60
- <% unless @route_groups.blank? %>
110
+ <% if @route_groups.present? %>
61
111
  <div class="row">
62
- <h2>Routes</h2>
63
- <%= render partial: 'rest_framework/routes' %>
112
+ <div class="w-100">
113
+ <ul class="nav nav-tabs">
114
+ <li class="nav-item">
115
+ <a class="nav-link active" href="#tab-routes" data-bs-toggle="tab" role="tab">
116
+ Routes
117
+ </a>
118
+ </li>
119
+ <% raw_form_routes = @route_groups.values[0].select { |r|
120
+ r[:matches_params] && r[:verb].in?(["POST", "PUT", "PATCH"])
121
+ } %>
122
+ <% unless raw_form_routes.empty? %>
123
+ <li class="nav-item">
124
+ <a class="nav-link" href="#tab-raw-form" data-bs-toggle="tab" role="tab">
125
+ Raw Form
126
+ </a>
127
+ </li>
128
+ <% end %>
129
+ </ul>
130
+ </div>
131
+ <div class="tab-content w-100 pt-3">
132
+ <div class="tab-pane fade show active" id="tab-routes" role="tab">
133
+ <%= render partial: 'rest_framework/routes' %>
134
+ </div>
135
+ <% unless raw_form_routes.empty? %>
136
+ <div class="tab-pane fade" id="tab-raw-form" role="tab">
137
+ <%= render partial: 'rest_framework/raw_form', locals: {raw_form_routes: raw_form_routes} %>
138
+ </div>
139
+ <% end %>
140
+ </div>
64
141
  </div>
65
142
  <% end %>
66
143
  </div>
@@ -6,36 +6,135 @@
6
6
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css" integrity="sha384-F3w7mX95PdgyTmZZMECAngseQB83DfGTowi0iMjiWaeVhAn4FJkqJByhZMI3AhiU" crossorigin="anonymous">
7
7
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.2.0/styles/vs.min.css" integrity="sha512-aWjgJTbdG4imzxTxistV5TVNffcYGtIQQm2NBNahV6LmX14Xq9WwZTL1wPjaSglUuVzYgwrq+0EuI4+vKvQHHw==" crossorigin="anonymous">
8
8
  <style>
9
- /* Adjust headers to always take up their entire row, and tweak the sizing. */
10
- h1,h2,h3,h4,h5,h6 { width: 100%; font-weight: normal; }
11
- h1 { font-size: 2rem; }
12
- h2 { font-size: 1.7rem; }
13
- h3 { font-size: 1.5rem; }
14
- h4 { font-size: 1.3rem; }
15
- h5 { font-size: 1.1rem; }
16
- h6 { font-size: 1rem; }
17
-
18
- /* Make route group expansion obvious to the user. */
19
- .rrf-routes .rrf-route-group-header {
20
- background-color: #f8f8f8;
21
- }
22
- .rrf-routes .rrf-route-group-header:hover {
23
- background-color: #f0f0f0;
24
- }
25
- .rrf-routes .rrf-route-group-header td {
26
- cursor: pointer;
27
- }
9
+ /* Adjust headers to always take up their entire row, and tweak the sizing. */
10
+ h1,h2,h3,h4,h5,h6 { display: inline-block; font-weight: normal; margin-bottom: 0; }
11
+ h1 { font-size: 2rem; }
12
+ h2 { font-size: 1.7rem; }
13
+ h3 { font-size: 1.5rem; }
14
+ h4 { font-size: 1.3rem; }
15
+ h5 { font-size: 1.1rem; }
16
+ h6 { font-size: 1rem; }
28
17
 
29
- /* Disable bootstrap's collapsing animation because in tables it causes delayed jerkiness. */
30
- .rrf-routes .collapsing {
31
- -webkit-transition: none;
32
- transition: none;
33
- display: none;
34
- }
18
+ /* Make code and code blocks a little nicer looking. */
19
+ code {
20
+ padding: 0 .35em;
21
+ background-color: #eee !important;
22
+ border: 1px solid #aaa;
23
+ border-radius: 3px;
24
+ }
25
+
26
+ /* Make route group expansion obvious to the user. */
27
+ .rrf-routes .rrf-route-group-header {
28
+ background-color: #f8f8f8;
29
+ }
30
+ .rrf-routes .rrf-route-group-header:hover {
31
+ background-color: #f0f0f0;
32
+ }
33
+ .rrf-routes .rrf-route-group-header td {
34
+ cursor: pointer;
35
+ }
36
+
37
+ /* Disable bootstrap's collapsing animation because in tables it causes delayed jerkiness. */
38
+ .rrf-routes .collapsing {
39
+ -webkit-transition: none;
40
+ transition: none;
41
+ display: none;
42
+ }
43
+
44
+ /* Copy-to-clipboard styles. */
45
+ .rrf-copy {
46
+ position: relative;
47
+ }
48
+ .rrf-copy .rrf-copy-link {
49
+ position: absolute;
50
+ top: .5em;
51
+ right: .5em;
52
+ transition: 0.3s ease;
53
+ }
54
+ .rrf-copy .rrf-copy-link.rrf-clicked{
55
+ color: green;
56
+ }
35
57
  </style>
36
58
 
37
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>
38
60
  <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>
39
61
  <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>
40
62
  <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>
41
- <script>hljs.initHighlightingOnLoad();</script>
63
+ <script>hljs.initHighlightingOnLoad()</script>
64
+ <script>
65
+ // Helper to replace the document when doing form submission (mainly to support PUT/PATCH/DELETE).
66
+ function rrfReplaceDocument(content) {
67
+ // Replace the document with provided content.
68
+ document.open()
69
+ document.write(content)
70
+ document.close()
71
+
72
+ // Trigger `DOMContentLoaded` manually so our custom JavaScript works.
73
+ document.dispatchEvent(new Event("DOMContentLoaded", {bubbles: true, cancelable: true}))
74
+ }
75
+
76
+ // Helper to copy the element's next `<code>` sibling's content to the clipboard.
77
+ function rrfCopyToClipboard(element) {
78
+ let range = document.createRange()
79
+ range.selectNode(element.nextSibling)
80
+ window.getSelection().removeAllRanges()
81
+ window.getSelection().addRange(range)
82
+ if (document.execCommand("copy")) {
83
+ // Trigger clicked animation.
84
+ element.classList.add("rrf-clicked")
85
+ element.innerText = "Copied!"
86
+ setTimeout(() => {
87
+ element.classList.remove("rrf-clicked")
88
+ element.innerText = "Copy to Clipboard"
89
+ }, 700)
90
+ }
91
+
92
+ // Return false to prevent normal link behavior.
93
+ return false
94
+ }
95
+
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.
107
+ function rrfRefresh(button) {
108
+ button.disabled = true
109
+ window.location.reload()
110
+ }
111
+
112
+ // Helper to call `DELETE` on the current path.
113
+ function rrfDelete(button) {
114
+ button.disabled = true
115
+ rrfAPICall(window.location.pathname, "DELETE")
116
+ }
117
+
118
+ // Helper to submit the raw form.
119
+ function rrfSubmitRawForm(button) {
120
+ button.disabled = true
121
+
122
+ // Grab the selected route/method, media type, and the body.
123
+ const [method, path] = document.getElementById("rawFormRoute").value.split(":")
124
+ const media_type = document.getElementById("rawFormMediaType").value
125
+ const body = document.getElementById("rawFormContent").value
126
+
127
+ // Perform the API call.
128
+ rrfAPICall(path, method, {body, headers: {"Content-Type": media_type}})
129
+ }
130
+
131
+ // Helper to make an HTML API call and replace the document with the response.
132
+ function rrfAPICall(path, method, kwargs={}) {
133
+ const headers = kwargs.headers || {}
134
+ delete kwargs.headers
135
+
136
+ fetch(path, {method, headers: {"Accept": "text/html", ...headers}, ...kwargs})
137
+ .then((response) => response.text())
138
+ .then((body) => { rrfReplaceDocument(body) })
139
+ }
140
+ </script>
@@ -0,0 +1,30 @@
1
+ <div style="max-width: 60em; margin: auto">
2
+ <div class="mb-2">
3
+ <label class="form-label w-100">Route
4
+ <select class="form-control" id="rawFormRoute">
5
+ <% raw_form_routes.each do |route| %>
6
+ <% path = @route_props[:with_path_args].call(route[:route]) %>
7
+ <option value="<%= route[:verb] %>:<%= path %>"><%= route[:verb] %> <%= route[:relative_path] %></option>
8
+ <% end %>
9
+ </select>
10
+ </label>
11
+ </div>
12
+
13
+ <div class="mb-2">
14
+ <label class="form-label w-100">Media Type
15
+ <select class="form-control" id="rawFormMediaType">
16
+ <% ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"].each do |t| %>
17
+ <option value="<%= t %>"><%= t %></option>
18
+ <% end %>
19
+ </select>
20
+ </label>
21
+ </div>
22
+
23
+ <div class="mb-2">
24
+ <label class="form-label w-100">Content
25
+ <textarea class="form-control" style="font-family: monospace" id="rawFormContent" rows="8" cols="60"></textarea>
26
+ </label>
27
+ </div>
28
+
29
+ <button type="button" class="btn btn-primary" style="float: right" onclick="rrfSubmitRawForm(this)">Submit</button>
30
+ </div>
@@ -1,7 +1,7 @@
1
1
  <tr>
2
2
  <td>
3
- <% if route[:route].name && link_args = route[:show_link_args] %>
4
- <%= link_to route[:relative_path], self.send("#{route[:route].name}_path", *link_args) %>
3
+ <% if route[:route].name && route[:verb] == "GET" && route[:matches_params] %>
4
+ <%= link_to route[:relative_path], @route_props[:with_path_args].call(route[:route]) %>
5
5
  <% else %>
6
6
  <%= route[:relative_path] %>
7
7
  <% end %>
@@ -104,6 +104,11 @@ module RESTFramework::BaseControllerMixin
104
104
  return serializer_class
105
105
  end
106
106
 
107
+ # Helper to serialize data using the `serializer_class`.
108
+ def serialize(data, **kwargs)
109
+ return self.get_serializer_class.new(data, controller: self, **kwargs).serialize
110
+ end
111
+
107
112
  # Helper to get filtering backends, defaulting to no backends.
108
113
  def get_filter_backends
109
114
  return self.class.filter_backends || []
@@ -148,11 +153,6 @@ module RESTFramework::BaseControllerMixin
148
153
  json_kwargs = kwargs.delete(:json_kwargs) || {}
149
154
  xml_kwargs = kwargs.delete(:xml_kwargs) || {}
150
155
 
151
- # Do not use any adapters by default, if configured.
152
- if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
153
- kwargs[:adapter] = nil
154
- end
155
-
156
156
  # Raise helpful error if payload is nil. Usually this happens when a record is not found (e.g.,
157
157
  # when passing something like `User.find_by(id: some_id)` to `api_response`). The caller should
158
158
  # actually be calling `find_by!` to raise ActiveRecord::RecordNotFound and allowing the REST
@@ -161,14 +161,24 @@ module RESTFramework::BaseControllerMixin
161
161
  raise RESTFramework::NilPassedToAPIResponseError
162
162
  end
163
163
 
164
+ # If `payload` is an `ActiveRecord::Relation` or `ActiveRecord::Base`, then serialize it.
165
+ if payload.is_a?(ActiveRecord::Base) || payload.is_a?(ActiveRecord::Relation)
166
+ payload = self.serialize(payload)
167
+ end
168
+
169
+ # Do not use any adapters by default, if configured.
170
+ if self.class.disable_adapters_by_default && !kwargs.key?(:adapter)
171
+ kwargs[:adapter] = nil
172
+ end
173
+
164
174
  # Flag to track if we had to rescue unknown format.
165
175
  already_rescued_unknown_format = false
166
176
 
167
177
  begin
168
178
  respond_to do |format|
169
179
  if payload == ""
170
- format.json { head(:no_content) } if self.class.serialize_to_json
171
- format.xml { head(:no_content) } if self.class.serialize_to_xml
180
+ format.json { head(kwargs[:status] || :no_content) } if self.class.serialize_to_json
181
+ format.xml { head(kwargs[:status] || :no_content) } if self.class.serialize_to_xml
172
182
  else
173
183
  format.json {
174
184
  jkwargs = kwargs.merge(json_kwargs)
@@ -191,11 +201,13 @@ module RESTFramework::BaseControllerMixin
191
201
  end
192
202
  @template_logo_text ||= "Rails REST Framework"
193
203
  @title ||= self.controller_name.camelize
194
- @route_groups ||= RESTFramework::Utils.get_routes(Rails.application.routes, request)
204
+ @route_props, @route_groups = RESTFramework::Utils.get_routes(
205
+ Rails.application.routes, request
206
+ )
195
207
  hkwargs = kwargs.merge(html_kwargs)
196
208
  begin
197
209
  render(**hkwargs)
198
- rescue ActionView::MissingTemplate # fallback to rest_framework layout
210
+ rescue ActionView::MissingTemplate # Fallback to `rest_framework` layout.
199
211
  hkwargs[:layout] = "rest_framework"
200
212
  hkwargs[:html] = ""
201
213
  render(**hkwargs)
@@ -114,11 +114,6 @@ module RESTFramework::BaseModelControllerMixin
114
114
  return super || RESTFramework::NativeSerializer
115
115
  end
116
116
 
117
- # Helper to serialize data using the `serializer_class`.
118
- def serialize(data, **kwargs)
119
- return self.get_serializer_class.new(data, controller: self, **kwargs).serialize
120
- end
121
-
122
117
  # Helper to get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`.
123
118
  def get_filter_backends
124
119
  return self.class.filter_backends || [
@@ -128,35 +123,33 @@ module RESTFramework::BaseModelControllerMixin
128
123
 
129
124
  # Filter the request body for keys in current action's allowed_parameters/fields config.
130
125
  def get_body_params
131
- return @_get_body_params ||= begin
132
- # Filter the request body and map to strings. Return all params if we cannot resolve a list of
133
- # allowed parameters or fields.
134
- body_params = if allowed_params = self.get_allowed_parameters&.map(&:to_s)
135
- request.request_parameters.select { |p| allowed_params.include?(p) }
136
- else
137
- request.request_parameters
138
- end
126
+ # Filter the request body and map to strings. Return all params if we cannot resolve a list of
127
+ # allowed parameters or fields.
128
+ body_params = if allowed_params = self.get_allowed_parameters&.map(&:to_s)
129
+ request.request_parameters.select { |p| allowed_params.include?(p) }
130
+ else
131
+ request.request_parameters
132
+ end
139
133
 
140
- # Add query params in place of missing body params, if configured. If fields are not defined,
141
- # fallback to using columns for this particular feature.
142
- if self.class.accept_generic_params_as_body_params
143
- (self.get_fields(fallback: true) - body_params.keys).each do |k|
144
- if (value = params[k])
145
- body_params[k] = value
146
- end
134
+ # Add query params in place of missing body params, if configured. If fields are not defined,
135
+ # fallback to using columns for this particular feature.
136
+ if self.class.accept_generic_params_as_body_params
137
+ (self.get_fields(fallback: true) - body_params.keys).each do |k|
138
+ if (value = params[k])
139
+ body_params[k] = value
147
140
  end
148
141
  end
142
+ end
149
143
 
150
- # Filter primary key if configured.
151
- if self.class.filter_pk_from_request_body
152
- body_params.delete(self.get_model&.primary_key)
153
- end
144
+ # Filter primary key if configured.
145
+ if self.class.filter_pk_from_request_body
146
+ body_params.delete(self.get_model&.primary_key)
147
+ end
154
148
 
155
- # Filter fields in exclude_body_fields.
156
- (self.class.exclude_body_fields || []).each { |f| body_params.delete(f) }
149
+ # Filter fields in exclude_body_fields.
150
+ (self.class.exclude_body_fields || []).each { |f| body_params.delete(f) }
157
151
 
158
- body_params
159
- end
152
+ return body_params
160
153
  end
161
154
  alias_method :get_create_params, :get_body_params
162
155
  alias_method :get_update_params, :get_body_params
@@ -182,9 +175,10 @@ module RESTFramework::BaseModelControllerMixin
182
175
  return nil
183
176
  end
184
177
 
185
- # Get the set of records this controller has access to.
178
+ # Get the set of records this controller has access to. The return value is cached and exposed to
179
+ # the view as the `@recordset` instance variable.
186
180
  def get_recordset
187
- return @recordset if instance_variable_defined?(:@recordset) && @recordset
181
+ return @recordset if instance_variable_defined?(:@recordset)
188
182
  return (@recordset = self.class.recordset) if self.class.recordset
189
183
 
190
184
  # If there is a model, return that model's default scope (all records by default).
@@ -192,13 +186,21 @@ module RESTFramework::BaseModelControllerMixin
192
186
  return @recordset = model.all
193
187
  end
194
188
 
195
- return nil
189
+ return @recordset = nil
196
190
  end
197
191
 
198
- # Get a single record by primary key or another column, if allowed.
192
+ # Helper to get the records this controller has access to *after* any filtering is applied.
193
+ def get_records
194
+ return @records if instance_variable_defined?(:@records)
195
+
196
+ return @records = self.get_filtered_data(self.get_recordset)
197
+ end
198
+
199
+ # Get a single record by primary key or another column, if allowed. The return value is cached and
200
+ # exposed to the view as the `@record` instance variable.
199
201
  def get_record
200
202
  # Cache the result.
201
- return @_get_record if @_get_record
203
+ return @record if instance_variable_defined?(:@record)
202
204
 
203
205
  recordset = self.get_recordset
204
206
  find_by_key = self.get_model.primary_key
@@ -216,78 +218,74 @@ module RESTFramework::BaseModelControllerMixin
216
218
 
217
219
  # Filter recordset, if configured.
218
220
  if self.filter_recordset_before_find
219
- recordset = self.get_filtered_data(recordset)
221
+ recordset = self.get_records
220
222
  end
221
223
 
222
- # Return the record. Route key is always :id by Rails convention.
223
- return @_get_record = recordset.find_by!(find_by_key => params[:id])
224
+ # Return the record. Route key is always `:id` by Rails convention.
225
+ return @record = recordset.find_by!(find_by_key => params[:id])
224
226
  end
225
227
  end
226
228
 
227
229
  # Mixin for listing records.
228
230
  module RESTFramework::ListModelMixin
229
231
  def index
230
- api_response(self.index!)
232
+ return api_response(self.get_index_records)
231
233
  end
232
234
 
233
- def index!
234
- @records ||= self.get_filtered_data(self.get_recordset)
235
+ # Helper to get records with both filtering and pagination applied.
236
+ def get_index_records
237
+ records = self.get_records
235
238
 
236
239
  # Handle pagination, if enabled.
237
240
  if self.class.paginator_class
238
- paginator = self.class.paginator_class.new(data: @records, controller: self)
241
+ paginator = self.class.paginator_class.new(data: records, controller: self)
239
242
  page = paginator.get_page
240
243
  serialized_page = self.serialize(page)
241
244
  return paginator.get_paginated_response(serialized_page)
242
- else
243
- return self.serialize(@records)
244
245
  end
246
+
247
+ return records
245
248
  end
246
249
  end
247
250
 
248
251
  # Mixin for showing records.
249
252
  module RESTFramework::ShowModelMixin
250
253
  def show
251
- api_response(self.show!)
252
- end
253
-
254
- def show!
255
- @record ||= self.get_record
256
- return self.serialize(@record)
254
+ return api_response(self.get_record)
257
255
  end
258
256
  end
259
257
 
260
258
  # Mixin for creating records.
261
259
  module RESTFramework::CreateModelMixin
262
260
  def create
263
- api_response(self.create!)
261
+ return api_response(self.create!, status: :created)
264
262
  end
265
263
 
264
+ # Helper to perform the `create!` call and return the created record.
266
265
  def create!
267
266
  if self.get_recordset.respond_to?(:create!) && self.create_from_recordset
268
267
  # Create with any properties inherited from the recordset. We exclude any `select` clauses in
269
268
  # case model callbacks need to call `count` on this collection, which typically raises a SQL
270
269
  # `SyntaxError`.
271
- @record ||= self.get_recordset.except(:select).create!(self.get_create_params)
270
+ return self.get_recordset.except(:select).create!(self.get_create_params)
272
271
  else
273
272
  # Otherwise, perform a "bare" create.
274
- @record ||= self.get_model.create!(self.get_create_params)
273
+ return self.get_model.create!(self.get_create_params)
275
274
  end
276
-
277
- return self.serialize(@record)
278
275
  end
279
276
  end
280
277
 
281
278
  # Mixin for updating records.
282
279
  module RESTFramework::UpdateModelMixin
283
280
  def update
284
- api_response(self.update!)
281
+ return api_response(self.update!)
285
282
  end
286
283
 
284
+ # Helper to perform the `update!` call and return the updated record.
287
285
  def update!
288
- @record ||= self.get_record
289
- @record.update!(self.get_update_params)
290
- return self.serialize(@record)
286
+ record = self.get_record
287
+ record.update!(self.get_update_params)
288
+ return record
291
289
  end
292
290
  end
293
291
 
@@ -295,12 +293,12 @@ end
295
293
  module RESTFramework::DestroyModelMixin
296
294
  def destroy
297
295
  self.destroy!
298
- api_response("")
296
+ return api_response("")
299
297
  end
300
298
 
299
+ # Helper to perform the `destroy!` call and return the destroyed (and frozen) record.
301
300
  def destroy!
302
- @record ||= self.get_record
303
- @record.destroy!
301
+ return self.get_record.destroy!
304
302
  end
305
303
  end
306
304
 
@@ -42,11 +42,6 @@ module RESTFramework::Utils
42
42
  application_routes.router.recognize(request) { |route, _| return route }
43
43
  end
44
44
 
45
- # Helper to get the route pattern for a route, stripped of the `(:format)` segment.
46
- def self.get_route_pattern(route)
47
- return route.path.spec.to_s.gsub(/\(\.:format\)$/, "")
48
- end
49
-
50
45
  # Helper to normalize a path pattern by replacing URL params with generic placeholder, and
51
46
  # removing the `(.:format)` at the end.
52
47
  def self.comparable_path(path)
@@ -56,47 +51,52 @@ module RESTFramework::Utils
56
51
  # Helper for showing routes under a controller action; used for the browsable API.
57
52
  def self.get_routes(application_routes, request, current_route: nil)
58
53
  current_route ||= self.get_request_route(application_routes, request)
59
- current_path = current_route.path.spec.to_s
54
+ current_path = current_route.path.spec.to_s.gsub("(.:format)", "")
60
55
  current_levels = current_path.count("/")
61
56
  current_comparable_path = self.comparable_path(current_path)
62
57
 
58
+ # Add helpful properties of the current route.
59
+ path_args = current_route.required_parts.map { |n| request.path_parameters[n] }
60
+ route_props = {
61
+ with_path_args: ->(r) {
62
+ r.format(r.required_parts.each_with_index.map { |p, i| [p, path_args[i]] }.to_h)
63
+ },
64
+ }
65
+
63
66
  # Return routes that match our current route subdomain/pattern, grouped by controller. We
64
67
  # precompute certain properties of the route for performance.
65
- return application_routes.routes.map { |r|
66
- path = r.path.spec.to_s
68
+ return route_props, application_routes.routes.select { |r|
69
+ # We `select` first to avoid unnecessarily calculating metadata for routes we don't even want
70
+ # to show.
71
+ (
72
+ (r.defaults[:subdomain].blank? || r.defaults[:subdomain] == request.subdomain) &&
73
+ self.comparable_path(r.path.spec.to_s).start_with?(current_comparable_path) &&
74
+ r.defaults[:controller].present? &&
75
+ r.defaults[:action].present?
76
+ )
77
+ }.map { |r|
78
+ path = r.path.spec.to_s.gsub("(.:format)", "")
67
79
  levels = path.count("/")
68
-
69
- # Show link if the route is GET and our current route has all required URL params.
70
- if r.verb == "GET" && r.path.required_names.length == current_route.path.required_names.length
71
- show_link_args = current_route.path.required_names.map { |n| request.params[n] }.compact
72
- else
73
- show_link_args = nil
74
- end
80
+ matches_path = current_path == path
81
+ matches_params = r.required_parts.length == current_route.required_parts.length
75
82
 
76
83
  {
77
84
  route: r,
78
85
  verb: r.verb,
79
86
  path: path,
80
- comparable_path: self.comparable_path(path),
81
87
  # Starts at the number of levels in current path, and removes the `(.:format)` at the end.
82
- relative_path: path.split("/")[current_levels..]&.join("/")&.gsub("(.:format)", ""),
88
+ relative_path: path.split("/")[current_levels..]&.join("/"),
83
89
  controller: r.defaults[:controller].presence,
84
90
  action: r.defaults[:action].presence,
85
- subdomain: r.defaults[:subdomain].presence,
86
- levels: levels,
87
- show_link_args: show_link_args,
91
+ matches_path: matches_path,
92
+ matches_params: matches_params,
93
+ # The following options are only used in subsequent processing in this method.
94
+ _levels: levels,
88
95
  }
89
- }.select { |r|
90
- (
91
- (!r[:subdomain] || r[:subdomain] == request.subdomain.presence) &&
92
- r[:comparable_path].start_with?(current_comparable_path) &&
93
- r[:controller] &&
94
- r[:action]
95
- )
96
96
  }.sort_by { |r|
97
97
  # Sort by levels first, so the routes matching closely with current request show first, then
98
98
  # by the path, and finally by the HTTP verb.
99
- [r[:levels], r[:path], HTTP_METHOD_ORDERING.index(r[:verb]) || 99]
99
+ [r[:_levels], r[:_path], HTTP_METHOD_ORDERING.index(r[:verb]) || 99]
100
100
  }.group_by { |r| r[:controller] }.sort_by { |c, _r|
101
101
  # Sort the controller groups by current controller first, then depth, then alphanumerically.
102
102
  [request.params[:controller] == c ? 0 : 1, c.count("/"), c]
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.5
4
+ version: 0.6.6
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-11-02 00:00:00.000000000 Z
11
+ date: 2022-11-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -36,6 +36,7 @@ files:
36
36
  - VERSION
37
37
  - app/views/layouts/rest_framework.html.erb
38
38
  - app/views/rest_framework/_head.html.erb
39
+ - app/views/rest_framework/_raw_form.html.erb
39
40
  - app/views/rest_framework/_route.html.erb
40
41
  - app/views/rest_framework/_routes.html.erb
41
42
  - lib/rest_framework.rb
@@ -74,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
75
  - !ruby/object:Gem::Version
75
76
  version: '0'
76
77
  requirements: []
77
- rubygems_version: 3.2.22
78
+ rubygems_version: 3.2.33
78
79
  signing_key:
79
80
  specification_version: 4
80
81
  summary: A framework for DRY RESTful APIs in Ruby on Rails.
data/README.md DELETED
@@ -1,128 +0,0 @@
1
- # Rails REST Framework
2
-
3
- [![Gem Version](https://badge.fury.io/rb/rest_framework.svg)](https://badge.fury.io/rb/rest_framework)
4
- [![Build Status](https://travis-ci.com/gregschmit/rails-rest-framework.svg?branch=master)](https://travis-ci.com/gregschmit/rails-rest-framework)
5
- [![Coverage Status](https://coveralls.io/repos/github/gregschmit/rails-rest-framework/badge.svg?branch=master)](https://coveralls.io/github/gregschmit/rails-rest-framework?branch=master)
6
- [![Maintainability](https://api.codeclimate.com/v1/badges/ba5df7706cb544d78555/maintainability)](https://codeclimate.com/github/gregschmit/rails-rest-framework/maintainability)
7
-
8
- A framework for DRY RESTful APIs in Ruby on Rails.
9
-
10
- **The Problem**: Building controllers for APIs usually involves writing a lot of redundant CRUD
11
- logic, and routing them can be obnoxious. Building and maintaining features like ordering,
12
- filtering, and pagination can be tedious.
13
-
14
- **The Solution**: This framework implements browsable API responses, CRUD actions for your models,
15
- and features like ordering/filtering/pagination, so you can focus on building awesome APIs.
16
-
17
- Website/Guide: [https://rails-rest-framework.com](https://rails-rest-framework.com)
18
-
19
- Source: [https://github.com/gregschmit/rails-rest-framework](https://github.com/gregschmit/rails-rest-framework)
20
-
21
- YARD Docs: [https://rubydoc.info/gems/rest_framework](https://rubydoc.info/gems/rest_framework)
22
-
23
- ## Installation
24
-
25
- Add this line to your application's Gemfile:
26
-
27
- ```ruby
28
- gem 'rest_framework'
29
- ```
30
-
31
- And then execute:
32
-
33
- ```shell
34
- $ bundle install
35
- ```
36
-
37
- Or install it yourself with:
38
-
39
- ```shell
40
- $ gem install rest_framework
41
- ```
42
-
43
- ## Quick Usage Tutorial
44
-
45
- ### Controller Mixins
46
-
47
- To transform a controller into a RESTful controller, you can either include `BaseControllerMixin`,
48
- `ReadOnlyModelControllerMixin`, or `ModelControllerMixin`. `BaseControllerMixin` provides a `root`
49
- action and a simple interface for routing arbitrary additional actions:
50
-
51
- ```ruby
52
- class ApiController < ApplicationController
53
- include RESTFramework::BaseControllerMixin
54
- self.extra_actions = {test: [:get]}
55
-
56
- def test
57
- render inline: "Test successful!"
58
- end
59
- end
60
- ```
61
-
62
- `ModelControllerMixin` assists with providing the standard model CRUD for your controller.
63
-
64
- ```ruby
65
- class Api::MoviesController < ApiController
66
- include RESTFramework::ModelControllerMixin
67
-
68
- self.recordset = Movie.where(enabled: true)
69
- end
70
- ```
71
-
72
- `ReadOnlyModelControllerMixin` only enables list/show actions, but since we're naming this
73
- controller in a way that doesn't make the model obvious, we can set that explicitly:
74
-
75
- ```ruby
76
- class Api::ReadOnlyMoviesController < ApiController
77
- include RESTFramework::ReadOnlyModelControllerMixin
78
-
79
- self.model = Movie
80
- end
81
- ```
82
-
83
- Note that you can also override the `get_recordset` instance method to override the API behavior
84
- dynamically per-request.
85
-
86
- ### Routing
87
-
88
- You can use Rails' `resource`/`resources` routers to route your API, however if you want
89
- `extra_actions` / `extra_member_actions` to be routed automatically, then you can use `rest_route`
90
- for non-resourceful controllers, or `rest_resource` / `rest_resources` resourceful routers. You can
91
- also use `rest_root` to route the root of your API:
92
-
93
- ```ruby
94
- Rails.application.routes.draw do
95
- rest_root :api # will find `api_controller` and route the `root` action to '/api'
96
- namespace :api do
97
- rest_resources :movies
98
- rest_resources :users
99
- end
100
- end
101
- ```
102
-
103
- Or if you want the API root to be routed to `Api::RootController#root`:
104
-
105
- ```ruby
106
- Rails.application.routes.draw do
107
- namespace :api do
108
- rest_root # will route `Api::RootController#root` to '/' in this namespace ('/api')
109
- rest_resources :movies
110
- rest_resources :users
111
- end
112
- end
113
- ```
114
-
115
- ## Development/Testing
116
-
117
- After you clone the repository, cd'ing into the directory should create a new gemset if you are
118
- using RVM. Then run `bundle install` to install the appropriate gems.
119
-
120
- To run the test suite:
121
-
122
- ```shell
123
- $ rails test
124
- ```
125
-
126
- The top-level `bin/rails` proxies all Rails commands to the test project, so you can operate it via
127
- the usual commands. Ensure you run `rails db:setup` before running `rails server` or
128
- `rails console`.