jpie 0.4.5 → 1.0.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +35 -110
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2072 -140
  9. data/Rakefile +3 -14
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +18 -35
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +3 -24
  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/{jpie → json_api}/version.rb +2 -2
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +50 -169
  49. data/.cursor/rules/dependencies.mdc +0 -19
  50. data/.cursor/rules/examples.mdc +0 -16
  51. data/.cursor/rules/git.mdc +0 -14
  52. data/.cursor/rules/project_structure.mdc +0 -30
  53. data/.cursor/rules/publish_gem.mdc +0 -73
  54. data/.cursor/rules/security.mdc +0 -14
  55. data/.cursor/rules/style.mdc +0 -15
  56. data/.cursor/rules/testing.mdc +0 -16
  57. data/.overcommit.yml +0 -35
  58. data/CHANGELOG.md +0 -164
  59. data/LICENSE.txt +0 -21
  60. data/PUBLISHING.md +0 -111
  61. data/examples/basic_example.md +0 -146
  62. data/examples/including_related_resources.md +0 -491
  63. data/examples/pagination.md +0 -303
  64. data/examples/relationships.md +0 -114
  65. data/examples/resource_attribute_configuration.md +0 -147
  66. data/examples/resource_meta_configuration.md +0 -244
  67. data/examples/rspec_testing.md +0 -130
  68. data/examples/single_table_inheritance.md +0 -160
  69. data/lib/jpie/configuration.rb +0 -12
  70. data/lib/jpie/controller/crud_actions.rb +0 -141
  71. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  72. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  73. data/lib/jpie/controller/error_handling.rb +0 -23
  74. data/lib/jpie/controller/json_api_validation.rb +0 -193
  75. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  76. data/lib/jpie/controller/related_actions.rb +0 -45
  77. data/lib/jpie/controller/relationship_actions.rb +0 -291
  78. data/lib/jpie/controller/relationship_validation.rb +0 -117
  79. data/lib/jpie/controller/rendering.rb +0 -154
  80. data/lib/jpie/controller.rb +0 -45
  81. data/lib/jpie/deserializer.rb +0 -110
  82. data/lib/jpie/errors.rb +0 -117
  83. data/lib/jpie/generators/resource_generator.rb +0 -116
  84. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  85. data/lib/jpie/railtie.rb +0 -42
  86. data/lib/jpie/resource/attributable.rb +0 -112
  87. data/lib/jpie/resource/inferrable.rb +0 -43
  88. data/lib/jpie/resource/sortable.rb +0 -93
  89. data/lib/jpie/resource.rb +0 -147
  90. data/lib/jpie/routing.rb +0 -59
  91. data/lib/jpie/serializer.rb +0 -205
