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
|
@@ -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
|
)
|
|
@@ -4,33 +4,31 @@ module JSONAPI
|
|
|
4
4
|
module ResourceActions
|
|
5
5
|
module Serialization
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
|
+
include IncludePreloading
|
|
7
8
|
|
|
8
9
|
def serialize_resource(resource)
|
|
9
|
-
run_preload_hook([resource])
|
|
10
10
|
JSONAPI::Serializer.new(resource).to_hash(
|
|
11
11
|
include: parse_include_param,
|
|
12
12
|
fields: parse_fields_param,
|
|
13
13
|
document_meta: jsonapi_document_meta,
|
|
14
14
|
)
|
|
15
|
-
ensure
|
|
16
|
-
clear_preload_data
|
|
17
15
|
end
|
|
18
16
|
|
|
19
17
|
def serialize_collection(resources)
|
|
18
|
+
includes = parse_include_param
|
|
19
|
+
fields = parse_fields_param
|
|
20
|
+
resources = scope_with_includes(resources)
|
|
20
21
|
resources_array = resources.to_a
|
|
21
|
-
run_preload_hook(resources_array)
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
preload_included_resource_associations(resources_array, includes)
|
|
24
|
+
|
|
25
|
+
data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
|
|
24
26
|
build_collection_response(data, all_included)
|
|
25
|
-
ensure
|
|
26
|
-
clear_preload_data
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
def serialize_resources_with_includes(resources)
|
|
32
|
-
includes = parse_include_param
|
|
33
|
-
fields = parse_fields_param
|
|
31
|
+
def serialize_resources_with_includes(resources, includes, fields)
|
|
34
32
|
all_included = []
|
|
35
33
|
processed = Set.new
|
|
36
34
|
|
|
@@ -71,21 +69,6 @@ module JSONAPI
|
|
|
71
69
|
|
|
72
70
|
result
|
|
73
71
|
end
|
|
74
|
-
|
|
75
|
-
def run_preload_hook(records)
|
|
76
|
-
return unless resource_class.respond_to?(:preload_for_serialization)
|
|
77
|
-
|
|
78
|
-
preloaded = resource_class.preload_for_serialization(records, jsonapi_context)
|
|
79
|
-
resource_class.preloaded_data = preloaded if preloaded.present?
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def clear_preload_data
|
|
83
|
-
resource_class.clear_preloaded_data! if resource_class.respond_to?(:clear_preloaded_data!)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def jsonapi_context
|
|
87
|
-
{ current_user: respond_to?(:current_user) ? current_user : nil }
|
|
88
|
-
end
|
|
89
72
|
end
|
|
90
73
|
end
|
|
91
74
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "resource_actions/filter_validation"
|
|
4
4
|
require_relative "resource_actions/field_validation"
|
|
5
|
-
require_relative "resource_actions/
|
|
5
|
+
require_relative "resource_actions/include_validation"
|
|
6
|
+
require_relative "resource_actions/include_preloading"
|
|
6
7
|
require_relative "resource_actions/serialization"
|
|
7
8
|
require_relative "resource_actions/pagination"
|
|
8
9
|
require_relative "resource_actions/type_validation"
|
|
@@ -15,7 +16,7 @@ module JSONAPI
|
|
|
15
16
|
include ActiveStorageSupport
|
|
16
17
|
include FilterValidation
|
|
17
18
|
include FieldValidation
|
|
18
|
-
include
|
|
19
|
+
include IncludeValidation
|
|
19
20
|
include Serialization
|
|
20
21
|
include Pagination
|
|
21
22
|
include TypeValidation
|
|
@@ -30,11 +31,10 @@ module JSONAPI
|
|
|
30
31
|
before_action :validate_include_param, only: %i[index show]
|
|
31
32
|
before_action :validate_resource_type!, only: %i[create update]
|
|
32
33
|
before_action :validate_resource_id!, only: [:update]
|
|
33
|
-
before_action :preload_includes, only: %i[index show]
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def index
|
|
37
|
-
scope = apply_authorization_scope(@
|
|
37
|
+
scope = apply_authorization_scope(@resource_class.records, action: :index)
|
|
38
38
|
query = build_query(scope)
|
|
39
39
|
@total_count = query.total_count
|
|
40
40
|
@pagination_applied = query.pagination_applied
|
|
@@ -42,9 +42,8 @@ module JSONAPI
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def show
|
|
45
|
-
resource
|
|
46
|
-
|
|
47
|
-
render json: serialize_resource(resource), status: :ok
|
|
45
|
+
authorize_resource_action!(@resource, action: :show)
|
|
46
|
+
render json: serialize_resource(@resource), status: :ok
|
|
48
47
|
end
|
|
49
48
|
|
|
50
49
|
def create
|
|
@@ -54,7 +53,7 @@ module JSONAPI
|
|
|
54
53
|
save_created(resource)
|
|
55
54
|
rescue ArgumentError => e
|
|
56
55
|
render_create_error(e)
|
|
57
|
-
rescue JSONAPI::
|
|
56
|
+
rescue JSONAPI::Errors::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
58
57
|
handle_create_exception(e)
|
|
59
58
|
end
|
|
60
59
|
|
|
@@ -66,7 +65,7 @@ module JSONAPI
|
|
|
66
65
|
|
|
67
66
|
def handle_create_exception(error)
|
|
68
67
|
case error
|
|
69
|
-
when JSONAPI::
|
|
68
|
+
when JSONAPI::Errors::ParameterNotAllowed then render_parameter_not_allowed_error(error)
|
|
70
69
|
when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
|
|
71
70
|
end
|
|
72
71
|
end
|
|
@@ -77,7 +76,7 @@ module JSONAPI
|
|
|
77
76
|
save_updated(params_hash, attachments)
|
|
78
77
|
rescue ArgumentError => e
|
|
79
78
|
render_invalid_relationship_error(e)
|
|
80
|
-
rescue JSONAPI::
|
|
79
|
+
rescue JSONAPI::Errors::ParameterNotAllowed => e
|
|
81
80
|
render_parameter_not_allowed_error(e)
|
|
82
81
|
rescue ActiveSupport::MessageVerifier::InvalidSignature => e
|
|
83
82
|
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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prosopite"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
module Rack
|
|
7
|
+
class N1Detection
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
return @app.call(env) unless JSONAPI.configuration.n1_detection_enabled
|
|
14
|
+
return @app.call(env) unless jsonapi_request?(env)
|
|
15
|
+
return @app.call(env) unless postgres?
|
|
16
|
+
|
|
17
|
+
Thread.current[:jpie_request_env] = env
|
|
18
|
+
Prosopite.custom_logger = JSONAPI::Support::ProsopiteInstrumentationLogger.new
|
|
19
|
+
|
|
20
|
+
result = Prosopite.scan { @app.call(env) }
|
|
21
|
+
result
|
|
22
|
+
ensure
|
|
23
|
+
Prosopite.finish
|
|
24
|
+
Thread.current[:jpie_request_env] = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def jsonapi_request?(env)
|
|
30
|
+
env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def postgres?
|
|
34
|
+
return false unless defined?(ActiveRecord) && ActiveRecord::Base.connection_db_config
|
|
35
|
+
|
|
36
|
+
adapter = ActiveRecord::Base.connection_db_config.adapter
|
|
37
|
+
adapter.to_s.include?("postgresql")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Rack
|
|
5
|
+
class QueryTracking
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
return @app.call(env) unless JSONAPI.configuration.query_tracking_enabled
|
|
12
|
+
return @app.call(env) unless jsonapi_request?(env)
|
|
13
|
+
|
|
14
|
+
call_with_tracking(env)
|
|
15
|
+
ensure
|
|
16
|
+
emit_excessive_queries_if_needed
|
|
17
|
+
Thread.current[:jpie_query_tracking] = nil
|
|
18
|
+
Thread.current[:jpie_warnings] = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def call_with_tracking(env)
|
|
24
|
+
Thread.current[:jpie_query_tracking] = { count: 0, queries: [], env: env }
|
|
25
|
+
Thread.current[:jpie_warnings] = [] if JSONAPI.configuration.jpie_headers_enabled
|
|
26
|
+
|
|
27
|
+
status, headers, body = @app.call(env)
|
|
28
|
+
add_jpie_headers(headers) if JSONAPI.configuration.jpie_headers_enabled
|
|
29
|
+
|
|
30
|
+
[status, headers, body]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def jsonapi_request?(env)
|
|
34
|
+
env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def emit_excessive_queries_if_needed
|
|
38
|
+
tracking = Thread.current[:jpie_query_tracking]
|
|
39
|
+
return if tracking.nil?
|
|
40
|
+
|
|
41
|
+
count = tracking[:count]
|
|
42
|
+
threshold = JSONAPI.configuration.query_count_threshold
|
|
43
|
+
return if count <= threshold
|
|
44
|
+
return unless defined?(Rails) && Rails.respond_to?(:event)
|
|
45
|
+
|
|
46
|
+
notify_excessive_queries(tracking, count, threshold)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def notify_excessive_queries(tracking, count, threshold)
|
|
50
|
+
cap = JSONAPI.configuration.query_tracking_queries_cap
|
|
51
|
+
queries = tracking[:queries].first(cap)
|
|
52
|
+
payload = build_excessive_queries_payload(tracking[:env], count, threshold, queries)
|
|
53
|
+
|
|
54
|
+
Rails.event.tagged("jsonapi", "excessive_queries") do
|
|
55
|
+
Rails.event.notify("jpie.excessive_queries_detected", payload)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_excessive_queries_payload(env, count, threshold, queries)
|
|
60
|
+
build_payload(env).merge(
|
|
61
|
+
query_count: count,
|
|
62
|
+
threshold: threshold,
|
|
63
|
+
queries: queries,
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_payload(env)
|
|
68
|
+
base = env.nil? ? {} : request_payload_from_env(env)
|
|
69
|
+
base.merge(correlation_id: JSONAPI::Support::CorrelationId.resolve)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def request_payload_from_env(env)
|
|
73
|
+
req = ActionDispatch::Request.new(env)
|
|
74
|
+
params = req.params
|
|
75
|
+
{
|
|
76
|
+
path: req.path,
|
|
77
|
+
method: req.request_method,
|
|
78
|
+
include: params[:include],
|
|
79
|
+
resource_type: params[:resource_type],
|
|
80
|
+
resource_id: params[:id],
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_jpie_headers(headers)
|
|
85
|
+
tracking = Thread.current[:jpie_query_tracking]
|
|
86
|
+
return if tracking.nil?
|
|
87
|
+
|
|
88
|
+
count = tracking[:count]
|
|
89
|
+
headers["X-JPie-Query-Count"] = count.to_s
|
|
90
|
+
|
|
91
|
+
warning_str = build_performance_warning_header(count)
|
|
92
|
+
headers["X-JPie-Performance-Warning"] = warning_str if warning_str.present?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_performance_warning_header(count)
|
|
96
|
+
warnings = (Thread.current[:jpie_warnings] || []).dup
|
|
97
|
+
warnings << "excessive_queries" if count > JSONAPI.configuration.query_count_threshold
|
|
98
|
+
warnings.uniq.join(",").presence
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -16,14 +16,45 @@ module JSONAPI
|
|
|
16
16
|
Mime::Type.register "application/vnd.api+json", :jsonapi
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
initializer "json_api.n1_log_subscriber", after: "action_dispatch.configure" do
|
|
20
|
+
if Rails.respond_to?(:event) && JSONAPI.configuration.n1_detection_enabled
|
|
21
|
+
Rails.event.subscribe(JSONAPI::Support::N1LogSubscriber.new)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "json_api.n1_detection_middleware", after: "action_dispatch.configure" do |app|
|
|
26
|
+
if app.config.respond_to?(:server_timing) && app.config.server_timing
|
|
27
|
+
app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Detection
|
|
28
|
+
else
|
|
29
|
+
app.config.middleware.use JSONAPI::Rack::N1Detection
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
initializer "json_api.query_tracking_subscriber", after: "action_dispatch.configure" do
|
|
34
|
+
ActiveSupport::Notifications.subscribe("sql.active_record", JSONAPI::Support::QueryTrackingSubscriber.new)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
initializer "json_api.query_tracking_log_subscriber", after: "action_dispatch.configure" do
|
|
38
|
+
if Rails.respond_to?(:event) && JSONAPI.configuration.query_tracking_enabled
|
|
39
|
+
Rails.event.subscribe(JSONAPI::Support::QueryTrackingLogSubscriber.new)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
initializer "json_api.header_warning_subscriber", after: "action_dispatch.configure" do
|
|
44
|
+
if Rails.respond_to?(:event) && JSONAPI.configuration.jpie_headers_enabled
|
|
45
|
+
Rails.event.subscribe(JSONAPI::Support::HeaderWarningSubscriber.new)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
initializer "json_api.query_tracking_middleware", after: "json_api.n1_detection_middleware" do |app|
|
|
50
|
+
app.config.middleware.insert_after JSONAPI::Rack::N1Detection, JSONAPI::Rack::QueryTracking
|
|
51
|
+
end
|
|
52
|
+
|
|
19
53
|
initializer "json_api.routes" do |_app|
|
|
20
54
|
require "json_api/routing"
|
|
21
55
|
ActionDispatch::Routing::Mapper.include JSONAPI::Routing
|
|
22
56
|
end
|
|
23
57
|
|
|
24
|
-
# Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
|
|
25
|
-
# Controllers and resources are autoloaded via Zeitwerk
|
|
26
|
-
|
|
27
58
|
initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
|
|
28
59
|
ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
|
|
29
60
|
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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "include_filtering"
|
|
4
|
+
|
|
3
5
|
module JSONAPI
|
|
4
6
|
module Serialization
|
|
5
7
|
IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, keyword_init: true)
|
|
@@ -9,6 +11,7 @@ module JSONAPI
|
|
|
9
11
|
|
|
10
12
|
module IncludesSerialization
|
|
11
13
|
include IncludePathHelpers
|
|
14
|
+
include IncludeFiltering
|
|
12
15
|
|
|
13
16
|
def serialize_included(includes, fields = {})
|
|
14
17
|
all_includes = normalize_include_paths(includes)
|
|
@@ -84,21 +87,16 @@ module JSONAPI
|
|
|
84
87
|
related_klass = association.klass
|
|
85
88
|
return [] if related_klass.blank?
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
|
|
93
|
-
loaded_ids = loaded_array.filter_map(&:id)
|
|
94
|
-
return [] if loaded_ids.empty?
|
|
95
|
-
|
|
96
|
-
scope.where(id: loaded_ids).to_a
|
|
90
|
+
if association.loaded?
|
|
91
|
+
filter_loaded_records(association, related_klass)
|
|
92
|
+
else
|
|
93
|
+
build_scoped_relation(related_klass, association).to_a
|
|
94
|
+
end
|
|
97
95
|
end
|
|
98
96
|
|
|
99
97
|
def build_scoped_relation(related_klass, association)
|
|
100
98
|
related_base_scope = ResourceLoader.find_for_model(related_klass).records
|
|
101
|
-
|
|
99
|
+
association.scope.merge(related_base_scope)
|
|
102
100
|
end
|
|
103
101
|
|
|
104
102
|
def get_active_storage_records(current_record, association_name)
|
|
@@ -22,17 +22,25 @@ module JSONAPI
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def variant_or_blob_path
|
|
25
|
-
return rails_blob_path unless
|
|
25
|
+
return rails_blob_path unless image_blob_with_parent?
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
|
|
29
|
-
variant_opts = rel_def&.dig(:options, :variant)
|
|
27
|
+
variant_opts = variant_options_for_parent_association
|
|
30
28
|
return rails_blob_path unless variant_opts.present?
|
|
31
29
|
|
|
32
30
|
representation = record.representation(**variant_opts.symbolize_keys)
|
|
33
31
|
Rails.application.routes.url_helpers.rails_representation_path(representation, only_path: true)
|
|
34
32
|
end
|
|
35
33
|
|
|
34
|
+
def image_blob_with_parent?
|
|
35
|
+
parent_record && association_name && record.content_type&.start_with?("image/")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def variant_options_for_parent_association
|
|
39
|
+
parent_definition = ResourceLoader.find_for_model(parent_record.class)
|
|
40
|
+
rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
|
|
41
|
+
rel_def&.dig(:options, :variant)
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
def rails_blob_path
|
|
37
45
|
Rails.application.routes.url_helpers.rails_blob_path(record, only_path: true)
|
|
38
46
|
end
|
|
@@ -11,7 +11,7 @@ module JSONAPI
|
|
|
11
11
|
ensure_relationship_writable!(association_name)
|
|
12
12
|
|
|
13
13
|
return handle_null_relationship(attrs, param_name, association_name) if data.nil?
|
|
14
|
-
return handle_empty_array_relationship(attrs, param_name, association_name) if empty_array?(data)
|
|
14
|
+
return handle_empty_array_relationship(attrs, param_name, association_name) if ParamHelpers.empty_array?(data)
|
|
15
15
|
|
|
16
16
|
validate_relationship_data_format!(data, association_name)
|
|
17
17
|
process_relationship_data(attrs, association_name, param_name, data)
|
|
@@ -27,10 +27,6 @@ module JSONAPI
|
|
|
27
27
|
value_hash[:data]
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def empty_array?(data)
|
|
31
|
-
data.is_a?(Array) && data.empty?
|
|
32
|
-
end
|
|
33
|
-
|
|
34
30
|
def handle_null_relationship(attrs, param_name, association_name)
|
|
35
31
|
if active_storage_attachment?(association_name)
|
|
36
32
|
attrs[association_name.to_s] = nil
|
|
@@ -44,8 +44,7 @@ module JSONAPI
|
|
|
44
44
|
return unless pagination_applied
|
|
45
45
|
|
|
46
46
|
has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
|
|
47
|
-
@total_count = @scope.count
|
|
48
|
-
@total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
|
|
47
|
+
@total_count = @scope.count if !has_virtual_sort || @scope.is_a?(Array)
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
def apply_filtering
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
module Support
|
|
5
5
|
module ConditionBuilding
|
|
6
|
+
include Support::FilterParsing
|
|
7
|
+
|
|
6
8
|
private
|
|
7
9
|
|
|
8
|
-
def build_condition(column, value, operator)
|
|
9
|
-
|
|
10
|
+
def build_condition(model, column, value, operator)
|
|
11
|
+
attr = model.arel_table[column.name]
|
|
12
|
+
build_operator_condition(attr, value, operator)
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def normalize_filter_value_for_model(model, column, raw_value)
|
|
@@ -19,15 +22,6 @@ module JSONAPI
|
|
|
19
22
|
type.cast(value)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
def build_condition_for_model(model, column, value, operator)
|
|
23
|
-
build_arel_condition(model, column, value, operator)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def build_arel_condition(model, column, value, operator)
|
|
27
|
-
attr = model.arel_table[column.name]
|
|
28
|
-
build_operator_condition(attr, value, operator)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
25
|
def build_operator_condition(attr, value, operator)
|
|
32
26
|
case operator
|
|
33
27
|
when :eq then attr.eq(value)
|
|
@@ -48,10 +42,6 @@ module JSONAPI
|
|
|
48
42
|
def apply_condition(scope, condition)
|
|
49
43
|
scope.where(condition)
|
|
50
44
|
end
|
|
51
|
-
|
|
52
|
-
def empty_filter_value?(filter_value)
|
|
53
|
-
filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
|
|
54
|
-
end
|
|
55
45
|
end
|
|
56
46
|
end
|
|
57
47
|
end
|
|
@@ -97,7 +97,7 @@ module JSONAPI
|
|
|
97
97
|
value = normalize_filter_value_for_model(target_model, column, filter_value)
|
|
98
98
|
return nil unless value
|
|
99
99
|
|
|
100
|
-
condition =
|
|
100
|
+
condition = build_condition(target_model, column, value, column_filter[:operator])
|
|
101
101
|
condition ? apply_condition(scope, condition) : nil
|
|
102
102
|
end
|
|
103
103
|
|
|
@@ -51,7 +51,7 @@ module JSONAPI
|
|
|
51
51
|
value = normalize_filter_value_for_model(model_class, context[:fk_column], attr_value)
|
|
52
52
|
return scope unless value
|
|
53
53
|
|
|
54
|
-
condition =
|
|
54
|
+
condition = build_condition(model_class, context[:fk_column], value, column_filter[:operator])
|
|
55
55
|
condition ? apply_condition(scope, condition) : scope
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -17,7 +17,7 @@ module JSONAPI
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
19
|
def apply_regular_filter(scope, filter_name, filter_value)
|
|
20
|
-
return scope if
|
|
20
|
+
return scope if empty_filter_value?(filter_value)
|
|
21
21
|
|
|
22
22
|
column_filter = parse_column_filter(filter_name)
|
|
23
23
|
if column_filter
|
|
@@ -27,16 +27,6 @@ module JSONAPI
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def parse_column_filter(filter_name)
|
|
31
|
-
match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
|
|
32
|
-
return nil unless match
|
|
33
|
-
|
|
34
|
-
column_name = match[1]
|
|
35
|
-
operator = match[2].to_sym
|
|
36
|
-
|
|
37
|
-
{ column: column_name, operator: }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
30
|
def apply_column_filter(scope, column_filter, raw_value)
|
|
41
31
|
condition = build_column_condition(column_filter, raw_value)
|
|
42
32
|
condition ? apply_condition(scope, condition) : scope
|
|
@@ -49,10 +39,10 @@ module JSONAPI
|
|
|
49
39
|
column = model_class.column_for_attribute(column_filter[:column])
|
|
50
40
|
return nil unless column
|
|
51
41
|
|
|
52
|
-
value =
|
|
42
|
+
value = normalize_filter_value_for_model(model_class, column, raw_value)
|
|
53
43
|
return nil if value.nil?
|
|
54
44
|
|
|
55
|
-
build_condition(column, value, column_filter[:operator])
|
|
45
|
+
build_condition(model_class, column, value, column_filter[:operator])
|
|
56
46
|
end
|
|
57
47
|
|
|
58
48
|
def log_filter_error(column_filter, operator, error)
|
|
@@ -61,14 +51,6 @@ module JSONAPI
|
|
|
61
51
|
Rails.logger.warn("Filter error for #{column_filter[:column]}_#{operator}: #{error.class} - #{error.message}")
|
|
62
52
|
end
|
|
63
53
|
|
|
64
|
-
def normalize_filter_value(column, raw_value)
|
|
65
|
-
value = raw_value.is_a?(Array) ? raw_value.first : raw_value
|
|
66
|
-
return nil if value.nil?
|
|
67
|
-
|
|
68
|
-
type = model_class.type_for_attribute(column.name)
|
|
69
|
-
type.cast(value)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
54
|
def apply_scope_fallback(scope, filter_name, filter_value)
|
|
73
55
|
return scope unless model_class.respond_to?(filter_name.to_sym)
|
|
74
56
|
|