jpie 0.4.0 → 0.4.2

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.
@@ -0,0 +1,193 @@
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
@@ -11,6 +11,16 @@ module JPie
11
11
  params[:sort]&.split(',')&.map(&:strip) || []
12
12
  end
13
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
+
14
24
  def deserialize_params
15
25
  deserializer.deserialize(request.body.read, context)
16
26
  rescue JSON::ParserError => e
@@ -30,6 +40,39 @@ module JPie
30
40
  action: action_name
31
41
  }
32
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
33
76
  end
34
77
  end
35
78
  end
@@ -23,9 +23,15 @@ module JPie
23
23
  end
24
24
 
25
25
  # More concise method names following Rails conventions
26
- def render_jsonapi(resource_or_resources, status: :ok, meta: nil)
26
+ def render_jsonapi(resource_or_resources, status: :ok, meta: nil, pagination: nil, original_scope: nil)
27
27
  includes = parse_include_params
28
28
  json_data = serializer.serialize(resource_or_resources, context, includes: includes)
29
+
30
+ # Add pagination metadata and links if pagination is provided and valid
31
+ if pagination && pagination[:per_page]
32
+ add_pagination_metadata(json_data, resource_or_resources, pagination, original_scope)
33
+ end
34
+
29
35
  json_data[:meta] = meta if meta
30
36
 
31
37
  render json: json_data, status:, content_type: 'application/vnd.api+json'
@@ -37,6 +43,94 @@ module JPie
37
43
 
38
44
  private
39
45
 
46
+ def add_pagination_metadata(json_data, resources, pagination, original_scope)
47
+ page = pagination[:page] || 1
48
+ per_page = pagination[:per_page]
49
+
50
+ # Get total count from the original scope before pagination
51
+ total_count = get_total_count(resources, original_scope)
52
+ total_pages = (total_count.to_f / per_page).ceil
53
+
54
+ # Add pagination metadata
55
+ json_data[:meta] ||= {}
56
+ json_data[:meta][:pagination] = {
57
+ page: page,
58
+ per_page: per_page,
59
+ total_pages: total_pages,
60
+ total_count: total_count
61
+ }
62
+
63
+ # Add pagination links
64
+ json_data[:links] = build_pagination_links(page, per_page, total_pages)
65
+ end
66
+
67
+ def get_total_count(resources, original_scope)
68
+ # Use original scope if provided, otherwise fall back to resources
69
+ scope_to_count = original_scope || resources
70
+
71
+ # If scope is an ActiveRecord relation, get the count
72
+ # Otherwise, if it's an array, get the length
73
+ if scope_to_count.respond_to?(:count) && !scope_to_count.loaded?
74
+ scope_to_count.count
75
+ elsif scope_to_count.respond_to?(:size)
76
+ scope_to_count.size
77
+ else
78
+ 0
79
+ end
80
+ end
81
+
82
+ def build_pagination_links(page, per_page, total_pages)
83
+ url_components = extract_url_components
84
+ pagination_data = { page: page, per_page: per_page, total_pages: total_pages }
85
+
86
+ links = build_base_pagination_links(url_components, pagination_data)
87
+ add_conditional_pagination_links(links, url_components, pagination_data)
88
+
89
+ links
90
+ end
91
+
92
+ def extract_url_components
93
+ base_url = request.respond_to?(:base_url) ? request.base_url : 'http://example.com'
94
+ path = request.respond_to?(:path) ? request.path : '/resources'
95
+ query_params = request.respond_to?(:query_parameters) ? request.query_parameters.except('page') : {}
96
+
97
+ { base_url: base_url, path: path, query_params: query_params }
98
+ end
99
+
100
+ def build_base_pagination_links(url_components, pagination_data)
101
+ full_url = url_components[:base_url] + url_components[:path]
102
+ query_params = url_components[:query_params]
103
+ page = pagination_data[:page]
104
+ per_page = pagination_data[:per_page]
105
+ total_pages = pagination_data[:total_pages]
106
+
107
+ {
108
+ self: build_page_url(full_url, query_params, page, per_page),
109
+ first: build_page_url(full_url, query_params, 1, per_page),
110
+ last: build_page_url(full_url, query_params, total_pages, per_page)
111
+ }
112
+ end
113
+
114
+ def add_conditional_pagination_links(links, url_components, pagination_data)
115
+ full_url = url_components[:base_url] + url_components[:path]
116
+ query_params = url_components[:query_params]
117
+ page = pagination_data[:page]
118
+ per_page = pagination_data[:per_page]
119
+ total_pages = pagination_data[:total_pages]
120
+
121
+ links[:prev] = build_page_url(full_url, query_params, page - 1, per_page) if page > 1
122
+ links[:next] = build_page_url(full_url, query_params, page + 1, per_page) if page < total_pages
123
+ end
124
+
125
+ def build_page_url(base_url, query_params, page_num, per_page)
126
+ params = query_params.merge(
127
+ 'page' => page_num.to_s,
128
+ 'per_page' => per_page.to_s
129
+ )
130
+ query_string = params.respond_to?(:to_query) ? params.to_query : params.map { |k, v| "#{k}=#{v}" }.join('&')
131
+ "#{base_url}?#{query_string}"
132
+ end
133
+
40
134
  def infer_resource_class
