rest_framework 1.0.0.beta1 → 1.0.0.rc1

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: 4261d5bdf6f4e59ffce35b2a5f36bafe104bddd9b9437f34b77b495acbeefac6
4
- data.tar.gz: 84f0213bfd2d54777059049b87b84f2ff4c727c45838c130ec7382b5d1aa451c
3
+ metadata.gz: 12d9337d2e8abbdde98e25233fc2cd9d1b3596b0f65a8a5b3210eee5d5a571bd
4
+ data.tar.gz: e77d714a217b1ecc6ab24cdab384d7ce82d2680775e15e701fa4f1c2b2e8d88d
5
5
  SHA512:
6
- metadata.gz: 2d2308561ba8ae8668f2e3fd83da4487d1b9ba3b4ca632c1be21fe6bfbf5cb88b8f0a661624dc97a837473263ae944a1231ef7c8b90284d8851b275780326886
7
- data.tar.gz: 030d5acc028ae343486c30968c66b25ab91d5577a48f236ccf4766ee8675b75c1f83ca8cb3f442dd9a0be93209142acd1aa4ba7d5cfd7c318521bbdc91cb986b
6
+ metadata.gz: 93d22bc0db33b0b99876bcf193b8f72bf7d12072a46c34d82e735c11223cb711329c685bda323f2cb4e7383c490f298f2225efa674ced397a275bd59597177dc
7
+ data.tar.gz: 7d0f5375e43bb4c696f566c851875f00587175d5429a20b9e502350b46dc7d09a4398fedd3ddf91d9b02e3b0e8e262beb8198d5fbe8a267f1c02b5ec73b82a59
data/README.md CHANGED
@@ -7,10 +7,12 @@
7
7
 
8
8
  A framework for DRY RESTful APIs in Ruby on Rails.
9
9
 
10
- **The Problem**: Building controllers for APIs usually involves writing a lot of redundant CRUD logic, and routing them can be obnoxious.
11
- Building and maintaining features like ordering, filtering, and pagination can be tedious.
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.
12
13
 
13
- **The Solution**: This framework implements browsable API responses, CRUD actions for your models, and features like ordering/filtering/pagination, so you can focus on building awesome APIs.
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 your application logic.
14
16
 
