jpie 0.3.1 → 0.4.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.
@@ -6,36 +6,187 @@ module JPie
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- rescue_from JPie::Errors::Error, with: :render_jsonapi_error
9
+ # Use class_attribute to allow easy overriding
10
+ class_attribute :jpie_error_handlers_enabled, default: true
10
11
 
11
- if defined?(ActiveRecord)
12
- rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
13
- rescue_from ActiveRecord::RecordInvalid, with: :render_validation_error
12
+ # Set up default handlers unless explicitly disabled
13
+ setup_jpie_error_handlers if jpie_error_handlers_enabled
14
+ end
15
+
16
+ class_methods do
17
+ # Allow applications to easily disable all JPie error handlers
18
+ def disable_jpie_error_handlers
19
+ self.jpie_error_handlers_enabled = false
20
+ # Remove any already-added handlers
21
+ remove_jpie_handlers
22
+ end
23
+
24
+ # Allow applications to enable specific handlers
25
+ def enable_jpie_error_handler(error_class, method_name = nil)
26
+ method_name ||= :"handle_#{error_class.name.demodulize.underscore}"
27
+ rescue_from error_class, with: method_name
28
+ end
29
+
30
+ # Check for application-defined error handlers
31
+ def rescue_handler?(exception_class)
32
+ # Use Rails' rescue_handlers method to check for existing handlers
33
+ return false unless respond_to?(:rescue_handlers, true)
34
+
35
+ begin
36
+ rescue_handlers.any? { |handler| handler.first == exception_class.name }
37
+ rescue NoMethodError
38
+ false
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def setup_jpie_error_handlers
45
+ setup_jpie_specific_handlers
46
+ end
47
+
48
+ def setup_jpie_specific_handlers
49
+ # Only add handlers if they don't already exist
50
+ rescue_from JPie::Errors::Error, with: :handle_jpie_error unless rescue_handler?(JPie::Errors::Error)
51
+ unless rescue_handler?(ActiveRecord::RecordNotFound)
52
+ rescue_from ActiveRecord::RecordNotFound,
53
+ with: :handle_record_not_found
54
+ end
55
+ return if rescue_handler?(ActiveRecord::RecordInvalid)
56
+
57
+ rescue_from ActiveRecord::RecordInvalid,
58
+ with: :handle_record_invalid
59
+
60
+ # JSON:API compliance error handlers
61
+ unless rescue_handler?(JPie::Errors::InvalidJsonApiRequestError)
62
+ rescue_from JPie::Errors::InvalidJsonApiRequestError,
63
+ with: :handle_invalid_json_api_request
64
+ end
65
+
66
+ unless rescue_handler?(JPie::Errors::UnsupportedIncludeError)
67
+ rescue_from JPie::Errors::UnsupportedIncludeError,
68
+ with: :handle_unsupported_include
69
+ end
70
+
71
+ unless rescue_handler?(JPie::Errors::UnsupportedSortFieldError)
72
+ rescue_from JPie::Errors::UnsupportedSortFieldError,
73
+ with: :handle_unsupported_sort_field
74
+ end
75
+
76
+ unless rescue_handler?(JPie::Errors::InvalidSortParameterError)
77
+ rescue_from JPie::Errors::InvalidSortParameterError,
78
+ with: :handle_invalid_sort_parameter
79
+ end
80
+
81
+ return if rescue_handler?(JPie::Errors::InvalidIncludeParameterError)
82
+
83
+ rescue_from JPie::Errors::InvalidIncludeParameterError,
84
+ with: :handle_invalid_include_parameter
85
+ end
86
+
87
+ def remove_jpie_handlers
88
+ # This is a placeholder - Rails doesn't provide an easy way to remove specific handlers
89
+ # In practice, applications should use the disable_jpie_error_handlers before including
14
90
  end
15
91
  end
16
92
 
17
93
  private
18
94
 
