rest_framework 1.0.0.beta2 → 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: acc3d4ff5a9d7669942f21eb7f3ecec06417c83322be936e1b862b1346e9bb14
4
- data.tar.gz: 53ddfa6b5a46338b174ec67742d6019d5d005231eb492b0af30ed7232a2d22ce
3
+ metadata.gz: 12d9337d2e8abbdde98e25233fc2cd9d1b3596b0f65a8a5b3210eee5d5a571bd
4
+ data.tar.gz: e77d714a217b1ecc6ab24cdab384d7ce82d2680775e15e701fa4f1c2b2e8d88d
5
5
  SHA512:
6
- metadata.gz: a93ccb739766ae11c9b0be6b52e60c698c72793cd109c1efbaf89eaac3f10b56a7bf11fd9e90b77ccc7c148aaa8436e51f73530f3ed3442f58ee93934fa37494
7
- data.tar.gz: 616ebaac8b55a68a38fc8d29f6cedf764403990bc057bf9f6d8c92e672764a4f23189a3acca1e803b1d34df70731d28729744380cc90e9b7ec49f133c258d0de
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.beta2
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
@@ -38,7 +38,7 @@ module RESTFramework::Mixins::BaseControllerMixin
38
38
 
39
39
  # Default action for API root.
40
40
  def root
41
- render_api({message: "This is the API root."})
41
+ render(api: {message: "This is the API root."})
42
42
  end
43
43
 
44
44
  module ClassMethods
@@ -71,50 +71,62 @@ module RESTFramework::Mixins::BaseControllerMixin
71
71
  end
72
72
  # :nocov:
73
73
 
