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 +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 +144 -74
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +6 -6
- data/lib/rest_framework/mixins/model_controller_mixin.rb +74 -43
- 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
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
357
|
+
# Compatibility alias for deprecated `api_response`.
|
243
358
|
alias_method :api_response, :render_api
|
244
359
|
|
245
|
-
def
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
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
|
-
|
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
|
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
|
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,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
|
-
|
417
|
+
render(api: model.send(action, **params))
|
353
418
|
else
|
354
|
-
|
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
|
-
|
433
|
+
render(api: record.send(action, **params))
|
369
434
|
else
|
370
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|