19
- def render_jsonapi_error(error)
20
- render json: { errors: [error.to_hash] },
21
- status: error.status,
22
- content_type: 'application/vnd.api+json'
95
+ # Handle JPie-specific errors
96
+ def handle_jpie_error(error)
97
+ render_json_api_error(
98
+ status: error.status,
99
+ title: error.title,
100
+ detail: error.detail
101
+ )
23
102
  end
24
103
 
25
- def render_not_found_error(error)
26
- json_error = JPie::Errors::NotFoundError.new(detail: error.message)
27
- render_jsonapi_error(json_error)
104
+ # Handle ActiveRecord::RecordNotFound
105
+ def handle_record_not_found(error)
106
+ render_json_api_error(
107
+ status: 404,
108
+ title: 'Not Found',
109
+ detail: error.message
110
+ )
28
111
  end
29
112
 
30
- def render_validation_error(error)
31
- errors = error.record.errors.full_messages.map do
32
- JPie::Errors::ValidationError.new(detail: it).to_hash
113
+ # Handle ActiveRecord::RecordInvalid
114
+ def handle_record_invalid(error)
115
+ errors = error.record.errors.full_messages.map do |message|
116
+ {
117
+ status: '422',
118
+ title: 'Validation Error',
119
+ detail: message
120
+ }
33
121
  end
34
122
 
35
- render json: { errors: },
36
- status: :unprocessable_entity,
37
- content_type: 'application/vnd.api+json'
123
+ render json: { errors: errors }, status: :unprocessable_content
124
+ end
125
+
126
+ # Render a single JSON:API error
127
+ def render_json_api_error(status:, title:, detail:)
128
+ render json: {
129
+ errors: [{
130
+ status: status.to_s,
131
+ title: title,
132
+ detail: detail
133
+ }]
134
+ }, status: status
135
+ end
136
+
137
+ # Handle JSON:API compliance errors
138
+ def handle_invalid_json_api_request(error)
139
+ render_json_api_error(
140
+ status: error.status,
141
+ title: error.title || 'Invalid JSON:API Request',
142
+ detail: error.detail
143
+ )
144
+ end
145
+
146
+ def handle_unsupported_include(error)
147
+ render_json_api_error(
148
+ status: error.status,
149
+ title: error.title || 'Unsupported Include',
150
+ detail: error.detail
151
+ )
38
152
  end
153
+
154
+ def handle_unsupported_sort_field(error)
155
+ render_json_api_error(
156
+ status: error.status,
157
+ title: error.title || 'Unsupported Sort Field',
158
+ detail: error.detail
159
+ )
160
+ end
161
+
162
+ def handle_invalid_sort_parameter(error)
163
+ render_json_api_error(
164
+ status: error.status,
165
+ title: error.title || 'Invalid Sort Parameter',
166
+ detail: error.detail
167
+ )
168
+ end
169
+
170
+ def handle_invalid_include_parameter(error)
171
+ render_json_api_error(
172
+ status: error.status,
173
+ title: error.title || 'Invalid Include Parameter',
174
+ detail: error.detail
175
+ )
176
+ end
177
+
178
+ # Backward compatibility aliases
179
+ alias jpie_handle_error handle_jpie_error
180
+ alias jpie_handle_not_found handle_record_not_found
181
+ alias jpie_handle_invalid handle_record_invalid
182
+
183
+ # Legacy method name aliases
184
+ alias render_jpie_error handle_jpie_error
185
+ alias render_jpie_not_found_error handle_record_not_found
186
+ alias render_jpie_validation_error handle_record_invalid
187
+ alias render_jsonapi_error handle_jpie_error
188
+ alias render_not_found_error handle_record_not_found
189
+ alias render_validation_error handle_record_invalid
39
190
  end
40
191
  end
41
192
  end
