typical_situation 1.1.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: 48bf00bdaee8edb5e3fbf54b68ca577960068cae3dac7804ca56169d738c834c
4
- data.tar.gz: e11a3d08dd5991937bb08b2b3a731d8bd67f91b19658c25239bef18e60e4f921
3
+ metadata.gz: 1784b34111a7c2de6a6f429d431d5fc36e00ec033f5e43c1618fc8a198ff534c
4
+ data.tar.gz: 4c40c7fab53aacffff4d30dbe5578fae9019e798384c086b36e4566bdb46baee
5
5
  SHA512:
6
- metadata.gz: 584cecff5e2be5ac3201983c4a2e0fbe1ec69cf5cd7cc18c91aa2d267bb73a8680194137774889fe9cd546c3f3dc55ee35bcef53ff643e8b5ff54eb741a7e6fe
7
- data.tar.gz: e31342f32bc78ce7aecf165ce64c51fb38f1fd3d8031ffeec891f38b972945fc0da002227a919afc7942ca61179fe3de4e4dbf4fe3f82af77cc9a014b7ed1b14
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
 
@@ -117,11 +113,12 @@ For index actions, resources move through this pipeline:
117
113
 
118
114
  1. `collection` - base relation from the host controller
119
115
  2. `scoped_resource` - visibility/tenant/security scoping
120
- 3. `prepare_resources` - search or filter transforms
116
+ 3. `apply_filtering` - request-driven search and filter transforms
121
117
  4. `apply_sorting` - default sorting from `default_sorting_attribute`
122
118
  5. `paginate_resources` - pagination adapter hook
119
+ 6. `prepare_resources` - post-process the loaded records
123
120
 
124
- **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:
125
122
 
126
123
  ```ruby
127
124
  def scoped_resource
@@ -136,21 +133,31 @@ end
136
133
  **Search and Filtering** - Apply request-driven filters after scoping and before sorting/pagination:
137
134
 
138
135
  ```ruby
139
- def prepare_resources(resources)
136
+ def apply_filtering(resources)
140
137
  resources = resources.where(status: params[:status]) if params[:status].present?
141
138
  resources = resources.where("title ILIKE ?", "%#{params[:q]}%") if params[:q].present?
142
139
  resources
143
140
  end
144
141
  ```
145
142
 
143
+ **Post-processing** - Mutate or decorate the final paginated record set (e.g. compute a derived attribute on every record):
144
+
145
+ ```ruby
146
+ def prepare_resources(resources)
147
+ resources.each { |post| post.current_user_liked = liked_post_ids.include?(post.id) }
148
+ end
149
+ ```
150
+
146
151
  **Custom Lookup** - Use different attributes for finding resources:
147
152
 
148
153
  ```ruby
149
- def find_resource(param)
150
- collection.find_by!(slug: param)
154
+ def find_in_collection(resources, slug)
155
+ resources.find_by(slug: slug)
151
156
  end
152
157
  ```
153
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
+
154
161
  **Custom Redirects** - Control where users go after actions:
155
162
 
156
163
  ```ruby
@@ -323,7 +330,9 @@ end
323
330
 
324
331
  #### Serialization
325
332
 
326
- 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.
327
336
 
328
337
  ##### Alba
329
338
 
@@ -343,16 +352,12 @@ class MockApplePiesController < ApplicationController
343
352
  private
344
353
 
345
354
  def serializable_resource(resource)
346
- MockApplePieResource.new(resource).serialize
355
+ MockApplePieResource.new(resource).to_h
347
356
  end
348
357
 
349
358
  def collection
350
359
  current_user.mock_apple_pies
351
360
  end
352
-
353
- def find_in_collection(id)
354
- collection.find_by_id(id)
355
- end
356
361
  end
357
362
  ```
358
363
 
@@ -364,15 +369,19 @@ class MockApplePieIndexSerializer < ActiveModel::Serializer
364
369
  end
365
370
 
366
371
  module TypicalSituation
367
- module Operations
372
+ module Serialization
368
373
  def serializable_resource(resource)
369
374
  if action_name == "index"
370
375
  ActiveModelSerializers::SerializableResource.new(
371
376
  resource,
372
- each_serializer: MockApplePieIndexSerializer
373
- )
377
+ each_serializer: MockApplePieIndexSerializer,
378
+ adapter: :attributes
379
+ ).as_json
374
380
  else
375
- ActiveModelSerializers::SerializableResource.new(resource)
381
+ ActiveModelSerializers::SerializableResource.new(
382
+ resource,
383
+ adapter: :attributes
384
+ ).as_json
376
385
  end
377
386
  end
378
387
  end
@@ -392,11 +401,22 @@ class MockApplePiesController < ApplicationController
392
401
  include TypicalSituation
393
402
 
394
403
  def serializable_resource(resource)
395
- MockApplePieSerializer.new(resource).serializable_hash
404
+ MockApplePieSerializer.new(resource).serializable_hash.fetch(:data)
396
405
  end
397
406
  end
398
407
  ```
399
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
+
400
420
  ## Development
401
421
 
402
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
@@ -46,22 +46,28 @@ module TypicalSituation
46
46
  # Collection pipeline lifecycle:
47
47
  # collection - base relation (user-defined, required)
48
48
  # scoped_resource - wraps/scopes the collection (visibility, tenancy, PHI)
49
- # prepare_resources - standardized additional transforms (search, filter)
49
+ # apply_filtering - applies search/filter params
50
50
  # apply_sorting - applies ORDER BY
51
51
  # paginate_resources - applies pagination
52
+ # prepare_resources - post-processes the loaded records (e.g. set a computed attribute)
52
53
  #
53
- # Override +prepare_resources+ in host controllers to add search/filter
54
- # behavior without touching sorting or pagination hooks.
54
+ # Override +apply_filtering+ to add search/filter behavior.
55
+ # Override +prepare_resources+ to mutate or decorate the final record set.
55
56
  def get_resources
56
57
  resources = scoped_resource
57
- resources = prepare_resources(resources)
58
+ resources = apply_filtering(resources)
58
59
  resources = apply_sorting(resources)
59
60
  resources = paginate_resources(resources)
61
+ resources = prepare_resources(resources)
60
62
  @resources = resources
61
63
  set_collection_instance
62
64
  @resources
63
65
  end
64
66
 
67
+ def apply_filtering(resources)
68
+ resources
69
+ end
70
+
65
71
  def prepare_resources(resources)
66
72
  resources
67
73
  end
@@ -80,7 +86,6 @@ module TypicalSituation
80
86
 
81
87
  def destroy_resource(resource)
82
88
  resource.destroy
83
- collection.reload if resource.errors.empty?
84
89
  resource
85
90
  end
86
91
 
@@ -96,28 +101,23 @@ module TypicalSituation
96
101
  @model_class ||= model_type.to_s.camelize.constantize
97
102
  end
98
103
 
99
- def serialize_resource(resource, options = {})
100
- serializable_resource(resource).to_json(options.merge(root: include_root?))
101
- end
102
-
103
- def serialize_resources(resources)
104
- if include_root?
105
- return {plural_model_type => serializable_resource(resources)}
106
- end
107
-
108
- serializable_resource(resources).to_json(root: false)
109
- end
110
-
111
- def serializable_resource(resource)
112
- resource
113
- end
114
-
115
104
  def id_param
116
105
  params[:id]
117
106
  end
118
107
 
119
108
  private
120
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
+
121
121
  def apply_sorting(resources)
122
122
  return resources unless default_sorting_attribute
123
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.1.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.1.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: []