wor-simple_crud 0.2.1 → 0.3.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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/Gemfile +1 -0
  4. data/README.md +157 -2
  5. data/lib/simple_crud/authorization/adapter.rb +7 -0
  6. data/lib/simple_crud/authorization/pundit_adapter.rb +6 -0
  7. data/lib/simple_crud/config.rb +6 -1
  8. data/lib/simple_crud/controller_helpers.rb +119 -0
  9. data/lib/simple_crud/pagination/adapter.rb +7 -1
  10. data/lib/simple_crud/pagination/kaminari_adapter.rb +23 -2
  11. data/lib/simple_crud/pagination/pagy_adapter.rb +7 -2
  12. data/lib/simple_crud/pagination/will_paginate_adapter.rb +7 -3
  13. data/lib/simple_crud/pagination/wor_paginate_adapter.rb +7 -2
  14. data/lib/simple_crud/rspec/config.rb +61 -0
  15. data/lib/simple_crud/rspec/helpers.rb +147 -0
  16. data/lib/simple_crud/rspec.rb +34 -0
  17. data/lib/simple_crud/simple_crud_controller.rb +51 -36
  18. data/lib/simple_crud/version.rb +1 -1
  19. data/lib/spec/shared_contexts/authenticate_user.rb +1 -7
  20. data/lib/spec/shared_examples/authorization_adapter_authorize.rb +11 -0
  21. data/lib/spec/shared_examples/simple_crud_for_create.rb +43 -11
  22. data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +18 -0
  23. data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +15 -0
  24. data/lib/spec/shared_examples/simple_crud_for_destroy.rb +19 -8
  25. data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +13 -0
  26. data/lib/spec/shared_examples/simple_crud_for_destroy_with_finder.rb +24 -0
  27. data/lib/spec/shared_examples/simple_crud_for_index.rb +8 -23
  28. data/lib/spec/shared_examples/simple_crud_for_index_with_block.rb +14 -0
  29. data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +21 -0
  30. data/lib/spec/shared_examples/simple_crud_for_new.rb +29 -0
  31. data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +9 -0
  32. data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +13 -0
  33. data/lib/spec/shared_examples/simple_crud_for_show.rb +14 -10
  34. data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +11 -0
  35. data/lib/spec/shared_examples/simple_crud_for_show_with_finder.rb +24 -0
  36. data/lib/spec/shared_examples/simple_crud_for_update.rb +55 -15
  37. data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +29 -0
  38. data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +13 -0
  39. data/lib/spec/shared_examples/simple_crud_renders_template.rb +8 -0
  40. data/lib/spec/shared_examples/simple_crud_when_not_authorized.rb +18 -0
  41. data/lib/spec/shared_examples/simple_crud_without_authenticated_user.rb +9 -0
  42. data/lib/spec/shared_examples/unauthorized_when_not_logged_in.rb +2 -2
  43. data/spec/block/dummy_models_controller_spec.rb +8 -0
  44. data/spec/block_create/dummy_models_controller_spec.rb +7 -0
  45. data/spec/block_destroy/dummy_models_controller_spec.rb +7 -0
  46. data/spec/block_new/dummy_models_controller_spec.rb +7 -0
  47. data/spec/block_show/dummy_models_controller_spec.rb +7 -0
  48. data/spec/built/dummy_models_controller_spec.rb +8 -0
  49. data/spec/dummy/app/controllers/block/dummy_models_controller.rb +17 -0
  50. data/spec/dummy/app/controllers/block_create/dummy_models_controller.rb +13 -0
  51. data/spec/dummy/app/controllers/block_destroy/dummy_models_controller.rb +9 -0
  52. data/spec/dummy/app/controllers/block_modes/base_controller.rb +11 -0
  53. data/spec/dummy/app/controllers/block_new/dummy_models_controller.rb +9 -0
  54. data/spec/dummy/app/controllers/block_show/dummy_models_controller.rb +9 -0
  55. data/spec/dummy/app/controllers/built/dummy_models_controller.rb +21 -0
  56. data/spec/dummy/app/controllers/dummy_models_controller.rb +2 -1
  57. data/spec/dummy/app/controllers/finder/dummy_models_controller.rb +22 -0
  58. data/spec/dummy/app/controllers/html/dummy_models_controller.rb +7 -0
  59. data/spec/dummy/app/controllers/html_create/dummy_models_controller.rb +7 -0
  60. data/spec/dummy/app/controllers/html_destroy/dummy_models_controller.rb +7 -0
  61. data/spec/dummy/app/controllers/html_modes/base_controller.rb +17 -0
  62. data/spec/dummy/app/controllers/html_new/dummy_models_controller.rb +7 -0
  63. data/spec/dummy/app/controllers/html_scoped/dummy_models_controller.rb +8 -0
  64. data/spec/dummy/app/controllers/html_show/dummy_models_controller.rb +7 -0
  65. data/spec/dummy/app/controllers/html_update/dummy_models_controller.rb +7 -0
  66. data/spec/dummy/app/controllers/invalid_status/dummy_models_controller.rb +13 -0
  67. data/spec/dummy/app/controllers/nested/dummy_models_controller.rb +21 -0
  68. data/spec/dummy/app/controllers/nested_route/dummy_models_controller.rb +26 -0
  69. data/spec/dummy/app/controllers/redirect/dummy_models_controller.rb +10 -0
  70. data/spec/dummy/app/controllers/redirect_auth/dummy_models_controller.rb +28 -0
  71. data/spec/dummy/app/controllers/scoped/dummy_models_controller.rb +16 -0
  72. data/spec/dummy/app/controllers/scoped_params/dummy_models_controller.rb +16 -0
  73. data/spec/dummy/app/controllers/strict/dummy_models_controller.rb +21 -0
  74. data/spec/dummy/app/models/dummy_model.rb +4 -0
  75. data/spec/dummy/app/models/dummy_model_policy.rb +4 -0
  76. data/spec/dummy/app/views/block/dummy_models/index.html.erb +3 -0
  77. data/spec/dummy/app/views/block_new/dummy_models/new.html.erb +1 -0
  78. data/spec/dummy/app/views/block_show/dummy_models/show.html.erb +1 -0
  79. data/spec/dummy/app/views/html/dummy_models/index.html.erb +3 -0
  80. data/spec/dummy/app/views/html_create/dummy_models/new.html.erb +1 -0
  81. data/spec/dummy/app/views/html_destroy/dummy_models/show.html.erb +1 -0
  82. data/spec/dummy/app/views/html_new/dummy_models/new.html.erb +1 -0
  83. data/spec/dummy/app/views/html_scoped/dummy_models/index.html.erb +3 -0
  84. data/spec/dummy/app/views/html_show/dummy_models/show.html.erb +1 -0
  85. data/spec/dummy/app/views/html_update/dummy_models/edit.html.erb +1 -0
  86. data/spec/dummy/app/views/invalid_status/dummy_models/new.html.erb +1 -0
  87. data/spec/dummy/app/views/layouts/application.html.erb +4 -0
  88. data/spec/dummy/app/views/redirect/dummy_models/new.html.erb +1 -0
  89. data/spec/dummy/config/routes.rb +90 -0
  90. data/spec/dummy/db/migrate/20260806000000_add_slug_to_dummy_models.rb +7 -0
  91. data/spec/dummy/db/migrate/20260807000000_add_classroom_slug_to_dummy_models.rb +7 -0
  92. data/spec/dummy/db/schema.rb +3 -1
  93. data/spec/dummy/spec/factories/dummy_models.rb +1 -0
  94. data/spec/dummy_controller_spec.rb +1 -0
  95. data/spec/dummy_model_policy_spec.rb +8 -16
  96. data/spec/finder/dummy_models_controller_spec.rb +12 -0
  97. data/spec/html/dummy_models_controller_spec.rb +7 -0
  98. data/spec/html_create/dummy_models_controller_spec.rb +7 -0
  99. data/spec/html_destroy/dummy_models_controller_spec.rb +7 -0
  100. data/spec/html_new/dummy_models_controller_spec.rb +7 -0
  101. data/spec/html_scoped/dummy_models_controller_spec.rb +7 -0
  102. data/spec/html_show/dummy_models_controller_spec.rb +7 -0
  103. data/spec/html_update/dummy_models_controller_spec.rb +7 -0
  104. data/spec/invalid_status/dummy_models_controller_spec.rb +10 -0
  105. data/spec/nested/dummy_models_controller_spec.rb +12 -0
  106. data/spec/nested_route/dummy_models_controller_spec.rb +19 -0
  107. data/spec/redirect/dummy_models_controller_spec.rb +23 -0
  108. data/spec/redirect_auth/dummy_models_controller_spec.rb +11 -0
  109. data/spec/scoped/dummy_models_controller_spec.rb +7 -0
  110. data/spec/scoped_params/dummy_models_controller_spec.rb +11 -0
  111. data/spec/simple_crud/authorization/action_policy_adapter_spec.rb +1 -9
  112. data/spec/simple_crud/authorization/can_can_can_adapter_spec.rb +1 -7
  113. data/spec/simple_crud/authorization/pundit_adapter_spec.rb +41 -0
  114. data/spec/simple_crud/controller_helpers_spec.rb +49 -0
  115. data/spec/simple_crud/pagination/adapters_spec.rb +37 -13
  116. data/spec/simple_crud/rspec_config_spec.rb +67 -0
  117. data/spec/simple_crud_configuration_spec.rb +19 -16
  118. data/spec/simple_crud_controller_spec.rb +4 -4
  119. data/spec/spec_helper.rb +1 -0
  120. data/spec/strict/dummy_models_controller_spec.rb +23 -0
  121. metadata +87 -4
  122. data/lib/spec/shared_examples/helpers.rb +0 -50
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f3a2ed3cb6cea9416a6a745959519ccfdd9fd3a35667432939ece4291368266
4
- data.tar.gz: 6b0625c10d66661cff9b09959ddd7f1a0eae630c0a519fa34116855b38588377
3
+ metadata.gz: 667672176b1c817bdcf1d9c5248e1b3701253d52f289a85bc2e4b3e632c47188
4
+ data.tar.gz: f03748bda6004b670a8989fed13d82e0c6e0e0ec6bd9323a1c4e43e786da4dfe
5
5
  SHA512:
6
- metadata.gz: c4b2ab3b62121f5409a0d11d498a094162f847027941deb4abb3c72dc0c5c623934c3aeee5a65c64149039fbf2c09d4e9f9a8e10da6818ad32e236545e592331
7
- data.tar.gz: 75211a88220e4328897ba0c36ebd55b04742bc253874f711cf14e644ff5a9d595455003facfb7ad9896346e63570d6dffb36fbeb947e34261a17e38ddb60aa80
6
+ metadata.gz: '04257318d37af96ef5df3d17d2561dfad551cf3eaca51dc4630d7e7d9a7d19cd05852a4b31e00902ab245610d0e35379e3314586f18646afe3ddb246a17a1756'
7
+ data.tar.gz: 81b0fda087fc2723f8f7a6789f7f2542037b7054b36e9db8aed05eba03f5cdbcb0c38fdf45da85f60e9b3e67c58332118d73be257e5d16b108559eade35adba7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  ## Change log
2
2
 
3
+ ### V0.3.0
4
+
5
+ Server-rendered (HTML) support:
6
+ * `html: true` or a render block on every action: `:index` renders `index.html.erb` with the paginated records as `@records`; `:show`/`:new` expose `@record`; `:create`/`:update` redirect to the record on success and re-render the form with errors on failure; `:destroy` redirects to the collection (re-rendering `show.html.erb` when a callback aborts). Blocks receive the records (`:index`), the record (`:show`/`:new`) or `(record, saved)` (`:create`/`:update`/`:destroy`) and override the auto-render.
7
+ * New `:new` action: builds a record, authorizes it, and renders `new.html.erb` (or returns JSON in API mode).
8
+
9
+ 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:` on `:new`/`:create` for owner-scoped or nested builds (`current_user.classrooms.build`); runs with the controller as `self`, and `:create` assigns the permitted params to the built record before saving.
12
+ * 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
+ * `redirect:` on HTML-mode `:create`/`:update`/`:destroy`: a `Proc` called with the record, or a literal path, overriding the default success redirect.
14
+
15
+ Validation errors:
16
+ * `:create` returns `201` / `422 { errors: [...] }` and `:update` returns `200` / `422` instead of raising; `raise_on_invalid: true` keeps strict `create!`/`update!` semantics.
17
+
18
+ Authorization changes:
19
+ * 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.
20
+ * `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
+
22
+ Adapter interfaces:
23
+ * Authorization adapters gain `policy_scope`; pagination adapters gain `paginated_records`, so HTML-mode `:index` can fetch the current page without rendering. Both are implemented by every shipped adapter (Pundit, CanCanCan, Action Policy / wor-paginate, Kaminari, will_paginate, Pagy).
24
+
25
+ Fixes:
26
+ * Kaminari adapter: string query params corrupted offsets (`?page=3&per_page=10` resolved to `OFFSET 1010`), and `per_page` now respects `Kaminari.config.max_per_page`.
27
+ * Custom finders returning anything but a single record raise `ActiveRecord::RecordNotFound` instead of failing later with an obscure error.
28
+ * Invalid `simple_crud_for` usage (unknown action, missing policy, missing serializer) raises `ArgumentError` instead of throwing uncatchable string tags.
29
+
30
+ Testing library:
31
+ * The shared examples are a reusable library: configure via `SimpleCrud::RSpec.configure`, wire up explicitly with `SimpleCrud::RSpec.install!`. Every app-specific assumption has an overridable default matching the gem's own stack (Devise-JWT + FactoryBot + Pundit + AMS): `authenticate`/`current_user`/`other_user`, `create_record`/`create_records`/`params_for`, `owner_association`/`required_attribute`/`required_error`, `finder_key`, `params_key`, `route_params`, `invalid_status`, `unauthenticated_status`, `assert_html_template`, `policy_class`, `serializer_class`.
32
+ * Examples are HTML-aware (branching on the `html:` option, inferring it for block-form actions) and issue requests by action name, so one set covers API and server-rendered controllers, namespaced and nested routes alike.
33
+ * Refactor `SimpleCrudController` helpers into `SimpleCrud::ControllerHelpers`, dedupe repeated test code, and require `rails-controller-testing` for template assertions. Tested across Rails 6.1-8.1.
34
+
3
35
  ### V0.2.1