@@ -0,0 +1,171 @@
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 = request.body.read
32
+ request.body.rewind # Reset for later reading
33
+
34
+ return if request_body.blank?
35
+
36
+ begin
37
+ parsed_body = JSON.parse(request_body)
38
+ rescue JSON::ParserError => e
39
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
40
+ detail: "Invalid JSON: #{e.message}"
41
+ )
42
+ end
43
+
44
+ unless parsed_body.is_a?(Hash) && parsed_body.key?('data')
45
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
46
+ detail: 'JSON:API request must have a top-level "data" member'
47
+ )
48
+ end
49
+
50
+ validate_data_structure(parsed_body['data'])
51
+ end
52
+
53
+ # Validate the structure of the data member
54
+ def validate_data_structure(data)
55
+ if data.is_a?(Array)
56
+ data.each { |item| validate_resource_object(item) }
57
+ elsif data.is_a?(Hash)
58
+ validate_resource_object(data)
59
+ elsif !data.nil?
60
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
61
+ detail: 'Data member must be an object, array, or null'
62
+ )
63
+ end
64
+ end
65
+
66
+ # Validate individual resource object structure
67
+ def validate_resource_object(resource)
68
+ unless resource.is_a?(Hash)
69
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
70
+ detail: 'Resource objects must be JSON objects'
71
+ )
72
+ end
73
+
74
+ unless resource.key?('type')
75
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
76
+ detail: 'Resource objects must have a "type" member'
77
+ )
78
+ end
79
+
80
+ # ID is required for updates but not for creates
81
+ return unless request.patch? || request.put?
82
+ return if resource.key?('id')
83
+
84
+ raise JPie::Errors::InvalidJsonApiRequestError.new(
85
+ detail: 'Resource objects must have an "id" member for updates'
86
+ )
87
+ end
88
+
89
+ # Validate include parameters against supported includes
90
+ def validate_include_params
91
+ return if params[:include].blank?
92
+
93
+ include_paths = params[:include].to_s.split(',').map(&:strip)
94
+ supported_includes = resource_class.supported_includes
95
+
96
+ include_paths.each do |include_path|
97
+ next if include_path.blank?
98
+
99
+ validate_include_path(include_path, supported_includes)
100
+ end
101
+ end
102
+
103
+ # Validate a single include path
104
+ def validate_include_path(include_path, supported_includes)
105
+ # Handle nested includes (e.g., "posts.comments")
106
+ path_parts = include_path.split('.')
107
+ current_level = supported_includes
108
+
109
+ path_parts.each_with_index do |part, index|
110
+ unless current_level.include?(part.to_sym) || current_level.include?(part)
111
+ current_path = path_parts[0..index].join('.')
112
+ available_at_level = current_level.is_a?(Hash) ? current_level.keys : current_level
113
+
114
+ raise JPie::Errors::UnsupportedIncludeError.new(
115
+ include_path: current_path,
116
+ supported_includes: available_at_level.map(&:to_s)
117
+ )
118
+ end
119
+
120
+ # Move to next level for nested validation
121
+ if current_level.is_a?(Hash) && current_level[part.to_sym].is_a?(Hash)
122
+ current_level = current_level[part.to_sym]
123
+ elsif current_level.is_a?(Hash) && current_level[part].is_a?(Hash)
124
+ current_level = current_level[part]
125
+ end
126
+ end
127
+ end
128
+
129
+ # Validate sort parameters against supported fields
130
+ def validate_sort_params
131
+ return if params[:sort].blank?
132
+
133
+ sort_fields = params[:sort].to_s.split(',').map(&:strip)
134
+ supported_fields = resource_class.supported_sort_fields
135
+
136
+ sort_fields.each do |sort_field|
137
+ next if sort_field.blank?
138
+
139
+ validate_sort_field(sort_field, supported_fields)
140
+ end
141
+ end
142
+
143
+ # Validate a single sort field
144
+ def validate_sort_field(sort_field, supported_fields)
145
+ # Remove leading minus for descending sort
146
+ field_name = sort_field.start_with?('-') ? sort_field[1..] : sort_field
147
+
148
+ # Validate field name format
149
+ unless field_name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
150
+ raise JPie::Errors::InvalidSortParameterError.new(
151
+ detail: "Invalid sort field format: '#{sort_field}'. Field names must start with a letter and contain only letters, numbers, and underscores"
152
+ )
153
+ end
154
+
155
+ return if supported_fields.include?(field_name.to_sym) || supported_fields.include?(field_name)
156
+
157
+ raise JPie::Errors::UnsupportedSortFieldError.new(
158
+ sort_field: field_name,
159
+ supported_fields: supported_fields.map(&:to_s)
160
+ )
161
+ end
162
+
163
+ # Validate all JSON:API request aspects
164
+ def validate_json_api_compliance
165
+ validate_json_api_request
166
+ validate_include_params
167
+ validate_sort_params
168
+ end
169
+ end
170
+ end
171
+ end
@@ -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
@@ -5,11 +5,17 @@ require 'rails/generators/base'
5
5
  module JPie
