jpie 0.4.4 → 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 (89) 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 -28
  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 -167
  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/security.mdc +0 -14
  54. data/.cursor/rules/style.mdc +0 -15
  55. data/.cursor/rules/testing.mdc +0 -16
  56. data/.overcommit.yml +0 -35
  57. data/CHANGELOG.md +0 -164
  58. data/LICENSE.txt +0 -21
  59. data/examples/basic_example.md +0 -146
  60. data/examples/including_related_resources.md +0 -491
  61. data/examples/pagination.md +0 -303
  62. data/examples/relationships.md +0 -114
  63. data/examples/resource_attribute_configuration.md +0 -147
  64. data/examples/resource_meta_configuration.md +0 -244
  65. data/examples/single_table_inheritance.md +0 -160
  66. data/lib/jpie/configuration.rb +0 -12
  67. data/lib/jpie/controller/crud_actions.rb +0 -141
  68. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  69. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  70. data/lib/jpie/controller/error_handling.rb +0 -23
  71. data/lib/jpie/controller/json_api_validation.rb +0 -193
  72. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  73. data/lib/jpie/controller/related_actions.rb +0 -45
  74. data/lib/jpie/controller/relationship_actions.rb +0 -291
  75. data/lib/jpie/controller/relationship_validation.rb +0 -117
  76. data/lib/jpie/controller/rendering.rb +0 -154
  77. data/lib/jpie/controller.rb +0 -45
  78. data/lib/jpie/deserializer.rb +0 -110
  79. data/lib/jpie/errors.rb +0 -117
  80. data/lib/jpie/generators/resource_generator.rb +0 -116
  81. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  82. data/lib/jpie/railtie.rb +0 -42
  83. data/lib/jpie/resource/attributable.rb +0 -112
  84. data/lib/jpie/resource/inferrable.rb +0 -43
  85. data/lib/jpie/resource/sortable.rb +0 -93
  86. data/lib/jpie/resource.rb +0 -147
  87. data/lib/jpie/routing.rb +0 -59
  88. data/lib/jpie/rspec.rb +0 -71
  89. data/lib/jpie/serializer.rb +0 -205
@@ -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