jpie 0.4.5 → 1.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.
Files changed (141) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +26 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +76 -107
  6. data/.travis.yml +7 -0
  7. data/Gemfile +23 -0
  8. data/Gemfile.lock +321 -0
  9. data/README.md +1508 -136
  10. data/Rakefile +3 -14
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/jpie.gemspec +21 -38
  14. data/kiln/app/resources/user_message_resource.rb +4 -0
  15. data/lib/jpie.rb +3 -25
  16. data/lib/json_api/active_storage/deserialization.rb +116 -0
  17. data/lib/json_api/active_storage/detection.rb +69 -0
  18. data/lib/json_api/active_storage/serialization.rb +34 -0
  19. data/lib/json_api/configuration.rb +57 -0
  20. data/lib/json_api/controllers/base_controller.rb +26 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  23. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  24. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  25. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  26. data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
  27. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  28. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  29. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  30. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  31. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  32. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  33. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  36. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  37. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  38. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  39. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  40. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  44. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  45. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  46. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  47. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  48. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  49. data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
  50. data/lib/json_api/controllers/relationships_controller.rb +108 -0
  51. data/lib/json_api/controllers/resources_controller.rb +6 -0
  52. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  53. data/lib/json_api/railtie.rb +112 -0
  54. data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
  55. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  56. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  57. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  58. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  59. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  60. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  61. data/lib/json_api/resources/resource.rb +32 -0
  62. data/lib/json_api/resources/resource_loader.rb +35 -0
  63. data/lib/json_api/routing.rb +81 -0
  64. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  65. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  66. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  67. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  68. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  69. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  70. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  71. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  72. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  73. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  74. data/lib/json_api/serialization/deserializer.rb +26 -0
  75. data/lib/json_api/serialization/serializer.rb +77 -0
  76. data/lib/json_api/support/active_storage_support.rb +82 -0
  77. data/lib/json_api/support/collection_query.rb +50 -0
  78. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  79. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  80. data/lib/json_api/support/concerns/pagination.rb +30 -0
  81. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  82. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  83. data/lib/json_api/support/concerns/sorting.rb +88 -0
  84. data/lib/json_api/support/instrumentation.rb +43 -0
  85. data/lib/json_api/support/param_helpers.rb +54 -0
  86. data/lib/json_api/support/relationship_guard.rb +16 -0
  87. data/lib/json_api/support/relationship_helpers.rb +76 -0
  88. data/lib/json_api/support/resource_identifier.rb +87 -0
  89. data/lib/json_api/support/responders.rb +100 -0
  90. data/lib/json_api/support/response_helpers.rb +10 -0
  91. data/lib/json_api/support/sort_parsing.rb +21 -0
  92. data/lib/json_api/support/type_conversion.rb +21 -0
  93. data/lib/json_api/testing/test_helper.rb +76 -0
  94. data/lib/json_api/testing.rb +3 -0
  95. data/lib/{jpie → json_api}/version.rb +2 -2
  96. data/lib/json_api.rb +50 -0
  97. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  98. metadata +100 -169
  99. data/.cursor/rules/dependencies.mdc +0 -19
  100. data/.cursor/rules/examples.mdc +0 -16
  101. data/.cursor/rules/git.mdc +0 -14
  102. data/.cursor/rules/project_structure.mdc +0 -30
  103. data/.cursor/rules/publish_gem.mdc +0 -73
  104. data/.cursor/rules/security.mdc +0 -14
  105. data/.cursor/rules/style.mdc +0 -15
  106. data/.cursor/rules/testing.mdc +0 -16
  107. data/.overcommit.yml +0 -35
  108. data/CHANGELOG.md +0 -164
  109. data/LICENSE.txt +0 -21
  110. data/PUBLISHING.md +0 -111
  111. data/examples/basic_example.md +0 -146
  112. data/examples/including_related_resources.md +0 -491
  113. data/examples/pagination.md +0 -303
  114. data/examples/relationships.md +0 -114
  115. data/examples/resource_attribute_configuration.md +0 -147
  116. data/examples/resource_meta_configuration.md +0 -244
  117. data/examples/rspec_testing.md +0 -130
  118. data/examples/single_table_inheritance.md +0 -160
  119. data/lib/jpie/configuration.rb +0 -12
  120. data/lib/jpie/controller/crud_actions.rb +0 -141
  121. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  122. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  123. data/lib/jpie/controller/error_handling.rb +0 -23
  124. data/lib/jpie/controller/json_api_validation.rb +0 -193
  125. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  126. data/lib/jpie/controller/related_actions.rb +0 -45
  127. data/lib/jpie/controller/relationship_actions.rb +0 -291
  128. data/lib/jpie/controller/relationship_validation.rb +0 -117
  129. data/lib/jpie/controller/rendering.rb +0 -154
  130. data/lib/jpie/controller.rb +0 -45
  131. data/lib/jpie/deserializer.rb +0 -110
  132. data/lib/jpie/errors.rb +0 -117
  133. data/lib/jpie/generators/resource_generator.rb +0 -116
  134. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  135. data/lib/jpie/railtie.rb +0 -42
  136. data/lib/jpie/resource/attributable.rb +0 -112
  137. data/lib/jpie/resource/inferrable.rb +0 -43
  138. data/lib/jpie/resource/sortable.rb +0 -93
  139. data/lib/jpie/resource.rb +0 -147
  140. data/lib/jpie/routing.rb +0 -59
  141. data/lib/jpie/serializer.rb +0 -205
