typical_situation 1.2.0 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1806328a0281819fd09cedb96002dd5e038f56a2b292c608b8e1920bd3e7cf25
4
- data.tar.gz: fb92828cdf08f013652d379c96e52177b1f6005184b24ac6ea9e9273b4d94383
3
+ metadata.gz: 1784b34111a7c2de6a6f429d431d5fc36e00ec033f5e43c1618fc8a198ff534c
4
+ data.tar.gz: 4c40c7fab53aacffff4d30dbe5578fae9019e798384c086b36e4566bdb46baee
5
5
  SHA512:
6
- metadata.gz: 4d997a83448cd809e5c884c67fb55cd5be7fe1cf2869b6b177e884300c06cd63b65fce54c85f594b68f239bee417b629e9e9335480ed049c6f05d02d0ad2592f
7
- data.tar.gz: c4b44e7839dfdcdc8d896354748e154e12c270bfc1c93be8f9167b65f774d24f970ebdc69dc493890bd0f47c582d8075a14d8622963647d69b0c0e55c7bf8c54
6
+ metadata.gz: e88563fb73ebb702a0b673920e1e85b925799a9287b3336fec394dc8a52952234e296576d7c39a05e015e95611185a1607e7cb0feb005af6a1406caef15b488b
7
+ data.tar.gz: 1a663cdf260368f6ab83b7fadd99128a62cbae89ba67b2d50881232a0f388cc319ae4b5d3b6c4029e47145710be28c344e2608bae30cd2203fcaf5ccb6c95fe8
data/README.md CHANGED
@@ -30,7 +30,7 @@ Add to your **Gemfile**:
30
30
 
31
31
  ### Define your model and methods
32
32
 
33
- Basic usage is to declare the `typical_situation`, and then two required helper methods. Everything else is handled automatically.
33
+ Basic usage is to declare `typical_situation` and define the required `collection` helper. Member lookup uses the model's configured single-column primary key within the security-scoped collection. Missing identifiers return `nil` from the lookup and produce a 404 response.
34
34
 
35
35
  ```rb
36
36
  class PostsController < ApplicationController
@@ -45,11 +45,6 @@ class PostsController < ApplicationController
45
45
  def collection
46
46
  current_user.posts
47
47
  end
48
-
49
- # Find a model instance by ID.
50
- def find_in_collection(id)
51
- collection.find_by_id(id)
52
- end
53
48
  end
54
49
  ```
55
50
 
@@ -108,6 +103,7 @@ The library is split into modules:
108
103
  - [operations](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/operations.rb) - loading, changing, & persisting the model
109
104
  - [permissions](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/permissions.rb) - handling authorization to records and actions
110
105
  - [responses](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/responses.rb) - HTTP responses & redirects
106
+ - [serialization](lib/typical_situation/serialization.rb) - resource representation and JSON root wrapping
111
107
 
112
108
  #### Common Customization Hooks
113
109
 
@@ -122,7 +118,7 @@ For index actions, resources move through this pipeline:
122
118
  5. `paginate_resources` - pagination adapter hook
123
119
  6. `prepare_resources` - post-process the loaded records
124
120
 
125
- **Scoped Collections** - Restrict the base collection based on user permissions, tenancy, or other security boundaries:
121
+ **Scoped Collections** - Restrict the base collection based on user permissions, tenancy, or other security boundaries. This scope applies to index and member lookup for show, edit, update, and destroy. Filtering, sorting, and pagination remain index-only. New/create use `collection`, so read-visibility conditions do not become creation defaults:
126
122
 
127
123
  ```ruby
128
124
  def scoped_resource
@@ -155,11 +151,13 @@ end
155
151
  **Custom Lookup** - Use different attributes for finding resources:
156
152
 
157
153
  ```ruby
158
- def find_resource(param)
159
- collection.find_by!(slug: param)
154
+ def find_in_collection(resources, slug)
155
+ resources.find_by(slug: slug)
160
156
  end
