rest_framework 1.0.0.beta2 → 1.0.0
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 +32 -24
- data/VERSION +1 -1
- data/lib/rest_framework/engine.rb +6 -0
- data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +1 -1
- data/lib/rest_framework/errors/unknown_model_error.rb +1 -1
- data/lib/rest_framework/filters/ordering_filter.rb +3 -3
- data/lib/rest_framework/filters/query_filter.rb +101 -33
- data/lib/rest_framework/filters/ransack_filter.rb +1 -1
- data/lib/rest_framework/filters/search_filter.rb +3 -3
- data/lib/rest_framework/generators/controller_generator.rb +2 -2
- data/lib/rest_framework/mixins/base_controller_mixin.rb +90 -51
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +12 -19
- data/lib/rest_framework/mixins/model_controller_mixin.rb +95 -56
- data/lib/rest_framework/paginators/page_number_paginator.rb +6 -6
- data/lib/rest_framework/routers.rb +16 -9
- data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +4 -2
- data/lib/rest_framework/serializers/base_serializer.rb +7 -5
- data/lib/rest_framework/serializers/native_serializer.rb +22 -22
- data/lib/rest_framework/utils.rb +33 -47
- data/lib/rest_framework/version.rb +1 -1
- data/lib/rest_framework.rb +26 -15
- metadata +5 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 90b0053684520db23e669f2badfea90f4bc138106f79cfece16e17fc4ef449f0
|
4
|
+
data.tar.gz: 03a2d6ec4d1178fca154641ca6522df27592fa2b32b7024de76ceec827d23a8f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bb23fb3f071a17bbc1aaf2f8c5b8571e47a46a8faeeeb43f851dc64d60d422b7a12ba924200e5ec4124b5b75fe06f868a8ee623c97345753f7d1dc3d5244c16d
|
7
|
+
data.tar.gz: 2828fe0ca8cf5415c781f09da370dc1e92e5408b21e0d77650151aa1d9234e0a31e1e4fb8db2737166acdeba4166b79f42659086fdbd0dcf6bd80150cfb73f11
|
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,13 @@ 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.
|
153
|
+
|
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 console`). For development, use `bin/dev` to run the
|
156
|
+
web server and the job queue, which serves the test app and coverage/brakeman reports:
|
151
157
|
|
152
|
-
|
158
|
+
- Test App: [http://127.0.0.1:3000](http://127.0.0.1:3000)
|
159
|
+
- API: [http://127.0.0.1:3000/api](http://127.0.0.1:3000/api)
|
160
|
+
- Reports: [http://127.0.0.1:3000/reports](http://127.0.0.1:3000/reports)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.0.0
|
1
|
+
1.0.0
|
@@ -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,6 @@
|
|
1
1
|
class RESTFramework::Errors::NilPassedToRenderAPIError < RESTFramework::Errors::BaseError
|
2
2
|
def message
|
3
|
-
|
3
|
+
<<~MSG.split("\n").join(" ")
|
4
4
|
Payload of `nil` was passed to `render_api`; this is unsupported. If you want a blank
|
5
5
|
response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
|
6
6
|
(or similar Active Record method) not finding a record, you should use the bang version (e.g.,
|
@@ -5,7 +5,7 @@ class RESTFramework::Errors::UnknownModelError < RESTFramework::Errors::BaseErro
|
|
5
5
|
end
|
6
6
|
|
7
7
|
def message
|
8
|
-
|
8
|
+
<<~MSG.split("\n").join(" ")
|
9
9
|
The model class for `#{@controller_class}` could not be determined. Any controller that
|
10
10
|
includes `RESTFramework::BaseModelControllerMixin` (directly or indirectly) must either set
|
11
11
|
the `model` attribute on the controller, or the model must be deducible from the controller
|
@@ -2,7 +2,7 @@
|
|
2
2
|
class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilter
|
3
3
|
# Get a list of ordering fields for the current action.
|
4
4
|
def _get_fields
|
5
|
-
|
5
|
+
@controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
|
6
6
|
end
|
7
7
|
|
8
8
|
# Convert ordering string to an ordering configuration.
|
@@ -34,7 +34,7 @@ class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilte
|
|
34
34
|
return ordering
|
35
35
|
end
|
36
36
|
|
37
|
-
|
37
|
+
nil
|
38
38
|
end
|
39
39
|
|
40
40
|
# Order data according to the request query parameters.
|
@@ -46,7 +46,7 @@ class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilte
|
|
46
46
|
return data.send(reorder ? :reorder : :order, ordering)
|
47
47
|
end
|
48
48
|
|
49
|
-
|
49
|
+
data
|
50
50
|
end
|
51
51
|
end
|
52
52
|
|
@@ -1,66 +1,134 @@
|
|
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
|
7
35
|
# Always return a list of strings; `@controller.get_fields` already does this.
|
8
|
-
|
36
|
+
@controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
|
37
|
+
end
|
38
|
+
|
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
|
9
49
|
end
|
10
50
|
|
11
|
-
# Filter params for keys allowed by the current action's filter_fields/fields config
|
12
|
-
|
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
|
-
return
|
108
|
+
return base_query, pred_queries, includes
|
49
109
|
end
|
50
110
|
|
51
111
|
# Filter data according to the request query parameters.
|
52
112
|
def filter_data(data)
|
53
|
-
|
113
|
+
base_query, pred_queries, includes = self._get_query_config
|
54
114
|
|
55
|
-
if
|
115
|
+
if base_query.any? || pred_queries.any?
|
56
116
|
if includes.any?
|
57
117
|
data = data.includes(*includes)
|
58
118
|
end
|
59
119
|
|
60
|
-
|
120
|
+
data = data.where(**base_query) if base_query.any?
|
121
|
+
|
122
|
+
pred_queries.each do |q|
|
123
|
+
if q.is_a?(Not)
|
124
|
+
data = data.where.not(q.q)
|
125
|
+
else
|
126
|
+
data = data.where(q)
|
127
|
+
end
|
128
|
+
end
|
61
129
|
end
|
62
130
|
|
63
|
-
|
131
|
+
data
|
64
132
|
end
|
65
133
|
end
|
66
134
|
|
@@ -6,7 +6,7 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
|
|
6
6
|
end
|
7
7
|
|
8
8
|
columns = @controller.class.get_model.column_names
|
9
|
-
|
9
|
+
@controller.get_fields.select { |f|
|
10
10
|
f.in?(RESTFramework.config.search_columns) && f.in?(columns)
|
11
11
|
}
|
12
12
|
end
|
@@ -30,12 +30,12 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
|
|
30
30
|
fields.map { |f|
|
31
31
|
"CAST(#{f} AS #{data_type}) #{@controller.class.search_ilike ? "ILIKE" : "LIKE"} ?"
|
32
32
|
}.join(" OR "),
|
33
|
-
*(["%#{search}%"] * fields.length),
|
33
|
+
*([ "%#{search}%" ] * fields.length),
|
34
34
|
)
|
35
35
|
end
|
36
36
|
end
|
37
37
|
|
38
|
-
|
38
|
+
data
|
39
39
|
end
|
40
40
|
end
|
41
41
|
|
@@ -5,7 +5,7 @@ require "rails/generators"
|
|
5
5
|
# :nocov:
|
6
6
|
class RESTFrameworkCustomGeneratorControllerNamespace < String
|
7
7
|
def camelize
|
8
|
-
|
8
|
+
"RESTFramework"
|
9
9
|
end
|
10
10
|
end
|
11
11
|
# :nocov:
|
@@ -41,7 +41,7 @@ class RESTFramework::Generators::ControllerGenerator < Rails::Generators::Base
|
|
41
41
|
# Some projects may not have the inflection "REST" as an acronym, which changes this generator to
|
42
42
|
# be namespaced in `r_e_s_t_framework`, which is weird.
|
43
43
|
def self.namespace
|
44
|
-
|
44
|
+
RESTFrameworkCustomGeneratorControllerNamespace.new("rest_framework:controller")
|
45
45
|
end
|
46
46
|
|
47
47
|
def create_rest_controller_file
|