Class: Rhales::Middleware::SchemaValidator
- Inherits:
-
Object
- Object
- Rhales::Middleware::SchemaValidator
- Defined in:
- lib/rhales/middleware/schema_validator.rb
Overview
Rack middleware that validates hydration data against JSON Schemas
This middleware extracts hydration JSON from HTML responses and validates it against the JSON Schema for the template. In development, it fails loudly on mismatches. In production, it logs warnings but continues serving.
Defined Under Namespace
Classes: ValidationError
Instance Method Summary collapse
-
#build_error_message(errors, template_name, template_path, elapsed_ms) ⇒ Object
private
Build detailed error message.
-
#call(env) ⇒ Array
Process the Rack request.
-
#extract_body(body) ⇒ Object
private
Extract response body as string.
-
#extract_hydration_data(html) ⇒ Object
private
Extract hydration JSON blocks from HTML.
-
#format_errors(validation_errors) ⇒ Object
private
Format json_schemer errors for display.
-
#handle_errors(errors, template_name, template_path, elapsed_ms) ⇒ Object
private
Handle validation errors.
-
#initialize(app, options = {}) ⇒ SchemaValidator
constructor
Initialize the middleware.
-
#load_schema_cached(template_name) ⇒ Object
private
Load and cache JSON schema for template.
-
#skip_validation?(env) ⇒ Boolean
private
Check if validation should be skipped for this request.
-
#stats ⇒ Hash
Get validation statistics.
-
#validate_hydration_data(hydration_data, schema, template_name) ⇒ Object
private
Validate hydration data against schema.
Constructor Details
#initialize(app, options = {}) ⇒ SchemaValidator
Initialize the middleware
40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/rhales/middleware/schema_validator.rb', line 40 def initialize(app, = {}) @app = app # Default to public/schemas in implementing project's directory @schemas_dir = .fetch(:schemas_dir, './public/schemas') @fail_on_error = .fetch(:fail_on_error, false) @enabled = .fetch(:enabled, true) @skip_paths = .fetch(:skip_paths, []) @schema_cache = {} @stats = { total_validations: 0, total_time_ms: 0, failures: 0 } end |
Instance Method Details
#build_error_message(errors, template_name, template_path, elapsed_ms) ⇒ Object (private)
Build detailed error message
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/rhales/middleware/schema_validator.rb', line 271 def (errors, template_name, template_path, elapsed_ms) msg = ["Schema validation failed for template: #{template_name}"] msg << "Template path: #{template_path}" if template_path msg << "Validation time: #{elapsed_ms}ms" msg << "" errors.each do |error| msg << "Window variable: #{error[:window]}" msg << "Errors:" error[:errors].each do |err| msg << " - #{err}" end msg << "" end msg << "This means your backend is sending data that doesn't match the contract" msg << "defined in the <schema> section of #{template_name}.rue" msg << "" msg << "To fix:" msg << "1. Check the schema definition in #{template_name}.rue" msg << "2. Verify the data passed to render('#{template_name}', ...)" msg << "3. Ensure types match (string vs number, required fields, etc.)" msg.join("\n") end |
#call(env) ⇒ Array
Process the Rack request
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/rhales/middleware/schema_validator.rb', line 59 def call(env) return @app.call(env) unless @enabled return @app.call(env) if skip_validation?(env) status, headers, body = @app.call(env) # Only validate HTML responses content_type = headers['Content-Type'] return [status, headers, body] unless content_type&.include?('text/html') # Get template name from env (set by View) template_name = env['rhales.template_name'] return [status, headers, body] unless template_name # Get template path if available (for better error messages) template_path = env['rhales.template_path'] # Load schema for template schema = load_schema_cached(template_name) return [status, headers, body] unless schema # Extract hydration data from response html_body = extract_body(body) hydration_data = extract_hydration_data(html_body) return [status, headers, body] if hydration_data.empty? # Validate each hydration block start_time = Time.now errors = validate_hydration_data(hydration_data, schema, template_name) elapsed_ms = ((Time.now - start_time) * 1000).round(2) # Update stats @stats[:total_validations] += 1 @stats[:total_time_ms] += elapsed_ms @stats[:failures] += 1 if errors.any? # Handle errors handle_errors(errors, template_name, template_path, elapsed_ms) if errors.any? [status, headers, body] end |
#extract_body(body) ⇒ Object (private)
Extract response body as string
156 157 158 159 160 161 162 163 164 |
# File 'lib/rhales/middleware/schema_validator.rb', line 156 def extract_body(body) if body.respond_to?(:each) body.each.to_a.join elsif body.respond_to?(:read) body.read else body.to_s end end |
#extract_hydration_data(html) ⇒ Object (private)
Extract hydration JSON blocks from HTML
Looks for
169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/rhales/middleware/schema_validator.rb', line 169 def extract_hydration_data(html) hydration_blocks = {} # Match script tags with data-window attribute html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content| begin hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip) rescue JSON::ParserError => e warn "Rhales::SchemaValidator: Failed to parse hydration JSON for window.#{window_var}: #{e.}" end end hydration_blocks end |
#format_errors(validation_errors) ⇒ Object (private)
Format json_schemer errors for display
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/rhales/middleware/schema_validator.rb', line 210 def format_errors(validation_errors) validation_errors.map do |error| # json_schemer provides detailed error hash # Example: { "data" => value, "data_pointer" => "/user/id", "schema" => {...}, "type" => "required", "error" => "..." } path = error['data_pointer'] || '/' type = error['type'] schema = error['schema'] || {} data = error['data'] # For type validation errors, format like json-schema did # "The property '#/count' of type string did not match the following type: number" if schema['type'] && data expected = schema['type'] actual = case data when String then 'string' when Integer, Float then 'number' when TrueClass, FalseClass then 'boolean' when Array then 'array' when Hash then 'object' when NilClass then 'null' else data.class.name.downcase end "The property '#{path}' of type #{actual} did not match the following type: #{expected}" elsif type == 'required' details = error['details'] || {} missing = details['missing_keys']&.join(', ') || 'unknown' "The property '#{path}' is missing required field(s): #{missing}" elsif schema['enum'] expected = schema['enum'].join(', ') "The property '#{path}' must be one of: #{expected}" elsif schema['minimum'] min = schema['minimum'] "The property '#{path}' must be >= #{min}" elsif schema['maximum'] max = schema['maximum'] "The property '#{path}' must be <= #{max}" elsif type == 'additionalProperties' "The property '#{path}' is not defined in the schema and the schema does not allow additional properties" else # Fallback: use json_schemer's built-in error message error['error'] || "The property '#{path}' failed '#{type}' validation" end end end |
#handle_errors(errors, template_name, template_path, elapsed_ms) ⇒ Object (private)
Handle validation errors
258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/rhales/middleware/schema_validator.rb', line 258 def handle_errors(errors, template_name, template_path, elapsed_ms) = (errors, template_name, template_path, elapsed_ms) if @fail_on_error # Development: Fail loudly raise ValidationError, else # Production: Log warning warn end end |
#load_schema_cached(template_name) ⇒ Object (private)
Load and cache JSON schema for template
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/rhales/middleware/schema_validator.rb', line 134 def load_schema_cached(template_name) @schema_cache[template_name] ||= begin schema_path = File.join(@schemas_dir, "#{template_name}.json") return nil unless File.exist?(schema_path) schema_json = File.read(schema_path) schema_hash = JSONSerializer.parse(schema_json) # Create JSONSchemer validator # Note: json_schemer handles $schema and $id properly JSONSchemer.schema(schema_hash) rescue JSON::ParserError => e warn "Rhales::SchemaValidator: Failed to parse schema for #{template_name}: #{e.}" nil rescue StandardError => e warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.}" nil end end |
#skip_validation?(env) ⇒ Boolean (private)
Check if validation should be skipped for this request
118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/rhales/middleware/schema_validator.rb', line 118 def skip_validation?(env) path = env['PATH_INFO'] # Skip static assets, APIs, public files return true if path.start_with?('/assets', '/api', '/public') # Skip configured custom paths return true if @skip_paths.any? { |skip_path| path.start_with?(skip_path) } # Skip files with extensions typically not rendered by templates return true if path.match?(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)\z/i) false end |
#stats ⇒ Hash
Get validation statistics
104 105 106 107 108 109 110 111 112 113 |
# File 'lib/rhales/middleware/schema_validator.rb', line 104 def stats avg_time = @stats[:total_validations] > 0 ? (@stats[:total_time_ms] / @stats[:total_validations]).round(2) : 0 @stats.merge( avg_time_ms: avg_time, success_rate: @stats[:total_validations] > 0 ? ((@stats[:total_validations] - @stats[:failures]).to_f / @stats[:total_validations] * 100).round(2) : 0 ) end |
#validate_hydration_data(hydration_data, schema, template_name) ⇒ Object (private)
Validate hydration data against schema
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/rhales/middleware/schema_validator.rb', line 185 def validate_hydration_data(hydration_data, schema, template_name) errors = [] hydration_data.each do |window_var, data| # Validate data against schema using json_schemer begin validation_errors = schema.validate(data).to_a if validation_errors.any? errors << { window: window_var, template: template_name, errors: format_errors(validation_errors) } end rescue StandardError => e warn "Rhales::SchemaValidator: Schema validation error for #{template_name}: #{e.}" # Don't add to errors array - this is a schema definition problem, not data problem end end errors end |