161
157
  ```
162
158
 
159
+ The lookup receives `scoped_resource`; query that relation rather than `collection` to preserve security boundaries. Overriding `find_resource` replaces the security-aware lookup entirely.
160
+
163
161
  **Custom Redirects** - Control where users go after actions:
164
162
 
165
163
  ```ruby
@@ -332,7 +330,9 @@ end
332
330
 
333
331
  #### Serialization
334
332
 
335
- Under the hood `TypicalSituation` calls `to_json` on your `ActiveRecord` models. This isn't always the optimal way to serialize resources, though, and so `TypicalSituation` offers a simple means of overriding the base Serialization --- either on an individual controller, or for your entire application.
333
+ `TypicalSituation` calls `as_json(root: false)` by default. Override `serializable_resource` to return an unrooted Ruby `Hash` for a member or an `Array` of resource data for a collection. Rails encodes the response once; returning an encoded JSON string raises `TypeError`.
334
+
335
+ `include_root?` controls wrapping with `model_type` for members and `plural_model_type` for collections. Error responses merge model errors under `errors`, independently of the custom serializer.
336
336
 
337
337
  ##### Alba
338
338
 
@@ -352,16 +352,12 @@ class MockApplePiesController < ApplicationController
352
352
  private
353
353
 
354
354
  def serializable_resource(resource)
355
- MockApplePieResource.new(resource).serialize
355
+ MockApplePieResource.new(resource).to_h
356
356
  end
357
357
 
358
358
  def collection
359
359
  current_user.mock_apple_pies
360
360
  end
361
-
362
- def find_in_collection(id)
363
- collection.find_by_id(id)
364
- end
365
361
  end
366
362
  ```
367
363
 
@@ -373,15 +369,19 @@ class MockApplePieIndexSerializer < ActiveModel::Serializer
373
369
  end
374
370
 
375
371
  module TypicalSituation
376
- module Operations
372
+ module Serialization
377
373
  def serializable_resource(resource)
378
374
  if action_name == "index"
379
375
  ActiveModelSerializers::SerializableResource.new(
380
376
  resource,
381
- each_serializer: MockApplePieIndexSerializer
382
- )
377
+ each_serializer: MockApplePieIndexSerializer,
378
+ adapter: :attributes
379
+ ).as_json
383
380
  else
384
- ActiveModelSerializers::SerializableResource.new(resource)
381
+ ActiveModelSerializers::SerializableResource.new(
382
+ resource,
383
+ adapter: :attributes
384
+ ).as_json
385
385
  end
386
386
  end
387
387
  end
@@ -401,11 +401,22 @@ class MockApplePiesController < ApplicationController
401
401
  include TypicalSituation
402
402
 
403
403
  def serializable_resource(resource)
404
- MockApplePieSerializer.new(resource).serializable_hash
404
+ MockApplePieSerializer.new(resource).serializable_hash.fetch(:data)
405
405
  end
406
406
  end
407
407
  ```
408
408
 
409
+ ## Migrating Controller Hooks
410
+
411
+ These hook changes are breaking:
412
+
413
+ - Remove redundant `find_in_collection(id)` overrides, or change them to `find_in_collection(resources, id)` and query the supplied scoped relation.
414
+ - `update_resource(resource, attrs)` must return a success boolean. `create_resource` and `destroy_resource` still return resources; success is determined by `persisted?` and `destroyed?`, respectively. Callback aborts return 422 even when no validation errors were added.
415
+ - Overrides of `respond_as_created`, `respond_as_changed`, and `respond_as_gone` must accept the required `success:` keyword and pass it to `super` when delegating.
416
+ - HTML creation uses `created_so_redirect` and `after_resource_created_path`; updates continue using `changed_so_redirect` and `after_resource_updated_path`.
417
+ - Serialization methods now live in `TypicalSituation::Serialization`, not `Operations`. Return Ruby data from `serializable_resource`, not JSON strings or serializer objects. Overrides of `serialize_resource` should accept `errors: false` instead of an options hash.
418
+ - Failed operations preserve model errors or add `typical_situation.errors.operation_failed` when none exist. Failed HTML destruction sets the error flash; JSON failures do not set flash messages.
419
+
409
420
  ## Development
410
421
 
