jpie 1.5.0 → 2.0.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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +22 -1
- data/lib/json_api/configuration.rb +36 -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/filter_validation.rb +3 -7
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +42 -25
- data/lib/json_api/controllers/concerns/resource_actions.rb +6 -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_warning.rb +72 -0
- data/lib/json_api/railtie.rb +8 -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 +57 -60
- 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/serialization/include_path_helpers.rb +34 -0
- data/lib/json_api/serialization/serializer.rb +1 -0
- 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/filter_parsing.rb +18 -0
- data/lib/json_api/support/param_helpers.rb +4 -0
- data/lib/json_api/support/query_counter.rb +32 -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 +3 -1
- metadata +6 -5
- 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: 1a598f58a7c0f80730e852f069276ea9d3066d3c7622a8faec599f1b58039f0e
|
|
4
|
+
data.tar.gz: 33292a3d2e2b98c09fb9868972b2936738ebf00b6ec0a1b7baf46ec51b017369
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b3830b35c1fc6690c755c5f90ad7568ffc6c57d7b8ad0516fdf368f476a15223a0489694acb5ac288d2c5f8f9f8889c154f5e0f09915dfac276b876ed437858
|
|
7
|
+
data.tar.gz: 0cdc51e099411e4f27342783e3e47bf5e34ff983308758fa904819009df8f23364521a7d1b32a634827fdcba7f4302501202ef4f0c193d119e97b9f9daaf7990
|
data/Gemfile.lock
CHANGED
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
|
|
|
@@ -659,6 +659,27 @@ JSONAPI.configure do |config|
|
|
|
659
659
|
end
|
|
660
660
|
```
|
|
661
661
|
|
|
662
|
+
### N+1 Query Warning
|
|
663
|
+
|
|
664
|
+
When clients omit the `include` parameter for nested relationships, the API may execute N+1 queries. The gem detects high query counts and:
|
|
665
|
+
|
|
666
|
+
1. **Logs** a warning to `Rails.logger` with path, include param, and query count
|
|
667
|
+
2. **Adds response headers** so clients can detect and fix their requests:
|
|
668
|
+
- `X-JPie-Query-Count` – number of DB queries executed
|
|
669
|
+
- `Server-Timing` – W3C standard header with query count in `desc`
|
|
670
|
+
- `X-JPie-Performance-Warning` – when count exceeds threshold, suggests adding `include` param
|
|
671
|
+
|
|
672
|
+
Configure in an initializer:
|
|
673
|
+
|
|
674
|
+
```ruby
|
|
675
|
+
JSONAPI.configure do |config|
|
|
676
|
+
config.n1_query_threshold = 20 # Warn when queries exceed this (default: 20)
|
|
677
|
+
config.n1_warning_enabled = true # Set false to disable (default: true)
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Clients can read `X-JPie-Query-Count` or `Server-Timing` to monitor performance and add appropriate `include` values when the warning header appears.
|
|
682
|
+
|
|
662
683
|
### Base Controller Class
|
|
663
684
|
|
|
664
685
|
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):
|
|
@@ -4,19 +4,11 @@ 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_query_threshold, :n1_warning_enabled
|
|
8
9
|
|
|
9
10
|
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
|
|
11
|
+
set_defaults
|
|
20
12
|
end
|
|
21
13
|
|
|
22
14
|
def base_controller_class=(value)
|
|
@@ -49,6 +41,39 @@ module JSONAPI
|
|
|
49
41
|
def base_controller_overridden?
|
|
50
42
|
@base_controller_class != "ActionController::API"
|
|
51
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def set_defaults
|
|
48
|
+
set_pagination_defaults
|
|
49
|
+
set_n1_defaults
|
|
50
|
+
set_nil_defaults
|
|
51
|
+
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
52
|
+
@base_controller_class = "ActionController::API"
|
|
53
|
+
set_namespace_defaults
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_pagination_defaults
|
|
57
|
+
@default_page_size = 25
|
|
58
|
+
@max_page_size = 100
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def set_n1_defaults
|
|
62
|
+
@n1_query_threshold = 20
|
|
63
|
+
@n1_warning_enabled = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_nil_defaults
|
|
67
|
+
@jsonapi_meta = nil
|
|
68
|
+
@authorization_handler = nil
|
|
69
|
+
@authorization_scope = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set_namespace_defaults
|
|
73
|
+
@namespace_type_format = :flat
|
|
74
|
+
@namespace_model_mapping = :same_namespace
|
|
75
|
+
@namespace_fallback = true
|
|
76
|
+
end
|
|
52
77
|
end
|
|
53
78
|
|
|
54
79
|
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,
|
|
@@ -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)
|
|
@@ -28,12 +28,12 @@ module JSONAPI
|
|
|
28
28
|
def load_resource_record
|
|
29
29
|
return unless params[:id] && @resource_class
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
scope = scope_with_includes(@resource_class.records)
|
|
32
|
+
@resource = scope.find(params[:id])
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def render_record_not_found
|
|
35
|
-
|
|
36
|
-
status: 404,
|
|
36
|
+
render_not_found_error(
|
|
37
37
|
title: "Record Not Found",
|
|
38
38
|
detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
|
|
39
39
|
)
|
|
@@ -6,31 +6,26 @@ module JSONAPI
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
def serialize_resource(resource)
|
|
9
|
-
run_preload_hook([resource])
|
|
10
9
|
JSONAPI::Serializer.new(resource).to_hash(
|
|
11
10
|
include: parse_include_param,
|
|
12
11
|
fields: parse_fields_param,
|
|
13
12
|
document_meta: jsonapi_document_meta,
|
|
14
13
|
)
|
|
15
|
-
ensure
|
|
16
|
-
clear_preload_data
|
|
17
14
|
end
|
|
18
15
|
|
|
19
16
|
def serialize_collection(resources)
|
|
17
|
+
includes = parse_include_param
|
|
18
|
+
fields = parse_fields_param
|
|
19
|
+
resources = scope_with_includes(resources)
|
|
20
20
|
resources_array = resources.to_a
|
|
21
|
-
run_preload_hook(resources_array)
|
|
22
21
|
|
|
23
|
-
data, all_included = serialize_resources_with_includes(resources_array)
|
|
22
|
+
data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
|
|
24
23
|
build_collection_response(data, all_included)
|
|
25
|
-
ensure
|
|
26
|
-
clear_preload_data
|
|
27
24
|
end
|
|
28
25
|
|
|
29
26
|
private
|
|
30
27
|
|
|
31
|
-
def serialize_resources_with_includes(resources)
|
|
32
|
-
includes = parse_include_param
|
|
33
|
-
fields = parse_fields_param
|
|
28
|
+
def serialize_resources_with_includes(resources, includes, fields)
|
|
34
29
|
all_included = []
|
|
35
30
|
processed = Set.new
|
|
36
31
|
|
|
@@ -61,30 +56,52 @@ module JSONAPI
|
|
|
61
56
|
processed.add(key)
|
|
62
57
|
end
|
|
63
58
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
def includes_to_hash(paths)
|
|
60
|
+
hash = paths.each_with_object({}) do |path, h|
|
|
61
|
+
path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
|
|
62
|
+
end
|
|
63
|
+
filter_includable(hash, model_class)
|
|
64
|
+
end
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
# Preloads associations from the include param so the serializer avoids N+1 queries
|
|
67
|
+
# when walking include paths (association.loaded? is true). Used by both show and index.
|
|
68
|
+
def scope_with_includes(scope)
|
|
69
|
+
includes = parse_include_param
|
|
70
|
+
return scope unless includes.any?
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
inc_hash = includes_to_hash(includes)
|
|
73
|
+
hash_contains_polymorphic?(inc_hash, model_class) ? scope.preload(inc_hash) : scope.includes(inc_hash)
|
|
73
74
|
end
|
|
74
75
|
|
|
75
|
-
def
|
|
76
|
-
|
|
76
|
+
def filter_includable(hash, klass)
|
|
77
|
+
hash.each_with_object({}) do |(key, value), filtered|
|
|
78
|
+
assoc = klass.reflect_on_association(key)
|
|
79
|
+
next unless assoc
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hash_contains_polymorphic?(hash, klass)
|
|
86
|
+
hash.any? { |key, value| polymorphic_in_hash_entry?(key, value, klass) }
|
|
80
87
|
end
|
|
81
88
|
|
|
82
|
-
def
|
|
83
|
-
|
|
89
|
+
def polymorphic_in_hash_entry?(key, value, klass)
|
|
90
|
+
assoc = klass.reflect_on_association(key)
|
|
91
|
+
return false unless assoc
|
|
92
|
+
|
|
93
|
+
assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
|
|
84
94
|
end
|
|
85
95
|
|
|
86
|
-
def
|
|
87
|
-
{
|
|
96
|
+
def build_collection_response(data, all_included)
|
|
97
|
+
result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
|
|
98
|
+
result[:included] = all_included if all_included.any?
|
|
99
|
+
|
|
100
|
+
pagination_meta = @pagination_applied ? build_pagination_meta : {}
|
|
101
|
+
result[:links] = build_pagination_links if @pagination_applied
|
|
102
|
+
result[:meta] = jsonapi_document_meta(pagination_meta)
|
|
103
|
+
|
|
104
|
+
result
|
|
88
105
|
end
|
|
89
106
|
end
|
|
90
107
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "resource_actions/filter_validation"
|
|
4
4
|
require_relative "resource_actions/field_validation"
|
|
5
|
-
require_relative "resource_actions/preloading"
|
|
6
5
|
require_relative "resource_actions/serialization"
|
|
7
6
|
require_relative "resource_actions/pagination"
|
|
8
7
|
require_relative "resource_actions/type_validation"
|
|
@@ -15,7 +14,6 @@ module JSONAPI
|
|
|
15
14
|
include ActiveStorageSupport
|
|
16
15
|
include FilterValidation
|
|
17
16
|
include FieldValidation
|
|
18
|
-
include Preloading
|
|
19
17
|
include Serialization
|
|
20
18
|
include Pagination
|
|
21
19
|
include TypeValidation
|
|
@@ -30,11 +28,10 @@ module JSONAPI
|
|
|
30
28
|
before_action :validate_include_param, only: %i[index show]
|
|
31
29
|
before_action :validate_resource_type!, only: %i[create update]
|
|
32
30
|
before_action :validate_resource_id!, only: [:update]
|
|
33
|
-
before_action :preload_includes, only: %i[index show]
|
|
34
31
|
end
|
|
35
32
|
|
|
36
33
|
def index
|
|
37
|
-
scope = apply_authorization_scope(@
|
|
34
|
+
scope = apply_authorization_scope(@resource_class.records, action: :index)
|
|
38
35
|
query = build_query(scope)
|
|
39
36
|
@total_count = query.total_count
|
|
40
37
|
@pagination_applied = query.pagination_applied
|
|
@@ -42,9 +39,8 @@ module JSONAPI
|
|
|
42
39
|
end
|
|
43
40
|
|
|
44
41
|
def show
|
|
45
|
-
resource
|
|
46
|
-
|
|
47
|
-
render json: serialize_resource(resource), status: :ok
|
|
42
|
+
authorize_resource_action!(@resource, action: :show)
|
|
43
|
+
render json: serialize_resource(@resource), status: :ok
|
|
48
44
|
end
|
|
49
45
|
|
|
50
46
|
def create
|
|
@@ -54,7 +50,7 @@ module JSONAPI
|
|
|
54
50
|
save_created(resource)
|
|
55
51
|
rescue ArgumentError => e
|
|
56
52
|
render_create_error(e)
|
|
57
|
-
rescue JSONAPI::
|
|
53
|
+
rescue JSONAPI::Errors::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
58
54
|
handle_create_exception(e)
|
|
59
55
|
end
|
|
60
56
|
|
|
@@ -66,7 +62,7 @@ module JSONAPI
|
|
|
66
62
|
|
|
67
63
|
def handle_create_exception(error)
|
|
68
64
|
case error
|
|
69
|
-
when JSONAPI::
|
|
65
|
+
when JSONAPI::Errors::ParameterNotAllowed then render_parameter_not_allowed_error(error)
|
|
70
66
|
when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
|
|
71
67
|
end
|
|
72
68
|
end
|
|
@@ -77,7 +73,7 @@ module JSONAPI
|
|
|
77
73
|
save_updated(params_hash, attachments)
|
|
78
74
|
rescue ArgumentError => e
|
|
79
75
|
render_invalid_relationship_error(e)
|
|
80
|
-
rescue JSONAPI::
|
|
76
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
81
77
|
render_parameter_not_allowed_error(e)
|
|
82
78
|
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
83
79
|
render_signed_id_error(e)
|
|
@@ -26,7 +26,6 @@ module JSONAPI
|
|
|
26
26
|
before_action :set_relationship_name
|
|
27
27
|
before_action :validate_relationship_exists
|
|
28
28
|
before_action :validate_sort_param, only: [:show]
|
|
29
|
-
skip_before_action :validate_resource_type!, :validate_resource_id!
|
|
30
29
|
|
|
31
30
|
def show
|
|
32
31
|
authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
|
|
@@ -40,7 +39,7 @@ module JSONAPI
|
|
|
40
39
|
save_and_render_relationship(relationship_data)
|
|
41
40
|
rescue ArgumentError => e
|
|
42
41
|
render_invalid_relationship_error(e)
|
|
43
|
-
rescue JSONAPI::
|
|
42
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
44
43
|
render_parameter_not_allowed_error(e)
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -53,7 +52,7 @@ module JSONAPI
|
|
|
53
52
|
finalize_relationship_removal(relationship_data)
|
|
54
53
|
rescue ArgumentError => e
|
|
55
54
|
render_invalid_relationship_error(e)
|
|
56
|
-
rescue JSONAPI::
|
|
55
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
57
56
|
render_parameter_not_allowed_error(e)
|
|
58
57
|
end
|
|
59
58
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Rack
|
|
5
|
+
class N1Warning
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
return @app.call(env) unless JSONAPI.configuration.n1_warning_enabled
|
|
12
|
+
return @app.call(env) unless jsonapi_request?(env)
|
|
13
|
+
|
|
14
|
+
result = nil
|
|
15
|
+
query_count = JSONAPI::QueryCounter.count_queries { result = @app.call(env) }
|
|
16
|
+
status, headers, body = result
|
|
17
|
+
headers = add_n1_headers(headers, query_count, env)
|
|
18
|
+
[status, headers, body]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def add_n1_headers(headers, query_count, env)
|
|
24
|
+
headers = headers.dup
|
|
25
|
+
headers["X-JPie-Query-Count"] = query_count.to_s
|
|
26
|
+
headers["Server-Timing"] = append_server_timing(headers["Server-Timing"].to_s, query_count)
|
|
27
|
+
add_perf_warning_if_needed!(headers, query_count, env)
|
|
28
|
+
headers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_perf_warning_if_needed!(headers, query_count, env)
|
|
32
|
+
threshold = JSONAPI.configuration.n1_query_threshold
|
|
33
|
+
return unless query_count > threshold
|
|
34
|
+
|
|
35
|
+
warning_msg = n1_warning_message(query_count, threshold)
|
|
36
|
+
headers["X-JPie-Performance-Warning"] = warning_msg
|
|
37
|
+
headers["Server-Timing"] = [headers["Server-Timing"], perf_warning_metric(warning_msg)].join(", ")
|
|
38
|
+
log_n1_warning(env, query_count, threshold)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def append_server_timing(existing, query_count)
|
|
42
|
+
db_metric = "db;desc=\"#{query_count} queries\""
|
|
43
|
+
[existing, db_metric].reject(&:empty?).join(", ")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def n1_warning_message(query_count, threshold)
|
|
47
|
+
"N+1 possible (#{query_count} queries, threshold #{threshold}) - " \
|
|
48
|
+
"add include param for nested relationships"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def perf_warning_metric(warning_msg)
|
|
52
|
+
safe_desc = warning_msg.tr('"', "'")
|
|
53
|
+
"perf-warning;desc=\"#{safe_desc}\""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def jsonapi_request?(env)
|
|
57
|
+
env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def log_n1_warning(env, count, threshold)
|
|
61
|
+
return unless defined?(Rails) && Rails.logger
|
|
62
|
+
|
|
63
|
+
req = ActionDispatch::Request.new(env)
|
|
64
|
+
Rails.logger.warn(
|
|
65
|
+
"[JPie] N+1 query warning: #{count} queries (threshold #{threshold}) " \
|
|
66
|
+
"path=#{req.path} include=#{req.params[:include].inspect} " \
|
|
67
|
+
"resource=#{req.params[:resource_type]}",
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -16,14 +16,19 @@ module JSONAPI
|
|
|
16
16
|
Mime::Type.register "application/vnd.api+json", :jsonapi
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
initializer "json_api.n1_warning_middleware", after: "action_dispatch.configure" do |app|
|
|
20
|
+
if app.config.respond_to?(:server_timing) && app.config.server_timing
|
|
21
|
+
app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Warning
|
|
22
|
+
else
|
|
23
|
+
app.config.middleware.use JSONAPI::Rack::N1Warning
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
initializer "json_api.routes" do |_app|
|
|
20
28
|
require "json_api/routing"
|
|
21
29
|
ActionDispatch::Routing::Mapper.include JSONAPI::Routing
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
# Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
|
|
25
|
-
# Controllers and resources are autoloaded via Zeitwerk
|
|
26
|
-
|
|
27
32
|
initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
|
|
28
33
|
ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
|
|
29
34
|
ActiveSupport::JSON.decode(raw_post)
|
|
@@ -6,8 +6,6 @@ require_relative "concerns/sortable_fields_dsl"
|
|
|
6
6
|
require_relative "concerns/relationships_dsl"
|
|
7
7
|
require_relative "concerns/meta_dsl"
|
|
8
8
|
require_relative "concerns/model_class_helpers"
|
|
9
|
-
require_relative "concerns/eager_load_dsl"
|
|
10
|
-
require_relative "concerns/preload_dsl"
|
|
11
9
|
|
|
12
10
|
module JSONAPI
|
|
13
11
|
class Resource
|
|
@@ -17,8 +15,6 @@ module JSONAPI
|
|
|
17
15
|
include Resources::RelationshipsDsl
|
|
18
16
|
include Resources::MetaDsl
|
|
19
17
|
include Resources::ModelClassHelpers
|
|
20
|
-
include Resources::EagerLoadDsl
|
|
21
|
-
include Resources::PreloadDsl
|
|
22
18
|
|
|
23
19
|
class << self
|
|
24
20
|
attr_accessor :type_format
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Serialization
|
|
5
|
+
module IncludeFiltering
|
|
6
|
+
def filter_loaded_records(association, related_klass)
|
|
7
|
+
loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
|
|
8
|
+
return [] if loaded_array.empty?
|
|
9
|
+
|
|
10
|
+
resource_scope = ResourceLoader.find_for_model(related_klass).records
|
|
11
|
+
return loaded_array if resource_scope.where_clause.empty?
|
|
12
|
+
|
|
13
|
+
filter_by_where_hash(loaded_array, resource_scope, related_klass)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def filter_by_where_hash(loaded_array, resource_scope, related_klass)
|
|
17
|
+
hash = where_values_hash_for_scope(resource_scope, related_klass)
|
|
18
|
+
return filter_by_query(loaded_array, resource_scope) if hash.blank?
|
|
19
|
+
|
|
20
|
+
loaded_array.select { |r| record_matches_where_hash?(r, hash) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def where_values_hash_for_scope(resource_scope, related_klass)
|
|
24
|
+
resource_scope.where_values_hash(related_klass.table_name)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record_matches_where_hash?(record, hash)
|
|
30
|
+
hash.all? do |attr, val|
|
|
31
|
+
r_val = record.read_attribute(attr)
|
|
32
|
+
val.is_a?(Array) ? val.include?(r_val) : r_val == val
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def filter_by_query(loaded_array, resource_scope)
|
|
37
|
+
valid_ids = resource_scope.where(id: loaded_array.filter_map(&:id)).pluck(:id).to_set
|
|
38
|
+
loaded_array.select { |r| valid_ids.include?(r.id) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|