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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +65 -0
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2159 -0
  9. data/Rakefile +8 -0
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +31 -0
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +6 -0
  15. data/lib/json_api/active_storage/deserialization.rb +106 -0
  16. data/lib/json_api/active_storage/detection.rb +74 -0
  17. data/lib/json_api/active_storage/serialization.rb +32 -0
  18. data/lib/json_api/configuration.rb +58 -0
  19. data/lib/json_api/controllers/base_controller.rb +26 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
  21. data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
  22. data/lib/json_api/controllers/relationships_controller.rb +504 -0
  23. data/lib/json_api/controllers/resources_controller.rb +6 -0
  24. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  25. data/lib/json_api/railtie.rb +75 -0
  26. data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
  27. data/lib/json_api/resources/resource.rb +238 -0
  28. data/lib/json_api/resources/resource_loader.rb +35 -0
  29. data/lib/json_api/routing.rb +72 -0
  30. data/lib/json_api/serialization/deserializer.rb +362 -0
  31. data/lib/json_api/serialization/serializer.rb +320 -0
  32. data/lib/json_api/support/active_storage_support.rb +85 -0
  33. data/lib/json_api/support/collection_query.rb +406 -0
  34. data/lib/json_api/support/instrumentation.rb +42 -0
  35. data/lib/json_api/support/param_helpers.rb +51 -0
  36. data/lib/json_api/support/relationship_guard.rb +16 -0
  37. data/lib/json_api/support/relationship_helpers.rb +74 -0
  38. data/lib/json_api/support/resource_identifier.rb +87 -0
  39. data/lib/json_api/support/responders.rb +100 -0
  40. data/lib/json_api/support/response_helpers.rb +10 -0
  41. data/lib/json_api/support/sort_parsing.rb +21 -0
  42. data/lib/json_api/support/type_conversion.rb +21 -0
  43. data/lib/json_api/testing/test_helper.rb +76 -0
  44. data/lib/json_api/testing.rb +3 -0
  45. data/lib/json_api/version.rb +5 -0
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +128 -0
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
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 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
@@ -0,0 +1,2 @@
1
+ class UserMessageResource < MessageResource
2
+ end
data/lib/jpie.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # jpie gem entry point
4
+ # Requires the json_api code which provides the JSONAPI namespace
5
+ require "json_api"
6
+
@@ -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