411
422
  After checking out the repo, run `bin/setup` to install dependencies.
@@ -1,5 +1,7 @@
1
1
  en:
2
2
  typical_situation:
3
+ errors:
4
+ operation_failed: "Operation could not be completed"
3
5
  flash:
4
6
  create:
5
7
  success: "%{resource} was successfully created"
@@ -35,8 +35,12 @@ module TypicalSituation
35
35
  get_resource
36
36
  raise TypicalSituation::ActionForbidden unless authorized?(:update, @resource)
37
37
 
38
- update_resource(@resource, update_params)
39
- respond_as_changed
38
+ success = normalize_operation_result(
39
+ @resource,
40
+ success: update_resource(@resource, update_params)
41
+ )
42
+
43
+ respond_as_changed(success: success)
40
44
  end
41
45
 
42
46
  def destroy
@@ -44,14 +48,26 @@ module TypicalSituation
44
48
  raise TypicalSituation::ActionForbidden unless authorized?(:destroy, @resource)
45
49
 
46
50
  destroy_resource(@resource)
47
- respond_as_gone
51
+
52
+ success = normalize_operation_result(
53
+ @resource,
54
+ success: @resource.destroyed?
55
+ )
56
+
57
+ respond_as_gone(success: success)
48
58
  end
49
59
 
50
60
  def create
51
61
  raise TypicalSituation::ActionForbidden unless authorized?(:create)
52
62
 
53
63
  @resource = create_resource(create_params)
54
- respond_as_created
64
+
65
+ success = normalize_operation_result(
66
+ @resource,
67
+ success: @resource.persisted?
68
+ )
69
+
70
+ respond_as_created(success: success)
55
71
  end
56
72
  end
57
73
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypicalSituation
4
- # These Identity methods must be defined for each implementation.
4
+ # Model identity, collection, and request configuration.
5
5
  module Identity
6
6
  protected
7
7
 
@@ -43,9 +43,9 @@ module TypicalSituation
43
43
  raise(NotImplementedError, "#collection must be defined in the TypicalSituation implementation.")
44
44
  end
45
45
 
46
- # Find a model instance by ID.
47
- def find_in_collection(_id)
48
- raise(NotImplementedError, "#find_in_collection must be defined in the TypicalSituation implementation.")
46
+ # Find a model instance by its configured primary key.
47
+ def find_in_collection(resources, identifier)
48
+ resources.find_by(resources.primary_key => identifier)
49
49
  end
50
50
 
51
51
  def include_root?
@@ -11,7 +11,7 @@ module TypicalSituation
11
11
  end
12
12
 
13
13
  def find_resource(param)
14
- find_in_collection(param)
14
+ find_in_collection(scoped_resource, param)
15
15
  end
16
16
 
17
17
  def default_sorting_attribute
@@ -86,7 +86,6 @@ module TypicalSituation
86
86
 
87
87
  def destroy_resource(resource)
88
88
  resource.destroy
89
- collection.reload if resource.errors.empty?
90
89
  resource
91
90
  end
92
91
 
@@ -102,28 +101,23 @@ module TypicalSituation
102
101
  @model_class ||= model_type.to_s.camelize.constantize
103
102
  end
104
103
 
105
- def serialize_resource(resource, options = {})
106
- serializable_resource(resource).to_json(options.merge(root: include_root?))
107
- end
108
-
109
- def serialize_resources(resources)
110
- if include_root?
111
- return {plural_model_type => serializable_resource(resources)}
112
- end
113
-
114
- serializable_resource(resources).to_json(root: false)
115
- end
116
-
117
- def serializable_resource(resource)
118
- resource
119
- end
120
-
121
104
  def id_param
122
105
  params[:id]
123
106
  end
124
107
 
125
108
  private
126
109
 
110
+ def normalize_operation_result(resource, success:)
111
+ if !success && resource.errors.empty?
112
+ resource.errors.add(
113
+ :base,
114
+ I18n.t("typical_situation.errors.operation_failed")
115
+ )
116
+ end
117
+
118
+ success
119
+ end
120
+
127
121
  def apply_sorting(resources)