6
6
  module Generators
7
7
  class ResourceGenerator < Rails::Generators::NamedBase
8
- desc 'Generate a JPie resource class'
8
+ source_root File.expand_path('templates', __dir__)
9
9
 
10
- argument :attributes, type: :array, default: [], banner: 'field:type field:type'
10
+ desc 'Generate a JPie resource class with semantic field definitions'
11
11
 
12
- class_option :model, type: :string, desc: 'Model class to associate with this resource'
12
+ argument :field_definitions, type: :array, default: [],
13
+ banner: 'attribute:field meta:field relationship:type:field'
14
+
15
+ class_option :model, type: :string,
16
+ desc: 'Model class to associate with this resource (defaults to inferred model)'
17
+ class_option :skip_model, type: :boolean, default: false,
18
+ desc: 'Skip explicit model declaration (use automatic inference)'
13
19
 
14
20
  def create_resource_file
15
21
  template 'resource.rb.erb', File.join('app/resources', "#{file_name}_resource.rb")
@@ -21,18 +27,89 @@ module JPie
21
27
  options[:model] || class_name
22
28
  end
23
29
 
30
+ def needs_explicit_model?
31
+ # Only need explicit model declaration if:
32
+ # 1. User explicitly provided a different model name, OR
33
+ # 2. User didn't use --skip-model flag AND the model name differs from the inferred name
34
+ return false if options[:skip_model]
35
+ return true if options[:model] && options[:model] != class_name
36
+
37
+ # For standard naming (UserResource -> User), we can skip the explicit declaration
38
+ false
39
+ end
40
+
24
41
  def resource_attributes
25
- return [] if attributes.empty?
42
+ parse_field_definitions.fetch(:attributes, [])
43
+ end
44
+
45
+ def meta_attributes_list
46
+ parse_field_definitions.fetch(:meta_attributes, [])
47
+ end
48
+
49
+ def relationships_list
50
+ parse_field_definitions.fetch(:relationships, [])
51
+ end
52
+
53
+ def parse_relationships
54
+ relationships_list.map do |rel|
55
+ # rel is already a hash with :type and :name from parse_field_definitions
56
+ rel
57
+ end
58
+ end
59
+
60
+ def parse_field_definitions
61
+ return @parsed_definitions if @parsed_definitions
62
+
63
+ @parsed_definitions = { attributes: [], meta_attributes: [], relationships: [] }
64
+
65
+ field_definitions.each do |definition|
66
+ process_field_definition(definition)
67
+ end
68
+
69
+ @parsed_definitions
70
+ end
71
+
72
+ def process_field_definition(definition)
73
+ case definition
74
+ when /^attribute:(.+)$/
75
+ @parsed_definitions[:attributes] << ::Regexp.last_match(1)
76
+ when /^meta:(.+)$/
77
+ @parsed_definitions[:meta_attributes] << ::Regexp.last_match(1)
78
+ when /^relationship:(.+):(.+)$/, /^(has_many|has_one|belongs_to):(.+)$/
79
+ add_relationship(::Regexp.last_match(1), ::Regexp.last_match(2))
80
+ when /^(.+):(.+)$/
81
+ process_legacy_field(::Regexp.last_match(1))
82
+ else
83
+ process_plain_field(definition)
84
+ end
85
+ end
86
+
87
+ def add_relationship(type, name)
88
+ @parsed_definitions[:relationships] << { type: type, name: name }
89
+ end
26
90
 
