Class: Rhales::Middleware::SchemaValidator

Inherits:
Object
  • Object
show all
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.

Examples:

Basic usage with Rack

use Rhales::Middleware::SchemaValidator,
  schemas_dir: './public/schemas',
  fail_on_error: ENV['RACK_ENV'] == 'development'

With Roda

use Rhales::Middleware::SchemaValidator,
  schemas_dir: File.expand_path('../public/schemas', __dir__),
  fail_on_error: ENV['RACK_ENV'] == 'development',
  enabled: true

Accessing statistics

validator = app.middleware.find { |m| m.is_a?(Rhales::Middleware::SchemaValidator) }
puts validator.stats

Defined Under Namespace

Classes: ValidationError

Instance Method Summary collapse

Constructor Details

#initialize(app, options = {}) ⇒ SchemaValidator

Initialize the middleware

Parameters:

  • app (#call)

    The Rack application

  • options (Hash) (defaults to: {})

    Configuration options

Options Hash (options):

  • :schemas_dir (String)

    Path to JSON schemas directory

  • :fail_on_error (Boolean)

    Whether to raise on validation errors

  • :enabled (Boolean)

    Whether validation is enabled

  • :skip_paths (Array<String>)

    Additional paths to skip validation



42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/rhales/middleware/schema_validator.rb', line 42

def initialize(app, options = {})
  @app = app
  # Default to public/schemas in implementing project's directory
  @schemas_dir = options.fetch(:schemas_dir, './public/schemas')
  @fail_on_error = options.fetch(:fail_on_error, false)
  @enabled = options.fetch(:enabled, true)
  @skip_paths = options.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



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/rhales/middleware/schema_validator.rb', line 273

def build_error_message(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

Parameters:

  • env (Hash)

    The Rack environment

Returns:

  • (Array)

    Rack response tuple [status, headers, body]



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
100
101
# File 'lib/rhales/middleware/schema_validator.rb', line 61

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



158
159
160
161
162
163
164
165
166
# File 'lib/rhales/middleware/schema_validator.rb', line 158

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



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/rhales/middleware/schema_validator.rb', line 171

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.message}"
    end
  end

  hydration_blocks
end

#format_errors(validation_errors) ⇒ Object (private)

Format json_schemer errors for display



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
256
257
# File 'lib/rhales/middleware/schema_validator.rb', line 212

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



260
261
262
263
264
265
266
267
268
269
270
# File 'lib/rhales/middleware/schema_validator.rb', line 260

def handle_errors(errors, template_name, template_path, elapsed_ms)
  error_message = build_error_message(errors, template_name, template_path, elapsed_ms)

  if @fail_on_error
    # Development: Fail loudly
    raise ValidationError, error_message
  else
    # Production: Log warning
    warn error_message
  end
end

#load_schema_cached(template_name) ⇒ Object (private)

Load and cache JSON schema for template



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/rhales/middleware/schema_validator.rb', line 136

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.message}"
    nil
  rescue StandardError => e
    warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.message}"
    nil
  end
end

#skip_validation?(env) ⇒ Boolean (private)

Check if validation should be skipped for this request

Returns:

  • (Boolean)


120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/rhales/middleware/schema_validator.rb', line 120

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

#statsHash

Get validation statistics

Returns:

  • (Hash)

    Statistics including avg_time_ms and success_rate



106
107
108
109
110
111
112
113
114
115
# File 'lib/rhales/middleware/schema_validator.rb', line 106

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



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/rhales/middleware/schema_validator.rb', line 187

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.message}"
      # Don't add to errors array - this is a schema definition problem, not data problem
    end
  end

  errors
end