rails-surrender 7.0.0 → 8.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/CHANGELOG.md +103 -0
- data/LICENSE +674 -0
- data/README.md +240 -0
- data/lib/rails/surrender/config/locales/en.yml +3 -0
- data/lib/rails/surrender/controller_additions.rb +13 -73
- data/lib/rails/surrender/fields/asks.rb +50 -0
- data/lib/rails/surrender/fields/attribute.rb +37 -0
- data/lib/rails/surrender/fields/expand.rb +65 -0
- data/lib/rails/surrender/fields/field_tree.rb +71 -0
- data/lib/rails/surrender/fields/id_list.rb +38 -0
- data/lib/rails/surrender/fields/node.rb +86 -0
- data/lib/rails/surrender/menu/field.rb +15 -0
- data/lib/rails/surrender/menu/filter.rb +11 -0
- data/lib/rails/surrender/menu/menu.rb +55 -0
- data/lib/rails/surrender/model_additions.rb +47 -32
- data/lib/rails/surrender/params/list_param.rb +23 -0
- data/lib/rails/surrender/railtie.rb +23 -9
- data/lib/rails/surrender/render/resource.rb +11 -30
- data/lib/rails/surrender/selection/filtering.rb +144 -0
- data/lib/rails/surrender/selection/page_size.rb +33 -0
- data/lib/rails/surrender/selection/selected.rb +50 -0
- data/lib/rails/surrender/selection/selection.rb +70 -0
- data/lib/rails/surrender/selection/sorting.rb +61 -0
- data/lib/rails/surrender/version.rb +1 -1
- data/lib/rails/surrender.rb +13 -12
- metadata +65 -41
- data/lib/rails/surrender/config/initializers/active_record_associations_patch.rb +0 -17
- data/lib/rails/surrender/config/initializers/active_record_preloader_patch.rb +0 -20
- data/lib/rails/surrender/helpers/filter_builder.rb +0 -50
- data/lib/rails/surrender/helpers/pagination_builder.rb +0 -25
- data/lib/rails/surrender/helpers/query_param_parser.rb +0 -98
- data/lib/rails/surrender/helpers/sort_builder.rb +0 -56
- data/lib/rails/surrender/model_filter_scopes.rb +0 -56
- data/lib/rails/surrender/render/configuration/inclusion_mapper_logic.rb +0 -69
- data/lib/rails/surrender/render/configuration/instance_logic.rb +0 -88
- data/lib/rails/surrender/render/configuration.rb +0 -93
- data/lib/rails/surrender/render/count.rb +0 -21
- data/lib/rails/surrender/render/ids.rb +0 -21
- data/lib/rails/surrender/render/resource/collection.rb +0 -26
- data/lib/rails/surrender/render/resource/inclusion_mapper.rb +0 -48
- data/lib/rails/surrender/render/resource/instance.rb +0 -75
- data/test/surrender_test.rb +0 -9
- data/test/test_helper.rb +0 -21
data/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Surrender
|
|
2
|
+
A JSON rendering platform for Rails APIs. Provides many controls through URL query parameters for clients to craft
|
|
3
|
+
API responses from your resources.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
* Ruby 3.3 or newer
|
|
7
|
+
* Rails 8.0 or 8.1
|
|
8
|
+
|
|
9
|
+
The gem's version names the Rails version it supports. Use 8.x for Rails 8.0 and 8.1, and 7.0.0 (on the
|
|
10
|
+
`7-0-stable` branch) for Rails 7.0.
|
|
11
|
+
|
|
12
|
+
Rails 8.0 is not compatible with version 3 of the `json` gem, so Rails 8.0 apps should keep `json` below 3:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem 'json', '< 3'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
Surrender adds a number of helpful features to API requests, through URL query parameters to craft the structure of the
|
|
20
|
+
response:
|
|
21
|
+
|
|
22
|
+
### Sorting
|
|
23
|
+
Most attributes on the requested resource can be targeted for sorting. Sorting is provided for to the API with the
|
|
24
|
+
'sort' query parameter.
|
|
25
|
+
|
|
26
|
+
| example query param | description |
|
|
27
|
+
|---------------------|-----------------------------------------------------------|
|
|
28
|
+
| `sort=name` | Sort the results by the `name` attribute |
|
|
29
|
+
| `sort=-created_at` | Sort the results in reverse by the `created_at` attribute |
|
|
30
|
+
| `sort=author.name` | Sort the results by the `name` of each result's `author` |
|
|
31
|
+
|
|
32
|
+
Sorting by a column of an association works for associations that hold one record, such as `belongs_to` and `has_one`.
|
|
33
|
+
An association that holds many would repeat each result once per associated record, so it is rejected.
|
|
34
|
+
|
|
35
|
+
A model can define its own sorts as scopes named `sort_by_<name>`, which take the direction, `'ASC'` or `'DESC'`. A
|
|
36
|
+
sort the model defines wins over a column of the same name, so `sort_by_title` can sort titles without regard to case:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
scope :sort_by_title, ->(direction) { order(Arel.sql("LOWER(title) #{direction}")) }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
### Pagination
|
|
44
|
+
Pagination is provided internally using the 'kaminari' gem. Access is provided to the API with the `page` and `per`
|
|
45
|
+
query parameters
|
|
46
|
+
|
|
47
|
+
| example query param | description |
|
|
48
|
+
|---------------------|------------------------------------|
|
|
49
|
+
| `per=20` | Request 20 results per page |
|
|
50
|
+
| `page=2` | Request the second page of results |
|
|
51
|
+
|
|
52
|
+
A page holds 50 resources unless the request sets `per`, and never more than 100, whatever `per` asks for. Both numbers
|
|
53
|
+
can be changed in the app's configuration (see Setup). `page` and `per` take positive whole numbers; anything else is
|
|
54
|
+
rejected with a `Rails::Surrender::Error`.
|
|
55
|
+
|
|
56
|
+
Paginated results will populate an `X-Pagination` header in the result set that provides a hash of the paginated
|
|
57
|
+
details. The hash is structured like:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"total": 70,
|
|
62
|
+
"page_total": 3,
|
|
63
|
+
"page": 2,
|
|
64
|
+
"previous_page": 1,
|
|
65
|
+
"next_page": 3,
|
|
66
|
+
"last_page": 3,
|
|
67
|
+
"per_page": 25,
|
|
68
|
+
"offset": 25
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Filtering
|
|
73
|
+
`rails-surrender` adds a number of scopes to your models for filtering, and provides access to the filters with the
|
|
74
|
+
`filter` query parameter. Most columns are provided with an equality filter and can be filtered on matching values.
|
|
75
|
+
Date and Time columns are also provided with `_before` and `_after` filters, named after the column without its `_at`
|
|
76
|
+
suffix, which select records strictly before or after the given date or time. Examples are provided below:
|
|
77
|
+
|
|
78
|
+
Note: Time-based filters take an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date or time, such as
|
|
79
|
+
`'2022-01-01'`, `'2022-01-01T13:30:00Z'` or `'2022-01-01T13:30:00-05:00'`, surrounded by single or double quotes. A time
|
|
80
|
+
without an offset is in the app's time zone. Any other value is rejected with a `Rails::Surrender::Error`.
|
|
81
|
+
|
|
82
|
+
| example query param | description |
|
|
83
|
+
|-----------------------------------------------|-----------------------------------------------------------------------------------|
|
|
84
|
+
| `filter=active:false` | filter for resources with the `active` attribute set to false |
|
|
85
|
+
| `filter=state:TX` | filter for resources with the `state` attribute of `TX` |
|
|
86
|
+
| `filter=created_before:'2022-01-01'` | filter for resources with a `created_at` value _before_ Jan 01, 2022 |
|
|
87
|
+
| `filter=created_after:'2022-01-01'` | filter for resources with a `created_at` value _after_ Jan 01, 2022 |
|
|
88
|
+
| `filter=created_after:'2022-01-01T13:30:00Z'` | filter for resources with a `created_at` value _after_ 1:30pm UTC on Jan 01, 2022 |
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
### Counting
|
|
92
|
+
Instead of rendering a full list of resources, `rails-surrender` can return only the _count_ of the number of resources
|
|
93
|
+
that _would_ be returned. A count ignores pagination: it counts every resource the filters select, not just one page. This can be useful when combined with filters for basic reporting. Access is provided with
|
|
94
|
+
the `count` query parameter.
|
|
95
|
+
|
|
96
|
+
| example query param | description |
|
|
97
|
+
|-----------------------------------------------------------------|------------------------------------------------------|
|
|
98
|
+
| `count` | count the number of resources |
|
|
99
|
+
| `filter=state:TX&count` | count the number of resources in the `state` of 'TX' |
|
|
100
|
+
| `filter=state:TX&count` | count the number of resources in the `state` of 'TX' |
|
|
101
|
+
| `filter=created_before:'2022-01-01'&count` | count the number of resources created before 2022 |
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
### Including
|
|
105
|
+
the `include` (and `exclude` described below) directive provides an easy but powerful mechanism for crafting the shape
|
|
106
|
+
of the response. For models that provide additional attributes or associations that are not rendered by default (see
|
|
107
|
+
setup below) the `include` query parameter can be used to request those attributes and associations be rendered in the
|
|
108
|
+
request. The `include` parameter can request multiple attributes and associations, and for associations can include
|
|
109
|
+
additional values on nested resources. Examples below:
|
|
110
|
+
|
|
111
|
+
| example query param | description |
|
|
112
|
+
|---------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
|
113
|
+
| `include=created_at, updated_at` | include the `created_at` and `updated at` attributes of the resource(s) |
|
|
114
|
+
| `include=work_orders` | include the `work_orders` association of the resource(s) |
|
|
115
|
+
| `include=work_orders:[activities]` | include the `work_orders` and it's `activities` association |
|
|
116
|
+
| `include=created_at, work_orders:[created_at, activities:[created_at ]]` | combine various attributes and associations and nested results |
|
|
117
|
+
|
|
118
|
+
Each resource renders its `id` first, then its other attributes, then its `*_ids` lists, then its associations. Within
|
|
119
|
+
each group, the model's default fields come first, then those the controller includes, then those the request includes.
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
### Excluding
|
|
123
|
+
Exclude (and exclude below) provide an easy but powerful mechanism for crafting the shape of the response. Excluding
|
|
124
|
+
works like the reverse of include. Models may define attributes or associations that are rendered by default, but
|
|
125
|
+
exclude can be used to prevent rendering of those attributes. Examples below:
|
|
126
|
+
|
|
127
|
+
| example query param | description |
|
|
128
|
+
|----------------------------------|------------------------------------------------------------------------------------------------------|
|
|
129
|
+
| `exclude=created_at, updated_at` | exclude the `created_at` and `updated at` attributes of the resource(s) that are rendered by default |
|
|
130
|
+
| `exclude=work_orders` | include the `work_orders` association of the resource(s) that is rendered by default |
|
|
131
|
+
| `exclude=activities:[creator]` | keep the default `activities` association, but leave out each activity's `creator` |
|
|
132
|
+
|
|
133
|
+
To list only the ids of the resources, exclude every other field they render by default, such as
|
|
134
|
+
`exclude=title,genre,author`.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## Example Project
|
|
138
|
+
A full working example project is provided in the [rails-surrender-demo](https://github.com/smmcbride/rails-surrender-demo)
|
|
139
|
+
repository. It provides several models and endpoints demonstrating a working configuration of `rails-surrender`. The
|
|
140
|
+
project provides seed data, a browser explorer where each query parameter is a checkbox and the response is shown as
|
|
141
|
+
you send it, and a `bin/http` command for trying each query parameter from the terminal.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
## Installation
|
|
145
|
+
|
|
146
|
+
This project is intended to be used in a Ruby on Rails project. To install, simply add this your Gemfile:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
gem 'rails-surrender', '~> 8.0'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
and bundle:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
% bundle
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Setup
|
|
159
|
+
Surrender setup begins in your models. To enable rendering of a model with this gem, call `surrenders` on it with the
|
|
160
|
+
fields to render by default, and optionally `available:` with the fields API clients can add with the `include` query
|
|
161
|
+
parameter. A field can be any model attribute or method, a `*_ids` attribute such as `chapter_ids`, or an association
|
|
162
|
+
such as one defined with `has_many` or `belongs_to`. Surrender renders associations as nested resources, using the
|
|
163
|
+
associated model's own `surrenders` fields, and renders every other field as an attribute.
|
|
164
|
+
|
|
165
|
+
An example invocation of the `surrenders` method is provided below:
|
|
166
|
+
```ruby
|
|
167
|
+
surrenders :id, :title, :description, :status, :activities,
|
|
168
|
+
available: [:created_at, :updated_at, :user]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A model that doesn't call `surrenders` renders its `id` and `created_at`. A subclass that calls `surrenders` adds its
|
|
172
|
+
fields to those of its superclass, and its records render them wherever they appear, including in a collection of the
|
|
173
|
+
superclass.
|
|
174
|
+
|
|
175
|
+
Surrender can't expand a polymorphic `belongs_to`, because it works out the fields to render before any record loads,
|
|
176
|
+
when the association has no class yet. Listing one as a field raises `ArgumentError`.
|
|
177
|
+
|
|
178
|
+
To invoke surrender in your controllers, simply end your controller action with a call to `surrender` with the
|
|
179
|
+
resource you wish to render. An example controller action is provided below:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
def index
|
|
183
|
+
books = Book.all
|
|
184
|
+
surrender books
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
### Configuration
|
|
190
|
+
|
|
191
|
+
Surrender's page sizes are set in `config/application.rb`, or per environment in `config/environments/*.rb`:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
config.surrender.default_per_page = 50 # the page size when a request doesn't set `per`
|
|
195
|
+
config.surrender.max_per_page = 100 # the most a page holds, whatever `per` asks for; nil removes the limit
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The app won't boot if the default is larger than the limit, or either isn't a positive whole number. Kaminari's own
|
|
199
|
+
page size settings don't apply to surrender's pages.
|
|
200
|
+
|
|
201
|
+
### Describing what a request may ask
|
|
202
|
+
|
|
203
|
+
`surrender_menu` lists everything a request may ask of a model, read from the same rules that check requests, for
|
|
204
|
+
documentation or an API explorer like the demo's:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
menu = Book.surrender_menu
|
|
208
|
+
menu.fields # each with its name, kind (:attribute, :id_list or :expand), default?, and an expand's own menu
|
|
209
|
+
menu.filters # each with its name and the column it compares, or nil for a filter the model defines
|
|
210
|
+
menu.sorts # ['id', 'title', ..., 'author.name', 'title_length']
|
|
211
|
+
menu.page_size.default, menu.page_size.max # 50 and 100 unless configured
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Additional Notes
|
|
215
|
+
|
|
216
|
+
Surrender prevents circular renderings. If a `Book` model, for example, expands its `Author`, and `Author` expands its
|
|
217
|
+
`books`, then surrender leaves out the `books` of an author rendered inside a book. It never expands an association
|
|
218
|
+
into a model already rendered above it, and it treats an STI subclass as its base class when checking.
|
|
219
|
+
|
|
220
|
+
Surrender prevents un-authorized access to associated resources by invoking the `current_ability` rule-set from the
|
|
221
|
+
CanCanCan gem, if it is used in your Rails project. Any controller method named `current_ability`, public or private,
|
|
222
|
+
that returns an object responding to `can?(:read, record)` is used the same way. If neither is present it will allow
|
|
223
|
+
full access to associated resources.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
The test suite boots a small Rails app (`spec/dummy`) against an in-memory SQLite database. Run it against the
|
|
228
|
+
latest Rails with:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
bundle install
|
|
232
|
+
bundle exec rake
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`rake` runs both the specs and RuboCop. To run the specs against each supported Rails version:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
bundle exec appraisal install
|
|
239
|
+
bundle exec appraisal rspec
|
|
240
|
+
```
|
|
@@ -6,7 +6,10 @@ en:
|
|
|
6
6
|
incorrect_format: "The %{param} parameter was improperly formatted."
|
|
7
7
|
filter:
|
|
8
8
|
not_available: "%{param} is not a valid filter parameter."
|
|
9
|
+
invalid_time: "%{param} needs an ISO 8601 date or time, such as '2026-01-01' or '2026-01-01T13:30:00Z', not '%{value}'."
|
|
9
10
|
include:
|
|
10
11
|
not_available: "%{param} is not a valid include parameter."
|
|
12
|
+
pagination:
|
|
13
|
+
invalid: "%{param} needs a positive whole number, not '%{value}'."
|
|
11
14
|
sort:
|
|
12
15
|
invalid_column: "%{param} is not a valid sort parameter."
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'helpers/query_param_parser'
|
|
4
|
-
require_relative 'helpers/filter_builder'
|
|
5
|
-
require_relative 'helpers/pagination_builder'
|
|
6
|
-
require_relative 'helpers/sort_builder'
|
|
7
|
-
|
|
8
3
|
module Rails
|
|
9
4
|
module Surrender
|
|
10
5
|
# Additions to the Rails ActionController to allow surrender's rendering.
|
|
@@ -14,36 +9,17 @@ module Rails
|
|
|
14
9
|
super
|
|
15
10
|
end
|
|
16
11
|
|
|
17
|
-
def surrender(resource, status: 200,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
resource = sort resource
|
|
22
|
-
response.headers['X-Sort'] = parsed_query_params.sort.request
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
if paginate?(resource)
|
|
26
|
-
resource = paginate resource
|
|
27
|
-
response.headers['X-Pagination'] = pagination_headers(resource)
|
|
28
|
-
end
|
|
12
|
+
def surrender(resource, status: 200, include: [], exclude: [])
|
|
13
|
+
selection = Selection.new(query_params, paginate: @will_paginate)
|
|
14
|
+
selected = selection.apply(resource)
|
|
15
|
+
response.headers.merge!(selected.headers)
|
|
29
16
|
|
|
30
|
-
surrender_response = if
|
|
31
|
-
|
|
32
|
-
elsif parsed_query_params.ids?
|
|
33
|
-
Render::Ids.new(resource).parse
|
|
17
|
+
surrender_response = if selection.count?
|
|
18
|
+
Response.new(data: { count: selected.count })
|
|
34
19
|
else
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
user_exclude: parsed_query_params.exclude,
|
|
39
|
-
user_include: parsed_query_params.include,
|
|
40
|
-
ctrl_exclude: exclude,
|
|
41
|
-
ctrl_include: include
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
Render::Resource.new(resource: resource,
|
|
45
|
-
ability: ability,
|
|
46
|
-
config: config).parse
|
|
20
|
+
asks = { include: query_params[:include], exclude: query_params[:exclude],
|
|
21
|
+
controller_include: include, controller_exclude: exclude }
|
|
22
|
+
Render::Resource.new(resource: selected.records, ability: ability, asks: asks).parse
|
|
47
23
|
end
|
|
48
24
|
|
|
49
25
|
# Allows the calling method to decorate the response data before returning the result
|
|
@@ -55,54 +31,18 @@ module Rails
|
|
|
55
31
|
private
|
|
56
32
|
|
|
57
33
|
def ability
|
|
58
|
-
|
|
34
|
+
# Apps commonly override CanCanCan's current_ability as a private method.
|
|
35
|
+
return current_ability if respond_to?(:current_ability, true)
|
|
59
36
|
|
|
60
37
|
DefaultAbility.new
|
|
61
38
|
end
|
|
62
39
|
|
|
63
|
-
def resource_class(resource)
|
|
64
|
-
resource.try(:klass) || resource.class
|
|
65
|
-
end
|
|
66
|
-
|
|
67
40
|
def skip_pagination
|
|
68
41
|
@will_paginate = false
|
|
69
42
|
end
|
|
70
43
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def paginate?(resource)
|
|
76
|
-
pagination(resource).paginatable? && @will_paginate
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def paginate(resource)
|
|
80
|
-
pagination(resource).build!
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def pagination(resource)
|
|
84
|
-
PaginationBuilder.new(resource: resource, pagination: parsed_query_params.pagination)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def pagination_headers(resource)
|
|
88
|
-
{
|
|
89
|
-
total: resource.total_count,
|
|
90
|
-
page_total: resource.count,
|
|
91
|
-
page: resource.current_page,
|
|
92
|
-
previous_page: resource.prev_page,
|
|
93
|
-
next_page: resource.next_page,
|
|
94
|
-
last_page: resource.total_pages,
|
|
95
|
-
per_page: parsed_query_params.pagination.per,
|
|
96
|
-
offset: resource.offset_value
|
|
97
|
-
}.to_json
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def sort(resource)
|
|
101
|
-
SortBuilder.new(resource: resource, sort: parsed_query_params.sort).build!
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def parsed_query_params
|
|
105
|
-
@parsed_query_params ||= QueryParamParser.new(request.query_parameters.symbolize_keys)
|
|
44
|
+
def query_params
|
|
45
|
+
request.query_parameters.symbolize_keys
|
|
106
46
|
end
|
|
107
47
|
end
|
|
108
48
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Surrender
|
|
5
|
+
module Fields
|
|
6
|
+
# What a request and a controller ask one level of the tree to include and exclude. Each list holds bare names,
|
|
7
|
+
# and hashes that carry the asks for the level below, such as { 'author' => ['name'] }.
|
|
8
|
+
Asks = Data.define(:request_include, :request_exclude, :controller_include, :controller_exclude) do
|
|
9
|
+
def initialize(request_include: [], request_exclude: [], controller_include: [], controller_exclude: [])
|
|
10
|
+
super(request_include: listed(request_include), request_exclude: listed(request_exclude),
|
|
11
|
+
controller_include: listed(controller_include), controller_exclude: listed(controller_exclude))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def request_includes
|
|
15
|
+
names_in(request_include)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def controller_includes
|
|
19
|
+
names_in(controller_include)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A request's include wins over any exclude. `author:[name]` excludes the author's name, not the author, so
|
|
23
|
+
# only bare names exclude at this level.
|
|
24
|
+
def excludes?(name)
|
|
25
|
+
return false if request_includes.include?(name)
|
|
26
|
+
|
|
27
|
+
(request_exclude + controller_exclude).grep_v(Hash).map(&:to_sym).include?(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def below(name)
|
|
31
|
+
self.class.new(**to_h.transform_values { |list| asks_below(list)[name] })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def listed(list)
|
|
37
|
+
(list.is_a?(Array) ? list : [list]).compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def names_in(list)
|
|
41
|
+
list.flat_map { |item| item.is_a?(Hash) ? item.keys : item }.map(&:to_sym).uniq
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def asks_below(list)
|
|
45
|
+
list.grep(Hash).reduce({}, :merge).transform_keys(&:to_sym)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Surrender
|
|
5
|
+
module Fields
|
|
6
|
+
# A field rendered as the value its record returns for it.
|
|
7
|
+
Attribute = Data.define(:name) do
|
|
8
|
+
# Every record leads with its id, wherever the model or the asks list it.
|
|
9
|
+
def rank
|
|
10
|
+
name == :id ? 0 : 1
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def kind
|
|
14
|
+
:attribute
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def preload; end
|
|
18
|
+
|
|
19
|
+
def circular?(_above)
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def branch(**); end
|
|
24
|
+
|
|
25
|
+
def menu(**); end
|
|
26
|
+
|
|
27
|
+
def write(record, into:, **)
|
|
28
|
+
into[name] = record.public_send(name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def describe(_branch)
|
|
32
|
+
:attribute
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Surrender
|
|
5
|
+
module Fields
|
|
6
|
+
# A field that names an association and renders as the nested resource, with the fields of its branch.
|
|
7
|
+
Expand = Data.define(:name, :reflection) do
|
|
8
|
+
def rank
|
|
9
|
+
3
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def kind
|
|
13
|
+
:expand
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def preload
|
|
17
|
+
name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Surrender never renders a model inside itself, and STI subclasses count as their base class.
|
|
21
|
+
def circular?(above)
|
|
22
|
+
target.base_class.in?(above)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def branch(asks:, above:)
|
|
26
|
+
Node.new(target, asks.below(name), above + [target.base_class])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def menu(above:)
|
|
30
|
+
Menu.new(target, above: above + [target.base_class])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The record at the top is never checked against the ability, only the records nested in it.
|
|
34
|
+
def write(record, into:, ability:, branch:)
|
|
35
|
+
value = record.public_send(name)
|
|
36
|
+
|
|
37
|
+
if reflection.collection?
|
|
38
|
+
into[name] = value.select { |nested| ability.can?(:read, nested) }
|
|
39
|
+
.map { |nested| branch.for(nested.class).render(nested, ability) }
|
|
40
|
+
elsif value.nil?
|
|
41
|
+
into[name] = nil
|
|
42
|
+
elsif ability.can?(:read, value)
|
|
43
|
+
into[name] = branch.for(value.class).render(value, ability)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def describe(branch)
|
|
48
|
+
branch.to_h
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# A polymorphic association has no class until a record loads, but the tree is worked out before any do.
|
|
54
|
+
def target
|
|
55
|
+
if reflection.polymorphic?
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"surrender can't expand the polymorphic association #{reflection.active_record}##{name}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
reflection.klass
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../params/list_param'
|
|
4
|
+
require_relative 'node'
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Surrender
|
|
8
|
+
# The fields one request renders, nested by expand, worked out from the model and the asks of the request and the
|
|
9
|
+
# controller before any record loads. Preloading and rendering both read it, so they always agree.
|
|
10
|
+
class FieldTree
|
|
11
|
+
# `include:` and `exclude:` are the request's asks, as the query string gives them: `'books:[pages], author'`.
|
|
12
|
+
# A controller's asks are lists: `[:pages, { author: [:name] }]`.
|
|
13
|
+
def initialize(model, include: '', exclude: '', controller_include: [], controller_exclude: [])
|
|
14
|
+
asks = Fields::Asks.new(request_include: Params::ListParam.parse(include, param: :include),
|
|
15
|
+
request_exclude: Params::ListParam.parse(exclude, param: :exclude),
|
|
16
|
+
controller_include: controller_include, controller_exclude: controller_exclude)
|
|
17
|
+
@root = Fields::Node.new(model, asks, [model.base_class])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Renders a record as a hash, or a relation as an array of them.
|
|
21
|
+
def render(resource, ability:)
|
|
22
|
+
records = resource.is_a?(ActiveRecord::Relation) ? resource.to_a : [resource]
|
|
23
|
+
preload(records)
|
|
24
|
+
rendered = records.map { |record| @root.for(record.class).render(record, ability) }
|
|
25
|
+
resource.is_a?(ActiveRecord::Relation) ? rendered : rendered.first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
@root.to_h
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Level by level, each association loads once for every record that renders it, whatever the record's class.
|
|
35
|
+
# STI subclasses then share a query for an association they inherit, and one only a subclass renders loads only
|
|
36
|
+
# for that subclass's records, which a nested `includes` on the parent's relation can't name.
|
|
37
|
+
def preload(records)
|
|
38
|
+
level = { @root => records }
|
|
39
|
+
until level.empty?
|
|
40
|
+
variants = variants_in(level)
|
|
41
|
+
load_associations(variants)
|
|
42
|
+
level = level_below(variants)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def variants_in(level)
|
|
47
|
+
level.flat_map do |node, records|
|
|
48
|
+
records.group_by(&:class).map { |klass, group| [node.for(klass), group] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_associations(variants)
|
|
53
|
+
loading = Hash.new { |by_association, association| by_association[association] = [] }
|
|
54
|
+
variants.each { |node, records| node.preloads.each { |association| loading[association].concat(records) } }
|
|
55
|
+
loading.each do |association, records|
|
|
56
|
+
ActiveRecord::Associations::Preloader.new(records: records, associations: association).call
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def level_below(variants)
|
|
61
|
+
level = Hash.new { |records_by_node, node| records_by_node[node] = [] }
|
|
62
|
+
variants.each do |node, records|
|
|
63
|
+
node.branches.each do |name, branch|
|
|
64
|
+
level[branch].concat(records.flat_map { |record| Array(record.public_send(name)) })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
level.transform_values { |records| records.compact.uniq }.reject { |_, records| records.empty? }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Surrender
|
|
5
|
+
module Fields
|
|
6
|
+
# A field that names an association by its ids, such as chapter_ids, and renders them as a flat list.
|
|
7
|
+
IdList = Data.define(:name, :association) do
|
|
8
|
+
def rank
|
|
9
|
+
2
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def kind
|
|
13
|
+
:id_list
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def preload
|
|
17
|
+
association
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def circular?(_above)
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def branch(**); end
|
|
25
|
+
|
|
26
|
+
def menu(**); end
|
|
27
|
+
|
|
28
|
+
def write(record, into:, **)
|
|
29
|
+
into[name] = record.public_send(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def describe(_branch)
|
|
33
|
+
:id_list
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|