41
135
  # Convert controller name to resource class name
42
136
  # e.g., "UsersController" -> "UserResource"
@@ -5,6 +5,7 @@ require_relative 'controller/error_handling'
5
5
  require_relative 'controller/parameter_parsing'
6
6
  require_relative 'controller/rendering'
7
7
  require_relative 'controller/crud_actions'
8
+ require_relative 'controller/json_api_validation'
8
9
 
9
10
  module JPie
10
11
  module Controller
@@ -14,5 +15,6 @@ module JPie
14
15
  include ParameterParsing
15
16
  include Rendering
16
17
  include CrudActions
18
+ include JsonApiValidation
17
19
  end
18
20
  end
data/lib/jpie/errors.rb CHANGED
@@ -66,5 +66,46 @@ module JPie
66
66
  super(status: 500, title: 'Resource Error', detail:)
67
67
  end
68
68
  end
69
+
70
+ # JSON:API Compliance Errors
71
+ class InvalidJsonApiRequestError < BadRequestError
72
+ def initialize(detail: 'Request is not JSON:API compliant')
73
+ super
74
+ end
75
+ end
76
+
77
+ class UnsupportedIncludeError < BadRequestError
78
+ def initialize(include_path:, supported_includes: [])
79
+ detail = if supported_includes.any?
80
+ "Unsupported include '#{include_path}'. Supported includes: #{supported_includes.join(', ')}"
81
+ else
82
+ "Unsupported include '#{include_path}'. No includes are supported for this resource"
83
+ end
84
+ super(detail: detail)
85
+ end
86
+ end
87
+
88
+ class UnsupportedSortFieldError < BadRequestError
89
+ def initialize(sort_field:, supported_fields: [])
90
+ detail = if supported_fields.any?
91
+ "Unsupported sort field '#{sort_field}'. Supported fields: #{supported_fields.join(', ')}"
92
+ else
93
+ "Unsupported sort field '#{sort_field}'. No sorting is supported for this resource"
94
+ end
95
+ super(detail: detail)
96
+ end
97
+ end
98
+
99
+ class InvalidSortParameterError < BadRequestError
100
+ def initialize(detail: 'Invalid sort parameter format')
101
+ super
102
+ end
103
+ end
104
+
105
+ class InvalidIncludeParameterError < BadRequestError
106
+ def initialize(detail: 'Invalid include parameter format')
107
+ super
108
+ end
109
+ end
69
110
  end
70
111
  end
@@ -45,8 +45,14 @@ module JPie
45
45
  return if method_defined?(name) || private_method_defined?(name)
46
46
 
47
47
  define_method(name) do