data/Rakefile CHANGED
@@ -1,19 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
5
- require 'rubocop/rake_task'
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
6
5
 
7
6
  RSpec::Core::RakeTask.new(:spec)
8
- RuboCop::RakeTask.new
9
7
 
10
- desc 'Run Brakeman security scanner'
11
- task brakeman: :environment do
12
- require 'brakeman'
13
- Brakeman.run app_path: '.', print_report: true, exit_on_warn: true
14
- end
15
-
16
- desc 'Run all checks (RSpec, RuboCop, Brakeman)'
17
- task check: %i[spec rubocop brakeman]
18
-
19
- task default: :check
8
+ task default: :spec
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/jpie.gemspec CHANGED
@@ -1,48 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'lib/jpie/version'
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "json_api/version"
4
6
 
5
7
  Gem::Specification.new do |spec|
6
- spec.name = 'jpie'
7
- spec.version = JPie::VERSION
8
- spec.authors = ['Emil Kampp']
9
- spec.email = ['emil@example.com']
8
+ spec.name = "jpie"
9
+ spec.version = JSONAPI::VERSION
10
+ spec.authors = ["Emil Kampp"]
11
+ spec.email = ["emil@kampp.me"]
10
12
 
11
- spec.summary = 'A resource-focused Rails library for developing JSON:API compliant servers'
12
- spec.description = 'JPie provides a framework for developing JSON:API compliant servers with Rails 8+. ' \
13
- 'It focuses on clean architecture with strong separation of concerns.'
14
- spec.homepage = 'https://github.com/emk-klaay/jpie'
15
- spec.license = 'MIT'
16
- spec.required_ruby_version = '>= 3.4.0'
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
- spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
- spec.metadata['homepage_uri'] = spec.homepage
20
- spec.metadata['source_code_uri'] = "#{spec.homepage}.git"
21
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
-
23
- # Specify which files should be added to the gem when it is released.
24
- spec.files = Dir.chdir(__dir__) do
25
- `git ls-files -z`.split("\x0").reject do |f|
26
- (File.expand_path(f) == __FILE__) ||
27
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
28
- end
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)/}) }
29
20
  end
30
- spec.bindir = 'exe'
31
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
- spec.require_paths = ['lib']
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"
33
26
 
34
- # Runtime dependencies
35
- spec.add_dependency 'activesupport', '~> 8.0', '>= 8.0.0'
36
- spec.add_dependency 'rails', '~> 8.0', '>= 8.0.0'
27
+ spec.add_dependency "actionpack", "~> 8.0", ">= 8.0.0"
28
+ spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
37
29
 