27
- attributes.map(&:name)
91
+ def process_legacy_field(field_name)
92
+ # Legacy support: field:type format - treat as attribute and ignore type
93
+ if meta_attribute_name?(field_name)
94
+ @parsed_definitions[:meta_attributes] << field_name
95
+ else
96
+ @parsed_definitions[:attributes] << field_name
97
+ end
28
98
  end
29
99
 
30
- def template_path
31
- File.expand_path('templates', __dir__)
100
+ def process_plain_field(field_name)
101
+ # Plain field name - treat as attribute
102
+ if meta_attribute_name?(field_name)
103
+ @parsed_definitions[:meta_attributes] << field_name
104
+ else
105
+ @parsed_definitions[:attributes] << field_name
106
+ end
32
107
  end
33
108
 
34
- def source_root
35
- template_path
109
+ def meta_attribute_name?(name)
110
+ # Check if field name suggests it's a meta attribute
111
+ meta_names = %w[created_at updated_at deleted_at published_at archived_at]
112
+ meta_names.include?(name)
36
113
  end
37
114
  end
38
115
  end
@@ -1,12 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= class_name %>Resource < JPie::Resource
4
+ <% if needs_explicit_model? -%>
4
5
  model <%= model_class_name %>
6
+ <% end -%>
5
7
 
6
8
  <% if resource_attributes.any? -%>
7
9
  attributes <%= resource_attributes.map { |attr| ":#{attr}" }.join(', ') %>
8
10
  <% else -%>
9
11
  # Define your attributes here:
10
- # attributes :name, :email, :created_at
12
+ # attributes :name, :email, :title
13
+ <% end -%>
14
+
15
+ <% if meta_attributes_list.any? -%>
16
+ meta_attributes <%= meta_attributes_list.map { |attr| ":#{attr}" }.join(', ') %>
17
+ <% else -%>
18
+ # Define your meta attributes here:
19
+ # meta_attributes :created_at, :updated_at
20
+ <% end -%>
21
+
22
+ <% if parse_relationships.any? -%>
23
+ <% parse_relationships.each do |rel| -%>
24
+ <%= rel[:type] %> :<%= rel[:name] %>
25
+ <% end -%>
26
+ <% else -%>
27
+ # Define your relationships here:
28
+ # has_many :posts
29
+ # has_one :user
11
30
  <% end -%>
12
31
  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,19 @@ 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
+ if options[:attr]
108
+ # Custom attribute name was provided - use it
109
+ @object.public_send(options[:attr])
110
+ else
111
+ # Use the relationship name directly - Rails will handle the through association
112
+ @object.public_send(name)
113
+ end
114
+ end
96
115
  end
97
116
  end
98
117
  end
data/lib/jpie/resource.rb CHANGED
@@ -37,6 +37,32 @@ 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
+ # Return all attributes and sortable fields as supported sort fields by default
55
+ fields = (_attributes + _sortable_fields.keys).uniq.map(&:to_s)
56
+
57
+ # Add common model timestamp fields if the model supports them
58
+ if model.respond_to?(:column_names)
59
+ fields << 'created_at' if model.column_names.include?('created_at') && fields.exclude?('created_at')
60
+
61
+ fields << 'updated_at' if model.column_names.include?('updated_at') && fields.exclude?('updated_at')
62
+ end
63
+
64
+ fields
65
+ end
40
66
  end
41
67
 
42
68
  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.3.1'
4
+ VERSION = '0.4.1'
5
5
  end