jpie 0.1.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 +7 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +65 -0
- data/.travis.yml +7 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +312 -0
- data/README.md +2159 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +31 -0
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +6 -0
- data/lib/json_api/active_storage/deserialization.rb +106 -0
- data/lib/json_api/active_storage/detection.rb +74 -0
- data/lib/json_api/active_storage/serialization.rb +32 -0
- data/lib/json_api/configuration.rb +58 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
- data/lib/json_api/controllers/relationships_controller.rb +504 -0
- data/lib/json_api/controllers/resources_controller.rb +6 -0
- data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
- data/lib/json_api/railtie.rb +75 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
- data/lib/json_api/resources/resource.rb +238 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +72 -0
- data/lib/json_api/serialization/deserializer.rb +362 -0
- data/lib/json_api/serialization/serializer.rb +320 -0
- data/lib/json_api/support/active_storage_support.rb +85 -0
- data/lib/json_api/support/collection_query.rb +406 -0
- data/lib/json_api/support/instrumentation.rb +42 -0
- data/lib/json_api/support/param_helpers.rb +51 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +74 -0
- data/lib/json_api/support/resource_identifier.rb +87 -0
- data/lib/json_api/support/responders.rb +100 -0
- data/lib/json_api/support/response_helpers.rb +10 -0
- data/lib/json_api/support/sort_parsing.rb +21 -0
- data/lib/json_api/support/type_conversion.rb +21 -0
- data/lib/json_api/testing/test_helper.rb +76 -0
- data/lib/json_api/testing.rb +3 -0
- data/lib/json_api/version.rb +5 -0
- data/lib/json_api.rb +50 -0
- data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
- metadata +128 -0
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "json_api"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
11
|
+
# require "pry"
|
|
12
|
+
# Pry.start
|
|
13
|
+
|
|
14
|
+
require "irb"
|
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/jpie.gemspec
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require 'json_api/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = 'jpie'
|
|
9
|
+
spec.version = JSONAPI::VERSION
|
|
10
|
+
spec.authors = ['Emil Kampp']
|
|
11
|
+
spec.email = ['emil@kampp.me']
|
|
12
|
+
|
|
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/jpie'
|
|
16
|
+
spec.license = 'MIT'
|
|
17
|
+
|
|
18
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
19
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
20
|
+
end
|
|
21
|
+
spec.bindir = 'exe'
|
|
22
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
23
|
+
spec.require_paths = ['lib']
|
|
24
|
+
|
|
25
|
+
spec.required_ruby_version = '>= 3.4.0'
|
|
26
|
+
|
|
27
|
+
spec.add_dependency 'actionpack', '~> 8.0', '>= 8.0.0'
|
|
28
|
+
spec.add_dependency 'rails', '~> 8.0', '>= 8.0.0'
|
|
29
|
+
|
|
30
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
31
|
+
end
|
data/lib/jpie.rb
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ActiveStorage
|
|
5
|
+
module Deserialization
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def extract_params_from_hash(params_hash, model_class)
|
|
9
|
+
return {} unless defined?(::ActiveStorage)
|
|
10
|
+
|
|
11
|
+
attachment_params = {}
|
|
12
|
+
model_class.attachment_reflections.each_key do |attachment_name|
|
|
13
|
+
attachment_name_str = attachment_name.to_s
|
|
14
|
+
attachment_params[attachment_name] = params_hash[attachment_name_str] if params_hash.key?(attachment_name_str)
|
|
15
|
+
end
|
|
16
|
+
attachment_params
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def attach_files(record, attachment_params, definition: nil)
|
|
20
|
+
return if attachment_params.empty?
|
|
21
|
+
|
|
22
|
+
attachment_params.each do |attachment_name, blob_or_blobs|
|
|
23
|
+
attachment = record.public_send(attachment_name)
|
|
24
|
+
is_has_many = blob_or_blobs.is_a?(Array)
|
|
25
|
+
append_only = is_has_many && append_only_enabled?(attachment_name, definition)
|
|
26
|
+
|
|
27
|
+
if is_has_many
|
|
28
|
+
handle_has_many_attachment(attachment, blob_or_blobs, append_only:,
|
|
29
|
+
attachment_name:, definition:)
|
|
30
|
+
else
|
|
31
|
+
handle_has_one_attachment(attachment, blob_or_blobs, attachment_name:,
|
|
32
|
+
definition:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def process_attachment(attrs, association_name, id_or_ids, singular:)
|
|
38
|
+
if singular
|
|
39
|
+
blob = find_blob_by_signed_id(id_or_ids)
|
|
40
|
+
attrs[association_name.to_s] = blob
|
|
41
|
+
else
|
|
42
|
+
blobs = Array(id_or_ids).map { |id| find_blob_by_signed_id(id) }
|
|
43
|
+
attrs[association_name.to_s] = blobs
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_blob_by_signed_id(signed_id)
|
|
48
|
+
return nil if signed_id.blank?
|
|
49
|
+
|
|
50
|
+
::ActiveStorage::Blob.find_signed!(signed_id)
|
|
51
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
52
|
+
raise
|
|
53
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
54
|
+
raise ArgumentError, "Blob not found for signed ID: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def purge_on_nil_enabled?(attachment_name, definition)
|
|
58
|
+
return true unless definition
|
|
59
|
+
|
|
60
|
+
relationship_def = find_relationship_definition(attachment_name, definition)
|
|
61
|
+
return true unless relationship_def
|
|
62
|
+
|
|
63
|
+
relationship_def[:options].fetch(:purge_on_nil, true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def append_only_enabled?(attachment_name, definition)
|
|
67
|
+
return false unless definition
|
|
68
|
+
|
|
69
|
+
relationship_def = find_relationship_definition(attachment_name, definition)
|
|
70
|
+
return false unless relationship_def
|
|
71
|
+
|
|
72
|
+
relationship_def[:options].fetch(:append_only, false)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_relationship_definition(attachment_name, definition)
|
|
76
|
+
return nil unless definition.respond_to?(:relationship_definitions)
|
|
77
|
+
|
|
78
|
+
definition.relationship_definitions.find { |r| r[:name].to_s == attachment_name.to_s }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Private helper methods
|
|
82
|
+
def handle_has_many_attachment(attachment, blob_or_blobs, append_only:, attachment_name:, definition:)
|
|
83
|
+
if blob_or_blobs.empty?
|
|
84
|
+
return if append_only
|
|
85
|
+
|
|
86
|
+
attachment.purge if purge_on_nil_enabled?(attachment_name, definition) && attachment.attached?
|
|
87
|
+
elsif append_only
|
|
88
|
+
existing_blobs = attachment.attached? ? attachment.blobs.to_a : []
|
|
89
|
+
attachment.attach(existing_blobs + blob_or_blobs)
|
|
90
|
+
else
|
|
91
|
+
attachment.purge if attachment.attached?
|
|
92
|
+
attachment.attach(blob_or_blobs)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_has_one_attachment(attachment, blob_or_blobs, attachment_name:, definition:)
|
|
97
|
+
if blob_or_blobs.present?
|
|
98
|
+
attachment.purge if attachment.attached?
|
|
99
|
+
attachment.attach(blob_or_blobs)
|
|
100
|
+
elsif blob_or_blobs.nil?
|
|
101
|
+
attachment.purge if purge_on_nil_enabled?(attachment_name, definition) && attachment.attached?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ActiveStorage
|
|
5
|
+
module Detection
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def attachment?(association_name, model_class)
|
|
9
|
+
return false unless defined?(::ActiveStorage)
|
|
10
|
+
|
|
11
|
+
model_class.respond_to?(:reflect_on_attachment) &&
|
|
12
|
+
model_class.reflect_on_attachment(association_name.to_sym).present?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def blob_type?(type)
|
|
16
|
+
return false unless defined?(::ActiveStorage)
|
|
17
|
+
|
|
18
|
+
type.to_s == "active_storage_blobs"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def filter_from_includes(includes_hash, current_model_class)
|
|
22
|
+
return {} unless defined?(::ActiveStorage)
|
|
23
|
+
|
|
24
|
+
filtered = {}
|
|
25
|
+
includes_hash.each do |key, value|
|
|
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
|
|
47
|
+
end
|
|
48
|
+
filtered
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def filter_polymorphic_from_includes(includes_hash, current_model_class)
|
|
52
|
+
filtered = {}
|
|
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
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
filtered
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ActiveStorage
|
|
5
|
+
module Serialization
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def serialize_relationship(attachment_name, record)
|
|
9
|
+
return nil unless defined?(::ActiveStorage)
|
|
10
|
+
|
|
11
|
+
attachment = record.public_send(attachment_name)
|
|
12
|
+
|
|
13
|
+
if attachment.respond_to?(:attached?) && attachment.attached?
|
|
14
|
+
# has_many_attached returns an array-like object
|
|
15
|
+
if attachment.is_a?(::ActiveStorage::Attached::Many)
|
|
16
|
+
attachment.blobs.map { |blob| serialize_blob_identifier(blob) }
|
|
17
|
+
else
|
|
18
|
+
# has_one_attached
|
|
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
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def serialize_blob_identifier(blob)
|
|
28
|
+
{ type: "active_storage_blobs", id: blob.id.to_s }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_page_size, :max_page_size, :jsonapi_version, :jsonapi_meta, :authorization_handler,
|
|
6
|
+
:authorization_scope, :document_meta_resolver
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@default_page_size = 25
|
|
10
|
+
@max_page_size = 100
|
|
11
|
+
@jsonapi_version = "1.1"
|
|
12
|
+
@jsonapi_meta = nil
|
|
13
|
+
@authorization_handler = nil
|
|
14
|
+
@authorization_scope = nil
|
|
15
|
+
@document_meta_resolver = ->(controller:) { {} }
|
|
16
|
+
@base_controller_class = "ActionController::API"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def base_controller_class=(value)
|
|
20
|
+
if value.is_a?(Class)
|
|
21
|
+
@base_controller_class = value.name
|
|
22
|
+
elsif value.is_a?(String)
|
|
23
|
+
raise ArgumentError, "base_controller_class cannot be blank" if value.blank?
|
|
24
|
+
|
|
25
|
+
@base_controller_class = value
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "base_controller_class must be a string or class"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return unless Rails.application&.initialized?
|
|
31
|
+
|
|
32
|
+
JSONAPI.rebuild_base_controllers!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def base_controller_class
|
|
36
|
+
@base_controller_class.constantize
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolved_base_controller_class
|
|
40
|
+
class_name = @base_controller_class
|
|
41
|
+
raise ArgumentError, "base_controller_class cannot be blank" if class_name.blank?
|
|
42
|
+
|
|
43
|
+
class_name.constantize
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def base_controller_overridden?
|
|
47
|
+
@base_controller_class != "ActionController::API"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.configuration
|
|
52
|
+
@configuration ||= Configuration.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.configure
|
|
56
|
+
yield configuration if block_given?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
class BaseController < JSONAPI.configuration.resolved_base_controller_class
|
|
5
|
+
include ControllerHelpers
|
|
6
|
+
include ResourceActions
|
|
7
|
+
|
|
8
|
+
rescue_from JSONAPI::AuthorizationError, with: :render_jsonapi_authorization_error
|
|
9
|
+
rescue_from Pundit::NotAuthorizedError, with: :render_jsonapi_authorization_error if defined?(Pundit)
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def render_jsonapi_authorization_error(error)
|
|
14
|
+
detail = error&.message.presence || "You are not authorized to perform this action"
|
|
15
|
+
render json: {
|
|
16
|
+
errors: [
|
|
17
|
+
{
|
|
18
|
+
status: "403",
|
|
19
|
+
title: "Forbidden",
|
|
20
|
+
detail:
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}, status: :forbidden
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ControllerHelpers
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
include Responders
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
attr_reader :resource_class, :model_class
|
|
10
|
+
|
|
11
|
+
before_action :parse_jsonapi_body, if: -> { request.post? || request.patch? || request.put? || request.delete? }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
protected
|
|
15
|
+
|
|
16
|
+
def parse_jsonapi_body
|
|
17
|
+
return unless request.content_type&.include?("application/vnd.api+json")
|
|
18
|
+
return if params[:data].present?
|
|
19
|
+
|
|
20
|
+
body = request.body.read
|
|
21
|
+
request.body.rewind
|
|
22
|
+
return if body.blank?
|
|
23
|
+
|
|
24
|
+
parsed = JSON.parse(body)
|
|
25
|
+
parsed.deep_transform_keys!(&:to_sym)
|
|
26
|
+
request.env["action_dispatch.request.request_parameters"] = parsed
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
# Invalid JSON - will be handled by validation
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def jsonapi_params
|
|
32
|
+
data = params.require(:data)
|
|
33
|
+
|
|
34
|
+
# Relationship requests may send an array of resource identifier objects.
|
|
35
|
+
return data if data.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
permitted = data.permit(:type, :id, attributes: {})
|
|
38
|
+
|
|
39
|
+
# Manually permit relationships with nested data structures
|
|
40
|
+
if data[:relationships].present?
|
|
41
|
+
permitted[:relationships] = {}
|
|
42
|
+
data[:relationships].each do |key, value|
|
|
43
|
+
permitted[:relationships][key] = value.permit(data: %i[type id]) if value.is_a?(ActionController::Parameters)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
permitted
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def jsonapi_attributes
|
|
51
|
+
(jsonapi_params[:attributes] || {}).to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def jsonapi_relationships
|
|
55
|
+
jsonapi_params[:relationships] || {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def jsonapi_type
|
|
59
|
+
data = jsonapi_params
|
|
60
|
+
return data.first[:type] if data.is_a?(Array)
|
|
61
|
+
|
|
62
|
+
data[:type]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def jsonapi_id
|
|
66
|
+
data = jsonapi_params
|
|
67
|
+
return data.first[:id].to_s.presence if data.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
data[:id].to_s.presence
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_include_param
|
|
73
|
+
return [] unless params[:include]
|
|
74
|
+
|
|
75
|
+
params[:include].to_s.split(",").map(&:strip)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_fields_param
|
|
79
|
+
return {} unless params[:fields]
|
|
80
|
+
|
|
81
|
+
params[:fields].permit!.to_h.each_with_object({}) do |(type, fields), hash|
|
|
82
|
+
hash[type.to_sym] = fields.to_s.split(",").map(&:strip)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_filter_param
|
|
87
|
+
return {} unless params[:filter]
|
|
88
|
+
|
|
89
|
+
raw_filters = params[:filter].permit!.to_h
|
|
90
|
+
JSONAPI::ParamHelpers.flatten_filter_hash(raw_filters)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_sort_param
|
|
94
|
+
return [] unless params[:sort]
|
|
95
|
+
|
|
96
|
+
params[:sort].to_s.split(",").map(&:strip)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def invalid_sort_fields_for_columns(sorts, available_columns)
|
|
100
|
+
sorts.filter_map do |sort_field|
|
|
101
|
+
field = JSONAPI::RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
102
|
+
field unless available_columns.include?(field.to_s)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def valid_sort_fields_for_resource(resource_class, model_class)
|
|
107
|
+
# Include both database columns and resource-defined sortable fields
|
|
108
|
+
# (attributes + sort-only fields via permitted_sortable_fields)
|
|
109
|
+
model_columns = model_class.column_names.map(&:to_sym)
|
|
110
|
+
resource_sortable_fields = resource_class.permitted_sortable_fields.map(&:to_sym)
|
|
111
|
+
(model_columns + resource_sortable_fields).uniq.map(&:to_s)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_page_param
|
|
115
|
+
return {} unless params[:page]
|
|
116
|
+
|
|
117
|
+
params[:page].permit(:number, :size).to_h
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def set_resource_name
|
|
121
|
+
@resource_name = params[:resource_type].to_s.singularize
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def set_resource_class
|
|
125
|
+
@resource_class = JSONAPI::ResourceLoader.find(params[:resource_type])
|
|
126
|
+
@model_class = @resource_class.model_class
|
|
127
|
+
rescue JSONAPI::ResourceLoader::MissingResourceClass => e
|
|
128
|
+
render_resource_not_found_error(e.message)
|
|
129
|
+
rescue NameError => e
|
|
130
|
+
render_model_not_found_error(e)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def set_resource
|
|
134
|
+
@resource = @preloaded_resource || model_class.find(params[:id])
|
|
135
|
+
rescue ActiveRecord::RecordNotFound
|
|
136
|
+
render_jsonapi_error(
|
|
137
|
+
status: 404,
|
|
138
|
+
title: "Record Not Found",
|
|
139
|
+
detail: "Could not find #{@resource_name} with id '#{params[:id]}'"
|
|
140
|
+
) and return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def render_resource_not_found_error(message)
|
|
144
|
+
render_jsonapi_error(
|
|
145
|
+
status: 404,
|
|
146
|
+
title: "Resource Not Found",
|
|
147
|
+
detail: message
|
|
148
|
+
) and return
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_model_not_found_error(error)
|
|
152
|
+
render_jsonapi_error(
|
|
153
|
+
status: 404,
|
|
154
|
+
title: "Resource Not Found",
|
|
155
|
+
detail: "Model class for '#{@resource_name}' not found: #{error.message}"
|
|
156
|
+
) and return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def render_invalid_relationship_error(error)
|
|
160
|
+
render_jsonapi_error(
|
|
161
|
+
status: 400,
|
|
162
|
+
title: "Invalid Relationship",
|
|
163
|
+
detail: error.message
|
|
164
|
+
) and return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_validation_errors(resource)
|
|
168
|
+
errors = resource.errors.map do |error|
|
|
169
|
+
{
|
|
170
|
+
status: "422",
|
|
171
|
+
title: "Validation Error",
|
|
172
|
+
detail: error.full_message,
|
|
173
|
+
source: { pointer: "/data/attributes/#{error.attribute}" }
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
render json: { errors: }, status: :unprocessable_entity
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def render_parameter_not_allowed_error(error)
|
|
181
|
+
params = error.respond_to?(:params) ? error.params : []
|
|
182
|
+
|
|
183
|
+
render json: {
|
|
184
|
+
errors: [
|
|
185
|
+
{
|
|
186
|
+
status: "400",
|
|
187
|
+
code: "parameter_not_allowed",
|
|
188
|
+
title: "Parameter Not Allowed",
|
|
189
|
+
detail: params.present? ? params.join(", ") : "Relationship or attribute is read-only"
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}, status: :bad_request
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply_authorization_scope(scope, action:)
|
|
196
|
+
handler = JSONAPI.configuration.authorization_scope
|
|
197
|
+
return scope unless handler
|
|
198
|
+
|
|
199
|
+
handler.call(controller: self, scope:, action:, model_class:)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def authorize_resource_action!(record, action:, context: nil)
|
|
203
|
+
handler = JSONAPI.configuration.authorization_handler
|
|
204
|
+
return unless handler
|
|
205
|
+
|
|
206
|
+
handler.call(controller: self, record:, action:, context:)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def current_user
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
public :current_user
|
|
213
|
+
|
|
214
|
+
def jsonapi_document_meta(extra_meta = {})
|
|
215
|
+
resolver = JSONAPI.configuration.document_meta_resolver
|
|
216
|
+
base_meta = resolver ? resolver.call(controller: self) : {}
|
|
217
|
+
meta = base_meta.merge(extra_meta || {})
|
|
218
|
+
return {} if meta.empty?
|
|
219
|
+
|
|
220
|
+
meta
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|