74
- def openapi_paths(routes, tag)
75
- response_content_types = [
74
+ def openapi_response_content_types
75
+ return @openapi_response_content_types ||= [
76
76
  "text/html",
77
77
  self.serialize_to_json ? "application/json" : nil,
78
78
  self.serialize_to_xml ? "application/xml" : nil,
79
79
  ].compact
80
- request_content_types = [
80
+ end
81
+
82
+ def openapi_request_content_types
83
+ return @openapi_request_content_types ||= [
81
84
  "application/json",
82
- "application/xml",
83
85
  "application/x-www-form-urlencoded",
84
86
  "multipart/form-data",
85
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
86
93
 
87
94
  return routes.group_by { |r| r[:concat_path] }.map { |concat_path, routes|
88
95
  [
89
96
  concat_path.gsub(/:([0-9A-Za-z_-]+)/, "{\\1}"),
90
97
  routes.map { |route|
91
- metadata = route[:route].defaults[:metadata] || {}
92
- summary = metadata[:label].presence || self.label_for(route[:action])
93
- description = metadata[:description].presence
94
- remaining_metadata = metadata.except(:label, :description).presence
95
-
96
- [
97
- route[:verb].downcase,
98
- {
99
- tags: [tag],
100
- summary: summary,
101
- description: description,
102
- responses: {
103
- 200 => {
104
- content: response_content_types.map { |ct|
105
- [ct, {}]
106
- }.to_h,
107
- description: "",
108
- },
109
- },
110
- requestBody: route[:verb].in?(["GET", "DELETE", "OPTIONS", "TRACE"]) ? nil : {
111
- content: request_content_types.map { |ct|
112
- [ct, {}]
113
- }.to_h,
114
- },
115
- "x-rrf-metadata": remaining_metadata,
116
- }.compact,
117
- ]
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
118
130
  }.to_h.merge(
119
131
  {
120
132
  parameters: routes.first[:route].required_parts.map { |p|
@@ -144,6 +156,31 @@ module RESTFramework::Mixins::BaseControllerMixin
144
156
  servers: [{url: server}],
145
157
  paths: self.openapi_paths(routes, route_group_name),
146
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
+ },
147
184
  }.compact
148
185
  end
149
186
  end
@@ -182,6 +219,7 @@ module RESTFramework::Mixins::BaseControllerMixin
182
219
  base.rescue_from(
183
220
  ActionController::ParameterMissing,
184
221
  ActionController::UnpermittedParameters,
222
+ ActionDispatch::Http::Parameters::ParseError,
185
223
  ActiveRecord::AssociationTypeMismatch,
186
224
  ActiveRecord::NotNullViolation,
187
225
  ActiveRecord::RecordNotFound,
@@ -195,7 +233,7 @@ module RESTFramework::Mixins::BaseControllerMixin
195
233
  end
196
234
 
197
235
  # Use `TracePoint` hook to automatically call `rrf_finalize`.
198
- unless RESTFramework.config.disable_auto_finalize
236
+ if RESTFramework.config.auto_finalize
199
237
  # :nocov:
200
238
  TracePoint.trace(:end) do |t|
201
239
  next if base != t.self
@@ -229,8 +267,8 @@ module RESTFramework::Mixins::BaseControllerMixin
229
267
  400
230
268
  end
231
269
 
232
- render_api(
233
- {
270
+ render(
271
+ api: {
234
272
  message: e.message,
235
273
  errors: e.try(:record).try(:errors),
236
274
  exception: RESTFramework.config.show_backtrace ? e.full_message : nil,
@@ -345,7 +383,7 @@ module RESTFramework::Mixins::BaseControllerMixin
345
383
  end
346
384
 
347
385
  def options
348
- render_api(self.openapi_document)
386
+ render(api: self.openapi_document)
349
387
  end
350
388
  end
351
389
 
@@ -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,31 +339,63 @@ module RESTFramework::Mixins::BaseModelControllerMixin
339
339
  return @openapi_schema
340
340
  end
341
341
 
342
- def openapi_document(request, route_group_name, routes)
343
- document = super
344
- schema_name = routes[0][:controller].camelize.gsub("::", ".")
342
+ def openapi_schema_name
343
+ return @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
344
+ end
345
345
 
346
- # Insert schema into the document.
347
- document[:components] ||= {}
348
- document[:components][:schemas] ||= {}
349
- document[:components][:schemas][schema_name] = self.openapi_schema
346
+ def openapi_paths(_routes, tag)
347
+ paths = super
348
+ schema_name = self.openapi_schema_name
350
349
 
351
- # Reference schema for specific actions with a `requestBody`.
352
- document[:paths].each do |_path, actions|
353
- actions.each do |_method, action|
350
+ # Reference the model schema for request body and successful default responses.
351
+ paths.each do |_path, actions|
352
+ actions.each do |method, action|
354
353
  next unless action.is_a?(Hash)
355
354
 
356
- injectables = [action.dig(:requestBody, :content), *action[:responses].values.map { |r|
357
- r[:content]
358
- }].compact
359
- injectables.each do |i|
360
- i.each do |_, v|
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
361
  v[:schema] = {"$ref" => "#/components/schemas/#{schema_name}"}
362
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
363
384
  end
364
385
  end
365
386
  end
366
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
+
367
399
  return document.merge(
368
400
  {
369
401
  "x-rrf-primary_key" => self.get_model.primary_key,
@@ -382,9 +414,9 @@ module RESTFramework::Mixins::BaseModelControllerMixin
382
414
  model = self.class.get_model
383
415
 
384
416
  if model.method(action).parameters.last&.first == :keyrest
385
- render_api(model.send(action, **params))
417
+ render(api: model.send(action, **params))
386
418
  else
387
- render_api(model.send(action))
419
+ render(api: model.send(action))
388
420
  end
389
421
  end
390
422
  end
@@ -398,9 +430,9 @@ module RESTFramework::Mixins::BaseModelControllerMixin
398
430
  record = self.get_record
399
431
 
400
432
  if record.method(action).parameters.last&.first == :keyrest
401
- render_api(record.send(action, **params))
433
+ render(api: record.send(action, **params))
402
434
  else
403
- render_api(record.send(action))
435
+ render(api: record.send(action))
404
436
  end
405
437
  end
406
438
  end
@@ -690,7 +722,7 @@ end
690
722
  # Mixin for listing records.
691
723
  module RESTFramework::Mixins::ListModelMixin
692
724
  def index
693
- render_api(self.get_index_records)
725
+ render(api: self.get_index_records)
694
726
  end
695
727
 
696
728
  # Get records with both filtering and pagination applied.
@@ -718,14 +750,14 @@ end
718
750
  # Mixin for showing records.
719
751
  module RESTFramework::Mixins::ShowModelMixin
720
752
  def show
721
- render_api(self.get_record)
753
+ render(api: self.get_record)
722
754
  end
723
755
  end
724
756
 
725
757
  # Mixin for creating records.
726
758
  module RESTFramework::Mixins::CreateModelMixin
727
759
  def create
728
- render_api(self.create!, status: :created)
760
+ render(api: self.create!, status: :created)
729
761
  end
730
762
 
731
763
  # Perform the `create!` call and return the created record.
@@ -737,7 +769,7 @@ end
737
769
  # Mixin for updating records.
738
770
  module RESTFramework::Mixins::UpdateModelMixin
739
771
  def update
740
- render_api(self.update!)
772
+ render(api: self.update!)
741
773
  end
742
774
 
743
775
  # Perform the `update!` call and return the updated record.
@@ -752,7 +784,7 @@ end
752
784
  module RESTFramework::Mixins::DestroyModelMixin
753
785
  def destroy
754
786
  self.destroy!
755
- render_api("")
787
+ render(api: "")
756
788
  end
757
789
 
758
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.beta2
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-26 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