wor-simple_crud 0.3.0 → 0.4.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/.gitignore +5 -0
- data/CHANGELOG.md +31 -2
- data/README.md +93 -26
- data/lib/simple_crud/action_lambdas.rb +81 -0
- data/lib/simple_crud/controller_helpers.rb +11 -2
- data/lib/simple_crud/rspec/config.rb +18 -5
- data/lib/simple_crud/rspec/helpers/authentication.rb +32 -0
- data/lib/simple_crud/rspec/helpers/controller_options.rb +24 -0
- data/lib/simple_crud/rspec/helpers/models.rb +70 -0
- data/lib/simple_crud/rspec/helpers/policies.rb +25 -0
- data/lib/simple_crud/rspec/helpers/requests.rb +45 -0
- data/lib/simple_crud/rspec/helpers/settings.rb +49 -0
- data/lib/simple_crud/rspec/helpers.rb +16 -138
- data/lib/simple_crud/rspec.rb +1 -0
- data/lib/simple_crud/simple_crud_controller.rb +33 -81
- data/lib/simple_crud/version.rb +1 -1
- data/lib/simple_crud.rb +1 -0
- data/lib/spec/shared_examples/authorization_adapter_authorize.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_create.rb +16 -13
- data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +26 -8
- data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +3 -3
- data/lib/spec/shared_examples/simple_crud_for_destroy.rb +26 -1
- data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +11 -3
- data/lib/spec/shared_examples/simple_crud_for_destroy_with_finder.rb +18 -7
- data/lib/spec/shared_examples/simple_crud_for_edit.rb +79 -0
- data/lib/spec/shared_examples/simple_crud_for_index.rb +2 -2
- data/lib/spec/shared_examples/simple_crud_for_index_with_block.rb +12 -4
- data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +26 -14
- data/lib/spec/shared_examples/simple_crud_for_new.rb +2 -2
- data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +14 -3
- data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +11 -5
- data/lib/spec/shared_examples/simple_crud_for_show.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +14 -3
- data/lib/spec/shared_examples/simple_crud_for_show_with_finder.rb +17 -8
- data/lib/spec/shared_examples/simple_crud_for_update.rb +17 -10
- data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +17 -7
- data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +3 -2
- data/lib/spec/shared_examples/simple_crud_renders_template.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_when_not_authorized.rb +1 -1
- data/lib/spec/shared_examples/simple_crud_without_authenticated_user.rb +1 -1
- data/lib/spec/shared_examples/unauthorized_when_not_logged_in.rb +1 -1
- data/spec/block_finder/dummy_models_controller_spec.rb +10 -0
- data/spec/block_json/dummy_models_controller_spec.rb +9 -0
- data/spec/block_redirect/dummy_models_controller_spec.rb +7 -0
- data/spec/dummy/app/controllers/block/dummy_models_controller.rb +1 -6
- data/spec/dummy/app/controllers/block_finder/dummy_models_controller.rb +25 -0
- data/spec/dummy/app/controllers/block_json/dummy_models_controller.rb +17 -0
- data/spec/dummy/app/controllers/block_new/dummy_models_controller.rb +1 -1
- data/spec/dummy/app/controllers/block_redirect/dummy_models_controller.rb +13 -0
- data/spec/dummy/app/controllers/block_show/dummy_models_controller.rb +1 -1
- data/spec/dummy/app/controllers/built/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/finder/dummy_models_controller.rb +1 -5
- data/spec/dummy/app/controllers/html_finder/dummy_models_controller.rb +17 -0
- data/spec/dummy/app/controllers/html_modes/base_controller.rb +0 -5
- data/spec/dummy/app/controllers/html_scoped/dummy_models_controller.rb +1 -1
- data/spec/dummy/app/controllers/invalid_status/dummy_models_controller.rb +1 -1
- data/spec/dummy/app/controllers/nested/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/nested_route/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/redirect_auth/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/scoped/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/scoped_params/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/strict/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/controllers/unpaginated_scoped/dummy_models_controller.rb +11 -0
- data/spec/dummy/app/controllers/without_pagination/dummy_models_controller.rb +0 -5
- data/spec/dummy/app/models/dummy_model_policy.rb +5 -1
- data/spec/dummy/app/views/html_finder/dummy_models/edit.html.erb +1 -0
- data/spec/dummy/app/views/html_finder/dummy_models/new.html.erb +1 -0
- data/spec/dummy/app/views/html_finder/dummy_models/show.html.erb +1 -0
- data/spec/dummy/config/routes.rb +21 -1
- data/spec/dummy_model_policy_spec.rb +18 -8
- data/spec/finder/dummy_models_controller_spec.rb +1 -0
- data/spec/html_finder/dummy_models_controller_spec.rb +11 -0
- data/spec/html_scoped/dummy_models_controller_spec.rb +1 -1
- data/spec/scoped_params/dummy_models_controller_spec.rb +4 -3
- data/spec/simple_crud/rspec_config_spec.rb +39 -0
- data/spec/simple_crud_controller_spec.rb +38 -0
- data/spec/unpaginated_scoped/dummy_models_controller_spec.rb +7 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbcb6881c359fa3b5c4ce518871a4f378d92e0698ab7d57dc489315f2f25b80b
|
|
4
|
+
data.tar.gz: 62cf8cbd6df845cd23a388e9b2a1f8ebfdae784ff91f4e57268688bce0c0bf87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4406326eab40876599274e927df02c9c3470d3f753dcd5f0f150b67a7b3087e2a965ec0fd18ad1e007bada514954f9a665d573d18da98bf7c93aee3ffebc1637
|
|
7
|
+
data.tar.gz: 1deddc698fb6008531d59e5656675086a309dbc26177b403c023e7743463425626b8e977785f6ef2cbe122d4f251ad697944d1c71f665e498666feecedbae792
|
data/.gitignore
CHANGED
|
@@ -147,6 +147,11 @@ config/initializers/secret_token.rb
|
|
|
147
147
|
## Environment normalization:
|
|
148
148
|
/.bundle
|
|
149
149
|
/vendor/bundle
|
|
150
|
+
# Appraisal gemfiles vendor their own bundles; never commit those.
|
|
151
|
+
/gemfiles/.bundle/
|
|
152
|
+
/gemfiles/vendor/
|
|
153
|
+
# Local tooling logs.
|
|
154
|
+
/.setting/
|
|
150
155
|
|
|
151
156
|
# these should all be checked in to normalize the environment:
|
|
152
157
|
# Gemfile.lock, .ruby-version, .ruby-gemset
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
## Change log
|
|
2
2
|
|
|
3
|
+
### V0.4.0
|
|
4
|
+
|
|
5
|
+
Breaking:
|
|
6
|
+
* Blocks given to `simple_crud_for` no longer imply `html: true`. HTML controllers with render blocks must pass `html: true` explicitly.
|
|
7
|
+
|
|
8
|
+
New options:
|
|
9
|
+
* `authenticate_headers:` controls whether shared examples set auth headers and run the unauthorized test, independent of `authenticate:`. Defaults to `authenticate:`. Set `authenticate: false, authenticate_headers: true` when a base-controller `before_action` handles auth so the lambda skips the call but tests still cover the auth path.
|
|
10
|
+
* `simple_crud_defaults(options)` sets option defaults at the controller level. Subclasses inherit. Per-action options override. Precedence: gem defaults < controller defaults < per-action options.
|
|
11
|
+
|
|
12
|
+
Fixes:
|
|
13
|
+
* Use `RSpec.shared_examples` instead of the top-level alias, which is unavailable with `config.disable_monkey_patching!`.
|
|
14
|
+
* Auto-require `SimpleCrud::Authorization::Adapter` on `require 'simple_crud'`.
|
|
15
|
+
* Remove `cattr_accessor :params, :permitted` (thread-unsafe, never read by the gem). Remove the `set_params` boilerplate from host controllers.
|
|
16
|
+
* Raise a clear error when `owner_association` names an attribute the model doesn't have, instead of a bare `NoMethodError` from FactoryBot.
|
|
17
|
+
|
|
18
|
+
### V0.3.1
|
|
19
|
+
|
|
20
|
+
Shared examples configuration:
|
|
21
|
+
* Every setting can be overridden per controller or per example via `simple_crud:` RSpec metadata. Metadata takes precedence over `SimpleCrud::RSpec.configure` and the defaults, and all reads happen at example runtime, so per-resource tweaks need no around hooks, no global mutation and no restore logic.
|
|
22
|
+
* New `model_attributes` setting (default: `{ owner_association => current_user }`) decides how records are built for the examples, so multi-key (`{ user:, project: }`), non-user-owned (`{ project: project }`) or owner-less models work without editing the shared examples.
|
|
23
|
+
* Dedicated examples (`*_with_block`, `*_with_build`, `*_with_scope`) no longer assume the gem's dummy app: request params derive from `params_for`/`owner_params`, authentication wraps conditionally on the action's `authenticate:` option, and render-block actions are asserted on status and persistence effects only (a block's response body is app-defined by definition).
|
|
24
|
+
* The finder/build dedicated families (`show/update/destroy with finder`, `not found with finder`, `new with build`) adapt to each action's declared options too: render-block actions are asserted success-only, `html: true` actions assert the rendered template or redirect, and plain JSON keeps the status/body assertions. Server-rendered apps no longer need to skip these families.
|
|
25
|
+
* `index with scope` builds its records through new `scoped_attributes` (default: `model_attributes`) and `other_scoped_attributes` (default: `model_attributes.merge(owner_association => other_user)`) settings instead of hardcoding `owner_association => user`, so factories without that attribute keep working and multi-key models express their scope via metadata. The assertion follows the controller: `assigns(:records)` ids for `html: true`, the wor-paginate envelope for paginated JSON, and the bare serialized array otherwise.
|
|
26
|
+
* Actions declared with a render block expose `block: true` in the controller metadata, and the shared examples treat them accordingly: failure/invalid paths assert persistence effects only (the block decides how the response is rendered), while default flows keep their pinned statuses/templates.
|
|
27
|
+
* The base destroy example covers the failure path (record always kept; non-block flows respond 422 as JSON or re-render `show` as HTML).
|
|
28
|
+
* New optional `created_record_check` setting: a lambda receiving the persisted record in the create/update success paths, to assert ownership/scope that `count == 1` alone can't catch.
|
|
29
|
+
|
|
3
30
|
### V0.3.0
|
|
4
31
|
|
|
5
32
|
Server-rendered (HTML) support:
|
|
@@ -7,8 +34,9 @@ Server-rendered (HTML) support:
|
|
|
7
34
|
* New `:new` action: builds a record, authorizes it, and renders `new.html.erb` (or returns JSON in API mode).
|
|
8
35
|
|
|
9
36
|
Custom lookups, scoping and building:
|
|
10
|
-
* `finder:` on `:show`/`:update`/`:destroy`: a `Proc`/lambda invoked with the controller's params, or a `Symbol` naming a class method on the model. Defaults to `klass.find(params[:id])`.
|
|
11
|
-
* `build
|
|
37
|
+
* `finder:` on `:show`/`:update`/`:destroy`/`:edit`: a `Proc`/lambda invoked with the controller's params, or a `Symbol` naming a class method on the model. Defaults to `klass.find(params[:id])`.
|
|
38
|
+
* New `simple_crud_for :edit`: the find-instead-of-build twin of `:new`. It looks the record up (honoring `finder:`), authorizes it and renders `edit.html.erb` with `@record` in HTML mode, returning the record as JSON otherwise. A matching `simple crud for edit` shared example covers the authorized/denied, not-found and html/json/block paths.
|
|
39
|
+
* `build:` on `:new`/`:create` for owner-scoped or nested builds (`current_user.projects.build`); runs with the controller as `self`, and `:create` assigns the permitted params to the built record before saving.
|
|
12
40
|
* With `authorize: true`, `:index` paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, with a per-action `scope: ->(user[, params]) { ... }` override.
|
|
13
41
|
* `redirect:` on HTML-mode `:create`/`:update`/`:destroy`: a `Proc` called with the record, or a literal path, overriding the default success redirect.
|
|
14
42
|
|
|
@@ -17,6 +45,7 @@ Validation errors:
|
|
|
17
45
|
|
|
18
46
|
Authorization changes:
|
|
19
47
|
* Breaking: `authorize: true` now always enforces policy checks, including for `authenticate: false` actions, which reach policies with a `nil` user. Policies must tolerate a `nil` user.
|
|
48
|
+
* Breaking: `:index` authorizes the model class instead of an unsaved instance, matching the Pundit/CanCanCan collection conventions. `index?` policies can no longer inspect record state (there is none), so write them against the user.
|
|
20
49
|
* `SimpleCrud::Config.user_method` (default `:current_user`) selects the controller method providing the user to policies and scope lambdas, so apps with other conventions (`current_admin`, ...) need no shims.
|
|
21
50
|
|
|
22
51
|
Adapter interfaces:
|
data/README.md
CHANGED
|
@@ -18,6 +18,7 @@ SimpleCrud
|
|
|
18
18
|
- [Serializer](#serializer)
|
|
19
19
|
- [HTML](#html)
|
|
20
20
|
- [Finder](#finder)
|
|
21
|
+
- [Controller-level defaults](#controller-level-defaults)
|
|
21
22
|
- [Shared examples](#shared-examples)
|
|
22
23
|
- [Contributing](#contributing)
|
|
23
24
|
- [Releases](#releases)
|
|
@@ -68,12 +69,6 @@ Before SimpleCrud can be used, some boilerplate is needed. Add the following to
|
|
|
68
69
|
include Pundit::Authorization
|
|
69
70
|
include Wor::Paginate
|
|
70
71
|
extend SimpleCrudController
|
|
71
|
-
|
|
72
|
-
before_action :set_params
|
|
73
|
-
|
|
74
|
-
def set_params
|
|
75
|
-
SimpleCrudController.params = params
|
|
76
|
-
end
|
|
77
72
|
```
|
|
78
73
|
|
|
79
74
|
(Skip `include Pundit::Authorization` / `include Wor::Paginate` if you're using a different adapter, or `authorize: false` / `paginate: false` everywhere.)
|
|
@@ -95,6 +90,7 @@ simple_crud_for :index
|
|
|
95
90
|
simple_crud_for :create
|
|
96
91
|
simple_crud_for :destroy
|
|
97
92
|
simple_crud_for :new
|
|
93
|
+
simple_crud_for :edit
|
|
98
94
|
```
|
|
99
95
|
|
|
100
96
|
Each method supports different options, as in:
|
|
@@ -104,15 +100,32 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
|
|
|
104
100
|
|
|
105
101
|
- Paginate: whether it should paginate or not. `true` paginates via the configured pagination adapter (wor-paginate by default), `false` doesn't paginate
|
|
106
102
|
- Authorize: whether it should check authorization via the configured authorization adapter (Pundit by default)
|
|
107
|
-
- Authenticate: whether
|
|
103
|
+
- Authenticate: whether the generated action calls `authenticate_user!`. `true` (default) or `false`
|
|
104
|
+
- Authenticate_headers: whether shared examples set auth headers and run the unauthorized test. Defaults to `authenticate:`. Set independently when a base-controller `before_action` handles auth and the lambda should skip `authenticate_user!`
|
|
108
105
|
- Serializer: specify a particular serializer you should use
|
|
109
|
-
- Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
|
|
106
|
+
- Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:edit`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
|
|
110
107
|
- Scope: only valid for `:index`. A `Proc`/`lambda` taking `current_user` (plus the controller's `params` if it takes a second argument) that returns the relation to list, overriding the default `policy_scope`. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
|
|
111
|
-
- Finder: only valid for `:show`, `:update` and `:
|
|
112
|
-
- Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.
|
|
108
|
+
- Finder: only valid for `:show`, `:update`, `:destroy` and `:edit`. A `Proc`/`lambda` (invoked with the controller's params) or a `Symbol` naming a class method on the model, used to look up the record instead of `klass.find(params[:id])`.
|
|
109
|
+
- Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.projects.build`. `:create` then assigns the permitted params to the built record before saving
|
|
113
110
|
- Raise_on_invalid: only valid for `:create` and `:update`. Keeps the strict `create!`/`update!` semantics (raising on invalid input) instead of returning `422` with the validation errors
|
|
114
111
|
- Redirect: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `Proc`/`lambda` (called with the record) or a literal path used as the success redirect target. Defaults to the record (`:create`/`:update`) or the model's collection path (`:destroy`)
|
|
115
112
|
|
|
113
|
+
#### Controller-level defaults
|
|
114
|
+
|
|
115
|
+
`simple_crud_defaults` sets option defaults for every `simple_crud_for` in the controller. Subclasses inherit; per-action options override.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class Api::BaseController < ActionController::API
|
|
119
|
+
extend SimpleCrudController
|
|
120
|
+
simple_crud_defaults authorize: false, authenticate: false, authenticate_headers: true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class Api::PostsController < Api::BaseController
|
|
124
|
+
simple_crud_for :show # inherits defaults
|
|
125
|
+
simple_crud_for :create, authorize: true # overrides for this action only
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
116
129
|
You'll need a few things so they work correctly:
|
|
117
130
|
|
|
118
131
|
### Options
|
|
@@ -246,7 +259,13 @@ end
|
|
|
246
259
|
```
|
|
247
260
|
|
|
248
261
|
#### Authenticate
|
|
249
|
-
|
|
262
|
+
`authenticate: true` (default) calls `authenticate_user!` inside the generated action. `authenticate: false` skips it.
|
|
263
|
+
|
|
264
|
+
If your base controller already runs `before_action :authenticate_user!`, set `authenticate: false` to avoid a double call and `authenticate_headers: true` so shared examples still cover the auth path:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
simple_crud_defaults authenticate: false, authenticate_headers: true
|
|
268
|
+
```
|
|
250
269
|
|
|
251
270
|
#### Serializer
|
|
252
271
|
The name of the serializer, by default, is the name of the model followed by Serializer, as is the standard for [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers). It's possible to just pass a custom serializer class though. As for the serializer itself, it's a standard serializer, with the gotcha that you need to include `:id` for the SimpleCrud examples to work.
|
|
@@ -263,6 +282,7 @@ For server-rendered apps, `html: true` renders the action's ERB template instead
|
|
|
263
282
|
- `:index` renders `index.html.erb` with the paginated records exposed as `@records` (pagination still applies, or use `paginate: false`).
|
|
264
283
|
- `:show` renders `show.html.erb` with the record exposed as `@record` (custom `finder:` still applies).
|
|
265
284
|
- `:new` builds a new record, authorizes it, and renders `new.html.erb` with it exposed as `@record`.
|
|
285
|
+
- `:edit` looks the record up (honoring `finder:`), authorizes it, and renders `edit.html.erb` with it exposed as `@record`.
|
|
266
286
|
- `:create` saves and redirects to the created record on success, or re-renders `new.html.erb` (with `@record` and its errors) on failure.
|
|
267
287
|
- `:update` saves and redirects to the record on success, or re-renders `edit.html.erb` on failure.
|
|
268
288
|
- `:destroy` destroys and redirects to the collection (`redirect_to Model`) on success, or re-renders `show.html.erb` if a callback aborts the destroy.
|
|
@@ -271,33 +291,47 @@ For server-rendered apps, `html: true` renders the action's ERB template instead
|
|
|
271
291
|
simple_crud_for :index, html: true
|
|
272
292
|
simple_crud_for :show, html: true
|
|
273
293
|
simple_crud_for :new, html: true
|
|
294
|
+
simple_crud_for :edit, html: true
|
|
274
295
|
simple_crud_for :create, html: true
|
|
275
296
|
simple_crud_for :update, html: true
|
|
276
297
|
simple_crud_for :destroy, html: true
|
|
277
298
|
```
|
|
278
299
|
|
|
279
|
-
Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`.
|
|
300
|
+
Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`. Blocks do not change the `html:` default; pass it explicitly so the shared examples know which request format to use:
|
|
280
301
|
|
|
281
302
|
```ruby
|
|
282
|
-
|
|
303
|
+
# Server-rendered block: pass html: true so shared examples send HTML requests
|
|
304
|
+
simple_crud_for :index, html: true do |records|
|
|
283
305
|
render :index, locals: { models: records }
|
|
284
306
|
end
|
|
285
307
|
|
|
286
|
-
simple_crud_for :create do |record, saved|
|
|
308
|
+
simple_crud_for :create, html: true do |record, saved|
|
|
287
309
|
saved ? redirect_to(record) : render(:new, locals: { model: record })
|
|
288
310
|
end
|
|
311
|
+
|
|
312
|
+
# API block: html: false is the default
|
|
313
|
+
simple_crud_for :show, html: false do |record|
|
|
314
|
+
render json: record.as_json(only: %i[id name])
|
|
315
|
+
end
|
|
289
316
|
```
|
|
290
317
|
|
|
291
318
|
#### Build
|
|
292
|
-
`simple_crud_for :new` and `simple_crud_for :create` build the record with `klass.new`, which can't express owner-scoped or nested records (`current_user.
|
|
319
|
+
`simple_crud_for :new` and `simple_crud_for :create` build the record with `klass.new`, which can't express owner-scoped or nested records (`current_user.projects.build`, `@project.tasks.build`). Pass a `build:` lambda; it runs with the controller as `self`, so `current_user`, `params` and any instance variables set by a `before_action` are available:
|
|
293
320
|
|
|
294
321
|
```ruby
|
|
295
|
-
simple_crud_for :new, build: -> { current_user.
|
|
296
|
-
simple_crud_for :create, build: -> {
|
|
322
|
+
simple_crud_for :new, build: -> { current_user.projects.build }
|
|
323
|
+
simple_crud_for :create, build: -> { @project.tasks.build }
|
|
297
324
|
```
|
|
298
325
|
|
|
299
326
|
`:create` assigns the permitted params to the built record before saving, so the owner/parent association survives. `:update` keeps finding the record via the `finder:`.
|
|
300
327
|
|
|
328
|
+
#### Edit
|
|
329
|
+
`simple_crud_for :edit` is the find-instead-of-build twin of `:new`, the last hand-written CRUD action in server-rendered apps. It looks the record up (honoring `finder:` exactly like `:show`), authorizes it, and renders `edit.html.erb` with the record exposed as `@record`; in JSON mode it returns the record:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
simple_crud_for :edit, finder: ->(params) { Project.find_by!(slug: params[:slug]) }
|
|
333
|
+
```
|
|
334
|
+
|
|
301
335
|
#### Finder
|
|
302
336
|
By default records are looked up by primary key via `klass.find(params[:id])`. The `finder:` option on `:show`, `:update` and `:destroy` replaces that with any lookup you want: pretty URLs (`resources :posts, param: :slug`), tokens, composite keys, or scoping by a parent resource. Pass a `Proc`/`lambda` that maps the controller's `params` to a record, or a `Symbol` naming a class method on the model that takes the params:
|
|
303
337
|
|
|
@@ -310,7 +344,7 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
|
|
|
310
344
|
When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
|
|
311
345
|
|
|
312
346
|
### Shared examples
|
|
313
|
-
While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use `simple_crud_for`, as a set of basic tests. To include them,
|
|
347
|
+
While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use `simple_crud_for`, as a set of basic tests. To include them, add `require 'simple_crud/rspec'` to your `rails_helper.rb` **after** `require "rspec/rails"`, then add the lines you need to your `*_spec.rb` files:
|
|
314
348
|
```ruby
|
|
315
349
|
require 'rails_helper'
|
|
316
350
|
|
|
@@ -323,10 +357,11 @@ describe V1::Backoffice::AuthorsController do
|
|
|
323
357
|
end
|
|
324
358
|
```
|
|
325
359
|
|
|
326
|
-
The `create` and `update` examples cover the `422` response with validation errors (skipped when `raise_on_invalid: true
|
|
360
|
+
The `create` and `update` examples cover the `422` response with validation errors (skipped when `raise_on_invalid: true`, or whenever the action uses a render block, since the block decides how the response is rendered), and all base examples adapt to `html: true` controllers (asserting the rendered template/redirect instead of JSON). Controllers using the extra options can include their dedicated examples too:
|
|
327
361
|
|
|
328
362
|
```ruby
|
|
329
363
|
include_examples 'simple crud for new' # the :new action
|
|
364
|
+
include_examples 'simple crud for edit' # the :edit action
|
|
330
365
|
include_examples 'simple crud for index with block' # render block
|
|
331
366
|
include_examples 'simple crud for index with scope' # scope: ->(user) { ... }
|
|
332
367
|
include_examples 'simple crud for show with block' # render block on :show
|
|
@@ -359,8 +394,11 @@ SimpleCrud::RSpec.configure do |config|
|
|
|
359
394
|
config.create_records = ->(klass, count, attributes) { count.times.map { klass.create!(attributes) } }
|
|
360
395
|
config.params_for = ->(klass) { klass.new.attributes.slice('title') }
|
|
361
396
|
|
|
362
|
-
# The owner association
|
|
363
|
-
|
|
397
|
+
# The owner association used when building records for the examples
|
|
398
|
+
# (model_attributes defaults to `{ owner_association => current_user }`;
|
|
399
|
+
# override model_attributes directly for multi-key or non-user-owned models).
|
|
400
|
+
config.owner_association = :owner
|
|
401
|
+
config.model_attributes = -> { { owner: current_user } }
|
|
364
402
|
config.required_attribute = :title
|
|
365
403
|
config.required_error = "Title can't be blank"
|
|
366
404
|
|
|
@@ -371,24 +409,53 @@ SimpleCrud::RSpec.configure do |config|
|
|
|
371
409
|
config.assert_html_template = false
|
|
372
410
|
|
|
373
411
|
# Server-rendered apps usually use nested strong params
|
|
374
|
-
# (params.require(:
|
|
412
|
+
# (params.require(:task).permit(:name)); wrap request bodies under the
|
|
375
413
|
# model's params key instead of posting flat params.
|
|
376
|
-
config.params_key = :
|
|
414
|
+
config.params_key = :task
|
|
377
415
|
|
|
378
|
-
# Nested resources (/
|
|
416
|
+
# Nested resources (/projects/:project_slug/tasks): extra params
|
|
379
417
|
# (e.g. the parent slug) added to every request.
|
|
380
|
-
config.route_params = -> { {
|
|
418
|
+
config.route_params = -> { { project_slug: project.slug } }
|
|
381
419
|
|
|
382
420
|
# Re-render the form with a 422 on validation failure (instead of 200).
|
|
383
421
|
config.invalid_status = :unprocessable_entity
|
|
384
422
|
|
|
423
|
+
# How the 'simple crud for index with scope' example builds the records
|
|
424
|
+
# that match the controller's scope and the ones that must be excluded.
|
|
425
|
+
# Defaults derive from model_attributes (mine) plus an owner_association
|
|
426
|
+
# => other_user variant (theirs); override both when your scope keys on
|
|
427
|
+
# something other than the owner association.
|
|
428
|
+
config.scoped_attributes = -> { { project: project } }
|
|
429
|
+
config.other_scoped_attributes = -> { { project: other_project } }
|
|
430
|
+
|
|
385
431
|
# Pundit policy and serializer class lookup.
|
|
386
432
|
config.policy_class = ->(klass) { "#{klass}Policy".constantize }
|
|
387
433
|
config.serializer_class = ->(model) { "#{model.class}Serializer".constantize }
|
|
434
|
+
|
|
435
|
+
# Optional extra assertions on the persisted record in the create/update
|
|
436
|
+
# success paths (catches regressions that save into the wrong parent).
|
|
437
|
+
config.created_record_check = ->(record) { expect(record.project).to eq(project) }
|
|
438
|
+
end
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `model_attributes`, `scoped_attributes`, `other_scoped_attributes`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc.; `created_record_check` receives the persisted record. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep `assert_html_template` on (the default), add `gem 'rails-controller-testing'` for the `render_template` matcher.
|
|
442
|
+
|
|
443
|
+
#### Per-controller overrides via metadata
|
|
444
|
+
|
|
445
|
+
Any setting can also be overridden per controller (or per example) with `simple_crud:` metadata instead of globally, useful when the app matches the defaults except for one resource. Metadata wins over global config, and every read happens at example runtime, so there are no around hooks or state to restore:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
RSpec.describe TasksController, type: :controller, simple_crud: {
|
|
449
|
+
model_attributes: -> { { project: project } },
|
|
450
|
+
route_params: -> { { project_slug: project.slug } }
|
|
451
|
+
} do
|
|
452
|
+
let(:project) { create(:project, owner: current_user) }
|
|
453
|
+
|
|
454
|
+
include_examples 'simple crud for show'
|
|
388
455
|
end
|
|
389
456
|
```
|
|
390
457
|
|
|
391
|
-
|
|
458
|
+
Callable settings resolve in the example context, so their lambdas can close over the spec's `let`s; that's how records land in the same project the route params point at.
|
|
392
459
|
|
|
393
460
|
## Contributing
|
|
394
461
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCrud
|
|
4
|
+
# Builds the lambda installed as each action declared with simple_crud_for.
|
|
5
|
+
module ActionLambdas
|
|
6
|
+
def crud_lambda_for_show(klass, parameters = {}, &block)
|
|
7
|
+
lambda do
|
|
8
|
+
authenticate_user! if parameters[:authenticate]
|
|
9
|
+
requested = SimpleCrudController.find_record(klass, self, parameters)
|
|
10
|
+
|
|
11
|
+
options = {}.merge(serializer: parameters[:serializer]).compact
|
|
12
|
+
SimpleCrudController.maybe_authorize(self, requested, parameters)
|
|
13
|
+
SimpleCrudController.render_show(self, requested, options, parameters, &block)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def crud_lambda_for_new(klass, parameters = {}, &block)
|
|
18
|
+
lambda do
|
|
19
|
+
authenticate_user! if parameters[:authenticate]
|
|
20
|
+
record = SimpleCrudController.build_record(self, klass, parameters)
|
|
21
|
+
SimpleCrudController.maybe_authorize(self, record, parameters)
|
|
22
|
+
SimpleCrudController.render_new(self, record, parameters, &block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The find-instead-of-build twin of :new.
|
|
27
|
+
def crud_lambda_for_edit(klass, parameters = {}, &block)
|
|
28
|
+
lambda do
|
|
29
|
+
authenticate_user! if parameters[:authenticate]
|
|
30
|
+
requested = SimpleCrudController.find_record(klass, self, parameters)
|
|
31
|
+
SimpleCrudController.maybe_authorize(self, requested, parameters)
|
|
32
|
+
SimpleCrudController.render_edit(self, requested, parameters, &block)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def crud_lambda_for_index(klass, parameters = {}, &block)
|
|
37
|
+
lambda do
|
|
38
|
+
authenticate_user! if parameters[:authenticate]
|
|
39
|
+
SimpleCrudController.maybe_authorize(self, klass, parameters)
|
|
40
|
+
options = {}.merge(each_serializer: parameters[:serializer]).compact
|
|
41
|
+
SimpleCrudController.render_index(self, klass, options, parameters, &block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def crud_lambda_for_create(klass, parameters = {}, &block)
|
|
46
|
+
lambda do
|
|
47
|
+
authenticate_user! if parameters[:authenticate]
|
|
48
|
+
permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
|
|
49
|
+
record = SimpleCrudController.build_record(self, klass, parameters)
|
|
50
|
+
record.assign_attributes(permitted_params)
|
|
51
|
+
SimpleCrudController.maybe_authorize(self, record, parameters)
|
|
52
|
+
persist = ->(bang:) { bang ? record.save! : record.save }
|
|
53
|
+
options = { status: :created, failure_template: :new }
|
|
54
|
+
SimpleCrudController.save_and_render(self, record, parameters, options, persist, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def crud_lambda_for_update(klass, parameters = {}, &block)
|
|
59
|
+
lambda do
|
|
60
|
+
authenticate_user! if parameters[:authenticate]
|
|
61
|
+
requested = SimpleCrudController.find_record(klass, self, parameters)
|
|
62
|
+
SimpleCrudController.maybe_authorize(self, requested, parameters)
|
|
63
|
+
permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
|
|
64
|
+
persist = ->(bang:) { bang ? requested.update!(permitted_params) : requested.update(permitted_params) }
|
|
65
|
+
options = { status: :ok, failure_template: :edit }
|
|
66
|
+
SimpleCrudController.save_and_render(self, requested, parameters, options, persist, &block)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def crud_lambda_for_destroy(klass, parameters = {}, &block)
|
|
71
|
+
lambda do
|
|
72
|
+
authenticate_user! if parameters[:authenticate]
|
|
73
|
+
requested = SimpleCrudController.find_record(klass, self, parameters)
|
|
74
|
+
SimpleCrudController.maybe_authorize(self, requested, parameters)
|
|
75
|
+
options = { status: :ok, failure_template: :show, redirect: parameters[:redirect] || klass }
|
|
76
|
+
persist = ->(bang:) { bang ? requested.destroy! : requested.destroy }
|
|
77
|
+
SimpleCrudController.persist_and_render(self, requested, parameters, options, persist, &block)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -33,11 +33,20 @@ module SimpleCrud
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def render_new(controller, record, parameters, &block)
|
|
36
|
+
render_form(controller, record, :new, parameters, &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render_edit(controller, record, parameters, &block)
|
|
40
|
+
render_form(controller, record, :edit, parameters, &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_form(controller, record, template, parameters, &block)
|
|
36
44
|
if parameters[:html] || block
|
|
37
45
|
controller.instance_variable_set(:@record, record)
|
|
38
|
-
block ? controller.instance_exec(record, &block) : controller.render(
|
|
46
|
+
block ? controller.instance_exec(record, &block) : controller.render(template)
|
|
39
47
|
else
|
|
40
|
-
|
|
48
|
+
options = {}.merge(serializer: parameters[:serializer]).compact
|
|
49
|
+
controller.render({ json: record }.merge(options))
|
|
41
50
|
end
|
|
42
51
|
end
|
|
43
52
|
|
|
@@ -14,10 +14,10 @@ module SimpleCrud
|
|
|
14
14
|
# Serializers), so apps on a different stack can override them via
|
|
15
15
|
# SimpleCrud::RSpec.configure instead of editing the examples.
|
|
16
16
|
#
|
|
17
|
-
# Callable settings (authenticate, current_user,
|
|
18
|
-
# create_records,
|
|
19
|
-
# the example-group context, so
|
|
20
|
-
# `request` and `current_user`.
|
|
17
|
+
# Callable settings (authenticate, current_user, other_user,
|
|
18
|
+
# create_record, create_records, params_for, model_attributes,
|
|
19
|
+
# policy_class, serializer_class) run in the example-group context, so
|
|
20
|
+
# they can call helpers such as `create`, `request` and `current_user`.
|
|
21
21
|
class Config
|
|
22
22
|
DEFAULTS = {
|
|
23
23
|
authenticate: lambda {
|
|
@@ -30,6 +30,18 @@ module SimpleCrud
|
|
|
30
30
|
create_records: ->(klass, count, attributes) { create_list(klass, count, **attributes) },
|
|
31
31
|
params_for: ->(klass) { attributes_for(klass) },
|
|
32
32
|
owner_association: :user,
|
|
33
|
+
model_attributes: -> { owner_association ? { owner_association => current_user } : {} },
|
|
34
|
+
scoped_attributes: -> { model_attributes },
|
|
35
|
+
other_scoped_attributes: lambda {
|
|
36
|
+
attributes = model_attributes
|
|
37
|
+
if owner_association && !attributes.is_a?(Hash)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
'model_attributes must resolve to a Hash when owner_association is set; ' \
|
|
40
|
+
'override other_scoped_attributes instead'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
owner_association ? attributes.merge(owner_association => other_user) : {}
|
|
44
|
+
},
|
|
33
45
|
required_attribute: :name,
|
|
34
46
|
required_error: "Name can't be blank",
|
|
35
47
|
finder_key: :slug,
|
|
@@ -39,7 +51,8 @@ module SimpleCrud
|
|
|
39
51
|
unauthenticated_status: :unauthorized,
|
|
40
52
|
assert_html_template: true,
|
|
41
53
|
policy_class: ->(klass) { "#{klass}Policy".constantize },
|
|
42
|
-
serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize }
|
|
54
|
+
serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize },
|
|
55
|
+
created_record_check: nil
|
|
43
56
|
}.freeze
|
|
44
57
|
|
|
45
58
|
class << self
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCrud
|
|
4
|
+
module RSpec
|
|
5
|
+
module Helpers
|
|
6
|
+
# Current/other users, signing requests in and the owner association
|
|
7
|
+
# attributed to the current user.
|
|
8
|
+
module Authentication
|
|
9
|
+
def current_user
|
|
10
|
+
@current_user ||= instance_exec(&setting(:current_user))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def other_user
|
|
14
|
+
@other_user ||= instance_exec(&setting(:other_user))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authenticate_request
|
|
18
|
+
instance_exec(&setting(:authenticate))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def owner_foreign_key
|
|
22
|
+
:"#{owner_association}_id"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The owner association attributed to current_user, if the model has one.
|
|
26
|
+
def owner_params
|
|
27
|
+
owner_association ? { owner_foreign_key => current_user.id } : {}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCrud
|
|
4
|
+
module RSpec
|
|
5
|
+
module Helpers
|
|
6
|
+
# Definition-time introspection of the options each action was declared
|
|
7
|
+
# with via simple_crud_for (read from the controller's metadata).
|
|
8
|
+
module ControllerOptions
|
|
9
|
+
def get_option(method, option)
|
|
10
|
+
described_class.instance_variable_get(:@simple_crud_metadata)[method][option]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
%i[paginate authorize authenticate serializer html finder scope build raise_on_invalid block
|
|
14
|
+
authenticate_headers].each do |option|
|
|
15
|
+
define_method("check_#{option}") { |method| get_option(method, option) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_authenticate(method)
|
|
19
|
+
check_authenticate_headers(method)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCrud
|
|
4
|
+
module RSpec
|
|
5
|
+
module Helpers
|
|
6
|
+
# Building the record under test and the params used to persist it.
|
|
7
|
+
module Models
|
|
8
|
+
def model_class
|
|
9
|
+
described_class.to_s.split('::')
|
|
10
|
+
.last.sub('Controller', '').singularize.underscore
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def model_class_object
|
|
14
|
+
model_class.classify.constantize
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def model
|
|
18
|
+
@model ||= begin
|
|
19
|
+
validate_owner_association!
|
|
20
|
+
create_record(model_class, model_attributes)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_owner_association!
|
|
25
|
+
assoc = owner_association
|
|
26
|
+
return unless assoc
|
|
27
|
+
|
|
28
|
+
klass = model_class_object
|
|
29
|
+
return if klass.method_defined?("#{assoc}=") || klass.column_names.include?(assoc.to_s)
|
|
30
|
+
|
|
31
|
+
raise "simple_crud: owner_association is :#{assoc} but #{klass} has no #{assoc}= setter. " \
|
|
32
|
+
'Set `simple_crud: { owner_association: nil }` in your describe block or ' \
|
|
33
|
+
'override model_attributes directly.'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_attributes
|
|
37
|
+
resolve(setting(:model_attributes))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_record(klass, attributes)
|
|
41
|
+
instance_exec(klass, attributes, &setting(:create_record))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_records(klass, count, attributes)
|
|
45
|
+
instance_exec(klass, count, attributes, &setting(:create_records))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def params_for(klass)
|
|
49
|
+
instance_exec(klass, &setting(:params_for))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def model_params
|
|
53
|
+
@model_params ||= params_for(model_class)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def model_serializer
|
|
57
|
+
defined?(serializer) ? serializer : instance_exec(model, &setting(:serializer_class))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rendered_record
|
|
61
|
+
controller.instance_variable_get(:@record)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rendered_records
|
|
65
|
+
controller.instance_variable_get(:@records)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCrud
|
|
4
|
+
module RSpec
|
|
5
|
+
module Helpers
|
|
6
|
+
# Locating the policy object for the model under test and stubbing it
|
|
7
|
+
# to allow or deny.
|
|
8
|
+
module Policies
|
|
9
|
+
def policy_class_object
|
|
10
|
+
instance_exec(model_class_object, &setting(:policy_class))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def make_policies_fail(method)
|
|
14
|
+
allow(policy_class_object).to receive(:new)
|
|
15
|
+
.and_return(instance_double(policy_class_object, "#{method}?" => false))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def make_policies_succeed(method)
|
|
19
|
+
allow(policy_class_object).to receive(:new)
|
|
20
|
+
.and_return(instance_double(policy_class_object, "#{method}?" => true))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|