@@ -1,124 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Controller
5
- module ErrorHandling
6
- module HandlerSetup
7
- extend ActiveSupport::Concern
8
-
9
- class_methods do
10
- # Allow applications to easily disable all JPie error handlers
11
- def disable_jpie_error_handlers
12
- self.jpie_error_handlers_enabled = false
13
- # Remove any already-added handlers
14
- remove_jpie_handlers
15
- end
16
-
17
- # Allow applications to enable specific handlers
18
- def enable_jpie_error_handler(error_class, method_name = nil)
19
- method_name ||= :"handle_#{error_class.name.demodulize.underscore}"
20
- rescue_from error_class, with: method_name
21
- end
22
-
23
- # Check for application-defined error handlers
24
- def rescue_handler?(exception_class)
25
- # Use Rails' rescue_handlers method to check for existing handlers
26
- return false unless respond_to?(:rescue_handlers, true)
27
-
28
- begin
29
- rescue_handlers.any? { |handler| handler.first == exception_class.name }
30
- rescue NoMethodError
31
- false
32
- end
33
- end
34
-
35
- private
36
-
37
- def setup_jpie_error_handlers
38
- setup_jpie_specific_handlers
39
- end
40
-
41
- def setup_jpie_specific_handlers
42
- setup_core_error_handlers
43
- setup_activerecord_handlers
44
- setup_json_api_compliance_handlers
45
- end
46
-
47
- def setup_core_error_handlers
48
- return if rescue_handler?(JPie::Errors::Error)
49
-
50
- rescue_from JPie::Errors::Error, with: :handle_jpie_error
51
- end
52
-
53
- def setup_activerecord_handlers
54
- setup_not_found_handler
55
- setup_invalid_record_handler
56
- end
57
-
58
- def setup_not_found_handler
59
- return if rescue_handler?(ActiveRecord::RecordNotFound)
60
-
61
- rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
62
- end
63
-
64
- def setup_invalid_record_handler
65
- return if rescue_handler?(ActiveRecord::RecordInvalid)
66
-
67
- rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid
68
- end
69
-
70
- def setup_json_api_compliance_handlers
71
- setup_json_api_request_handler
72
- setup_include_handlers
73
- setup_sort_handlers
74
- end
75
-
76
- def setup_json_api_request_handler
77
- return if rescue_handler?(JPie::Errors::InvalidJsonApiRequestError)
78
-
79
- rescue_from JPie::Errors::InvalidJsonApiRequestError, with: :handle_invalid_json_api_request
80
- end
81
-
82
- def setup_include_handlers
83
- setup_unsupported_include_handler
84
- setup_invalid_include_handler
85
- end
86
-
87
- def setup_unsupported_include_handler
88
- return if rescue_handler?(JPie::Errors::UnsupportedIncludeError)
89
-
90
- rescue_from JPie::Errors::UnsupportedIncludeError, with: :handle_unsupported_include
91
- end
92
-
93
- def setup_invalid_include_handler
94
- return if rescue_handler?(JPie::Errors::InvalidIncludeParameterError)
95
-
96
- rescue_from JPie::Errors::InvalidIncludeParameterError, with: :handle_invalid_include_parameter
97
- end
98
-
99
- def setup_sort_handlers
100
- setup_unsupported_sort_handler
101
- setup_invalid_sort_handler
102
- end
103
-
104
- def setup_unsupported_sort_handler
105
- return if rescue_handler?(JPie::Errors::UnsupportedSortFieldError)
106
-
107
- rescue_from JPie::Errors::UnsupportedSortFieldError, with: :handle_unsupported_sort_field
108
- end
109
-
110
- def setup_invalid_sort_handler
111
- return if rescue_handler?(JPie::Errors::InvalidSortParameterError)
112
-
113
- rescue_from JPie::Errors::InvalidSortParameterError, with: :handle_invalid_sort_parameter
114
- end
115
-
116
- def remove_jpie_handlers
117
- # This is a placeholder - Rails doesn't provide an easy way to remove specific handlers
118
- # In practice, applications should use the disable_jpie_error_handlers before including
119
- end
120
- end
121
- end
122
- end
123
- end
124
- end
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Controller
5
- module ErrorHandling
6
- module Handlers
7
- extend ActiveSupport::Concern
8
-
9
- private
10
-
11
- # Handle JPie-specific errors
12
- def handle_jpie_error(error)
13
- render_json_api_error(
14
- status: error.status,
15
- title: error.title,
16
- detail: error.detail
17
- )
18
- end
19
-
20
- # Handle ActiveRecord::RecordNotFound
21
- def handle_record_not_found(error)
22
- render_json_api_error(
23
- status: 404,
24
- title: 'Not Found',
25
- detail: error.message
26
- )
27
- end
28
-
29
- # Handle ActiveRecord::RecordInvalid
30
- def handle_record_invalid(error)
31
- errors = error.record.errors.full_messages.map do |message|
32
- {
33
- status: '422',
34
- title: 'Validation Error',
35
- detail: message
36
- }
37
- end
38
-
39
- render json: { errors: errors }, status: :unprocessable_content
40
- end
41
-
42
- # Render a single JSON:API error
43
- def render_json_api_error(status:, title:, detail:)
44
- render json: {
45
- errors: [{
46
- status: status.to_s,
47
- title: title,
48
- detail: detail
49
- }]
50
- }, status: status
51
- end
52
-
53
- # Handle JSON:API compliance errors
54
- def handle_invalid_json_api_request(error)
55
- render_json_api_error(
56
- status: error.status,
57
- title: error.title || 'Invalid JSON:API Request',
58
- detail: error.detail
59
- )
60
- end
61
-
62
- def handle_unsupported_include(error)
63
- render_json_api_error(
64
- status: error.status,
65
- title: error.title || 'Unsupported Include',
66
- detail: error.detail
67
- )
68
- end
69
-
70
- def handle_unsupported_sort_field(error)
71
- render_json_api_error(
72
- status: error.status,
73
- title: error.title || 'Unsupported Sort Field',
74
- detail: error.detail
75
- )
76
- end
77
-
78
- def handle_invalid_sort_parameter(error)
79
- render_json_api_error(
80
- status: error.status,
81
- title: error.title || 'Invalid Sort Parameter',
82
- detail: error.detail
83
- )
84
- end
85
-
86
- def handle_invalid_include_parameter(error)
87
- render_json_api_error(
88
- status: error.status,
89
- title: error.title || 'Invalid Include Parameter',
90
- detail: error.detail
91
- )
92
- end
93
-
94
- # Backward compatibility aliases
95
- alias jpie_handle_error handle_jpie_error
96
- alias jpie_handle_not_found handle_record_not_found
97
- alias jpie_handle_invalid handle_record_invalid
98
-
99
- # Legacy method name aliases
100
- alias render_jpie_error handle_jpie_error
101
- alias render_jpie_not_found_error handle_record_not_found
102
- alias render_jpie_validation_error handle_record_invalid
103
- alias render_jsonapi_error handle_jpie_error
104
- alias render_not_found_error handle_record_not_found
105
- alias render_validation_error handle_record_invalid
106
- end
107
- end
108
- end
109
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'error_handling/handler_setup'
4
- require_relative 'error_handling/handlers'
5
-
6
- module JPie
7
- module Controller
8
- module ErrorHandling
9
- extend ActiveSupport::Concern
10
-
11
- include HandlerSetup
12
- include Handlers
13
-
14
- included do
15
- # Use class_attribute to allow easy overriding
16
- class_attribute :jpie_error_handlers_enabled, default: true
17
-
18
- # Set up default handlers unless explicitly disabled
19
- setup_jpie_error_handlers if jpie_error_handlers_enabled
20
- end
21
- end
22
- end
23
- end
@@ -1,193 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Controller
5
- module JsonApiValidation
6
- extend ActiveSupport::Concern
7
-
8
- private
9
-
10
- # Validate that the request is JSON:API compliant
11
- def validate_json_api_request
12
- validate_content_type if request.post? || request.patch? || request.put?
13
- validate_json_api_structure if request.post? || request.patch? || request.put?
14
- end
15
-
16
- # Validate Content-Type header for write operations
17
- def validate_content_type
18
- # Only validate content type for write operations
19
- return unless request.post? || request.patch? || request.put?
20
-
21
- content_type = request.content_type
22
- return if content_type&.include?('application/vnd.api+json')
23
-
24
- raise JPie::Errors::InvalidJsonApiRequestError.new(
25
- detail: 'Content-Type must be application/vnd.api+json for JSON:API requests'
26
- )
27
- end
28
-
29
- # Validate basic JSON:API request structure
30
- def validate_json_api_structure
31
- request_body = read_request_body
32
- return if request_body.blank?
33
-
34
- parsed_body = parse_json_body(request_body)
35
- validate_top_level_structure(parsed_body)
36
- validate_data_structure(parsed_body['data'])
37
- end
38
-
39
- def read_request_body
40
- request_body = request.body.read
41
- request.body.rewind # Reset for later reading
42
- request_body
43
- end
44
-
45
- def parse_json_body(request_body)
46
- JSON.parse(request_body)
47
- rescue JSON::ParserError => e
48
- raise JPie::Errors::InvalidJsonApiRequestError.new(
49
- detail: "Invalid JSON: #{e.message}"
50
- )
51
- end
52
-
53
- def validate_top_level_structure(parsed_body)
54
- return if parsed_body.is_a?(Hash) && parsed_body.key?('data')
55
-
56
- raise JPie::Errors::InvalidJsonApiRequestError.new(
57
- detail: 'JSON:API request must have a top-level "data" member'
58
- )
59
- end
60
-
61
- # Validate the structure of the data member
62
- def validate_data_structure(data)
63
- if data.is_a?(Array)
64
- data.each { |item| validate_resource_object(item) }
65
- elsif data.is_a?(Hash)
66
- validate_resource_object(data)
67
- elsif !data.nil?
68
- raise JPie::Errors::InvalidJsonApiRequestError.new(
69
- detail: 'Data member must be an object, array, or null'
70
- )
71
- end
72
- end
73
-
74
- # Validate individual resource object structure
75
- def validate_resource_object(resource)
76
- unless resource.is_a?(Hash)
77
- raise JPie::Errors::InvalidJsonApiRequestError.new(
78
- detail: 'Resource objects must be JSON objects'
79
- )
80
- end
81
-
82
- unless resource.key?('type')
83
- raise JPie::Errors::InvalidJsonApiRequestError.new(
84
- detail: 'Resource objects must have a "type" member'
85
- )
86
- end
87
-
88
- # ID is required for updates but not for creates
89
- return unless request.patch? || request.put?
90
- return if resource.key?('id')
91
-
92
- raise JPie::Errors::InvalidJsonApiRequestError.new(
93
- detail: 'Resource objects must have an "id" member for updates'
94
- )
95
- end
96
-
97
- # Validate include parameters against supported includes
98
- def validate_include_params
99
- return if params[:include].blank?
100
-
101
- include_paths = params[:include].to_s.split(',').map(&:strip)
102
- supported_includes = resource_class.supported_includes
103
-
104
- include_paths.each do |include_path|
105
- next if include_path.blank?
106
-
107
- validate_include_path(include_path, supported_includes)
108
- end
109
- end
110
-
111
- # Validate a single include path
112
- def validate_include_path(include_path, supported_includes)
113
- path_parts = include_path.split('.')
114
- current_level = supported_includes
115
-
116
- path_parts.each_with_index do |part, index|
117
- validate_include_part(part, current_level, path_parts, index)
118
- current_level = move_to_next_include_level(part, current_level)
119
- end
120
- end
121
-
122
- def validate_include_part(part, current_level, path_parts, index)
123
- return if include_part_supported?(part, current_level)
124
-
125
- current_path = path_parts[0..index].join('.')
126
- available_at_level = extract_available_includes(current_level)
127
-
128
- raise JPie::Errors::UnsupportedIncludeError.new(
129
- include_path: current_path,
130
- supported_includes: available_at_level.map(&:to_s)
131
- )
132
- end
133
-
134
- def include_part_supported?(part, current_level)
135
- current_level.include?(part.to_sym) || current_level.include?(part)
136
- end
137
-
138
- def extract_available_includes(current_level)
139
- current_level.is_a?(Hash) ? current_level.keys : current_level
140
- end
141
-
142
- def move_to_next_include_level(part, current_level)
143
- return current_level unless current_level.is_a?(Hash)
144
-
145
- current_level[part.to_sym] if current_level[part.to_sym].is_a?(Hash)
146
- current_level[part] if current_level[part].is_a?(Hash)
147
- current_level
148
- end
149
-
150
- # Validate sort parameters against supported fields
151
- def validate_sort_params
152
- return if params[:sort].blank?
153
-
154
- sort_fields = params[:sort].to_s.split(',').map(&:strip)
155
- supported_fields = resource_class.supported_sort_fields
156
-
157
- sort_fields.each do |sort_field|
158
- next if sort_field.blank?
159
-
160
- validate_sort_field(sort_field, supported_fields)
161
- end
162
- end
163
-
164
- # Validate a single sort field
165
- def validate_sort_field(sort_field, supported_fields)
166
- # Remove leading minus for descending sort
167
- field_name = sort_field.start_with?('-') ? sort_field[1..] : sort_field
168
-
169
- # Validate field name format
170
- unless field_name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
171
- raise JPie::Errors::InvalidSortParameterError.new(
172
- detail: "Invalid sort field format: '#{sort_field}'. " \
173
- 'Field names must start with a letter and contain only letters, numbers, and underscores'
174
- )
175
- end
176
-
177
- return if supported_fields.include?(field_name.to_sym) || supported_fields.include?(field_name)
178
-
179
- raise JPie::Errors::UnsupportedSortFieldError.new(
180
- sort_field: field_name,
181
- supported_fields: supported_fields.map(&:to_s)
182
- )
183
- end
184
-
185
- # Validate all JSON:API request aspects
186
- def validate_json_api_compliance
187
- validate_json_api_request
188
- validate_include_params
189
- validate_sort_params
190
- end
191
- end
192
- end
193
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Controller
5
- module ParameterParsing
6
- def parse_include_params
7
- params[:include]&.split(',')&.map(&:strip) || []
8
- end
9
-
10
- def parse_sort_params
11
- params[:sort]&.split(',')&.map(&:strip) || []
12
- end
13
-
14
- def parse_pagination_params
15
- page_params = params[:page] || {}
16
- per_page_param = params[:per_page]
17
-
18
- {
19
- page: extract_page_number(page_params, per_page_param),
20
- per_page: extract_per_page_size(page_params, per_page_param)
21
- }
22
- end
23
-
24
- def deserialize_params
25
- deserializer.deserialize(request.body.read, context)
26
- rescue JSON::ParserError => e
27
- raise JPie::Errors::BadRequestError.new(detail: "Invalid JSON: #{e.message}")
28
- end
29
-
30
- def context
31
- @context ||= build_context
32
- end
33
-
34
- private
35
-
36
- def build_context
37
- {
38
- current_user: try(:current_user),
39
- controller: self,
40
- action: action_name
41
- }
42
- end
43
-
44
- def extract_page_number(page_params, per_page_param)
45
- page_number = determine_page_number(page_params)
46
- page_number = '1' if page_number.nil? && per_page_param.present?
47
- return 1 if page_number.blank?
48
-
49
- parsed_page = page_number.to_i
50
- parsed_page.positive? ? parsed_page : 1
51
- end
52
-
53
- def extract_per_page_size(page_params, per_page_param)
54
- per_page_size = determine_per_page_size(page_params, per_page_param)
55
- return nil if per_page_size.blank?
56
-
57
- parsed_size = per_page_size.to_i
58
- parsed_size.positive? ? parsed_size : nil
59
- end
60
-
61
- def determine_page_number(page_params)
62
- if page_params.is_a?(String) || page_params.is_a?(Integer)
63
- page_params
64
- else
65
- page_params[:number] || page_params['number']
66
- end
67
- end
68
-
69
- def determine_per_page_size(page_params, per_page_param)
70
- if page_params.is_a?(String) || page_params.is_a?(Integer)
71
- per_page_param
72
- else
73
- page_params[:size] || page_params['size'] || per_page_param
74
- end
75
- end
76
- end
77
- end
78
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JPie
4
- module Controller
5
- module RelatedActions
6
- extend ActiveSupport::Concern
7
-
8
- # GET /resources/:id/:relationship_name
9
- # Returns the related resources themselves (not just linkage)
10
- def related_show
11
- validate_relationship_exists
12
- validate_include_params
13
- resource = find_resource
14
- related_resources = get_related_resources(resource)
15
- render_jsonapi(related_resources)
16
- end
17
-
18
- private
19
-
20
- def validate_relationship_exists
21
- relationship_name = params[:relationship_name]
22
- return unless relationship_name # Skip validation if no relationship_name param
23
-
24
- return if resource_class._relationships.key?(relationship_name.to_sym)
25
-
26
- raise JPie::Errors::NotFoundError.new(
27
- detail: "Relationship '#{relationship_name}' does not exist for #{resource_class.name}"
28
- )
29
- end
30
-
31
- def find_resource
32
- resource_class.scope(context).find(params[:id])
33
- end
34
-
35
- def relationship_name
36
- @relationship_name ||= params[:relationship_name].to_sym
37
- end
38
-
39
- def get_related_resources(resource)
40
- relationship_method = relationship_name
41
- resource.send(relationship_method)
42
- end
43
- end
44
- end
45
- end