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
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResponseHelpers
5
+ # Delegates to Serializer.jsonapi_object for backward compatibility
6
+ def self.jsonapi_object
7
+ JSONAPI::Serializer.jsonapi_object
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/test_helper"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module JPie
4
- VERSION = '0.4.5'
3
+ module JSONAPI
4
+ VERSION = "1.0.1"
5
5
  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)