jpie 1.5.1 → 2.0.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.lock +29 -3
  4. data/README.md +149 -2
  5. data/jpie.gemspec +4 -2
  6. data/lib/json_api/configuration.rb +49 -11
  7. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +6 -7
  8. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +2 -3
  9. data/lib/json_api/controllers/concerns/relationships/serialization.rb +42 -1
  10. data/lib/json_api/controllers/concerns/relationships/updating.rb +1 -5
  11. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +0 -8
  12. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +0 -39
  13. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +3 -7
  14. data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +89 -0
  15. data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +50 -0
  16. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
  17. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +8 -25
  18. data/lib/json_api/controllers/concerns/resource_actions.rb +9 -10
  19. data/lib/json_api/controllers/relationships_controller.rb +2 -3
  20. data/lib/json_api/errors/parameter_not_allowed.rb +0 -5
  21. data/lib/json_api/rack/n1_detection.rb +41 -0
  22. data/lib/json_api/rack/query_tracking.rb +102 -0
  23. data/lib/json_api/railtie.rb +34 -3
  24. data/lib/json_api/resources/resource.rb +0 -4
  25. data/lib/json_api/serialization/concerns/include_filtering.rb +42 -0
  26. data/lib/json_api/serialization/concerns/includes_serialization.rb +9 -11
  27. data/lib/json_api/serialization/concerns/links_serialization.rb +12 -4
  28. data/lib/json_api/serialization/concerns/relationship_processing.rb +1 -5
  29. data/lib/json_api/support/collection_query.rb +1 -2
  30. data/lib/json_api/support/concerns/condition_building.rb +5 -15
  31. data/lib/json_api/support/concerns/nested_filters.rb +1 -1
  32. data/lib/json_api/support/concerns/polymorphic_filters.rb +1 -1
  33. data/lib/json_api/support/concerns/regular_filters.rb +3 -21
  34. data/lib/json_api/support/correlation_id.rb +16 -0
  35. data/lib/json_api/support/filter_parsing.rb +18 -0
  36. data/lib/json_api/support/header_warning_subscriber.rb +17 -0
  37. data/lib/json_api/support/n1_log_subscriber.rb +38 -0
  38. data/lib/json_api/support/param_helpers.rb +4 -0
  39. data/lib/json_api/support/prosopite_instrumentation_logger.rb +56 -0
  40. data/lib/json_api/support/query_tracking_log_subscriber.rb +57 -0
  41. data/lib/json_api/support/query_tracking_subscriber.rb +76 -0
  42. data/lib/json_api/support/relationship_guard.rb +1 -1
  43. data/lib/json_api/support/resource_identifier.rb +2 -1
  44. data/lib/json_api/support/responders.rb +1 -1
  45. data/lib/json_api/version.rb +1 -1
  46. data/lib/json_api.rb +9 -1
  47. metadata +49 -13
  48. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +0 -80
  49. data/lib/json_api/resources/concerns/eager_load_dsl.rb +0 -50
  50. data/lib/json_api/resources/concerns/preload_dsl.rb +0 -49
  51. data/lib/json_api/support/response_helpers.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be60282bbba362a7ac36dca3370a4733981bce7a949352513244879bc76546a7
4
- data.tar.gz: 6c391beea011b7fd348f8a2439130ba174dd31fa7b8f9f4ea6eaef5c9fd916ee
3
+ metadata.gz: 48369caaf5706cefe367cf5450398a4620741a09193d1ad37e4b42738eb33cc3
4
+ data.tar.gz: 767a5a640a1e0ba22902d7d6d850bcf1c1491ec3903c041845a09d5db9eb60fa
5
5
  SHA512:
6
- metadata.gz: 13f21d51760a269d54d1bcd46a9be3916506bae8c25af82eaabea8dc7a214d2f3d39b29437ca0703141a794444d13b7ab20ba26ac4f35f4fe3a8ca15207d1630
7
- data.tar.gz: 35e11173e5942bc01059217acf3a5028f5980108b2c76e88b33fe03c9c2ae00a4a6a9c0051328e491a257d83700f02f6a206c7a34406cde968af1a6698ac63fd
6
+ metadata.gz: 73f3dd657b1efa1eda908b32fe45e189ce355a159e8d48b805ebc1b2cf7afdbca2633e31fe93e56d9cf298956561f993db87c9acbe9cf13c12f8d72f8f82e717
7
+ data.tar.gz: d2f3c7e883efe3a27cadf78287903c26ae6fde6c411f84b553e6fe85fd0e4433eac589dfa42ce43886dccb5ead4828205e1b34139dc5fc0d18792adc8dcc4ca4
data/.rubocop.yml CHANGED
@@ -107,3 +107,4 @@ RSpec/NestedGroups:
107
107
  # Allow more memoized helpers in complex specs
108
108
  RSpec/MultipleMemoizedHelpers:
109
109
  Max: 8
110
+
data/Gemfile.lock CHANGED
@@ -1,9 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (1.5.1)
5
- actionpack (~> 8.0, >= 8.0.0)
6
- rails (~> 8.0, >= 8.0.0)
4
+ jpie (2.0.1)
5
+ actionpack (~> 8.1, >= 8.1.0)
6
+ pg_query (>= 4)
7
+ prosopite (>= 1)
8
+ rails (~> 8.1, >= 8.1.0)
7
9
 
8
10
  GEM
9
11
  remote: https://rubygems.org/
@@ -101,6 +103,27 @@ GEM
101
103
  erubi (1.13.1)
102
104
  globalid (1.3.0)
103
105
  activesupport (>= 6.1)
106
+ google-protobuf (4.33.5)
107
+ bigdecimal
108
+ rake (>= 13)
109
+ google-protobuf (4.33.5-aarch64-linux-gnu)
110
+ bigdecimal
111
+ rake (>= 13)
112
+ google-protobuf (4.33.5-aarch64-linux-musl)
113
+ bigdecimal
114
+ rake (>= 13)
115
+ google-protobuf (4.33.5-arm64-darwin)
116
+ bigdecimal
117
+ rake (>= 13)
118
+ google-protobuf (4.33.5-x86_64-darwin)
119
+ bigdecimal
120
+ rake (>= 13)
121
+ google-protobuf (4.33.5-x86_64-linux-gnu)
122
+ bigdecimal
123
+ rake (>= 13)
124
+ google-protobuf (4.33.5-x86_64-linux-musl)
125
+ bigdecimal
126
+ rake (>= 13)
104
127
  hana (1.3.7)
105
128
  i18n (1.14.7)
106
129
  concurrent-ruby (~> 1.0)
@@ -160,10 +183,13 @@ GEM
160
183
  parser (3.3.10.0)
161
184
  ast (~> 2.4.1)
162
185
  racc
186
+ pg_query (6.2.2)
187
+ google-protobuf (>= 3.25.3)
163
188
  pp (0.6.3)
164
189
  prettyprint
165
190
  prettyprint (0.2.0)
166
191
  prism (1.6.0)
192
+ prosopite (2.1.2)
167
193
  psych (5.2.6)
168
194
  date
169
195
  stringio
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # JSONAPI
1
+ # JPie
2
2
 
3
3
  A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API controllers for producing and consuming JSON:API resources.
4
4
 
@@ -25,7 +25,7 @@ bundle add jpie
25
25
  ## Requirements
26
26
 
27
27
  - Ruby >= 3.4.0
28
- - Rails >= 8.0.0
28
+ - Rails >= 8.1.0
29
29
 
30
30
  ## Routing
31
31
 
@@ -659,6 +659,153 @@ JSONAPI.configure do |config|
659
659
  end
660
660
  ```
661
661
 
662
+ ### N+1 Detection (Rails 8.1+)
663
+
664
+ The gem uses [Prosopite](https://github.com/charkost/prosopite) to detect real N+1 query patterns on JSON:API requests. When N+1 is detected, it emits a `jpie.n1_detected` event via `Rails.event.notify`. When `n1_detection_enabled` is true, a built-in subscriber logs to `Rails.logger.warn` automatically—no setup required.
665
+
666
+ **Event:** `jpie.n1_detected`
667
+
668
+ **Payload:**
669
+
670
+ ```ruby
671
+ {
672
+ path: "/users",
673
+ method: "GET",
674
+ include: nil,
675
+ resource_type: "users",
676
+ resource_id: nil,
677
+ n1_details: "N+1 queries detected:\n SELECT ... FROM users WHERE id = ?\nCall stack:\n app/...",
678
+ n1_query: "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"id\" = 1 LIMIT 1", # First duplicate query
679
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
680
+ }
681
+ ```
682
+
683
+ **Custom subscriber (e.g. for APM):**
684
+
685
+ ```ruby
686
+ if Rails.respond_to?(:event)
687
+ Rails.event.subscribe(Class.new do
688
+ def emit(event)
689
+ ev = event.is_a?(Hash) ? event : event.to_h
690
+ return unless ev[:name] == "jpie.n1_detected"
691
+
692
+ payload = ev[:payload] || {}
693
+ # Forward to Datadog, AppSignal, New Relic, etc.
694
+ end
695
+ end.new)
696
+ end
697
+ ```
698
+
699
+ **Configuration:**
700
+
701
+ ```ruby
702
+ JSONAPI.configure do |config|
703
+ config.n1_detection_enabled = true # Override: default is !Rails.env.production?
704
+ end
705
+ ```
706
+
707
+ Default: enabled in development and test, disabled in production. Prosopite options (e.g. `Prosopite.rails_logger`, `Prosopite.raise`) remain under app control; the gem only sets `custom_logger` for instrumentation.
708
+
709
+ ### Query Tracking (Slow Query & Excessive Count) (Rails 8.1+)
710
+
711
+ The gem detects slow SQL queries and excessive query count per JSON:API request. When a single query exceeds the duration threshold or total queries exceed the count threshold, it emits `jpie.slow_query_detected` or `jpie.excessive_queries_detected` via `Rails.event.notify`. When `query_tracking_enabled` is true, a built-in subscriber logs to `Rails.logger.warn` automatically—no setup required.
712
+
713
+ **Events:** `jpie.slow_query_detected`, `jpie.excessive_queries_detected`
714
+
715
+ **Payloads:**
716
+
717
+ ```ruby
718
+ # jpie.slow_query_detected
719
+ {
720
+ path: "/users",
721
+ method: "GET",
722
+ include: nil,
723
+ resource_type: "users",
724
+ resource_id: nil,
725
+ sql: "SELECT \"users\".* FROM \"users\" WHERE ...",
726
+ duration_ms: 523.4,
727
+ threshold_ms: 200,
728
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
729
+ }
730
+
731
+ # jpie.excessive_queries_detected
732
+ {
733
+ path: "/users",
734
+ method: "GET",
735
+ include: nil,
736
+ resource_type: "users",
737
+ resource_id: nil,
738
+ query_count: 127,
739
+ threshold: 10,
740
+ queries: ["SELECT ...", "SELECT ...", ...], # up to query_tracking_queries_cap
741
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
742
+ }
743
+ ```
744
+
745
+ **Custom subscriber (e.g. for APM):**
746
+
747
+ ```ruby
748
+ if Rails.respond_to?(:event)
749
+ Rails.event.subscribe(Class.new do
750
+ def emit(event)
751
+ ev = event.is_a?(Hash) ? event : event.to_h
752
+ return unless %w[jpie.slow_query_detected jpie.excessive_queries_detected].include?(ev[:name])
753
+
754
+ payload = ev[:payload] || {}
755
+ # Forward to Datadog, AppSignal, New Relic, etc.
756
+ end
757
+ end.new)
758
+ end
759
+ ```
760
+
761
+ **Configuration:**
762
+
763
+ ```ruby
764
+ JSONAPI.configure do |config|
765
+ config.query_tracking_enabled = true
766
+ config.query_count_threshold = 10
767
+ config.slow_query_threshold_ms = 200
768
+ config.query_tracking_queries_cap = 100
769
+ end
770
+ ```
771
+
772
+ Default: enabled in development and test, disabled in production. Thresholds are configurable.
773
+
774
+ ### Correlation ID
775
+
776
+ When set, `correlation_id_resolver` is a proc that returns the current request's correlation ID (string or nil). The gem adds `correlation_id` to all JPie event payloads (`jpie.n1_detected`, `jpie.slow_query_detected`, `jpie.excessive_queries_detected`) and tags JPie log output with `CID(...)` when present.
777
+
778
+ **Configuration:**
779
+
780
+ ```ruby
781
+ JSONAPI.configure do |config|
782
+ config.correlation_id_resolver = -> { Current.cid }
783
+ end
784
+ ```
785
+
786
+ Default: `nil` (no correlation ID in payloads or logs).
787
+
788
+ ### Response Headers (Development)
789
+
790
+ When `jpie_headers_enabled` is true (default: development and test only), the gem adds performance headers to JSON:API responses so frontends can surface warnings in dev tools.
791
+
792
+ **Headers:**
793
+
794
+ | Header | When Set | Example |
795
+ |--------|----------|---------|
796
+ | `X-JPie-Query-Count` | Always (when query tracking + headers enabled) | `42` |
797
+ | `X-JPie-Performance-Warning` | Only when any event fired (n1, excessive_queries, slow_query) | `n1,excessive_queries` |
798
+
799
+ **Configuration:**
800
+
801
+ ```ruby
802
+ JSONAPI.configure do |config|
803
+ config.jpie_headers_enabled = true # Override: default is !Rails.env.production?
804
+ end
805
+ ```
806
+
807
+ Default: enabled in development and test, disabled in production. Ensure CORS exposes these headers if your frontend runs on a different origin.
808
+
662
809
  ### Base Controller Class
663
810
 
664
811
  By default, `JSONAPI::BaseController` inherits from `ActionController::API`. You can configure it to inherit from a different base class (e.g., `ActionController::Base` or a custom base controller):
data/jpie.gemspec CHANGED
@@ -24,8 +24,10 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.required_ruby_version = ">= 3.4.0"
26
26
 
27
- spec.add_dependency "actionpack", "~> 8.0", ">= 8.0.0"
28
- spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
27
+ spec.add_dependency "actionpack", "~> 8.1", ">= 8.1.0"
28
+ spec.add_dependency "pg_query", ">= 4"
29
+ spec.add_dependency "prosopite", ">= 1"
30
+ spec.add_dependency "rails", "~> 8.1", ">= 8.1.0"
29
31
 
30
32
  spec.metadata["rubygems_mfa_required"] = "true"
31
33
  end
@@ -4,19 +4,15 @@ module JSONAPI
4
4
  class Configuration
5
5
  attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
6
6
  :authorization_scope, :document_meta_resolver,
7
- :namespace_type_format, :namespace_model_mapping, :namespace_fallback
7
+ :namespace_type_format, :namespace_model_mapping, :namespace_fallback,
8
+ :n1_detection_enabled,
9
+ :query_tracking_enabled, :query_count_threshold, :slow_query_threshold_ms,
10
+ :query_tracking_queries_cap,
11
+ :jpie_headers_enabled,
12
+ :correlation_id_resolver
8
13
 
9
14
  def initialize
10
- @default_page_size = 25
11
- @max_page_size = 100
12
- @jsonapi_meta = nil
13
- @authorization_handler = nil
14
- @authorization_scope = nil
15
- @document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
16
- @base_controller_class = "ActionController::API"
17
- @namespace_type_format = :flat
18
- @namespace_model_mapping = :same_namespace
19
- @namespace_fallback = true
15
+ set_defaults
20
16
  end
21
17
 
22
18
  def base_controller_class=(value)
@@ -49,6 +45,48 @@ module JSONAPI
49
45
  def base_controller_overridden?
50
46
  @base_controller_class != "ActionController::API"
51
47
  end
48
+
49
+ private
50
+
51
+ def set_defaults
52
+ set_pagination_defaults
53
+ set_n1_detection_defaults
54
+ set_query_tracking_defaults
55
+ set_nil_defaults
56
+ @document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
57
+ @base_controller_class = "ActionController::API"
58
+ set_namespace_defaults
59
+ end
60
+
61
+ def set_pagination_defaults
62
+ @default_page_size = 25
63
+ @max_page_size = 100
64
+ end
65
+
66
+ def set_n1_detection_defaults
67
+ @n1_detection_enabled = !defined?(Rails) || !Rails.env.production?
68
+ end
69
+
70
+ def set_query_tracking_defaults
71
+ @query_tracking_enabled = !defined?(Rails) || !Rails.env.production?
72
+ @query_count_threshold = 10
73
+ @slow_query_threshold_ms = 200
74
+ @query_tracking_queries_cap = 100
75
+ @jpie_headers_enabled = !defined?(Rails) || !Rails.env.production?
76
+ end
77
+
78
+ def set_nil_defaults
79
+ @jsonapi_meta = nil
80
+ @authorization_handler = nil
81
+ @authorization_scope = nil
82
+ @correlation_id_resolver = nil
83
+ end
84
+
85
+ def set_namespace_defaults
86
+ @namespace_type_format = :flat
87
+ @namespace_model_mapping = :same_namespace
88
+ @namespace_fallback = true
89
+ end
52
90
  end
53
91
 
54
92
  def self.configuration
@@ -7,17 +7,16 @@ module JSONAPI
7
7
 
8
8
  protected
9
9
 
10
+ def render_not_found_error(title:, detail:)
11
+ render_jsonapi_error(status: 404, title:, detail:)
12
+ end
13
+
10
14
  def render_resource_not_found_error(message)
11
- render_jsonapi_error(
12
- status: 404,
13
- title: "Resource Not Found",
14
- detail: message,
15
- )
15
+ render_not_found_error(title: "Resource Not Found", detail: message)
16
16
  end
17
17
 
18
18
  def render_model_not_found_error(error)
19
- render_jsonapi_error(
20
- status: 404,
19
+ render_not_found_error(
21
20
  title: "Resource Not Found",
22
21
  detail: "Model class for '#{@resource_name}' not found: #{error.message}",
23
22
  )
@@ -30,10 +30,9 @@ module JSONAPI
30
30
  end
31
31
 
32
32
  def set_resource
33
- @resource = @preloaded_resource || model_class.find(params[:id])
33
+ @resource = @resource_class.records.find(params[:id])
34
34
  rescue ActiveRecord::RecordNotFound
35
- render_jsonapi_error(
36
- status: 404,
35
+ render_not_found_error(
37
36
  title: "Record Not Found",
38
37
  detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
39
38
  )
@@ -16,12 +16,53 @@ module JSONAPI
16
16
  end
17
17
 
18
18
  def fetch_related_data(association)
19
- related = @resource.public_send(@relationship_name)
19
+ related = fetch_related_through_resource_records(association)
20
20
  return related unless association.collection? && related.respond_to?(:order)
21
21
 
22
22
  apply_sorting_to_relationship(related, association)
23
23
  end
24
24
 
25
+ def fetch_related_through_resource_records(association)
26
+ if association.polymorphic?
27
+ fetch_polymorphic_related_through_resource_records(association)
28
+ else
29
+ fetch_non_polymorphic_related_through_resource_records(association)
30
+ end
31
+ end
32
+
33
+ def fetch_polymorphic_related_through_resource_records(association)
34
+ type_value = @resource.read_attribute(association.foreign_type)
35
+ id_value = @resource.read_attribute(association.foreign_key)
36
+
37
+ return fetch_blank_polymorphic(association) if type_value.blank? || id_value.blank?
38
+
39
+ related_model_class = type_value.constantize
40
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
41
+ record = related_resource_class.records.find(id_value)
42
+
43
+ association.collection? ? Array(record) : record
44
+ end
45
+
46
+ def fetch_blank_polymorphic(association)
47
+ return nil unless association.collection?
48
+
49
+ fetch_polymorphic_has_many_through_resource_records(association)
50
+ end
51
+
52
+ def fetch_polymorphic_has_many_through_resource_records(association)
53
+ association_instance = @resource.association(@relationship_name)
54
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
55
+ related_resource_class.records.merge(association_instance.scope)
56
+ end
57
+
58
+ def fetch_non_polymorphic_related_through_resource_records(association)
59
+ association_instance = @resource.association(@relationship_name)
60
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
61
+ scope = related_resource_class.records.merge(association_instance.scope)
62
+
63
+ association.collection? ? scope : scope.first
64
+ end
65
+
25
66
  def serialize_related(related, association)
26
67
  return serialize_collection_relationship(related, association) if association.collection?
27
68
 
@@ -24,7 +24,7 @@ module JSONAPI
24
24
  end
25
25
 
26
26
  def update_to_many_relationship(association, relationship_data)
27
- if relationship_data.nil? || empty_array?(relationship_data)
27
+ if relationship_data.nil? || ParamHelpers.empty_array?(relationship_data)
28
28
  @resource.public_send("#{@relationship_name}=", [])
29
29
  return
30
30
  end
@@ -35,10 +35,6 @@ module JSONAPI
35
35
  @resource.public_send("#{@relationship_name}=", related_resources)
36
36
  end
37
37
 
38
- def empty_array?(data)
39
- data.is_a?(Array) && data.empty?
40
- end
41
-
42
38
  def resolve_related_resources(relationship_data, association)
43
39
  relationship_data.map { |identifier| find_related_resource(identifier, association) }
44
40
  end
@@ -61,14 +61,6 @@ module JSONAPI
61
61
  end
62
62
  end
63
63
 
64
- def render_update_error(error)
65
- if error.is_a?(ActiveSupport::MessageVerifier::InvalidSignature)
66
- render_signed_id_error(error)
67
- else
68
- render_invalid_relationship_error(error)
69
- end
70
- end
71
-
72
64
  def render_signed_id_error(error)
73
65
  render_jsonapi_error(
74
66
  status: 400,
@@ -22,15 +22,6 @@ module JSONAPI
22
22
  render_sort_errors(invalid) if invalid.any?
23
23
  end
24
24
 
25
- def validate_include_param
26
- includes = parse_include_param
27
- return if includes.empty?
28
-
29
- permitted = @resource_class.relationship_names.map(&:to_s)
30
- invalid = includes.reject { |p| include_path_valid?(p, permitted) }
31
- render_include_errors(invalid) if invalid.any?
32
- end
33
-
34
25
  private
35
26
 
36
27
  def first_invalid_field(fields)
@@ -62,27 +53,6 @@ module JSONAPI
62
53
  invalid.any? ? { type: resource_type.to_s, invalid: } : nil
63
54
  end
64
55
 
65
- def include_path_valid?(path, permitted)
66
- parts = path.split(".")
67
- return false unless permitted.include?(parts.first)
68
-
69
- nested_path_valid?(parts)
70
- end
71
-
72
- def nested_path_valid?(parts)
73
- current = model_class
74
- parts.each do |name|
75
- return true if self.class.active_storage_attachment?(name, current)
76
-
77
- assoc = current.reflect_on_association(name.to_sym)
78
- return false unless assoc
79
- break if assoc.polymorphic?
80
-
81
- current = assoc.klass
82
- end
83
- true
84
- end
85
-
86
56
  def render_field_error(error)
87
57
  render_parameter_errors(
88
58
  error[:invalid],
@@ -100,15 +70,6 @@ module JSONAPI
100
70
  source_proc: ->(_) { { parameter: "sort" } },
101
71
  )
102
72
  end
103
-
104
- def render_include_errors(invalid)
105
- render_parameter_errors(
106
- invalid,
107
- title: "Invalid Include Path",
108
- detail_proc: ->(p) { "Invalid include path requested: #{p}" },
109
- source_proc: ->(_) { { parameter: "include" } },
110
- )
111
- end
112
73
  end
113
74
  end
114
75
  end
@@ -4,6 +4,7 @@ module JSONAPI
4
4
  module ResourceActions
5
5
  module FilterValidation
6
6
  extend ActiveSupport::Concern
7
+ include JSONAPI::Support::FilterParsing
7
8
 
8
9
  def validate_filter_param
9
10
  filters = parse_filter_param
@@ -69,13 +70,8 @@ module JSONAPI
69
70
  def polymorphic_filter_valid?(parts)
70
71
  return false if parts.empty? || parts.length > 1
71
72
 
72
- m = parts.first.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
73
- %w[id type].include?(m ? m[1] : parts.first)
74
- end
75
-
76
- def parse_column_filter(name)
77
- m = name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
78
- m ? { column: m[1], operator: m[2].to_sym } : nil
73
+ col_filter = parse_column_filter(parts.first)
74
+ %w[id type].include?(col_filter ? col_filter[:column] : parts.first)
79
75
  end
80
76
 
81
77
  def render_filter_errors(invalid)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResourceActions
5
+ module IncludePreloading
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def includes_to_hash(paths)
11
+ hash = paths.each_with_object({}) do |path, h|
12
+ path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
13
+ end
14
+ filter_includable(hash, model_class)
15
+ end
16
+
17
+ # Preloads associations from the include param so the serializer avoids N+1 queries
18
+ # when walking include paths (association.loaded? is true). Used by both show and index.
19
+ def scope_with_includes(scope)
20
+ includes = parse_include_param
21
+ return scope unless includes.any?
22
+
23
+ inc_hash = includes_to_hash(includes)
24
+ hash_contains_polymorphic?(inc_hash, model_class) ? scope.preload(inc_hash) : scope.includes(inc_hash)
25
+ end
26
+
27
+ def filter_includable(hash, klass)
28
+ hash.each_with_object({}) do |(key, value), filtered|
29
+ assoc = klass.reflect_on_association(key)
30
+ next unless assoc
31
+
32
+ filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
33
+ end
34
+ end
35
+
36
+ def hash_contains_polymorphic?(hash, klass)
37
+ hash.any? { |key, value| polymorphic_in_hash_entry?(key, value, klass) }
38
+ end
39
+
40
+ def polymorphic_in_hash_entry?(key, value, klass)
41
+ assoc = klass.reflect_on_association(key)
42
+ return false unless assoc
43
+
44
+ assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
45
+ end
46
+
47
+ def preload_included_resource_associations(resources, includes)
48
+ return if includes.empty? || resources.empty?
49
+
50
+ includes.each do |include_path|
51
+ association_name = include_path.split(".").first.to_sym
52
+ assoc_reflection = model_class.reflect_on_association(association_name)
53
+ next unless assoc_reflection
54
+
55
+ targets = collect_include_targets(resources, association_name)
56
+ next if targets.empty?
57
+
58
+ apply_preloads_for_targets(targets, assoc_reflection.polymorphic?)
59
+ end
60
+ end
61
+
62
+ def apply_preloads_for_targets(targets, polymorphic)
63
+ if polymorphic
64
+ targets.group_by(&:class).each_value { |recs| apply_resource_preloads(recs) }
65
+ else
66
+ apply_resource_preloads(targets)
67
+ end
68
+ end
69
+
70
+ def collect_include_targets(resources, association_name)
71
+ resources.flat_map do |r|
72
+ target = r.association(association_name).target
73
+ target.respond_to?(:to_a) ? target.to_a : Array(target)
74
+ end.compact.uniq
75
+ end
76
+
77
+ def apply_resource_preloads(records)
78
+ return if records.empty?
79
+
80
+ klass = records.first.class
81
+ resource_scope = ResourceLoader.find_for_model(klass).records
82
+ preload_values = resource_scope.preload_values + resource_scope.includes_values
83
+ return if preload_values.empty?
84
+
85
+ ActiveRecord::Associations::Preloader.new(records:, associations: preload_values).call
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResourceActions
5
+ module IncludeValidation
6
+ extend ActiveSupport::Concern
7
+
8
+ def validate_include_param
9
+ includes = parse_include_param
10
+ return if includes.empty?
11
+
12
+ permitted = @resource_class.relationship_names.map(&:to_s)
13
+ invalid = includes.reject { |p| include_path_valid?(p, permitted) }
14
+ render_include_errors(invalid) if invalid.any?
15
+ end
16
+
17
+ private
18
+
19
+ def include_path_valid?(path, permitted)
20
+ parts = path.split(".")
21
+ return false unless permitted.include?(parts.first)
22
+
23
+ nested_path_valid?(parts)
24
+ end
25
+
26
+ def nested_path_valid?(parts)
27
+ current = model_class
28
+ parts.each do |name|
29
+ return true if self.class.active_storage_attachment?(name, current)
30
+
31
+ assoc = current.reflect_on_association(name.to_sym)
32
+ return false unless assoc
33
+ break if assoc.polymorphic?
34
+
35
+ current = assoc.klass
36
+ end
37
+ true
38
+ end
39
+
40
+ def render_include_errors(invalid)
41
+ render_parameter_errors(
42
+ invalid,
43
+ title: "Invalid Include Path",
44
+ detail_proc: ->(p) { "Invalid include path requested: #{p}" },
45
+ source_proc: ->(_) { { parameter: "include" } },
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end