128
122
  return resources unless default_sorting_attribute
129
123
  resources.order(default_sorting_attribute => default_sorting_direction)
@@ -39,42 +39,40 @@ module TypicalSituation
39
39
  end
40
40
  end
41
41
 
42
- def respond_as_changed
43
- if has_errors?
44
- respond_as_error
45
- else
46
- respond_to do |format|
47
- yield(format) if block_given?
48
-
49
- format.html do
50
- set_success_flash(:update)
51
- set_single_instance
52
- changed_so_redirect || render
53
- end
54
- format.json do
55
- render json: serialize_resource(@resource)
56
- end
42
+ def respond_as_changed(success:)
43
+ return respond_as_failure(:update) unless success
44
+
45
+ respond_to do |format|
46
+ yield(format) if block_given?
47
+
48
+ format.html do
49
+ set_success_flash(:update)
50
+ set_single_instance
51
+ changed_so_redirect || render
52
+ end
53
+
54
+ format.json do
55
+ render json: serialize_resource(@resource)
57
56
  end
58
57
  end
59
58
  end
60
59
 
61
- def respond_as_created
62
- if has_errors?
63
- respond_as_error
64
- else
65
- respond_to do |format|
66
- yield(format) if block_given?
67
-
68
- format.html do
69
- set_success_flash(:create)
70
- set_single_instance
71
- changed_so_redirect || render
72
- end
73
- format.json do
74
- render json: serialize_resource(@resource),
75
- location: location_url,
76
- status: :created
77
- end
60
+ def respond_as_created(success:)
61
+ return respond_as_failure(:create) unless success
62
+
63
+ respond_to do |format|
64
+ yield(format) if block_given?
65
+
66
+ format.html do
67
+ set_success_flash(:create)
68
+ set_single_instance
69
+ created_so_redirect || render
70
+ end
71
+
72
+ format.json do
73
+ render json: serialize_resource(@resource),
74
+ location: location_url,
75
+ status: :created
78
76
  end
79
77
  end
80
78
  end
@@ -89,27 +87,25 @@ module TypicalSituation
89
87
  status: :unprocessable_entity
90
88
  end
91
89
  format.json do
92
- render json: serialize_resource(@resource, methods: [:errors]),
90
+ render json: serialize_resource(@resource, errors: true),
93
91
  status: :unprocessable_entity
94
92
  end
95
93
  end
96
94
  end
97
95
 
98
- def respond_as_gone
99
- if has_errors?
100
- set_error_flash
101
- respond_as_error
102
- else
103
- respond_to do |format|
104
- yield(format) if block_given?
105
-
106
- format.html do
107
- set_single_instance
108
- gone_so_redirect || render
109
- end
110
- format.json do
111
- head :no_content
112
- end
96
+ def respond_as_gone(success:)
97
+ return respond_as_failure(:destroy) unless success
98
+
99
+ respond_to do |format|
100
+ yield(format) if block_given?
101
+
102
+ format.html do
103
+ set_single_instance
104
+ gone_so_redirect || render
105
+ end
106
+
107
+ format.json do
108
+ head :no_content
113
109
  end
114
110
  end
115
111
  end
@@ -135,17 +131,22 @@ module TypicalSituation
135
131
  end
136
132
 
137
133
  def after_resource_created_path(resource)
138
- {action: :show, id: resource.id}
134
+ {action: :show, id: resource.to_param}
139
135
  end
140
136
 
141
137
  def after_resource_updated_path(resource)
142
- {action: :show, id: resource.id}
138
+ {action: :show, id: resource.to_param}
143
139
  end
144
140
 
145
141
  def after_resource_destroyed_path(_resource)
146
142
  {action: :index}
147
143
  end
148
144
 
145
+ def created_so_redirect
146
+ redirect_to after_resource_created_path(@resource)
147
+ true
148
+ end
149
+
149
150
  # HTML response when @resource saved or updated.
150
151
  def changed_so_redirect
151
152
  redirect_to after_resource_updated_path(@resource)
@@ -154,10 +155,16 @@ module TypicalSituation
154
155
 
155
156
  # HTML response when @resource deleted.