48
- attr_name = options[:attr] || name
49
- @object.public_send(attr_name)
48
+ # Handle through associations
49
+ if options[:through]
50
+ handle_through_association(name, options)
51
+ else
52
+ # Standard association handling
53
+ attr_name = options[:attr] || name
54
+ @object.public_send(attr_name)
55
+ end
50
56
  end
51
57
  end
52
58
 
@@ -93,6 +99,14 @@ module JPie
93
99
  "#{singularized_name.camelize}Resource"
94
100
  end
95
101
  end
102
+
103
+ private
104
+
105
+ # Handle through associations by calling the appropriate association method
106
+ def handle_through_association(name, options)
107
+ attr_name = options[:attr] || name
108
+ @object.public_send(attr_name)
109
+ end
96
110
  end
97
111
  end
98
112
  end
data/lib/jpie/resource.rb CHANGED
@@ -37,6 +37,46 @@ module JPie
37
37
  def scope(_context = {})
38
38
  model.all
39
39
  end
40
+
41
+ # Return supported include paths for validation
42
+ # Override this method to customize supported includes
43
+ def supported_includes
44
+ # Return relationship names as supported includes by default
45
+ _relationships.keys.map(&:to_s)
46
+
47
+ # Convert to nested hash format for complex includes
48
+ # For simple includes, return array format
49
+ end
50
+
51
+ # Return supported sort fields for validation
52
+ # Override this method to customize supported sort fields
53
+ def supported_sort_fields
54
+ base_fields = extract_base_sort_fields
55
+ timestamp_fields = extract_timestamp_fields
56
+ (base_fields + timestamp_fields).uniq
57
+ end
58
+
59
+ private
60
+
61
+ def extract_base_sort_fields
62
+ (_attributes + _sortable_fields.keys).uniq.map(&:to_s)
63
+ end
64
+
65
+ def extract_timestamp_fields
66
+ return [] unless model.respond_to?(:column_names)
67
+
68
+ timestamp_fields = []
69
+ add_timestamp_field(timestamp_fields, 'created_at')
70
+ add_timestamp_field(timestamp_fields, 'updated_at')
71
+ timestamp_fields
72
+ end
73
+
74
+ def add_timestamp_field(fields, field_name)
75
+ return unless model.column_names.include?(field_name)
76
+ return if fields.include?(field_name)
77
+
78
+ fields << field_name
79
+ end
40
80
  end
41
81
 
42
82
  attr_reader :object, :context
data/lib/jpie/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JPie
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.2'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jpie
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-24 00:00:00.000000000 Z
10
+ date: 2025-05-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -169,18 +169,28 @@ executables: []
169
169
  extensions: []
170
170
  extra_rdoc_files: []
171
171
  files:
172
- - ".aiconfig"
172
+ - ".cursorrules"
173
+ - ".overcommit.yml"
173
174
  - ".rubocop.yml"
174
175
  - CHANGELOG.md
175
176
  - LICENSE.txt
176
177
  - README.md
177
178
  - Rakefile
179
+ - examples/basic_example.md
180
+ - examples/including_related_resources.md
181
+ - examples/pagination.md
182
+ - examples/resource_attribute_configuration.md
183
+ - examples/resource_meta_configuration.md
184
+ - examples/single_table_inheritance.md
178
185
  - jpie.gemspec
179
186
  - lib/jpie.rb
180
187
  - lib/jpie/configuration.rb
181
188
  - lib/jpie/controller.rb
182
189
  - lib/jpie/controller/crud_actions.rb
183
190
  - lib/jpie/controller/error_handling.rb
191
+ - lib/jpie/controller/error_handling/handler_setup.rb
192
+ - lib/jpie/controller/error_handling/handlers.rb
193
+ - lib/jpie/controller/json_api_validation.rb
184
194
  - lib/jpie/controller/parameter_parsing.rb
185
195
  - lib/jpie/controller/rendering.rb
186
196
  - lib/jpie/deserializer.rb