38
- # Development dependencies
39
- spec.add_development_dependency 'brakeman', '~> 6.0'
40
- spec.add_development_dependency 'rake', '~> 13.0'
41
- spec.add_development_dependency 'rspec', '~> 3.12'
42
- spec.add_development_dependency 'rspec-rails', '~> 7.0'
43
- spec.add_development_dependency 'rubocop', '~> 1.50'
44
- spec.add_development_dependency 'rubocop-performance', '~> 1.16'
45
- spec.add_development_dependency 'rubocop-rails', '~> 2.18'
46
- spec.add_development_dependency 'rubocop-rspec', '~> 2.20'
47
- spec.metadata['rubygems_mfa_required'] = 'true'
30
+ spec.metadata["rubygems_mfa_required"] = "true"
48
31
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UserMessageResource < MessageResource
4
+ end
data/lib/jpie.rb CHANGED
@@ -1,27 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support'
4
- require 'active_support/core_ext'
5
- require 'jpie/version'
6
- require_relative 'jpie/resource'
7
- require_relative 'jpie/errors'
8
-
9
- module JPie
10
- autoload :Serializer, 'jpie/serializer'
11
- autoload :Deserializer, 'jpie/deserializer'
12
- autoload :Controller, 'jpie/controller'
13
- autoload :Configuration, 'jpie/configuration'
14
- autoload :Routing, 'jpie/routing'
15
-
16
- class << self
17
- def configuration
18
- @configuration ||= Configuration.new
19
- end
20
-
21
- def configure
22
- yield(configuration)
23
- end
24
- end
25
- end
26
-
27
- require 'jpie/railtie' if defined?(Rails)
3
+ # jpie gem entry point
4
+ # Requires the json_api code which provides the JSONAPI namespace
5
+ require "json_api"
@@ -0,0 +1,116 @@
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 |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:)
35
+ end
36
+ end
37
+
38
+ def process_attachment(attrs, association_name, id_or_ids, singular:)
39
+ if singular
40
+ blob = find_blob_by_signed_id(id_or_ids)
41
+ attrs[association_name.to_s] = blob
42
+ else
43
+ blobs = Array(id_or_ids).map { |id| find_blob_by_signed_id(id) }
44
+ attrs[association_name.to_s] = blobs
45
+ end
46
+ end
47
+
48
+ def find_blob_by_signed_id(signed_id)
49
+ return nil if signed_id.blank?
50
+
51
+ ::ActiveStorage::Blob.find_signed!(signed_id)
52
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
53
+ raise
54
+ rescue ActiveRecord::RecordNotFound => e
55
+ raise ArgumentError, "Blob not found for signed ID: #{e.message}"
56
+ end
57
+
58
+ def purge_on_nil_enabled?(attachment_name, definition)
59
+ return true unless definition
60
+
61
+ relationship_def = find_relationship_definition(attachment_name, definition)
62
+ return true unless relationship_def
63
+
64
+ relationship_def[:options].fetch(:purge_on_nil, true)
65
+ end
66
+
67
+ def append_only_enabled?(attachment_name, definition)
68
+ return false unless definition
69
+
70
+ relationship_def = find_relationship_definition(attachment_name, definition)
71
+ return false unless relationship_def
72
+
73
+ relationship_def[:options].fetch(:append_only, false)
74
+ end
75
+
76
+ def find_relationship_definition(attachment_name, definition)
77
+ return nil unless definition.respond_to?(:relationship_definitions)
78
+
79
+ definition.relationship_definitions.find { |r| r[:name].to_s == attachment_name.to_s }
80
+ end
81
+
82
+ # Private helper methods
83
+ def handle_has_many_attachment(attachment, blob_or_blobs, append_only:, attachment_name:, definition:)
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
86
+
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)
104
+ end
105
+
106
+ def handle_has_one_attachment(attachment, blob_or_blobs, attachment_name:, definition:)
107
+ if blob_or_blobs.present?
108
+ attachment.purge if attachment.attached?
109
+ attachment.attach(blob_or_blobs)
110
+ elsif blob_or_blobs.nil?
111
+ attachment.purge if purge_on_nil_enabled?(attachment_name, definition) && attachment.attached?
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,69 @@
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
+ includes_hash.each_with_object({}) do |(key, value), filtered|
25
+ process_include_entry(key, value, current_model_class, filtered)
26
+ end
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
45
+ end
46
+
47
+ def filter_polymorphic_from_includes(includes_hash, current_model_class)
48
+ includes_hash.each_with_object({}) do |(key, value), filtered|
49
+ process_polymorphic_include(key, value, current_model_class, filtered)
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?
63
+
64
+ nested = filter_polymorphic_from_includes(value, association.klass)
65
+ nested.any? ? nested : nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
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
+ return nil unless attachment.respond_to?(:attached?)
13
+
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) }
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
27
+ end
28
+
29
+ def serialize_blob_identifier(blob)
30
+ { type: "active_storage_blobs", id: blob.id.to_s }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class Configuration
5
+ attr_accessor :default_page_size, :max_page_size, :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_meta = nil
12
+ @authorization_handler = nil
13
+ @authorization_scope = nil
14
+ @document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
15
+ @base_controller_class = "ActionController::API"
16
+ end
17
+
18
+ def base_controller_class=(value)
19
+ if value.is_a?(Class)
20
+ @base_controller_class = value.name
21
+ elsif value.is_a?(String)
22
+ raise ArgumentError, "base_controller_class cannot be blank" if value.nil? || value.strip.empty?
23
+
24
+ @base_controller_class = value
25
+ else
26
+ raise ArgumentError, "base_controller_class must be a string or class"
27
+ end
28
+
29
+ return unless Rails.application&.initialized?
30
+
31
+ JSONAPI.rebuild_base_controllers!
32
+ end
33
+
34
+ def base_controller_class
35
+ @base_controller_class.constantize
36
+ end
37
+
38
+ def resolved_base_controller_class
39
+ class_name = @base_controller_class
40
+ raise ArgumentError, "base_controller_class cannot be blank" if class_name.nil? || class_name.empty?
41
+
42
+ class_name.constantize
43
+ end
44
+
45
+ def base_controller_overridden?
46
+ @base_controller_class != "ActionController::API"
47
+ end
48
+ end
49
+
50
+ def self.configuration
51
+ @configuration ||= Configuration.new
52
+ end
53
+
54
+ def self.configure
55
+ yield configuration if block_given?
56
+ end
57
+ 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,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