jpie 1.0.0 → 1.0.2
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/.cursor/rules/release.mdc +62 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +82 -38
- data/Gemfile +13 -10
- data/Gemfile.lock +18 -1
- data/README.md +675 -1235
- data/Rakefile +22 -0
- data/jpie.gemspec +15 -15
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +0 -1
- data/lib/json_api/active_storage/deserialization.rb +32 -22
- data/lib/json_api/active_storage/detection.rb +36 -41
- data/lib/json_api/active_storage/serialization.rb +13 -11
- data/lib/json_api/configuration.rb +4 -5
- data/lib/json_api/controllers/base_controller.rb +3 -3
- data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
- data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
- data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
- data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
- data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
- data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
- data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
- data/lib/json_api/controllers/relationships_controller.rb +26 -422
- data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
- data/lib/json_api/railtie.rb +46 -9
- data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
- data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
- data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
- data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
- data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
- data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
- data/lib/json_api/resources/resource.rb +13 -219
- data/lib/json_api/routing.rb +56 -47
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
- data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
- data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
- data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
- data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
- data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
- data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
- data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
- data/lib/json_api/serialization/deserializer.rb +10 -346
- data/lib/json_api/serialization/serializer.rb +17 -260
- data/lib/json_api/support/active_storage_support.rb +10 -13
- data/lib/json_api/support/collection_query.rb +14 -370
- data/lib/json_api/support/concerns/condition_building.rb +57 -0
- data/lib/json_api/support/concerns/nested_filters.rb +130 -0
- data/lib/json_api/support/concerns/pagination.rb +30 -0
- data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
- data/lib/json_api/support/concerns/regular_filters.rb +81 -0
- data/lib/json_api/support/concerns/sorting.rb +88 -0
- data/lib/json_api/support/instrumentation.rb +13 -12
- data/lib/json_api/support/param_helpers.rb +9 -6
- data/lib/json_api/support/relationship_helpers.rb +4 -2
- data/lib/json_api/support/resource_identifier.rb +29 -29
- data/lib/json_api/support/responders.rb +5 -5
- data/lib/json_api/version.rb +1 -1
- metadata +44 -1
data/Rakefile
CHANGED
|
@@ -6,3 +6,25 @@ require "rspec/core/rake_task"
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
8
|
task default: :spec
|
|
9
|
+
|
|
10
|
+
# Override release task to require OTP code
|
|
11
|
+
# Usage: GEM_HOST_OTP_CODE=123456 rake release
|
|
12
|
+
Rake::Task["release"].enhance do
|
|
13
|
+
# This runs after the original release task
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Rake::Task["release"].prerequisites.unshift("release:require_otp")
|
|
17
|
+
|
|
18
|
+
namespace :release do
|
|
19
|
+
task :require_otp do
|
|
20
|
+
unless ENV["GEM_HOST_OTP_CODE"]
|
|
21
|
+
abort <<~ERROR
|
|
22
|
+
ERROR: GEM_HOST_OTP_CODE environment variable is required for release.
|
|
23
|
+
|
|
24
|
+
Usage: GEM_HOST_OTP_CODE=123456 rake release
|
|
25
|
+
|
|
26
|
+
Get your OTP code from your authenticator app.
|
|
27
|
+
ERROR
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/jpie.gemspec
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
lib = File.expand_path(
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
4
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
-
require
|
|
5
|
+
require "json_api/version"
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |spec|
|
|
8
|
-
spec.name =
|
|
8
|
+
spec.name = "jpie"
|
|
9
9
|
spec.version = JSONAPI::VERSION
|
|
10
|
-
spec.authors = [
|
|
11
|
-
spec.email = [
|
|
10
|
+
spec.authors = ["Emil Kampp"]
|
|
11
|
+
spec.email = ["emil@kampp.me"]
|
|
12
12
|
|
|
13
|
-
spec.summary =
|
|
14
|
-
spec.description =
|
|
15
|
-
spec.homepage =
|
|
16
|
-
spec.license =
|
|
13
|
+
spec.summary = "JSON:API compliant Rails gem for producing and consuming JSON:API resources"
|
|
14
|
+
spec.description = "A Rails 8+ gem that provides jsonapi_resources routing DSL and generic JSON:API controllers"
|
|
15
|
+
spec.homepage = "https://github.com/klaay/json_api"
|
|
16
|
+
spec.license = "MIT"
|
|
17
17
|
|
|
18
18
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
19
19
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
20
20
|
end
|
|
21
|
-
spec.bindir =
|
|
21
|
+
spec.bindir = "exe"
|
|
22
22
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
23
|
-
spec.require_paths = [
|
|
23
|
+
spec.require_paths = ["lib"]
|
|
24
24
|
|
|
25
|
-
spec.required_ruby_version =
|
|
25
|
+
spec.required_ruby_version = ">= 3.4.0"
|
|
26
26
|
|
|
27
|
-
spec.add_dependency
|
|
28
|
-
spec.add_dependency
|
|
27
|
+
spec.add_dependency "actionpack", "~> 8.0", ">= 8.0.0"
|
|
28
|
+
spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
|
|
29
29
|
|
|
30
|
-
spec.metadata[
|
|
30
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
31
31
|
end
|
data/lib/jpie.rb
CHANGED
|
@@ -19,18 +19,19 @@ module JSONAPI
|
|
|
19
19
|
def attach_files(record, attachment_params, definition: nil)
|
|
20
20
|
return if attachment_params.empty?
|
|
21
21
|
|
|
22
|
-
attachment_params.each do |
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
attachment_params.each do |name, blobs|
|
|
23
|
+
attach_single(record, name, blobs, definition)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def attach_single(record, name, blobs, definition)
|
|
28
|
+
attachment = record.public_send(name)
|
|
29
|
+
if blobs.is_a?(Array)
|
|
30
|
+
handle_has_many_attachment(attachment, blobs,
|
|
31
|
+
append_only: append_only_enabled?(name, definition),
|
|
32
|
+
attachment_name: name, definition:,)
|
|
33
|
+
else
|
|
34
|
+
handle_has_one_attachment(attachment, blobs, attachment_name: name, definition:)
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -80,17 +81,26 @@ module JSONAPI
|
|
|
80
81
|
|
|
81
82
|
# Private helper methods
|
|
82
83
|
def handle_has_many_attachment(attachment, blob_or_blobs, append_only:, attachment_name:, definition:)
|
|
83
|
-
if blob_or_blobs.empty?
|
|
84
|
-
|
|
84
|
+
return handle_empty_blobs(attachment, append_only, attachment_name, definition) if blob_or_blobs.empty?
|
|
85
|
+
return append_blobs(attachment, blob_or_blobs) if append_only
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
replace_blobs(attachment, blob_or_blobs)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_empty_blobs(attachment, append_only, attachment_name, definition)
|
|
91
|
+
return if append_only
|
|
92
|
+
|
|
93
|
+
attachment.purge if purge_on_nil_enabled?(attachment_name, definition) && attachment.attached?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def append_blobs(attachment, blob_or_blobs)
|
|
97
|
+
existing_blobs = attachment.attached? ? attachment.blobs.to_a : []
|
|
98
|
+
attachment.attach(existing_blobs + blob_or_blobs)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def replace_blobs(attachment, blob_or_blobs)
|
|
102
|
+
attachment.purge if attachment.attached?
|
|
103
|
+
attachment.attach(blob_or_blobs)
|
|
94
104
|
end
|
|
95
105
|
|
|
96
106
|
def handle_has_one_attachment(attachment, blob_or_blobs, attachment_name:, definition:)
|
|
@@ -21,53 +21,48 @@ module JSONAPI
|
|
|
21
21
|
def filter_from_includes(includes_hash, current_model_class)
|
|
22
22
|
return {} unless defined?(::ActiveStorage)
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Skip ActiveStorage attachments - they'll be loaded on-demand by the serializer
|
|
27
|
-
next if current_model_class.reflect_on_attachment(key).present?
|
|
28
|
-
|
|
29
|
-
# Check if this is a regular association
|
|
30
|
-
association = current_model_class.reflect_on_association(key)
|
|
31
|
-
next if association.nil?
|
|
32
|
-
|
|
33
|
-
if value.is_a?(Hash) && value.any?
|
|
34
|
-
# For polymorphic associations, we can't determine the class at compile time,
|
|
35
|
-
# so we skip filtering and include the nested hash as-is
|
|
36
|
-
if association.polymorphic?
|
|
37
|
-
filtered[key] = value
|
|
38
|
-
else
|
|
39
|
-
# Recursively filter nested includes, using the associated class
|
|
40
|
-
nested_class = association.klass
|
|
41
|
-
nested_filtered = filter_from_includes(value, nested_class)
|
|
42
|
-
filtered[key] = nested_filtered if nested_filtered.any?
|
|
43
|
-
end
|
|
44
|
-
else
|
|
45
|
-
filtered[key] = value
|
|
46
|
-
end
|
|
24
|
+
includes_hash.each_with_object({}) do |(key, value), filtered|
|
|
25
|
+
process_include_entry(key, value, current_model_class, filtered)
|
|
47
26
|
end
|
|
48
|
-
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def process_include_entry(key, value, current_model_class, filtered)
|
|
30
|
+
return if current_model_class.reflect_on_attachment(key).present?
|
|
31
|
+
|
|
32
|
+
association = current_model_class.reflect_on_association(key)
|
|
33
|
+
return if association.nil?
|
|
34
|
+
|
|
35
|
+
result = filter_include_value(value, association)
|
|
36
|
+
filtered[key] = result unless result.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def filter_include_value(value, association)
|
|
40
|
+
return value unless value.is_a?(Hash) && value.any?
|
|
41
|
+
return value if association.polymorphic?
|
|
42
|
+
|
|
43
|
+
nested_filtered = filter_from_includes(value, association.klass)
|
|
44
|
+
nested_filtered.any? ? nested_filtered : nil
|
|
49
45
|
end
|
|
50
46
|
|
|
51
47
|
def filter_polymorphic_from_includes(includes_hash, current_model_class)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
includes_hash.each do |key, value|
|
|
55
|
-
association = current_model_class.reflect_on_association(key)
|
|
56
|
-
next unless association
|
|
57
|
-
|
|
58
|
-
# Skip polymorphic associations entirely for preloading
|
|
59
|
-
next if association.polymorphic?
|
|
60
|
-
|
|
61
|
-
if value.is_a?(Hash) && value.any?
|
|
62
|
-
nested_class = association.klass
|
|
63
|
-
nested_filtered = filter_polymorphic_from_includes(value, nested_class)
|
|
64
|
-
filtered[key] = nested_filtered if nested_filtered.any?
|
|
65
|
-
else
|
|
66
|
-
filtered[key] = value
|
|
67
|
-
end
|
|
48
|
+
includes_hash.each_with_object({}) do |(key, value), filtered|
|
|
49
|
+
process_polymorphic_include(key, value, current_model_class, filtered)
|
|
68
50
|
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def process_polymorphic_include(key, value, model_class, filtered)
|
|
54
|
+
association = model_class.reflect_on_association(key)
|
|
55
|
+
return unless association && !association.polymorphic?
|
|
56
|
+
|
|
57
|
+
result = resolve_polymorphic_value(value, association)
|
|
58
|
+
filtered[key] = result if result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_polymorphic_value(value, association)
|
|
62
|
+
return value unless value.is_a?(Hash) && value.any?
|
|
69
63
|
|
|
70
|
-
|
|
64
|
+
nested = filter_polymorphic_from_includes(value, association.klass)
|
|
65
|
+
nested.any? ? nested : nil
|
|
71
66
|
end
|
|
72
67
|
end
|
|
73
68
|
end
|
|
@@ -9,19 +9,21 @@ module JSONAPI
|
|
|
9
9
|
return nil unless defined?(::ActiveStorage)
|
|
10
10
|
|
|
11
11
|
attachment = record.public_send(attachment_name)
|
|
12
|
+
return nil unless attachment.respond_to?(:attached?)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
serialize_blob_identifier(attachment.blob)
|
|
20
|
-
end
|
|
21
|
-
elsif attachment.respond_to?(:attached?) && !attachment.attached?
|
|
22
|
-
# Not attached - return nil for has_one, empty array for has_many
|
|
23
|
-
attachment.is_a?(::ActiveStorage::Attached::Many) ? [] : nil
|
|
14
|
+
attachment.attached? ? serialize_attached(attachment) : empty_attachment_value(attachment)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def serialize_attached(attachment)
|
|
18
|
+
if attachment.is_a?(::ActiveStorage::Attached::Many)
|
|
19
|
+
return attachment.blobs.map { |blob| serialize_blob_identifier(blob) }
|
|
24
20
|
end
|
|
21
|
+
|
|
22
|
+
serialize_blob_identifier(attachment.blob)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty_attachment_value(attachment)
|
|
26
|
+
attachment.is_a?(::ActiveStorage::Attached::Many) ? [] : nil
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def serialize_blob_identifier(blob)
|
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :default_page_size, :max_page_size, :
|
|
5
|
+
attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
|
|
6
6
|
:authorization_scope, :document_meta_resolver
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@default_page_size = 25
|
|
10
10
|
@max_page_size = 100
|
|
11
|
-
@jsonapi_version = "1.1"
|
|
12
11
|
@jsonapi_meta = nil
|
|
13
12
|
@authorization_handler = nil
|
|
14
13
|
@authorization_scope = nil
|
|
15
|
-
@document_meta_resolver = ->(controller:) { {} }
|
|
14
|
+
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
16
15
|
@base_controller_class = "ActionController::API"
|
|
17
16
|
end
|
|
18
17
|
|
|
@@ -20,7 +19,7 @@ module JSONAPI
|
|
|
20
19
|
if value.is_a?(Class)
|
|
21
20
|
@base_controller_class = value.name
|
|
22
21
|
elsif value.is_a?(String)
|
|
23
|
-
raise ArgumentError, "base_controller_class cannot be blank" if value.
|
|
22
|
+
raise ArgumentError, "base_controller_class cannot be blank" if value.nil? || value.strip.empty?
|
|
24
23
|
|
|
25
24
|
@base_controller_class = value
|
|
26
25
|
else
|
|
@@ -38,7 +37,7 @@ module JSONAPI
|
|
|
38
37
|
|
|
39
38
|
def resolved_base_controller_class
|
|
40
39
|
class_name = @base_controller_class
|
|
41
|
-
raise ArgumentError, "base_controller_class cannot be blank" if class_name.
|
|
40
|
+
raise ArgumentError, "base_controller_class cannot be blank" if class_name.nil? || class_name.empty?
|
|
42
41
|
|
|
43
42
|
class_name.constantize
|
|
44
43
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
module Authorization
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def apply_authorization_scope(scope, action:)
|
|
11
|
+
handler = JSONAPI.configuration.authorization_scope
|
|
12
|
+
return scope unless handler
|
|
13
|
+
|
|
14
|
+
handler.call(controller: self, scope:, action:, model_class:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def authorize_resource_action!(record, action:, context: nil)
|
|
18
|
+
handler = JSONAPI.configuration.authorization_handler
|
|
19
|
+
return unless handler
|
|
20
|
+
|
|
21
|
+
handler.call(controller: self, record:, action:, context:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current_user
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
public :current_user
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
module DocumentMeta
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def jsonapi_document_meta(extra_meta = {})
|
|
11
|
+
resolver = JSONAPI.configuration.document_meta_resolver
|
|
12
|
+
base_meta = resolver ? resolver.call(controller: self) : {}
|
|
13
|
+
meta = base_meta.merge(extra_meta || {})
|
|
14
|
+
return {} if meta.empty?
|
|
15
|
+
|
|
16
|
+
meta
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
module ErrorRendering
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def render_resource_not_found_error(message)
|
|
11
|
+
render_jsonapi_error(
|
|
12
|
+
status: 404,
|
|
13
|
+
title: "Resource Not Found",
|
|
14
|
+
detail: message,
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render_model_not_found_error(error)
|
|
19
|
+
render_jsonapi_error(
|
|
20
|
+
status: 404,
|
|
21
|
+
title: "Resource Not Found",
|
|
22
|
+
detail: "Model class for '#{@resource_name}' not found: #{error.message}",
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render_invalid_relationship_error(error)
|
|
27
|
+
render_jsonapi_error(
|
|
28
|
+
status: 400,
|
|
29
|
+
title: "Invalid Relationship",
|
|
30
|
+
detail: error.message,
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_validation_errors(resource)
|
|
35
|
+
errors = resource.errors.map do |error|
|
|
36
|
+
{
|
|
37
|
+
status: "422",
|
|
38
|
+
title: "Validation Error",
|
|
39
|
+
detail: error.full_message,
|
|
40
|
+
source: { pointer: "/data/attributes/#{error.attribute}" },
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
render json: { errors: }, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render_parameter_not_allowed_error(error)
|
|
48
|
+
error_params = error.respond_to?(:params) ? error.params : []
|
|
49
|
+
detail = error_params.present? ? error_params.join(", ") : "Relationship or attribute is read-only"
|
|
50
|
+
|
|
51
|
+
render json: { errors: [parameter_not_allowed_error(detail)] }, status: :bad_request
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parameter_not_allowed_error(detail)
|
|
55
|
+
{
|
|
56
|
+
status: "400",
|
|
57
|
+
code: "parameter_not_allowed",
|
|
58
|
+
title: "Parameter Not Allowed",
|
|
59
|
+
detail:,
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
module Parsing
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_action :parse_jsonapi_body, if: -> { modifying_request? }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
def parse_jsonapi_body
|
|
15
|
+
return unless jsonapi_content_type?
|
|
16
|
+
return if params[:data].present?
|
|
17
|
+
|
|
18
|
+
parse_and_apply_json_body
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
# Invalid JSON - will be handled by validation
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def modifying_request?
|
|
24
|
+
request.post? || request.patch? || request.put? || request.delete?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def jsonapi_content_type?
|
|
28
|
+
request.content_type&.include?("application/vnd.api+json")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_and_apply_json_body
|
|
32
|
+
body = request.body.read
|
|
33
|
+
request.body.rewind
|
|
34
|
+
return if body.blank?
|
|
35
|
+
|
|
36
|
+
parsed = JSON.parse(body)
|
|
37
|
+
parsed.deep_transform_keys!(&:to_sym)
|
|
38
|
+
request.env["action_dispatch.request.request_parameters"] = parsed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def jsonapi_params
|
|
42
|
+
data = params.require(:data)
|
|
43
|
+
return data if data.is_a?(Array)
|
|
44
|
+
|
|
45
|
+
permitted = data.permit(:type, :id, attributes: {})
|
|
46
|
+
permitted[:relationships] = permit_relationships(data) if data[:relationships].present?
|
|
47
|
+
permitted
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def permit_relationships(data)
|
|
51
|
+
result = {}
|
|
52
|
+
data[:relationships].each do |key, value|
|
|
53
|
+
result[key] = value.permit(data: %i[type id]) if value.is_a?(ActionController::Parameters)
|
|
54
|
+
end
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def jsonapi_attributes
|
|
59
|
+
(jsonapi_params[:attributes] || {}).to_h
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def jsonapi_relationships
|
|
63
|
+
jsonapi_params[:relationships] || {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def jsonapi_type
|
|
67
|
+
data = jsonapi_params
|
|
68
|
+
return data.first[:type] if data.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
data[:type]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def jsonapi_id
|
|
74
|
+
data = jsonapi_params
|
|
75
|
+
return data.first[:id].to_s.presence if data.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
data[:id].to_s.presence
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_include_param
|
|
81
|
+
return [] unless params[:include]
|
|
82
|
+
|
|
83
|
+
params[:include].to_s.split(",").map(&:strip)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_fields_param
|
|
87
|
+
return {} unless params[:fields]
|
|
88
|
+
|
|
89
|
+
params[:fields].permit!.to_h.each_with_object({}) do |(type, fields), hash|
|
|
90
|
+
hash[type.to_sym] = fields.to_s.split(",").map(&:strip)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_filter_param
|
|
95
|
+
return {} unless params[:filter]
|
|
96
|
+
|
|
97
|
+
raw_filters = params[:filter].permit!.to_h
|
|
98
|
+
JSONAPI::ParamHelpers.flatten_filter_hash(raw_filters)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_sort_param
|
|
102
|
+
return [] unless params[:sort]
|
|
103
|
+
|
|
104
|
+
params[:sort].to_s.split(",").map(&:strip)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_page_param
|
|
108
|
+
return {} unless params[:page]
|
|
109
|
+
|
|
110
|
+
params[:page].permit(:number, :size).to_h
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def invalid_sort_fields_for_columns(sorts, available_columns)
|
|
114
|
+
sorts.filter_map do |sort_field|
|
|
115
|
+
field = JSONAPI::RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
116
|
+
field unless available_columns.include?(field.to_s)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def valid_sort_fields_for_resource(resource_class, model_class)
|
|
121
|
+
model_columns = model_class.column_names.map(&:to_sym)
|
|
122
|
+
resource_sortable_fields = resource_class.permitted_sortable_fields.map(&:to_sym)
|
|
123
|
+
(model_columns + resource_sortable_fields).uniq.map(&:to_s)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
module ResourceSetup
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
attr_reader :resource_class, :model_class
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
def set_resource_name
|
|
15
|
+
@resource_name = params[:resource_type].to_s.singularize
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_resource_class
|
|
19
|
+
@resource_class = JSONAPI::ResourceLoader.find(params[:resource_type])
|
|
20
|
+
@model_class = @resource_class.model_class
|
|
21
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass => e
|
|
22
|
+
render_resource_not_found_error(e.message)
|
|
23
|
+
rescue NameError => e
|
|
24
|
+
render_model_not_found_error(e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def set_resource
|
|
28
|
+
@resource = @preloaded_resource || model_class.find(params[:id])
|
|
29
|
+
rescue ActiveRecord::RecordNotFound
|
|
30
|
+
render_jsonapi_error(
|
|
31
|
+
status: 404,
|
|
32
|
+
title: "Record Not Found",
|
|
33
|
+
detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|