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.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +26 -0
- data/.rspec +3 -0
- data/.rubocop.yml +76 -107
- data/.travis.yml +7 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +321 -0
- data/README.md +1508 -136
- data/Rakefile +3 -14
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/jpie.gemspec +21 -38
- data/kiln/app/resources/user_message_resource.rb +4 -0
- data/lib/jpie.rb +3 -25
- data/lib/json_api/active_storage/deserialization.rb +116 -0
- data/lib/json_api/active_storage/detection.rb +69 -0
- data/lib/json_api/active_storage/serialization.rb +34 -0
- data/lib/json_api/configuration.rb +57 -0
- data/lib/json_api/controllers/base_controller.rb +26 -0
- 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 +19 -0
- 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/relationships_controller/active_storage_removal.rb +67 -0
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships_controller/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 +106 -0
- data/lib/json_api/controllers/relationships_controller.rb +108 -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 +112 -0
- data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
- 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 +32 -0
- data/lib/json_api/resources/resource_loader.rb +35 -0
- data/lib/json_api/routing.rb +81 -0
- 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 +26 -0
- data/lib/json_api/serialization/serializer.rb +77 -0
- data/lib/json_api/support/active_storage_support.rb +82 -0
- data/lib/json_api/support/collection_query.rb +50 -0
- 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 +43 -0
- data/lib/json_api/support/param_helpers.rb +54 -0
- data/lib/json_api/support/relationship_guard.rb +16 -0
- data/lib/json_api/support/relationship_helpers.rb +76 -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/{jpie → json_api}/version.rb +2 -2
- data/lib/json_api.rb +50 -0
- data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
- metadata +100 -169
- data/.cursor/rules/dependencies.mdc +0 -19
- data/.cursor/rules/examples.mdc +0 -16
- data/.cursor/rules/git.mdc +0 -14
- data/.cursor/rules/project_structure.mdc +0 -30
- data/.cursor/rules/publish_gem.mdc +0 -73
- data/.cursor/rules/security.mdc +0 -14
- data/.cursor/rules/style.mdc +0 -15
- data/.cursor/rules/testing.mdc +0 -16
- data/.overcommit.yml +0 -35
- data/CHANGELOG.md +0 -164
- data/LICENSE.txt +0 -21
- data/PUBLISHING.md +0 -111
- data/examples/basic_example.md +0 -146
- data/examples/including_related_resources.md +0 -491
- data/examples/pagination.md +0 -303
- data/examples/relationships.md +0 -114
- data/examples/resource_attribute_configuration.md +0 -147
- data/examples/resource_meta_configuration.md +0 -244
- data/examples/rspec_testing.md +0 -130
- data/examples/single_table_inheritance.md +0 -160
- data/lib/jpie/configuration.rb +0 -12
- data/lib/jpie/controller/crud_actions.rb +0 -141
- data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
- data/lib/jpie/controller/error_handling/handlers.rb +0 -109
- data/lib/jpie/controller/error_handling.rb +0 -23
- data/lib/jpie/controller/json_api_validation.rb +0 -193
- data/lib/jpie/controller/parameter_parsing.rb +0 -78
- data/lib/jpie/controller/related_actions.rb +0 -45
- data/lib/jpie/controller/relationship_actions.rb +0 -291
- data/lib/jpie/controller/relationship_validation.rb +0 -117
- data/lib/jpie/controller/rendering.rb +0 -154
- data/lib/jpie/controller.rb +0 -45
- data/lib/jpie/deserializer.rb +0 -110
- data/lib/jpie/errors.rb +0 -117
- data/lib/jpie/generators/resource_generator.rb +0 -116
- data/lib/jpie/generators/templates/resource.rb.erb +0 -31
- data/lib/jpie/railtie.rb +0 -42
- data/lib/jpie/resource/attributable.rb +0 -112
- data/lib/jpie/resource/inferrable.rb +0 -43
- data/lib/jpie/resource/sortable.rb +0 -93
- data/lib/jpie/resource.rb +0 -147
- data/lib/jpie/routing.rb +0 -59
- data/lib/jpie/serializer.rb +0 -205
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Support
|
|
5
|
+
module Sorting
|
|
6
|
+
def apply_sorting(scope)
|
|
7
|
+
return scope if sort_params.empty?
|
|
8
|
+
|
|
9
|
+
has_virtual_sorts = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
|
|
10
|
+
has_virtual_sorts ? apply_virtual_sorting(scope) : apply_db_sorting(scope)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def apply_virtual_sorting(scope)
|
|
16
|
+
records = scope.to_a
|
|
17
|
+
apply_mixed_sorting(records, sort_params)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def apply_db_sorting(scope)
|
|
21
|
+
sort_params.each do |sort_field|
|
|
22
|
+
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
23
|
+
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
24
|
+
scope = scope.order(field => direction)
|
|
25
|
+
end
|
|
26
|
+
scope
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def virtual_attribute_sort?(sort_field)
|
|
30
|
+
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
31
|
+
!model_class.column_names.include?(field.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def apply_mixed_sorting(records, all_sorts)
|
|
35
|
+
records.sort do |a, b|
|
|
36
|
+
compare_by_sort_criteria(a, b, all_sorts)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def compare_by_sort_criteria(record_a, record_b, all_sorts)
|
|
41
|
+
all_sorts.each do |sort_field|
|
|
42
|
+
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
43
|
+
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
44
|
+
value_a = fetch_sort_value(record_a, field)
|
|
45
|
+
value_b = fetch_sort_value(record_b, field)
|
|
46
|
+
comparison = compare_values(value_a, value_b)
|
|
47
|
+
next if comparison.zero?
|
|
48
|
+
|
|
49
|
+
return direction == :desc ? -comparison : comparison
|
|
50
|
+
end
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch_sort_value(record, field)
|
|
55
|
+
return fetch_column_value(record, field) if model_class.column_names.include?(field.to_s)
|
|
56
|
+
|
|
57
|
+
fetch_virtual_attribute_value(record, field)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fetch_column_value(record, field)
|
|
61
|
+
return record.public_send(field.to_sym) if record.respond_to?(field.to_sym)
|
|
62
|
+
return nil unless record.respond_to?(:attributes) && record.attributes.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
record.attributes[field.to_s] || record.attributes[field.to_sym]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_virtual_attribute_value(record, field)
|
|
68
|
+
definition_instance = definition.new(record, {})
|
|
69
|
+
fetch_virtual_value(definition_instance, field)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fetch_virtual_value(definition_instance, field)
|
|
73
|
+
field_sym = field.to_sym
|
|
74
|
+
return definition_instance.public_send(field_sym) if definition_instance.respond_to?(field_sym, false)
|
|
75
|
+
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compare_values(value_a, value_b)
|
|
80
|
+
return 0 if value_a.nil? && value_b.nil?
|
|
81
|
+
return -1 if value_a.nil?
|
|
82
|
+
return 1 if value_b.nil?
|
|
83
|
+
|
|
84
|
+
value_a <=> value_b
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Instrumentation
|
|
5
|
+
def self.enabled?
|
|
6
|
+
defined?(Rails) && Rails.respond_to?(:event) && Rails.event.respond_to?(:notify)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.resource_event(action:, resource_type:, resource_id:, changes: {})
|
|
10
|
+
return unless enabled?
|
|
11
|
+
|
|
12
|
+
Rails.event.tagged("jsonapi") do
|
|
13
|
+
Rails.event.notify(
|
|
14
|
+
"jsonapi.#{resource_type}.#{action}",
|
|
15
|
+
resource_type:,
|
|
16
|
+
resource_id:,
|
|
17
|
+
changes: changes.compact,
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.relationship_event(action:, resource_type:, resource_id:, relationship_name:, related_ids: nil,
|
|
23
|
+
related_type: nil)
|
|
24
|
+
return unless enabled?
|
|
25
|
+
|
|
26
|
+
payload = build_relationship_payload(resource_type, resource_id, relationship_name, related_type, related_ids)
|
|
27
|
+
notify_relationship(action, resource_type, payload)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build_relationship_payload(resource_type, resource_id, relationship_name, related_type, related_ids)
|
|
31
|
+
{ resource_type:, resource_id:, relationship_name: }.tap do |p|
|
|
32
|
+
p[:related_type] = related_type if related_type
|
|
33
|
+
p[:related_ids] = Array(related_ids) if related_ids
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.notify_relationship(action, resource_type, payload)
|
|
38
|
+
Rails.event.tagged("jsonapi", "relationship") do
|
|
39
|
+
Rails.event.notify("jsonapi.#{resource_type}.relationship.#{action}", payload)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
module ParamHelpers
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def flatten_filter_hash(hash, parent_key = nil, accumulator = {})
|
|
10
|
+
hash.each do |key, value|
|
|
11
|
+
next unless key
|
|
12
|
+
|
|
13
|
+
full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
|
|
14
|
+
|
|
15
|
+
if value.is_a?(Hash)
|
|
16
|
+
flatten_filter_hash(value, full_key, accumulator)
|
|
17
|
+
else
|
|
18
|
+
accumulator[full_key] = value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
accumulator
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def deep_symbolize_params(params)
|
|
26
|
+
if params.is_a?(ActionController::Parameters)
|
|
27
|
+
params.to_unsafe_h.deep_symbolize_keys
|
|
28
|
+
else
|
|
29
|
+
params.to_h.deep_symbolize_keys
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_query_string(query_params)
|
|
34
|
+
query_parts = []
|
|
35
|
+
query_params.each do |key, value|
|
|
36
|
+
query_parts.concat(build_query_parts_for_param(key, value))
|
|
37
|
+
end
|
|
38
|
+
query_parts.join("&")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_query_parts_for_param(key, value)
|
|
42
|
+
encoded_key = CGI.escape(key.to_s)
|
|
43
|
+
case value
|
|
44
|
+
when Hash then value.map { |k, v| "#{encoded_key}[#{esc(k)}]=#{esc(v)}" }
|
|
45
|
+
when Array then value.map { |v| "#{encoded_key}=#{esc(v)}" }
|
|
46
|
+
else ["#{encoded_key}=#{esc(value)}"]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def esc(val)
|
|
51
|
+
CGI.escape(val.to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_api/errors/parameter_not_allowed"
|
|
4
|
+
|
|
5
|
+
module JSONAPI
|
|
6
|
+
module RelationshipGuard
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def ensure_writable!(association, error_target, readonly: false)
|
|
10
|
+
return unless association
|
|
11
|
+
return unless readonly
|
|
12
|
+
|
|
13
|
+
raise JSONAPI::Exceptions::ParameterNotAllowed, [error_target]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module RelationshipHelpers
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Delegate to TypeConversion
|
|
8
|
+
def type_to_class_name(type)
|
|
9
|
+
TypeConversion.type_to_class_name(type)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def model_type_name(model_class)
|
|
13
|
+
TypeConversion.model_type_name(model_class)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resource_type_name(definition_class)
|
|
17
|
+
TypeConversion.resource_type_name(definition_class)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Delegate to SortParsing
|
|
21
|
+
def extract_sort_field_name(sort_field)
|
|
22
|
+
SortParsing.extract_sort_field_name(sort_field)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def extract_sort_direction(sort_field)
|
|
26
|
+
SortParsing.extract_sort_direction(sort_field)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Delegate to ResourceIdentifier
|
|
30
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
31
|
+
def serialize_resource_identifier(
|
|
32
|
+
record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil
|
|
33
|
+
)
|
|
34
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
35
|
+
ResourceIdentifier.serialize_identifier(
|
|
36
|
+
record,
|
|
37
|
+
association:,
|
|
38
|
+
definition: resource_class,
|
|
39
|
+
use_instance_class:,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_id_from_identifier(identifier)
|
|
44
|
+
ResourceIdentifier.extract_id(identifier)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def extract_type_from_identifier(identifier)
|
|
48
|
+
ResourceIdentifier.extract_type(identifier)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve_and_find_related_resource(identifier, association:, resource_class:, relationship_name:)
|
|
52
|
+
ResourceIdentifier.resolve_and_find_related_record(
|
|
53
|
+
identifier,
|
|
54
|
+
association:,
|
|
55
|
+
definition: resource_class,
|
|
56
|
+
relationship_name:,
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def polymorphic_association?(definition, relationship_name)
|
|
61
|
+
ResourceIdentifier.polymorphic_association?(definition, relationship_name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sti_subclass?(instance_class, association_class)
|
|
65
|
+
ResourceIdentifier.sti_subclass?(instance_class, association_class)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def polymorphic_association_for_association?(association, definition)
|
|
69
|
+
ResourceIdentifier.polymorphic_association_for_association?(association, definition)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_relationship_definition(definition, relationship_name)
|
|
73
|
+
definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module ResourceIdentifier
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def serialize_identifier(record, association:, definition:, use_instance_class: false)
|
|
8
|
+
model_class = determine_model_class(record, association:, definition:,
|
|
9
|
+
use_instance_class:,)
|
|
10
|
+
related_definition = JSONAPI::ResourceLoader.find_for_model(model_class)
|
|
11
|
+
related_type = TypeConversion.resource_type_name(related_definition)
|
|
12
|
+
|
|
13
|
+
{ type: related_type, id: record.id.to_s }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def extract_id(identifier)
|
|
17
|
+
identifier[:id].to_s.presence
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def extract_type(identifier)
|
|
21
|
+
identifier[:type]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolve_and_find_related_record(identifier, association:, definition:, relationship_name:)
|
|
25
|
+
type = extract_type(identifier)
|
|
26
|
+
id = extract_id(identifier)
|
|
27
|
+
raise ArgumentError, "Missing type or id in relationship data" unless type && id
|
|
28
|
+
|
|
29
|
+
is_polymorphic = polymorphic_association?(definition, relationship_name)
|
|
30
|
+
validate_relationship_type!(type, association) unless is_polymorphic
|
|
31
|
+
|
|
32
|
+
find_related_record(type, id, association, is_polymorphic)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_relationship_type!(type, association)
|
|
36
|
+
expected_type = TypeConversion.model_type_name(association.klass)
|
|
37
|
+
return if type == expected_type
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "Invalid relationship type: expected #{expected_type}, got #{type}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_related_record(type, id, association, is_polymorphic)
|
|
43
|
+
related_model_class = resolve_related_model_class(type, association, is_polymorphic)
|
|
44
|
+
related_model_class.find(id)
|
|
45
|
+
rescue ActiveRecord::RecordNotFound
|
|
46
|
+
raise ArgumentError, "Related resource not found: #{type} with id #{id}"
|
|
47
|
+
rescue NameError
|
|
48
|
+
raise ArgumentError, "Invalid relationship type: #{type} does not correspond to a valid model class"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve_related_model_class(type, association, is_polymorphic)
|
|
52
|
+
return TypeConversion.type_to_class_name(type).constantize if is_polymorphic
|
|
53
|
+
|
|
54
|
+
association.klass
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def polymorphic_association?(definition, relationship_name)
|
|
58
|
+
relationship_def = definition.relationship_definitions.find do |r|
|
|
59
|
+
r[:name].to_s == relationship_name.to_s
|
|
60
|
+
end
|
|
61
|
+
return false unless relationship_def
|
|
62
|
+
|
|
63
|
+
relationship_def[:options][:polymorphic] == true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sti_subclass?(instance_class, association_class)
|
|
67
|
+
return false unless instance_class.respond_to?(:base_class)
|
|
68
|
+
|
|
69
|
+
instance_class.base_class == association_class && instance_class != association_class
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def determine_model_class(record, association:, definition:, use_instance_class:)
|
|
73
|
+
return record.class if association.nil?
|
|
74
|
+
return record.class if polymorphic_association_for_association?(association, definition)
|
|
75
|
+
return record.class if use_instance_class && sti_subclass?(record.class, association.klass)
|
|
76
|
+
|
|
77
|
+
association.klass
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def polymorphic_association_for_association?(association, definition)
|
|
81
|
+
return false unless definition
|
|
82
|
+
|
|
83
|
+
relationship_name = association.name
|
|
84
|
+
polymorphic_association?(definition, relationship_name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module JSONAPI
|
|
7
|
+
module Responders
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
before_action :ensure_jsonapi_content_type, if: -> { request.post? || request.patch? || request.put? }
|
|
12
|
+
before_action :ensure_jsonapi_accept_header
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def ensure_jsonapi_content_type
|
|
18
|
+
return if request.content_type&.include?("application/vnd.api+json")
|
|
19
|
+
|
|
20
|
+
render json: {
|
|
21
|
+
errors: [
|
|
22
|
+
{
|
|
23
|
+
status: "415",
|
|
24
|
+
title: "Unsupported Media Type",
|
|
25
|
+
detail: "Content-Type must be application/vnd.api+json",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}, status: :unsupported_media_type
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_jsonapi_accept_header
|
|
32
|
+
# Allow requests without Accept header or with */* (browser defaults)
|
|
33
|
+
# Only validate when Accept header is explicitly set to non-JSON:API media types
|
|
34
|
+
accept_header = request.headers["Accept"]
|
|
35
|
+
|
|
36
|
+
# Allow blank Accept header (browser default)
|
|
37
|
+
return if accept_header.blank?
|
|
38
|
+
|
|
39
|
+
# Allow */* Accept header (browser default)
|
|
40
|
+
return if accept_header == "*/*"
|
|
41
|
+
|
|
42
|
+
# Check if request accepts */* (wildcard)
|
|
43
|
+
return if request.accepts.any? { |mime| mime.to_s == "*/*" }
|
|
44
|
+
|
|
45
|
+
# Check if JSON:API media type is explicitly requested
|
|
46
|
+
return if jsonapi_requested?
|
|
47
|
+
|
|
48
|
+
# If Accept header is present and doesn't include JSON:API, return 406
|
|
49
|
+
# This ensures we honor explicit Accept preferences while allowing defaults
|
|
50
|
+
render_not_acceptable_error
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def jsonapi_requested?
|
|
54
|
+
request.accepts.any? { |mime| mime.to_s.include?("application/vnd.api+json") }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_not_acceptable_error
|
|
58
|
+
render_parameter_errors(
|
|
59
|
+
[nil],
|
|
60
|
+
title: "Not Acceptable",
|
|
61
|
+
detail_proc: ->(_) { "Accept header must include application/vnd.api+json or be omitted" },
|
|
62
|
+
status: :not_acceptable,
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_jsonapi_error(status:, title:, detail: nil, source: nil)
|
|
67
|
+
render_jsonapi_errors([{ status:, title:, detail:, source: }], status:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_jsonapi_errors(errors, status:)
|
|
71
|
+
normalized_errors = Array(errors).map do |error|
|
|
72
|
+
normalized = error.compact
|
|
73
|
+
normalized_status = normalized[:status] || status
|
|
74
|
+
normalized[:status] = status_code_for(normalized_status)
|
|
75
|
+
normalized
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
render json: { errors: normalized_errors }, status:
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_parameter_errors(values, title:, detail_proc:, source_proc: nil, status: :bad_request)
|
|
82
|
+
errors = Array(values).map do |value|
|
|
83
|
+
error = {
|
|
84
|
+
title:,
|
|
85
|
+
detail: detail_proc.call(value),
|
|
86
|
+
}
|
|
87
|
+
error[:source] = source_proc.call(value) if source_proc
|
|
88
|
+
error
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
render_jsonapi_errors(errors, status:)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def status_code_for(status)
|
|
95
|
+
return status if status.is_a?(String) && status.match?(/\A\d+\z/)
|
|
96
|
+
|
|
97
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE.fetch(status, status).to_s
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module SortParsing
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def parse_sort_field(sort_field)
|
|
8
|
+
descending = sort_field.start_with?("-")
|
|
9
|
+
field = descending ? sort_field[1..] : sort_field
|
|
10
|
+
{ field:, direction: descending ? :desc : :asc }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def extract_sort_field_name(sort_field)
|
|
14
|
+
parse_sort_field(sort_field)[:field]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extract_sort_direction(sort_field)
|
|
18
|
+
parse_sort_field(sort_field)[:direction]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module TypeConversion
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def type_to_class_name(type)
|
|
8
|
+
type.to_s.singularize.classify
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def model_type_name(model_class)
|
|
12
|
+
model_class.name.underscore.pluralize
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resource_type_name(definition_class)
|
|
16
|
+
type_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
|
|
17
|
+
# Remove namespace prefix if present (e.g., "json_api/active_storage_blobs" -> "active_storage_blobs")
|
|
18
|
+
type_name.split("/").last
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JSONAPI
|
|
4
|
+
module Testing
|
|
5
|
+
# Test helper for Rails integration/request tests that provides consistent
|
|
6
|
+
# `as: :jsonapi` behavior across all HTTP methods.
|
|
7
|
+
#
|
|
8
|
+
# Usage in RSpec:
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include JSONAPI::Testing::TestHelper, type: :request
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Then use `as: :jsonapi` in your tests:
|
|
14
|
+
# get users_path, params: { filter: { active: true } }, headers:, as: :jsonapi
|
|
15
|
+
# post users_path, params: payload, headers:, as: :jsonapi
|
|
16
|
+
#
|
|
17
|
+
# Behavior:
|
|
18
|
+
# - GET: Sets Accept header, params go to query string (no body encoding)
|
|
19
|
+
# - POST/PATCH/PUT/DELETE: Sets Content-Type and Accept headers, JSON-encodes body
|
|
20
|
+
#
|
|
21
|
+
module TestHelper
|
|
22
|
+
JSONAPI_MIME = "application/vnd.api+json"
|
|
23
|
+
|
|
24
|
+
def get(path, **options)
|
|
25
|
+
if options[:as] == :jsonapi
|
|
26
|
+
options = apply_jsonapi_headers(options, include_content_type: false)
|
|
27
|
+
options.delete(:as)
|
|
28
|
+
end
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def post(path, **options)
|
|
33
|
+
options = apply_jsonapi_options_for_body(options) if options[:as] == :jsonapi
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def patch(path, **options)
|
|
38
|
+
options = apply_jsonapi_options_for_body(options) if options[:as] == :jsonapi
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def put(path, **options)
|
|
43
|
+
options = apply_jsonapi_options_for_body(options) if options[:as] == :jsonapi
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete(path, **options)
|
|
48
|
+
options = apply_jsonapi_options_for_body(options) if options[:as] == :jsonapi
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def apply_jsonapi_headers(options, include_content_type: true)
|
|
55
|
+
options = options.dup
|
|
56
|
+
headers = (options[:headers] || {}).dup
|
|
57
|
+
|
|
58
|
+
headers["Accept"] = JSONAPI_MIME
|
|
59
|
+
headers["Content-Type"] = JSONAPI_MIME if include_content_type
|
|
60
|
+
|
|
61
|
+
options[:headers] = headers
|
|
62
|
+
options
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def apply_jsonapi_options_for_body(options)
|
|
66
|
+
options = apply_jsonapi_headers(options, include_content_type: true)
|
|
67
|
+
options.delete(:as)
|
|
68
|
+
|
|
69
|
+
# JSON-encode params for request body
|
|
70
|
+
options[:params] = options[:params].to_json if options[:params].is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
options
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/json_api.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_api/version"
|
|
4
|
+
require "json_api/configuration"
|
|
5
|
+
|
|
6
|
+
module JSONAPI
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class AuthorizationError < Error; end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require "json_api/errors/parameter_not_allowed"
|
|
12
|
+
|
|
13
|
+
module JSONAPI
|
|
14
|
+
# Rebuild BaseController and RelationshipsController to reflect the current
|
|
15
|
+
# base_controller_class configuration. Safe to call repeatedly.
|
|
16
|
+
def self.rebuild_base_controllers!
|
|
17
|
+
remove_const(:BaseController) if const_defined?(:BaseController)
|
|
18
|
+
load "json_api/controllers/base_controller.rb"
|
|
19
|
+
|
|
20
|
+
remove_const(:RelationshipsController) if const_defined?(:RelationshipsController)
|
|
21
|
+
load "json_api/controllers/relationships_controller.rb"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require "json_api/resources/resource"
|
|
26
|
+
require "json_api/resources/resource_loader"
|
|
27
|
+
require "json_api/support/type_conversion"
|
|
28
|
+
require "json_api/support/sort_parsing"
|
|
29
|
+
require "json_api/support/resource_identifier"
|
|
30
|
+
require "json_api/support/relationship_helpers"
|
|
31
|
+
require "json_api/support/param_helpers"
|
|
32
|
+
require "json_api/active_storage/detection"
|
|
33
|
+
require "json_api/active_storage/serialization"
|
|
34
|
+
require "json_api/active_storage/deserialization"
|
|
35
|
+
require "json_api/support/active_storage_support"
|
|
36
|
+
require "json_api/support/collection_query"
|
|
37
|
+
require "json_api/routing"
|
|
38
|
+
require "json_api/support/responders"
|
|
39
|
+
require "json_api/support/instrumentation"
|
|
40
|
+
require "json_api/serialization/serializer"
|
|
41
|
+
require "json_api/serialization/deserializer"
|
|
42
|
+
require "json_api/support/response_helpers"
|
|
43
|
+
require "json_api/controllers/concerns/controller_helpers"
|
|
44
|
+
require "json_api/controllers/concerns/resource_actions"
|
|
45
|
+
require "json_api/controllers/base_controller"
|
|
46
|
+
require "json_api/controllers/resources_controller"
|
|
47
|
+
require "json_api/controllers/relationships_controller"
|
|
48
|
+
require "json_api/resources/active_storage_blob_resource" if defined?(ActiveStorage)
|
|
49
|
+
|
|
50
|
+
require "json_api/railtie" if defined?(Rails)
|