15
17
  Website/Guide: [rails-rest-framework.com](https://rails-rest-framework.com)
16
18
 
@@ -38,7 +40,8 @@ bundle install
38
40
 
39
41
  This section provides some simple examples to quickly get you started using the framework.
40
42
 
41
- For the purpose of this example, you'll want to add an `api_controller.rb` to your controllers, as well as a directory for the resources:
43
+ For the purpose of this example, you'll want to add an `api_controller.rb` to your controllers, as
44
+ well as a directory for the resources:
42
45
 
43
46
  ```text
44
47
  controllers/
@@ -51,7 +54,8 @@ controllers/
51
54
 
52
55
  ### Controller Mixins
53
56
 
54
- The root `ApiController` can include any common behavior you want to share across all your API controllers:
57
+ The root `ApiController` can include any common behavior you want to share across all your API
58
+ controllers:
55
59
 
56
60
  ```ruby
57
61
  class ApiController < ApplicationController
@@ -59,24 +63,21 @@ class ApiController < ApplicationController
59
63
 
60
64
  # Setting up a paginator class here makes more sense than defining it on every child controller.
61
65
  self.paginator_class = RESTFramework::PageNumberPaginator
62
-
63
- # The page_size attribute doesn't exist on the `BaseControllerMixin`, but for child controllers
64
- # that include the `ModelControllerMixin`, they will inherit this attribute and will not overwrite
65
- # it.
66
- class_attribute(:page_size, default: 30)
66
+ self.page_size = 30
67
67
  end
68
68
  ```
69
69
 
70
- A root controller can provide actions that exist on the root of your API.
71
- It's best to define a dedicated root controller, rather than using the `ApiController` for this purpose, so that actions don't propagate to child controllers:
70
+ A root controller can provide actions that exist on the root of your API. It's best to define a
71
+ dedicated root controller, rather than using the `ApiController` for this purpose, so that actions
72
+ don't propagate to child controllers:
72
73
 
73
74
  ```ruby
74
75
  class Api::RootController < ApiController
75
76
  self.extra_actions = {test: :get}
76
77
 
77
78
  def root
78
- render_api(
79
- {
79
+ render(
80
+ api: {
80
81
  message: "Welcome to the API.",
81
82
  how_to_authenticate: <<~END.lines.map(&:strip).join(" "),
82
83
  You can use this API with your normal login session. Otherwise, you can insert your API
@@ -88,7 +89,7 @@ class Api::RootController < ApiController
88
89
  end
89
90
 
90
91
  def test
91
- render_api({message: "Hello, world!"})
92
+ render(api: {message: "Hello, world!"})
92
93
  end
93
94
  end
94
95
  ```
@@ -105,7 +106,7 @@ class Api::MoviesController < ApiController
105
106
  def first
106
107
  # Always use the bang method, since the framework will rescue `RecordNotFound` and return a
107
108
  # sensible error response.
108
- render_api(self.get_records.first!)
109
+ render(api: self.get_records.first!)
109
110
  end
110
111
 
111
112
  def get_recordset
@@ -114,8 +115,8 @@ class Api::MoviesController < ApiController
114
115
  end
115
116
  ```
116
117
 
117
- When `fields` is nil, then it will default to all columns.
118
- The `fields` attribute can also be a hash to include or exclude fields rather than defining them manually:
118
+ When `fields` is nil, then it will default to all columns. The `fields` attribute can also be a hash
119
+ to include or exclude fields rather than defining them manually:
119
120
 
120
121
  ```ruby
121
122
  class Api::UsersController < ApiController
@@ -127,9 +128,10 @@ end
127
128
 
128
129
  ### Routing
129
130
 
130
- Use `rest_route` for non-resourceful controllers, or `rest_resource` / `rest_resources` resourceful routers.
131
- These routers add some features to the Rails builtin `resource`/`resources` routers, such as automatically routing extra actions defined on the controller.
132
- To route the root, use `rest_root`.
131
+ Use `rest_route` for non-resourceful controllers, or `rest_resource` / `rest_resources` resourceful
132
+ routers. These routers add some features to the Rails builtin `resource`/`resources` routers, such
133
+ as automatically routing extra actions defined on the controller. To route the root, use
134
+ `rest_root`.
133
135
 
134
136
  ```ruby
135
137
  Rails.application.routes.draw do
@@ -146,7 +148,9 @@ end
146
148
 
147
149
  ## Development/Testing
148
150
 
149
- After you clone the repository, cd'ing into the directory should create a new gemset if you are using RVM.
150
- Then run `bin/setup` to install the appropriate gems and set things up.
151
+ After you clone the repository, cd'ing into the directory should create a new gemset if you are
152
+ using RVM. Then run `bin/setup` to install the appropriate gems and set things up.
151
153
 
152
- The top-level `bin/rails` proxies all Rails commands to the test project, so you can operate it via the usual commands (e.g., `rails test`, `rails server` and `rails console`). For development, use `foreman start` to run the web server and the job queue.
154
+ The top-level `bin/rails` proxies all Rails commands to the test project, so you can operate it via
155
+ the usual commands (e.g., `rails test`, `rails server` and `rails console`). For development, use
156
+ `foreman start` to run the web server and the job queue.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0.beta1
1
+ 1.0.0.rc1
@@ -8,6 +8,12 @@ class RESTFramework::Engine < Rails::Engine
8
8
  *RESTFramework::EXTERNAL_UNSUMMARIZED_ASSETS.keys.map { |name| "rest_framework/#{name}" },
9
9
  ]
10
10
  end
11
+
12
+ if RESTFramework.config.register_api_renderer
13
+ ActionController::Renderers.add(:api) do |data, kwargs|
14
+ render_api(data, **kwargs)
15
+ end
16
+ end
11
17
  end
12
18
  end
13
19
  end
@@ -1,6 +1,34 @@
1
1
  # A simple filtering backend that supports filtering a recordset based on query parameters.
2
2
  class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
3
- NIL_VALUES = ["nil", "null"].freeze
3
+ # Wrapper to indicate a type of query that must be negated with `where.not(...)`.
4
+ class Not
5
+ attr_reader :q
6
+
7
+ def initialize(q)
8
+ @q = q
9
+ end
10
+ end
11
+
12
+ PREDICATES = {
13
+ true: true,
14
+ false: false,
15
+ null: nil,
16
+ lt: ->(f, v) { {f => ...v} },
17
+ # `gt` must negate `lte` because Rails doesn't support `>` with endless ranges.
18
+ gt: ->(f, v) { Not.new({f => ..v}) },
19
+ lte: ->(f, v) { {f => ..v} },
20
+ gte: ->(f, v) { {f => v..} },
21
+ not: ->(f, v) { Not.new({f => v}) },
22
+ cont: ->(f, v) { ["#{f} LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(v)}%"] },
23
+ in: ->(f, v) {
24
+ if v.is_a?(Array)
25
+ {f => v.map { |v| v == "null" ? nil : v }}
26
+ elsif v.is_a?(String)
27
+ {f => v.split(",").map { |v| v == "null" ? nil : v }}
28
+ end
29
+ },
30
+ }.freeze
31
+ PREDICATES_REGEX = /^(.*)_(#{PREDICATES.keys.join("|")})$/
4
32
 
5
33
  # Get a list of filter fields for the current action.
6
34
  def _get_fields
@@ -8,56 +36,97 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
8
36
  return @controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
9
37
  end
10
38
 
11
- # Filter params for keys allowed by the current action's filter_fields/fields config.
12
- def _get_filter_params
39
+ # Helper to find a variation of a field using a predicate. For example, there could be a field
40
+ # called `age`, and if `age_lt` it passed, we should return `["age", "lt"]`. Otherwise, if
41
+ # something like `age` is passed, then we should return `["age", nil]`.
42
+ def parse_predicate(field)
43
+ if match = PREDICATES_REGEX.match(field)
44
+ field = match[1]
45
+ predicate = match[2]
46
+ end
47
+
48
+ return field, predicate
49
+ end
50
+
51
+ # Filter params for keys allowed by the current action's filter_fields/fields config and return a
52
+ # query config in the form of: `[base_query, pred_queries, includes]`.
53
+ def _get_query_config
13
54
  fields = self._get_fields
14
55
  includes = []
15
56
 
16
- filter_params = @controller.request.query_parameters.select { |p, _|
17
- # Remove any trailing `__in` from the field name.
18
- field = p.chomp("__in")
57
+ # Predicate queries must be added to a separate list because multiple predicates can be used.
58
+ # E.g., `age_lt=10&age_gte=5` would transform to `[{age: ...10}, {age: 5..}]` to avoid conflict
59
+ # on the `age` key.
60
+ pred_queries = []
19
61
 
20
- # Remove any associations whose sub-fields are not filterable.
21
- if match = /(.*)\.(.*)/.match(field)
22
- field, sub_field = match[1..2]
23
- next false unless field.in?(fields)
62
+ base_query = @controller.request.query_parameters.map { |field, v|
63
+ # First, if field is a simple filterable field, return early.
64
+ if field.in?(fields)
65
+ next [field, v]
66
+ end
24
67
 
25
- sub_fields = @controller.class.field_configuration[field][:sub_fields] || []
26
- if sub_field.in?(sub_fields)
27
- includes << field.to_sym
28
- next true
29
- end
68
+ # First, try to parse a simple predicate and check if it is filterable.
69
+ pred_field, predicate = self.parse_predicate(field)
70
+ if predicate && pred_field.in?(fields)
71
+ field = pred_field
72
+ else
73
+ # Last, try to parse a sub-field or sub-field w/predicate.
74
+ root_field, sub_field = field.split(".", 2)
75
+ _, pred_sub_field = pred_field.split(".", 2) if predicate
30
76
 
31
- next false
32
- end
77
+ # Check if sub-field or sub-field w/predicate is filterable.
78
+ if sub_field
79
+ next nil unless root_field.in?(fields)
33
80
 
34
- next field.in?(fields)
35
- }.map { |p, v|
36
- # Convert fields ending in `__in` to array values.
37
- if p.end_with?("__in")
38
- p = p.chomp("__in")
39
- v = v.split(",").map { |v| v.in?(NIL_VALUES) ? nil : v }
81
+ sub_fields = @controller.class.field_configuration[root_field][:sub_fields] || []
82
+ if sub_field.in?(sub_fields)
83
+ includes << root_field.to_sym
84
+ next [field, v]
85
+ elsif pred_sub_field && pred_sub_field.in?(sub_fields)
86
+ includes << root_field.to_sym
87
+ field = pred_field
88
+ else
89
+ next nil
90
+ end
91
+ else
92
+ next nil
93
+ end
40
94
  end
41
95
 
42
- # Convert "nil" and "null" to nil.
43
- v = nil if v.in?(NIL_VALUES)
96
+ # If we get here, we must have a predicate, either from a field or a sub-field. Transform the
97
+ # value into a query that can be used in the ActiveRecord `where` API.
98
+ cfg = PREDICATES[predicate.to_sym]
99
+ if cfg.is_a?(Proc)
100
+ pred_queries << cfg.call(field, v)
101
+ else
102
+ pred_queries << {field => cfg}
103
+ end
44
104
 
45
- [p, v]
46
- }.to_h.symbolize_keys
105
+ next nil
106
+ }.compact.to_h.symbolize_keys
47
107
 
48
- return filter_params, includes
108
+ puts "GNS: #{base_query.inspect} #{pred_queries.inspect} #{includes.inspect}"
109
+ return base_query, pred_queries, includes
49
110
  end
50
111
 
51
112
  # Filter data according to the request query parameters.
52
113
  def filter_data(data)
53
- filter_params, includes = self._get_filter_params
114
+ base_query, pred_queries, includes = self._get_query_config
54
115
 
55
- if filter_params.any?
116
+ if base_query.any? || pred_queries.any?
56
117
  if includes.any?
57
118
  data = data.includes(*includes)
58
119
  end
59
120
 
60
- return data.where(**filter_params)
121
+ data = data.where(**base_query) if base_query.any?
122
+
123
+ pred_queries.each do |q|
124
+ if q.is_a?(Not)
125
+ data = data.where.not(q.q)
126
+ else
127
+ data = data.where(q)
128
+ end
129
+ end
61
130
  end
62
131
 
63
132
  return data
@@ -12,6 +12,7 @@ module RESTFramework::Mixins::BaseControllerMixin
12
12
  description: nil,
13
13
  version: nil,
14
14
  inflect_acronyms: ["ID", "IDs", "REST", "API", "APIs"].freeze,
15
+ openapi_include_children: false,
15
16
 
16
17
  # Options related to serialization.
17
18
  rescue_unknown_format_with: :json,
@@ -37,7 +38,7 @@ module RESTFramework::Mixins::BaseControllerMixin
37
38
 
38
39
  # Default action for API root.
39
40
  def root
40
- render_api({message: "This is the API root."})
41
+ render(api: {message: "This is the API root."})
41
42
  end
42
43
 
43
44
  module ClassMethods
@@ -69,6 +70,119 @@ module RESTFramework::Mixins::BaseControllerMixin
69
70
  end
70
71
  end
71
72
  # :nocov:
73
+
74
+ def openapi_response_content_types
75
+ return @openapi_response_content_types ||= [
76
+ "text/html",
77
+ self.serialize_to_json ? "application/json" : nil,
78
+ self.serialize_to_xml ? "application/xml" : nil,
79
+ ].compact
80
+ end
81
+
82
+ def openapi_request_content_types
83
+ return @openapi_request_content_types ||= [
84
+ "application/json",
85
+ "application/x-www-form-urlencoded",
86
+ "multipart/form-data",
87
+ ]
88
+ end
89
+
90
+ def openapi_paths(routes, tag)
91
+ resp_cts = self.openapi_response_content_types
92
+ req_cts = self.openapi_request_content_types
93
+
94
+ return routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
95
+ [
96
+ concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
97
+ routes.map { |route|
98
+ metadata = RESTFramework::ROUTE_METADATA[route[:path]] || {}
99
+ summary = metadata.delete(:label).presence || self.label_for(route[:action])
100
+ description = metadata.delete(:description).presence
101
+ extra_action = RESTFramework::EXTRA_ACTION_ROUTES.include?(route[:path])
102
+ error_response = {"$ref" => "#/components/responses/BadRequest"}
103
+ not_found_response = {"$ref" => "#/components/responses/NotFound"}
104
+ spec = {tags: [tag], summary: summary, description: description}.compact
105
+
106
+ # All routes should have a successful response.
107
+ spec[:responses] = {
108
+ 200 => {content: resp_cts.map { |ct| [ct, {}] }.to_h, description: "Success"},
109
+ }
110
+
111
+ # Builtin POST, PUT, PATCH, and DELETE should have a 400 and 404 response.
112
+ if route[:verb].in?(["POST", "PUT", "PATCH", "DELETE"]) && !extra_action
113
+ spec[:responses][400] = error_response
114
+ spec[:responses][404] = not_found_response
115
+ end
116
+
117
+ # All POST, PUT, PATCH should have a request body.
118
+ if route[:verb].in?(["POST", "PUT", "PATCH"])
119
+ spec[:requestBody] ||= {
120
+ content: req_cts.map { |ct|
121
+ [ct, {}]
122
+ }.to_h,
123
+ }
124
+ end
125
+
126
+ # Add remaining metadata as an extension.
127
+ spec["x-rrf-metadata"] = metadata if metadata.present?
128
+
129
+ next route[:verb].downcase, spec
130
+ }.to_h.merge(
131
+ {
132
+ parameters: routes.first[:route].required_parts.map { |p|
133
+ {
134
+ name: p,
135
+ in: "path",
136
+ required: true,
137
+ schema: {type: "integer"},
138
+ }
139
+ },
140
+ },
141
+ ),
142
+ ]
143
+ }.to_h
144
+ end
145
+
146
+ def openapi_document(request, route_group_name, routes)
147
+ server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")
148
+
149
+ return {
150
+ openapi: "3.1.1",
151
+ info: {
152
+ title: self.get_title,
153
+ description: self.description,
154
+ version: self.version.to_s,
155
+ }.compact,
156
+ servers: [{url: server}],
157
+ paths: self.openapi_paths(routes, route_group_name),
158
+ tags: [{name: route_group_name, description: self.description}.compact],
159
+ components: {
160
+ schemas: {
161
+ "Error" => {
162
+ type: "object",
163
+ required: ["message"],
164
+ properties: {
165
+ message: {type: "string"}, errors: {type: "object"}, exception: {type: "string"}
166
+ },
167
+ },
168
+ },
169
+ responses: {
170
+ "BadRequest": {
171
+ description: "Bad Request",
172
+ content: self.openapi_response_content_types.map { |ct|
173
+ [ct, ct == "text/html" ? {} : {schema: {"$ref" => "#/components/schemas/Error"}}]
174
+ }.to_h,
175
+ },
176
+ "NotFound": {
177
+ description: "Not Found",
178
+ content: self.openapi_response_content_types.map { |ct|
179
+ [ct, ct == "text/html" ? {} : {schema: {"$ref" => "#/components/schemas/Error"}}]
180
+ }.to_h,
181
+ },
182
+ },
183
+ },
184
+ }.compact
185
+ end
72
186
  end
73
187
 
74
188
  def self.included(base)
@@ -105,6 +219,7 @@ module RESTFramework::Mixins::BaseControllerMixin
105
219
  base.rescue_from(
106
220
  ActionController::ParameterMissing,
107
221
  ActionController::UnpermittedParameters,
222
+ ActionDispatch::Http::Parameters::ParseError,
108
223
  ActiveRecord::AssociationTypeMismatch,
109
224
  ActiveRecord::NotNullViolation,
110
225
  ActiveRecord::RecordNotFound,
@@ -118,7 +233,7 @@ module RESTFramework::Mixins::BaseControllerMixin
118
233
  end
119
234
 
120
235
  # Use `TracePoint` hook to automatically call `rrf_finalize`.
121
- unless RESTFramework.config.disable_auto_finalize
236
+ if RESTFramework.config.auto_finalize
122
237
  # :nocov:
123
238
  TracePoint.trace(:end) do |t|
124
239
  next if base != t.self
@@ -152,8 +267,8 @@ module RESTFramework::Mixins::BaseControllerMixin
152
267
  400
153
268
  end
154
269
 
155
- render_api(
156
- {
270
+ render(
271
+ api: {
157
272
  message: e.message,
158
273
  errors: e.try(:record).try(:errors),
159
274
  exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
@@ -239,81 +354,36 @@ module RESTFramework::Mixins::BaseControllerMixin
239
354
  end
240
355
  end
241
356
 
242
- # TODO: Might make this the default render method in the future.
357
+ # Compatibility alias for deprecated `api_response`.
243
358
  alias_method :api_response, :render_api
244
359
 
245
- def openapi_metadata
246
- response_content_types = [
247
- "text/html",
248
- self.class.serialize_to_json ? "application/json" : nil,
249
- self.class.serialize_to_xml ? "application/xml" : nil,
250
- ].compact
251
- request_content_types = [
252
- "application/json",
253
- "application/xml",
254
- "application/x-www-form-urlencoded",
255
- "multipart/form-data",
256
- ].compact
257
- routes = self.route_groups.values[0]
258
- server = request.base_url + request.original_fullpath.gsub(/\?.*/, "")
259
-
260
- return {
261
- openapi: "3.1.1",
262
- info: {
263
- title: self.class.get_title,
264
- description: self.class.description,
265
- version: self.class.version.to_s,
266
- }.compact,
267
- servers: [{url: server}],
268
- paths: routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
269
- [
270
- concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
271
- routes.map { |route|
272
- metadata = route[:route].defaults[:metadata] || {}
273
- summary = metadata[:label].presence || self.class.label_for(route[:action])
274
- description = metadata[:description].presence
275
- remaining_metadata = metadata.except(:label, :description).presence
276
-
277
- [
278
- route[:verb].downcase,
279
- {
280
- summary: summary,
281
- description: description,
282
- responses: {
283
- 200 => {
284
- content: response_content_types.map { |ct|
285
- [ct, {}]
286
- }.to_h,
287
- description: "",
288
- },
289
- },
290
- requestBody: route[:verb].in?(["GET", "DELETE", "OPTIONS", "TRACE"]) ? nil : {
291
- content: request_content_types.map { |ct|
292
- [ct, {}]
293
- }.to_h,
294
- },
295
- "x-rrf-metadata": remaining_metadata,
296
- }.compact,
297
- ]
298
- }.to_h.merge(
299
- {
300
- parameters: routes.first[:route].required_parts.map { |p|
301
- {
302
- name: p,
303
- in: "path",
304
- required: true,
305
- schema: {type: "integer"},
306
- }
307
- },
308
- },
309
- ),
310
- ]
311
- }.to_h,
312
- }.compact
360
+ def openapi_document
361
+ first, *rest = self.route_groups.to_a
362
+ document = self.class.openapi_document(request, *first)
363
+
364
+ if self.class.openapi_include_children
365
+ rest.each do |route_group_name, routes|
366
+ controller = "#{routes.first[:route].defaults[:controller]}_controller".camelize.constantize
367
+ child_document = controller.openapi_document(request, route_group_name, routes)
368
+
369
+ # Merge child paths and tags into the parent document.
370
+ document[:paths].merge!(child_document[:paths])
371
+ document[:tags] += child_document[:tags]
372
+
373
+ # If the child document has schemas, merge them into the parent document.
374
+ if schemas = child_document.dig(:components, :schemas) # rubocop:disable Style/Next
375
+ document[:components] ||= {}
376
+ document[:components][:schemas] ||= {}
377
+ document[:components][:schemas].merge!(schemas)
378
+ end
379
+ end
380
+ end
381
+
382
+ return document
313
383
  end
314
384
 
315
385
  def options
316
- render_api(self.openapi_metadata)
386
+ render(api: self.openapi_document)
317
387
  end
318
388
  end
319
389
 
@@ -6,7 +6,7 @@ module RESTFramework::Mixins::BulkCreateModelMixin
6
6
  # While bulk update/destroy are obvious because they create new router endpoints, bulk create
7
7
  # overloads the existing collection `POST` endpoint, so we add a special key to the OpenAPI
8
8
  # metadata to indicate bulk create is supported.
9
- def openapi_metadata
9
+ def openapi_document
10
10
  return super.merge({"x-rrf-bulk-create": true})
11
11
  end
12
12
 
@@ -14,7 +14,7 @@ module RESTFramework::Mixins::BulkCreateModelMixin
14
14
  if params[:_json].is_a?(Array)
15
15
  records = self.create_all!
16
16
  serialized_records = self.bulk_serialize(records)
17
- return render_api(serialized_records)
17
+ return render(api: serialized_records)
18
18
  end
19
19
 
20
20
  return super
@@ -36,7 +36,7 @@ module RESTFramework::Mixins::BulkUpdateModelMixin
36
36
  def update_all
37
37
  records = self.update_all!
38
38
  serialized_records = self.bulk_serialize(records)
39
- render_api(serialized_records)
39
+ render(api: serialized_records)
40
40
  end
41
41
 
42
42
  # Perform the `update` call and return the collection of (possibly) updated records.
@@ -62,11 +62,11 @@ module RESTFramework::Mixins::BulkDestroyModelMixin
62
62
  if params[:_json].is_a?(Array)
63
63
  records = self.destroy_all!
64
64
  serialized_records = self.bulk_serialize(records)
65
- return render_api(serialized_records)
65
+ return render(api: serialized_records)
66
66
  end
67
67
 
68
- render_api(
69
- {message: "Bulk destroy requires an array of primary keys as input."},
68
+ render(
69
+ api: {message: "Bulk destroy requires an array of primary keys as input."},
70
70
  status: 400,
71
71
  )
72
72
  end
@@ -339,6 +339,71 @@ module RESTFramework::Mixins::BaseModelControllerMixin
339
339
  return @openapi_schema
340
340
  end
341
341
 
342
+ def openapi_schema_name
343
+ return @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
344
+ end
345
+
346
+ def openapi_paths(_routes, tag)
347
+ paths = super
348
+ schema_name = self.openapi_schema_name
349
+
350
+ # Reference the model schema for request body and successful default responses.
351
+ paths.each do |_path, actions|
352
+ actions.each do |method, action|
353
+ next unless action.is_a?(Hash)
354
+
355
+ extra_action = action.dig("x-rrf-metadata", :extra_action)
356
+
357
+ # Adjustments for builtin actions:
358
+ if !extra_action && method != "options" # rubocop:disable Style/Next
359
+ # Add schema to request body content types.
360
+ action.dig(:requestBody, :content)&.each do |_t, v|
361
+ v[:schema] = {"$ref" => "#/components/schemas/#{schema_name}"}
362
+ end
363
+
364
+ # Add schema to successful response body content types.
365
+ action[:responses].each do |status, response|
366
+ next unless status.to_s.start_with?("2")
367
+
368
+ response[:content]&.each do |t, v|
369
+ next if t == "text/html"
370
+
371
+ v[:schema] = {"$ref" => "#/components/schemas/#{schema_name}"}
372
+ end
373
+ end
374
+
375
+ # Translate 200->201 for the create action.
376
+ if action[:summary] == "Create"
377
+ action[:responses][201] = action[:responses].delete(200)
378
+ end
379
+
380
+ # Translate 200->204 for the destroy action.
381
+ if action[:summary] == "Destroy"
382
+ action[:responses][204] = action[:responses].delete(200)
383
+ end
384
+ end
385
+ end
386
+ end
387
+
388
+ return paths
389
+ end
390
+
391
+ def openapi_document(request, route_group_name, _routes)
392
+ document = super
393
+
394
+ # Insert schema into the document.
395
+ document[:components] ||= {}
396
+ document[:components][:schemas] ||= {}
397
+ document[:components][:schemas][self.openapi_schema_name] = self.openapi_schema
398
+
399
+ return document.merge(
400
+ {
401
+ "x-rrf-primary_key" => self.get_model.primary_key,
402
+ "x-rrf-callbacks" => self._process_action_callbacks.as_json,
403
+ },
404
+ )
405
+ end
406
+
342
407
  def setup_delegation
343
408
  # Delegate extra actions.
344
409
  self.extra_actions&.each do |action, config|
@@ -349,9 +414,9 @@ module RESTFramework::Mixins::BaseModelControllerMixin
349
414
  model = self.class.get_model
350
415
 
351
416
  if model.method(action).parameters.last&.first == :keyrest
352
- render_api(model.send(action, **params))
417
+ render(api: model.send(action, **params))
353
418
  else
354
- render_api(model.send(action))
419
+ render(api: model.send(action))
355
420
  end
356
421
  end
357
422
  end
@@ -365,9 +430,9 @@ module RESTFramework::Mixins::BaseModelControllerMixin
365
430
  record = self.get_record
366
431
 
367
432
  if record.method(action).parameters.last&.first == :keyrest
368
- render_api(record.send(action, **params))
433
+ render(api: record.send(action, **params))
369
434
  else
370
- render_api(record.send(action))
435
+ render(api: record.send(action))
371
436
  end
372
437
  end
373
438
  end
@@ -409,40 +474,6 @@ module RESTFramework::Mixins::BaseModelControllerMixin
409
474
  return self.class.get_fields(input_fields: self.class.fields)
410
475
  end
411
476
 
412
- def openapi_metadata
413
- data = super
414
- routes = self.route_groups.values[0]
415
- schema_name = routes[0][:controller].camelize.gsub("::", ".")
416
-
417
- # Insert schema into metadata.
418
- data[:components] ||= {}
419
- data[:components][:schemas] ||= {}
420
- data[:components][:schemas][schema_name] = self.class.openapi_schema
421
-
422
- # Reference schema for specific actions with a `requestBody`.
423
- data[:paths].each do |_path, actions|
424
- actions.each do |_method, action|
425
- next unless action.is_a?(Hash)
426
-
427
- injectables = [action.dig(:requestBody, :content), *action[:responses].values.map { |r|
428
- r[:content]
429
- }].compact
430
- injectables.each do |i|
431
- i.each do |_, v|
432
- v[:schema] = {"$ref" => "#/components/schemas/#{schema_name}"}
433
- end
434
- end
435
- end
436
- end
437
-
438
- return data.merge(
439
- {
440
- "x-rrf-primary_key" => self.class.get_model.primary_key,
441
- "x-rrf-callbacks" => self._process_action_callbacks.as_json,
442
- },
443
- )
444
- end
445
-
446
477
  # Get a hash of strong parameters for the current action.
447
478
  def get_allowed_parameters
448
479
  return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
@@ -691,7 +722,7 @@ end
691
722
  # Mixin for listing records.
692
723
  module RESTFramework::Mixins::ListModelMixin
693
724
  def index
694
- render_api(self.get_index_records)
725
+ render(api: self.get_index_records)
695
726
  end
696
727
 
697
728
  # Get records with both filtering and pagination applied.
@@ -719,14 +750,14 @@ end
719
750
  # Mixin for showing records.
720
751
  module RESTFramework::Mixins::ShowModelMixin
721
752
  def show
722
- render_api(self.get_record)
753
+ render(api: self.get_record)
723
754
  end
724
755
  end
725
756
 
726
757
  # Mixin for creating records.
727
758
  module RESTFramework::Mixins::CreateModelMixin
728
759
  def create
729
- render_api(self.create!, status: :created)
760
+ render(api: self.create!, status: :created)
730
761
  end
731
762
 
732
763
  # Perform the `create!` call and return the created record.
@@ -738,7 +769,7 @@ end
738
769
  # Mixin for updating records.
739
770
  module RESTFramework::Mixins::UpdateModelMixin
740
771
  def update
741
- render_api(self.update!)
772
+ render(api: self.update!)
742
773
  end
743
774
 
744
775
  # Perform the `update!` call and return the updated record.
@@ -753,7 +784,7 @@ end
753
784
  module RESTFramework::Mixins::DestroyModelMixin
754
785
  def destroy
755
786
  self.destroy!
756
- render_api("")
787
+ render(api: "")
757
788
  end
758
789
 
759
790
  # Perform the `destroy!` call and return the destroyed (and frozen) record.
@@ -51,9 +51,16 @@ module ActionDispatch::Routing
51
51
  parsed_actions = RESTFramework::Utils.parse_extra_actions(actions)
52
52
 
53
53
  parsed_actions.each do |action, config|
54
- [config[:methods]].flatten.each do |m|
55
- public_send(m, config[:path], action: action, **(config[:kwargs] || {}))
54
+ config[:methods].each do |m|
55
+ public_send(m, config[:path], action: action, **config[:kwargs])
56
56
  end
57
+
58
+ # Record that this route is an extra action and any metadata associated with it.
59
+ metadata = config[:metadata]
60
+ key = "#{@scope[:path]}/#{config[:path]}"
61
+ RESTFramework::EXTRA_ACTION_ROUTES.add(key)
62
+ RESTFramework::ROUTE_METADATA[key] = metadata if metadata
63
+
57
64
  yield if block_given?
58
65
  end
59
66
  end
@@ -1,49 +1,32 @@
1
1
  module RESTFramework::Utils
2
2
  HTTP_VERB_ORDERING = %w(GET POST PUT PATCH DELETE OPTIONS HEAD)
3
3
 
4
- # Convert `extra_actions` hash to a consistent format: `{path:, methods:, kwargs:}`.
5
- #
6
- # If a controller is provided, labels will be added to any metadata fields.
7
- def self.parse_extra_actions(extra_actions, controller: nil)
4
+ # Convert `extra_actions` hash to a consistent format: `{path:, methods:, metadata:, kwargs:}`.
5
+ def self.parse_extra_actions(extra_actions)
8
6
  return (extra_actions || {}).map { |k, v|
9
7
  path = k
8
+ kwargs = {}
10
9
 
11
10
  # Convert structure to path/methods/kwargs.
12
- if v.is_a?(Hash) # Allow kwargs to be used to define path differently from the key.
11
+ if v.is_a?(Hash)
13
12
  # Symbolize keys (which also makes a copy so we don't mutate the original).
14
13
  v = v.symbolize_keys
15
- methods = v.delete(:methods)
16
- if v.key?(:method)
17
- methods = v.delete(:method)
18
- end
19
14
 
20
- # Add label to metadata fields, if any exist.
21
- metadata = v[:metadata]
22
- if controller && metadata&.key?(:fields)
23
- metadata[:fields] = metadata[:fields].map { |f|
24
- [f, {}]
25
- }.to_h if metadata[:fields].is_a?(Array)
26
- metadata[:fields]&.each do |field, cfg|
27
- cfg[:label] = controller.label_for(field) unless cfg[:label]
28
- end
29
- end
15
+ # Cast method/methods to an array.
16
+ methods = [v.delete(:methods), v.delete(:method)].flatten.compact
30
17
 
31
18
  # Override path if it's provided.
32
19
  if v.key?(:path)
33
20
  path = v.delete(:path)
34
21
  end
35
22
 
23
+ # Extract metadata, if provided.
24
+ metadata = v.delete(:metadata).presence
25
+
36
26
  # Pass any further kwargs to the underlying Rails interface.
37
- kwargs = v.presence
38
- elsif v.is_a?(Array) && v.length == 1
39
- methods = v[0]
27
+ kwargs = v
40
28
  else
41
- methods = v
42
- end
43
-
44
- # Insert action label if it's not provided.
45
- if controller
46
- metadata[:label] ||= controller.label_for(k)
29
+ methods = [v].flatten
47
30
  end
48
31
 
49
32
  next [
@@ -51,6 +34,7 @@ module RESTFramework::Utils
51
34
  {
52
35
  path: path,
53
36
  methods: methods,
37
+ metadata: metadata,
54
38
  kwargs: kwargs,
55
39
  }.compact,
56
40
  ]
@@ -139,7 +123,9 @@ module RESTFramework::Utils
139
123
  ]
140
124
  }.group_by { |r| r[:controller] }.sort_by { |c, _r|
141
125
  # Sort the controller groups by current controller first, then alphanumerically.
142
- [request.params[:controller] == c ? 0 : 1, c]
126
+ # Note: Use `controller_path` instead of `params[:controller]` to avoid re-raising a
127
+ # `ActionDispatch::Http::Parameters::ParseError` exception.
128
+ [request.controller_class.controller_path == c ? 0 : 1, c]
143
129
  }.to_h
144
130
  end
145
131
 
@@ -20,6 +20,10 @@ module RESTFramework
20
20
  destroy_all: :delete,
21
21
  }.freeze
22
22
 
23
+ # Storage for extra routes and associated metadata.
24
+ EXTRA_ACTION_ROUTES = Set.new
25
+ ROUTE_METADATA = {}
26
+
23
27
  # We put most vendored external assets into these files to make precompilation and serving faster.
24
28
  EXTERNAL_CSS_NAME = "rest_framework/external.min.css"
25
29
  EXTERNAL_JS_NAME = "rest_framework/external.min.js"
@@ -136,15 +140,18 @@ module RESTFramework
136
140
  authenticity_token
137
141
  ].freeze
138
142
 
139
- # Do not run `rrf_finalize` on controllers automatically using a `TracePoint` hook. This is a
140
- # performance option and must be global because we have to determine this before any
141
- # controller-specific configuration is set. If this is set to `true`, then you must manually
142
- # call `rrf_finalize` after any configuration on each controller that needs to participate
143
- # in:
143
+ # Permits use of `render(api: obj)` syntax over `render_api(obj)`; `true` by default.
144
+ attr_accessor :register_api_renderer
145
+
146
+ # Run `rrf_finalize` on controllers automatically using a `TracePoint` hook. This is `true` by
147
+ # default, and can be disabled for performance, and must be global because we have to determine
148
+ # this before any controller-specific configuration is set. If this is set to `false`, then you
149
+ # must manually call `rrf_finalize` after any configuration on each controller that needs to
150
+ # participate in:
144
151
  # - Model delegation, for the helper methods to be defined dynamically.
145
152
  # - Websockets, for `::Channel` class to be defined dynamically.
146
153
  # - Controller configuration freezing.
147
- attr_accessor :disable_auto_finalize
154
+ attr_accessor :auto_finalize
148
155
 
149
156
  # Freeze configuration attributes during finalization to prevent accidental mutation.
150
157
  attr_accessor :freeze_config
@@ -173,7 +180,11 @@ module RESTFramework
173
180
  attr_accessor :use_vendored_assets
174
181
 
175
182
  def initialize
183
+ self.register_api_renderer = true
184
+ self.auto_finalize = true
185
+
176
186
  self.show_backtrace = Rails.env.development?
187
+
177
188
  self.label_fields = DEFAULT_LABEL_FIELDS
178
189
  self.search_columns = DEFAULT_SEARCH_COLUMNS
179
190
  self.exclude_body_fields = DEFAULT_EXCLUDE_BODY_FIELDS
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: 1.0.0.beta1
4
+ version: 1.0.0.rc1
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: 2024-12-25 00:00:00.000000000 Z
11
+ date: 2025-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails