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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/Gemfile.lock +29 -3
- data/README.md +149 -2
- data/jpie.gemspec +4 -2
- data/lib/json_api/configuration.rb +49 -11
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +6 -7
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +2 -3
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +42 -1
- data/lib/json_api/controllers/concerns/relationships/updating.rb +1 -5
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +0 -8
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +0 -39
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +3 -7
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +89 -0
- data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +50 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +8 -25
- data/lib/json_api/controllers/concerns/resource_actions.rb +9 -10
- data/lib/json_api/controllers/relationships_controller.rb +2 -3
- data/lib/json_api/errors/parameter_not_allowed.rb +0 -5
- data/lib/json_api/rack/n1_detection.rb +41 -0
- data/lib/json_api/rack/query_tracking.rb +102 -0
- data/lib/json_api/railtie.rb +34 -3
- data/lib/json_api/resources/resource.rb +0 -4
- data/lib/json_api/serialization/concerns/include_filtering.rb +42 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +9 -11
- data/lib/json_api/serialization/concerns/links_serialization.rb +12 -4
- data/lib/json_api/serialization/concerns/relationship_processing.rb +1 -5
- data/lib/json_api/support/collection_query.rb +1 -2
- data/lib/json_api/support/concerns/condition_building.rb +5 -15
- data/lib/json_api/support/concerns/nested_filters.rb +1 -1
- data/lib/json_api/support/concerns/polymorphic_filters.rb +1 -1
- data/lib/json_api/support/concerns/regular_filters.rb +3 -21
- data/lib/json_api/support/correlation_id.rb +16 -0
- data/lib/json_api/support/filter_parsing.rb +18 -0
- data/lib/json_api/support/header_warning_subscriber.rb +17 -0
- data/lib/json_api/support/n1_log_subscriber.rb +38 -0
- data/lib/json_api/support/param_helpers.rb +4 -0
- data/lib/json_api/support/prosopite_instrumentation_logger.rb +56 -0
- data/lib/json_api/support/query_tracking_log_subscriber.rb +57 -0
- data/lib/json_api/support/query_tracking_subscriber.rb +76 -0
- data/lib/json_api/support/relationship_guard.rb +1 -1
- data/lib/json_api/support/resource_identifier.rb +2 -1
- data/lib/json_api/support/responders.rb +1 -1
- data/lib/json_api/version.rb +1 -1
- data/lib/json_api.rb +9 -1
- metadata +49 -13
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +0 -80
- data/lib/json_api/resources/concerns/eager_load_dsl.rb +0 -50
- data/lib/json_api/resources/concerns/preload_dsl.rb +0 -49
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48369caaf5706cefe367cf5450398a4620741a09193d1ad37e4b42738eb33cc3
|
|
4
|
+
data.tar.gz: 767a5a640a1e0ba22902d7d6d850bcf1c1491ec3903c041845a09d5db9eb60fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73f3dd657b1efa1eda908b32fe45e189ce355a159e8d48b805ebc1b2cf7afdbca2633e31fe93e56d9cf298956561f993db87c9acbe9cf13c12f8d72f8f82e717
|
|
7
|
+
data.tar.gz: d2f3c7e883efe3a27cadf78287903c26ae6fde6c411f84b553e6fe85fd0e4433eac589dfa42ce43886dccb5ead4828205e1b34139dc5fc0d18792adc8dcc4ca4
|
data/.rubocop.yml
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
jpie (
|
|
5
|
-
actionpack (~> 8.
|
|
6
|
-
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
28
|
-
spec.add_dependency "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = @
|
|
33
|
+
@resource = @resource_class.records.find(params[:id])
|
|
34
34
|
rescue ActiveRecord::RecordNotFound
|
|
35
|
-
|
|
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 =
|
|
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
|
-
|
|
73
|
-
%w[id type].include?(
|
|
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
|