156
157
  def gone_so_redirect
157
- set_error_flash if has_errors?
158
- set_success_flash(:destroy) unless has_errors?
158
+ set_success_flash(:destroy)
159
159
  redirect_to after_resource_destroyed_path(@resource)
160
160
  true
161
161
  end
162
+
163
+ private
164
+
165
+ def respond_as_failure(action)
166
+ set_error_flash if action == :destroy && request.format.html?
167
+ respond_as_error
168
+ end
162
169
  end
163
170
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypicalSituation
4
+ module Serialization
5
+ protected
6
+
7
+ def serialize_resource(resource, errors: false)
8
+ data = serialization_data(resource, Hash)
9
+ data = data.merge("errors" => resource.errors.as_json) if errors
10
+
11
+ include_root? ? {model_type => data} : data
12
+ end
13
+
14
+ def serialize_resources(resources)
15
+ data = serialization_data(resources, Array)
16
+
17
+ include_root? ? {plural_model_type => data} : data
18
+ end
19
+
20
+ def serializable_resource(resource)
21
+ resource.as_json(root: false)
22
+ end
23
+
24
+ private
25
+
26
+ def serialization_data(resource, expected_type)
27
+ data = serializable_resource(resource)
28
+
29
+ unless data.is_a?(expected_type)
30
+ raise TypeError,
31
+ "serializable_resource must return #{expected_type}, got #{data.class}"
32
+ end
33
+
34
+ data
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypicalSituation
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -7,19 +7,28 @@ require "typical_situation/flash_messages"
7
7
  require "typical_situation/actions"
8
8
  require "typical_situation/operations"
9
9
  require "typical_situation/responses"
10
+ require "typical_situation/serialization"
10
11
 
11
12
  module TypicalSituation
12
13
  class Error < StandardError; end
13
14
  class ActionForbidden < Error; end
14
15
 
16
+ ACTION_NAMES = Actions.public_instance_methods(false).map(&:to_s).freeze
17
+
15
18
  include Identity
16
19
  include Permissions
17
20
  include FlashMessages
18
21
  include Operations
19
22
  include Responses
23
+ include Serialization
20
24
 
21
25
  def self.included(base)
22
26
  add_rescues(base)
27
+
28
+ base.class_attribute :typical_action_names,
29
+ instance_accessor: false,
30
+ default: ACTION_NAMES
31
+
23
32
  base.extend(ClassMethods)
24
33
  end
25
34
 
@@ -37,20 +46,23 @@ module TypicalSituation
37
46
  # :post
38
47
  # end
39
48
  def typical_situation(model_type_symbol, only: nil)
40
- define_method :model_type do
41
- model_type_symbol
49
+ actions = only.nil? ? ACTION_NAMES : Array(only).map(&:to_s)
50
+ unknown = actions - ACTION_NAMES
51
+
52
+ if unknown.any?
53
+ raise ArgumentError, "Unknown actions: #{unknown.join(", ")}"
42
54
  end
55
+
56
+ self.typical_action_names = actions.freeze
57
+
58
+ define_method(:model_type) { model_type_symbol }
43
59
  protected :model_type
44
60
 
45
- if only
46
- only.each do |action|
47
- if TypicalSituation::Actions.method_defined?(action)
48
- define_method(action, TypicalSituation::Actions.instance_method(action))
49
- end
50
- end
51
- else
52
- include TypicalSituation::Actions
53
- end
61
+ include TypicalSituation::Actions
62
+ end
63
+
64
+ def action_methods
65
+ super - (ACTION_NAMES - typical_action_names)
54
66
  end
55
67
 
56
68
  def typical_rest(model_type_symbol)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typical_situation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mars Hall
@@ -213,6 +213,7 @@ files:
213
213
  - lib/typical_situation/operations.rb
214
214
  - lib/typical_situation/permissions.rb
215
215
  - lib/typical_situation/responses.rb
216
+ - lib/typical_situation/serialization.rb
216
217
  - lib/typical_situation/version.rb
217
218
  homepage: https://github.com/apsislabs/typical_situation
218
219
  licenses: []