4
36
  * Rename the published gem from `simple_crud` to `wor-simple_crud`. `simple_crud` is already taken on RubyGems by an unrelated gem, and RubyGems also rejects names that differ from an existing one only by hyphen/underscore (so `simple-crud` was rejected too). Install with `gem 'wor-simple_crud', require: 'simple_crud'` (the require path is unchanged).
5
37
  * Drop the last Wolox references now that this gem lives at icoluccio/simple-crud, and point the wor-paginate link and CI/gem badges at their new homes.
data/Gemfile CHANGED
@@ -28,6 +28,7 @@ group :development, :test do
28
28
  gem 'overcommit', '~> 0.72'
29
29
  gem 'pagy', '~> 43.0', require: false
30
30
  gem 'pundit', '~> 2.5'
31
+ gem 'rails-controller-testing', '~> 1.0'
31
32
  gem 'rake', '~> 13.4'
32
33
  gem 'rspec', '~> 3.13'
33
34
  gem 'rspec-rails', '>= 6.0', '< 9'
data/README.md CHANGED
@@ -14,7 +14,10 @@ SimpleCrud
14
14
  - [Options](#options)
15
15
  - [Paginate](#paginate)
16
16
  - [Authorize](#authorize)
17
+ - [Authenticate](#authenticate)
17
18
  - [Serializer](#serializer)
19
+ - [HTML](#html)
20
+ - [Finder](#finder)
18
21
  - [Shared examples](#shared-examples)
19
22
  - [Contributing](#contributing)
20
23
  - [Releases](#releases)
@@ -64,7 +67,7 @@ Before SimpleCrud can be used, some boilerplate is needed. Add the following to
64
67
  ```ruby
65
68
  include Pundit::Authorization
66
69
  include Wor::Paginate
67
- extend SimpleCrud
70
+ extend SimpleCrudController
68
71
 
69
72
  before_action :set_params
70
73
 
@@ -91,6 +94,7 @@ simple_crud_for :show
91
94
  simple_crud_for :index
92
95
  simple_crud_for :create
93
96
  simple_crud_for :destroy
97
+ simple_crud_for :new
94
98
  ```
95
99
 
96
100
  Each method supports different options, as in:
@@ -102,6 +106,12 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
102
106
  - Authorize: whether it should check authorization via the configured authorization adapter (Pundit by default)
103
107
  - Authenticate: whether it should use Devise to check for a current_user
104
108
  - 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
110
+ - 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 `:destroy`. 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])`.
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.classrooms.build`. `:create` then assigns the permitted params to the built record before saving
113
+ - 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
+ - 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`)
105
115
 
106
116
  You'll need a few things so they work correctly:
107
117
 
@@ -143,10 +153,11 @@ end
143
153
 
144
154
  SimpleCrud.configure { |config| config.pagination_adapter = MyPaginationAdapter.new }
145
155
  ```
146
-
147
156
  #### Authorize
148
157
  Authorization checks go through [Pundit](https://github.com/varvet/pundit) by default. Name the policy after the model plus `Policy`, e.g. `AuthorPolicy`, written as a regular Pundit policy:
149
158
 
159
+ > **Note:** authorization runs whenever `authorize: true`, independently of `authenticate`. With no signed-in user the policy receives `nil` as the user, so write policies that tolerate it (e.g. `user.nil? ? false : ...`). Devise provides `authenticate_user!` and `current_user` for you; non-Devise apps must define a `current_user` (or point `SimpleCrud::Config.user_method` at their own method) for policies and `scope:` lambdas to receive the user.
160
+
150
161
  ```ruby
151
162
  class AuthorPolicy
152
163
  attr_reader :user, :author
@@ -159,10 +170,30 @@ class AuthorPolicy
159
170
  def show?
160
171
  user.present?
161
172
  end
173
+
174
+ class Scope
175
+ def initialize(user, scope)
176
+ @user = user
177
+ @scope = scope
178
+ end
179
+
180
+ # Used by :index to scope the listed records to the current user.
181
+ def resolve
182
+ @scope.where(user: @user)
183
+ end
184
+ end
162
185
  end
163
186
 
164
187
  ```
165
188
 
189
+ When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with the `scope:` option, a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument):
190
+
191
+ ```ruby
192
+ SimpleCrud.configure { |c| c.user_method = :current_admin } # non-Devise naming conventions
193
+ simple_crud_for :index, scope: ->(user) { Model.visible_to(user) }
194
+ simple_crud_for :index, scope: ->(user, params) { Model.where(status: params[:status]).visible_to(user) }
195
+ ```
196
+
166
197
  Prefer CanCanCan or Action Policy instead? Both have adapters ready to go:
167
198
 
168
199
  ```ruby
@@ -200,6 +231,13 @@ class MyAuthorizationAdapter
200
231
  def policy_defined?(model_class)
201
232
  Kernel.const_defined?("#{model_class}Policy")
202
233
  end
234
+
235
+ # Called when rendering :index with authorize: true. Must return the
236
+ # relation the current user is allowed to list. The default returns
237
+ # the full relation.
238
+ def policy_scope(controller, model_class)
239
+ model_class.all
240
+ end
203
241
  end
204
242
 
205
243
  SimpleCrud.configure do |config|
@@ -219,6 +257,58 @@ class AuthorSerializer < ActiveModel::Serializer
219
257
  end
220
258
  ```
221
259
 
260
+ #### HTML
261
+ For server-rendered apps, `html: true` renders the action's ERB template instead of JSON, so be sure the controller can render templates (e.g. `ActionController::Base`). Behavior per action:
262
+
263
+ - `:index` renders `index.html.erb` with the paginated records exposed as `@records` (pagination still applies, or use `paginate: false`).
264
+ - `:show` renders `show.html.erb` with the record exposed as `@record` (custom `finder:` still applies).
265
+ - `:new` builds a new record, authorizes it, and renders `new.html.erb` with it exposed as `@record`.
266
+ - `:create` saves and redirects to the created record on success, or re-renders `new.html.erb` (with `@record` and its errors) on failure.
267
+ - `:update` saves and redirects to the record on success, or re-renders `edit.html.erb` on failure.
268
+ - `:destroy` destroys and redirects to the collection (`redirect_to Model`) on success, or re-renders `show.html.erb` if a callback aborts the destroy.
269
+
270
+ ```ruby
271
+ simple_crud_for :index, html: true
272
+ simple_crud_for :show, html: true
273
+ simple_crud_for :new, html: true
274
+ simple_crud_for :create, html: true
275
+ simple_crud_for :update, html: true
276
+ simple_crud_for :destroy, html: true
277
+ ```
278
+
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`. Passing a block also implies `html: true` for the shared examples (so a server-rendered block is asserted as HTML); if your block renders JSON instead, pass `html: false` explicitly:
280
+
281
+ ```ruby
282
+ simple_crud_for :index do |records|
283
+ render :index, locals: { models: records }
284
+ end
285
+
286
+ simple_crud_for :create do |record, saved|
287
+ saved ? redirect_to(record) : render(:new, locals: { model: record })
288
+ end
289
+ ```
290
+
291
+ #### 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.classrooms.build`, `@classroom.assignments.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
+
294
+ ```ruby
295
+ simple_crud_for :new, build: -> { current_user.classrooms.build }
296
+ simple_crud_for :create, build: -> { current_user.classrooms.build }
297
+ ```
298
+
299
+ `: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
+
301
+ #### Finder
302
+ 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
+
304
+ ```ruby
305
+ simple_crud_for :show, finder: ->(params) { Model.find_by!(token: params[:token]) }
306
+ simple_crud_for :update, finder: :find_by_slug
307
+ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:id]) }
308
+ ```
309
+
310
+ When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
311
+
222
312
  ### Shared examples
223
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, just add `require 'simple_crud/rspec'` to your `rails_helper.rb` file and add the lines you need to your `*_spec.rb` files:
224
314
  ```ruby
@@ -233,8 +323,73 @@ describe V1::Backoffice::AuthorsController do
233
323
  end
234
324
  ```
235
325
 
326
+ The `create` and `update` examples cover the `422` response with validation errors (skipped when `raise_on_invalid: true`), 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
+
328
+ ```ruby
329
+ include_examples 'simple crud for new' # the :new action
330
+ include_examples 'simple crud for index with block' # render block
331
+ include_examples 'simple crud for index with scope' # scope: ->(user) { ... }
332
+ include_examples 'simple crud for show with block' # render block on :show
333
+ include_examples 'simple crud for new with block' # render block on :new
334
+ include_examples 'simple crud for create with block' # render block on :create
335
+ include_examples 'simple crud for destroy with block' # render block on :destroy
336
+ include_examples 'simple crud for show with finder' # finder on :show
337
+ include_examples 'simple crud for update with finder' # finder on :update
338
+ include_examples 'simple crud for destroy with finder' # finder on :destroy
339
+ include_examples 'simple crud for new with build' # build: -> { ... } on :new
340
+ include_examples 'simple crud for create with build' # build: -> { ... } on :create
341
+ ```
342
+
236
343
  It's not needed to specify paginate: true and such, since the shared examples will use the configuration that was originally passed to simple_crud_for
237
344
 
345
+ #### Adopting the shared examples
346
+ The shared examples assume the gem's own stack by default (Devise-JWT authentication, FactoryBot, Pundit, ActiveModel Serializers). Wiring is opt-in: require the file and call `SimpleCrud::RSpec.install!` in `spec/spec_helper.rb` (or `rails_helper.rb`), then configure anything your app differs on:
347
+
348
+ ```ruby
349
+ SimpleCrud::RSpec.configure do |config|
350
+ # How the current user (and a secondary "other user") is built.
351
+ config.current_user = -> { User.create!(email: 'user@example.com', password: 'secret') }
352
+ config.other_user = -> { User.create!(email: 'other@example.com', password: 'secret') }
353
+
354
+ # How to sign the current user in for a request (no Devise-JWT here).
355
+ config.authenticate = -> { request.session[:user_id] = current_user.id }
356
+
357
+ # How records and create/update params are built (no FactoryBot here).
358
+ config.create_record = ->(klass, attributes) { klass.create!(attributes) }
359
+ config.create_records = ->(klass, count, attributes) { count.times.map { klass.create!(attributes) } }
360
+ config.params_for = ->(klass) { klass.new.attributes.slice('title') }
361
+
362
+ # The owner association and the validation this app's models enforce.
363
+ config.owner_association = :instructor
364
+ config.required_attribute = :title
365
+ config.required_error = "Title can't be blank"
366
+
367
+ # Redirect-based apps: unauthenticated requests get a redirect, not a 401.
368
+ # And if the rendered template name doesn't match the action, drop the
369
+ # render_template assertion.
370
+ config.unauthenticated_status = :found
371
+ config.assert_html_template = false
372
+
373
+ # Server-rendered apps usually use nested strong params
374
+ # (params.require(:classroom).permit(:name)); wrap request bodies under the
375
+ # model's params key instead of posting flat params.
376
+ config.params_key = :classroom
377
+
378
+ # Nested resources (/classrooms/:classroom_slug/assignments): extra params
379
+ # (e.g. the parent slug) added to every request.
380
+ config.route_params = -> { { classroom_slug: model.classroom_slug } }
381
+
382
+ # Re-render the form with a 422 on validation failure (instead of 200).
383
+ config.invalid_status = :unprocessable_entity
384
+
385
+ # Pundit policy and serializer class lookup.
386
+ config.policy_class = ->(klass) { "#{klass}Policy".constantize }
387
+ config.serializer_class = ->(model) { "#{model.class}Serializer".constantize }
388
+ end
389
+ ```
390
+
391
+ Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc. 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.
392
+
238
393
  ## Contributing
239
394
 
240
395
  1. Fork it
@@ -15,6 +15,13 @@ module SimpleCrud
15
15
  def policy_defined?(model_class)
16
16
  raise NotImplementedError
17
17
  end
18
+
19
+ # Called when rendering :index with authorize: true. Must return the
20
+ # relation the current user is allowed to list. Defaults to the full
21
+ # relation so no scoping dependency is required.
22
+ def policy_scope(_controller, model_class)
23
+ model_class.all
24
+ end
18
25
  end
19
26
  end
20
27
  end
@@ -16,6 +16,12 @@ module SimpleCrud
16
16
  def policy_defined?(model_class)
17
17
  Kernel.const_defined?("#{model_class}Policy")
18
18
  end
19
+
20
+ def policy_scope(controller, model_class)
21
+ controller.send(:policy_scope, model_class)
22
+ rescue Pundit::NotDefinedError
23
+ model_class.all
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -4,7 +4,7 @@ module SimpleCrud
4
4
  # Holds simple_crud's configurable pieces, set via SimpleCrud.configure.
5
5
  class Config
6
6
  class << self
7
- attr_writer :authorization_adapter, :pagination_adapter
7
+ attr_writer :authorization_adapter, :pagination_adapter, :user_method
8
8
 
9
9
  def authorization_adapter
10
10
  @authorization_adapter ||= begin
@@ -19,6 +19,11 @@ module SimpleCrud
19
19
  Pagination::WorPaginateAdapter.new
20
20
  end
21
21
  end
22
+
23
+ # Controller method providing the current user; override for non-Devise apps.
24
+ def user_method
25
+ @user_method ||= :current_user
26
+ end
22
27
  end
23
28
  end
24
29
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Class-level helpers shared by the CRUD lambdas defined in SimpleCrudController.
5
+ module ControllerHelpers
6
+ def maybe_authorize(controller, record, parameters)
7
+ return unless parameters[:authorize]
8
+
9
+ SimpleCrud::Config.authorization_adapter.authorize(controller, record)
10
+ end
11
+
12
+ def render_index(controller, klass, options, parameters, &block)
13
+ relation = index_relation(controller, klass, parameters)
14
+
15
+ if parameters[:html] || block
16
+ records = index_records(controller, relation, options, parameters)
17
+ controller.instance_variable_set(:@records, records)
18
+ block ? controller.instance_exec(records, &block) : controller.render(:index)
19
+ elsif parameters[:paginate]
20
+ SimpleCrud::Config.pagination_adapter.paginate(controller, relation, options)
21
+ else
22
+ controller.render({ json: relation }.merge(options))
23
+ end
24
+ end
25
+
26
+ def render_show(controller, record, options, parameters, &block)
27
+ if parameters[:html] || block
28
+ controller.instance_variable_set(:@record, record)
29
+ block ? controller.instance_exec(record, &block) : controller.render(:show)
30
+ else
31
+ controller.render({ json: record }.merge(options))
32
+ end
33
+ end
34
+
35
+ def render_new(controller, record, parameters, &block)
36
+ if parameters[:html] || block
37
+ controller.instance_variable_set(:@record, record)
38
+ block ? controller.instance_exec(record, &block) : controller.render(:new)
39
+ else
40
+ controller.render json: record
41
+ end
42
+ end
43
+
44
+ def build_record(controller, klass, parameters)
45
+ parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
46
+ end
47
+
48
+ def index_relation(controller, klass, parameters)
49
+ if parameters[:scope]
50
+ call_scope(parameters[:scope], controller)
51
+ elsif parameters[:authorize]
52
+ SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
53
+ else
54
+ klass.all
55
+ end
56
+ end
57
+
58
+ def call_scope(scope, controller)
59
+ user_method = SimpleCrud::Config.user_method
60
+ user = controller.respond_to?(user_method) ? controller.public_send(user_method) : nil
61
+ return scope.call(user) if scope.arity == 1
62
+
63
+ scope.call(user, controller.params)
64
+ end
65
+
66
+ def index_records(controller, relation, options, parameters)
67
+ return relation unless parameters[:paginate]
68
+
69
+ SimpleCrud::Config.pagination_adapter.paginated_records(controller, relation, options)
70
+ end
71
+
72
+ def find_record(klass, controller, parameters)
73
+ record = lookup_record(klass, controller, parameters)
74
+ raise ActiveRecord::RecordNotFound, "couldn't find #{klass}" if record.nil?
75
+ unless record.is_a?(ActiveRecord::Base)
76
+ raise ActiveRecord::RecordNotFound, "#{klass} finder must return a single record"
77
+ end
78
+
79
+ record
80
+ end
81
+
82
+ def lookup_record(klass, controller, parameters)
83
+ finder = parameters[:finder]
84
+ return klass.find(controller.params[:id]) if finder.nil?
85
+ return klass.send(finder, controller.params) unless finder.respond_to?(:call)
86
+
87
+ finder.call(controller.params)
88
+ end
89
+
90
+ def persist_and_render(controller, record, parameters, options, persist, &block)
91
+ saved = parameters[:raise_on_invalid] ? persist.call(bang: true) : persist.call(bang: false)
92
+ return render_persisted(controller, record, saved, options) unless block || parameters[:html]
93
+
94
+ controller.instance_variable_set(:@record, record)
95
+ return controller.instance_exec(record, saved, &block) if block
96
+
97
+ if saved
98
+ controller.redirect_to(redirect_target(controller, record, options[:redirect]))
99
+ else
100
+ controller.render(options[:failure_template])
101
+ end
102
+ end
103
+
104
+ def save_and_render(controller, record, parameters, options, persist, &block)
105
+ persist_and_render(controller, record, parameters,
106
+ options.merge(redirect: parameters[:redirect] || record), persist, &block)
107
+ end
108
+
109
+ def redirect_target(controller, record, target)
110
+ target.is_a?(Proc) ? controller.instance_exec(record, &target) : target
111
+ end
112
+
113
+ def render_persisted(controller, record, saved, options)
114
+ return controller.render(json: record, status: options[:status]) if saved
115
+
116
+ controller.render json: { errors: record.errors.full_messages }, status: 422
117
+ end
118
+ end
119
+ end
@@ -6,7 +6,13 @@ module SimpleCrud
6
6
  # (the default, see WorPaginateAdapter).
7
7
  module Adapter
8
8
  # Called when paginate: true. Must render the response itself.
9
- def paginate(controller, klass, options)
9
+ def paginate(controller, relation, options)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ # Called when rendering :index as HTML (html: true or a block given) and
14
+ # paginate: true. Must return the current page's records without rendering.
15
+ def paginated_records(controller, relation, options)
10
16
  raise NotImplementedError
11
17
  end
12
18
  end
@@ -10,10 +10,31 @@ module SimpleCrud
10
10
  class KaminariAdapter
11
11
  include Adapter
12
12
 
13
- def paginate(controller, klass, options)
14
- records = klass.page(controller.params[:page]).per(controller.params[:per_page])
13
+ def paginate(controller, relation, options)
14
+ records = paginated_records(controller, relation, options)
15
15
  controller.render({ json: records }.merge(options))
16
16
  end
17
+
18
+ # Manual page scope: will_paginate shadows Relation#page when both load.
19
+ def paginated_records(controller, relation, _options)
20
+ page = [controller.params[:page].to_i, 1].max
21
+ per_page = page_size(controller)
22
+
23
+ relation.limit(per_page).offset(per_page * (page - 1)).extending do
24
+ include Kaminari::ActiveRecordRelationMethods
25
+ include Kaminari::PageScopeMethods
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # Params arrive as strings: coerce before arithmetic or "5" * 2 == "55".
32
+ def page_size(controller)
33
+ size = (controller.params[:per_page] || Kaminari.config.default_per_page).to_i
34
+ size = [size, 1].max
35
+ size = [size, Kaminari.config.max_per_page].min if Kaminari.config.max_per_page
36
+ size
37
+ end
17
38
  end
18
39
  end
19
40
  end
@@ -13,10 +13,15 @@ module SimpleCrud
13
13
  # the pre-rename 43.4.x, so pick whichever key the loaded Pagy expects.
14
14
  MAX_LIMIT_KEY = Gem::Version.new(Pagy::VERSION) >= Gem::Version.new('43.5.0') ? :max_limit : :client_max_limit
15
15
 
16
- def paginate(controller, klass, options)
17
- _pagy, records = controller.send(:pagy, klass.all, MAX_LIMIT_KEY => 100)
16
+ def paginate(controller, relation, options)
17
+ records = paginated_records(controller, relation, options)
18
18
  controller.render({ json: records }.merge(options))
19
19
  end
20
+
21
+ def paginated_records(controller, relation, _options)
22
+ _pagy, records = controller.send(:pagy, relation, MAX_LIMIT_KEY => 100)
23
+ records
24
+ end
20
25
  end
21
26
  end
22
27
  end
@@ -11,11 +11,15 @@ module SimpleCrud
11
11
  class WillPaginateAdapter
12
12
  include Adapter
13
13
 
14
- def paginate(controller, klass, options)
14
+ def paginate(controller, relation, options)
15
+ records = paginated_records(controller, relation, options)
16
+ controller.render({ json: records }.merge(options))
17
+ end
18
+
19
+ def paginated_records(controller, relation, _options)
15
20
  page = controller.params[:page] || 1
16
21
  per_page = controller.params[:per_page] || WillPaginate.per_page
17
- records = klass.paginate(page: page, per_page: per_page)
18
- controller.render({ json: records }.merge(options))
22
+ relation.paginate(page: page, per_page: per_page)
19
23
  end
20
24
  end
21
25
  end
@@ -9,8 +9,13 @@ module SimpleCrud
9
9
  class WorPaginateAdapter
10
10
  include Adapter
11
11
 
12
- def paginate(controller, klass, options)
13
- controller.send(:render_paginated, klass, options)
12
+ def paginate(controller, relation, options)
13
+ controller.send(:render_paginated, relation, options)
14
+ end
15
+
16
+ def paginated_records(controller, relation, options)
17
+ adapter = controller.send(:find_adapter_for_content, relation, options)
18
+ adapter.paginated_content
14
19
  end
15
20
  end
16
21
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Test helpers: SimpleCrud::RSpec.configure exposes the shared examples'
5
+ # configuration for apps on a different stack than the gem's.
6
+ module RSpec
7
+ def self.configure
8
+ yield Config.instance
9
+ end
10
+
11
+ # Configures how the shared examples create records, authenticate
12
+ # requests and introspect policies/serializers. The defaults match the
13
+ # gem's own stack (Devise-JWT + FactoryBot + Pundit + ActiveModel
14
+ # Serializers), so apps on a different stack can override them via
15
+ # SimpleCrud::RSpec.configure instead of editing the examples.
16
+ #
17
+ # Callable settings (authenticate, current_user, create_record,
18
+ # create_records, attributes_for, policy_class, serializer_class) run in
19
+ # the example-group context, so they can call helpers such as `create`,
20
+ # `request` and `current_user`.
21
+ class Config
22
+ DEFAULTS = {
23
+ authenticate: lambda {
24
+ headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
25
+ request.headers.merge!(Devise::JWT::TestHelpers.auth_headers(headers, current_user))
26
+ },
27
+ current_user: -> { create(:user) },
28
+ other_user: -> { create(:user) },
29
+ create_record: ->(klass, attributes) { create(klass, **attributes) },
30
+ create_records: ->(klass, count, attributes) { create_list(klass, count, **attributes) },
31
+ params_for: ->(klass) { attributes_for(klass) },
32
+ owner_association: :user,
33
+ required_attribute: :name,
34
+ required_error: "Name can't be blank",
35
+ finder_key: :slug,
36
+ params_key: nil,
37
+ route_params: -> { {} },
38
+ invalid_status: :ok,
39
+ unauthenticated_status: :unauthorized,
40
+ assert_html_template: true,
41
+ policy_class: ->(klass) { "#{klass}Policy".constantize },
42
+ serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize }
43
+ }.freeze
44
+
45
+ class << self
46
+ def instance
47
+ @instance ||= new
48
+ end
49
+ end
50
+
51
+ def initialize
52
+ @values = {}
53
+ end
54
+
55
+ DEFAULTS.each_key do |key|
56
+ define_method(key) { @values.fetch(key, DEFAULTS[key]) }
57
+ define_method("#{key}=") { |value| @values[key] = value }
58
+ end
59
+ end
60
+ end
61
+ end