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 +4 -4
- data/README.md +28 -24
- data/VERSION +1 -1
- data/lib/rest_framework/engine.rb +6 -0
- data/lib/rest_framework/filters/query_filter.rb +100 -31
- data/lib/rest_framework/mixins/base_controller_mixin.rb +74 -36
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +5 -5
- data/lib/rest_framework/mixins/model_controller_mixin.rb +56 -24
- data/lib/rest_framework/routers.rb +9 -2
- data/lib/rest_framework/utils.rb +15 -29
- data/lib/rest_framework.rb +17 -6
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12d9337d2e8abbdde98e25233fc2cd9d1b3596b0f65a8a5b3210eee5d5a571bd
|
4
|
+
data.tar.gz: e77d714a217b1ecc6ab24cdab384d7ce82d2680775e15e701fa4f1c2b2e8d88d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
11
|
-
|
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,
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
131
|
-
These routers add some features to the Rails builtin `resource`/`resources` routers, such
|
132
|
-
To route the root, use
|
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
|
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
|
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.
|
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
|
-
|
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
|
-
#
|
12
|
-
|
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
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
if
|
22
|
-
|
23
|
-
next
|
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
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
32
|
-
|
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
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
#
|
43
|
-
|
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
|
-
|
46
|
-
}.to_h.symbolize_keys
|
105
|
+
next nil
|
106
|
+
}.compact.to_h.symbolize_keys
|
47
107
|
|
48
|
-
|
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
|
-
|
114
|
+
base_query, pred_queries, includes = self._get_query_config
|
54
115
|
|
55
|
-
if
|
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
|
-
|
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
|
-
|
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
|
75
|
-
|
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
|
-
|
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 =
|
92
|
-
summary = metadata
|
93
|
-
description = metadata
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
}
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
65
|
+
return render(api: serialized_records)
|
66
66
|
end
|
67
67
|
|
68
|
-
|
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
|
343
|
-
|
344
|
-
|
342
|
+
def openapi_schema_name
|
343
|
+
return @openapi_schema_name ||= self.name.chomp("Controller").gsub("::", ".")
|
344
|
+
end
|
345
345
|
|
346
|
-
|
347
|
-
|
348
|
-
|
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
|
352
|
-
|
353
|
-
actions.each do |
|
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
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
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
|
-
|
417
|
+
render(api: model.send(action, **params))
|
386
418
|
else
|
387
|
-
|
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
|
-
|
433
|
+
render(api: record.send(action, **params))
|
402
434
|
else
|
403
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
55
|
-
public_send(m, config[:path], action: action, **
|
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
|
data/lib/rest_framework/utils.rb
CHANGED
@@ -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)
|
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
|
-
#
|
21
|
-
|
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
|
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
|
-
|
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
|
|
data/lib/rest_framework.rb
CHANGED
@@ -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
|
-
#
|
140
|
-
|
141
|
-
|
142
|
-
#
|
143
|
-
#
|
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 :
|
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.
|
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:
|
11
|
+
date: 2025-04-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|