rhales 0.4.0 → 0.5.4
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.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +69 -7
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +49 -2
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# lib/rhales/hydration/safe_injection_validator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
1
5
|
require 'strscan'
|
|
2
6
|
|
|
3
7
|
module Rhales
|
|
@@ -51,16 +55,21 @@ module Rhales
|
|
|
51
55
|
def calculate_unsafe_ranges
|
|
52
56
|
ranges = []
|
|
53
57
|
scanner = StringScanner.new(@html)
|
|
58
|
+
byte_to_char_map = build_byte_to_char_map(@html)
|
|
54
59
|
|
|
55
60
|
UNSAFE_CONTEXTS.each do |context|
|
|
56
61
|
scanner.pos = 0
|
|
57
62
|
|
|
58
63
|
while scanner.scan_until(context[:start])
|
|
59
|
-
|
|
64
|
+
# Convert byte position to character position using pre-built map
|
|
65
|
+
byte_start_pos = scanner.pos - scanner.matched.length
|
|
66
|
+
start_pos = byte_to_char_map[byte_start_pos]
|
|
60
67
|
|
|
61
68
|
# Find the corresponding end tag
|
|
62
69
|
if scanner.scan_until(context[:end])
|
|
63
|
-
|
|
70
|
+
# Convert byte position to character position using pre-built map
|
|
71
|
+
byte_end_pos = scanner.pos
|
|
72
|
+
end_pos = byte_to_char_map[byte_end_pos]
|
|
64
73
|
ranges << (start_pos...end_pos)
|
|
65
74
|
else
|
|
66
75
|
# If no closing tag found, consider rest of document unsafe
|
|
@@ -95,5 +104,43 @@ module Rhales
|
|
|
95
104
|
|
|
96
105
|
pos < @html.length && @html[pos] == '<'
|
|
97
106
|
end
|
|
107
|
+
|
|
108
|
+
# Builds a mapping from byte positions to character positions for efficient
|
|
109
|
+
# conversion when processing UTF-8 strings with StringScanner.
|
|
110
|
+
#
|
|
111
|
+
# This method creates a hash where keys are byte positions and values are
|
|
112
|
+
# the corresponding character positions. For multibyte UTF-8 characters,
|
|
113
|
+
# only the starting byte position has an entry in the map.
|
|
114
|
+
#
|
|
115
|
+
# @param str [String] The UTF-8 encoded string to map
|
|
116
|
+
# @return [Hash<Integer, Integer>] A hash mapping byte positions to character positions
|
|
117
|
+
#
|
|
118
|
+
# @example ASCII string
|
|
119
|
+
# build_byte_to_char_map("Hello")
|
|
120
|
+
# # => {0=>0, 1=>1, 2=>2, 3=>3, 4=>4, 5=>5}
|
|
121
|
+
#
|
|
122
|
+
# @example UTF-8 with multibyte characters
|
|
123
|
+
# build_byte_to_char_map("café") # é is 2 bytes
|
|
124
|
+
# # => {0=>0, 1=>1, 2=>2, 3=>3, 5=>4} # Note: byte 4 is continuation byte
|
|
125
|
+
#
|
|
126
|
+
def build_byte_to_char_map(str)
|
|
127
|
+
map = {}
|
|
128
|
+
char_pos = 0
|
|
129
|
+
byte_pos = 0
|
|
130
|
+
|
|
131
|
+
# Iterate through each character (not byte) in the string
|
|
132
|
+
str.each_char do |char|
|
|
133
|
+
# Map the starting byte position of this character
|
|
134
|
+
map[byte_pos] = char_pos
|
|
135
|
+
|
|
136
|
+
# Advance byte position by the byte size of this character
|
|
137
|
+
byte_pos += char.bytesize
|
|
138
|
+
char_pos += 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Add final mapping for the end of the string
|
|
142
|
+
map[byte_pos] = char_pos
|
|
143
|
+
map
|
|
144
|
+
end
|
|
98
145
|
end
|
|
99
146
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# lib/rhales/hydration.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'hydration/hydrator'
|
|
6
|
+
require_relative 'hydration/hydration_data_aggregator'
|
|
7
|
+
require_relative 'hydration/mount_point_detector'
|
|
8
|
+
require_relative 'hydration/safe_injection_validator'
|
|
9
|
+
require_relative 'hydration/earliest_injection_detector'
|
|
10
|
+
require_relative 'hydration/link_based_injection_detector'
|
|
11
|
+
require_relative 'hydration/hydration_injector'
|
|
12
|
+
require_relative 'hydration/hydration_endpoint'
|
|
13
|
+
require_relative 'hydration/hydration_registry'
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
# lib/rhales/tilt.rb
|
|
1
|
+
# lib/rhales/integrations/tilt.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'tilt'
|
|
4
|
-
|
|
5
|
-
require_relative 'adapters/base_request'
|
|
6
|
+
require_relative '../adapters/base_request'
|
|
6
7
|
|
|
7
8
|
module Rhales
|
|
8
9
|
# Tilt integration for Rhales templates
|
|
@@ -146,7 +147,7 @@ module Rhales
|
|
|
146
147
|
)
|
|
147
148
|
end
|
|
148
149
|
|
|
149
|
-
#
|
|
150
|
+
# Build session and auth adapters
|
|
150
151
|
session_data = if scope.respond_to?(:logged_in?) && scope.logged_in?
|
|
151
152
|
Rhales::Adapters::AuthenticatedSession.new(
|
|
152
153
|
{
|
|
@@ -158,25 +159,31 @@ module Rhales
|
|
|
158
159
|
Rhales::Adapters::AnonymousSession.new
|
|
159
160
|
end
|
|
160
161
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
auth_data = Rhales::Adapters::AuthenticatedAuth.new({
|
|
162
|
+
auth_data = if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
|
|
163
|
+
user = scope.current_user
|
|
164
|
+
Rhales::Adapters::AuthenticatedAuth.new({
|
|
165
165
|
id: user[:id],
|
|
166
166
|
email: user[:email],
|
|
167
167
|
authenticated: true,
|
|
168
|
-
}
|
|
169
|
-
)
|
|
168
|
+
})
|
|
170
169
|
else
|
|
171
|
-
|
|
170
|
+
Rhales::Adapters::AnonymousAuth.new
|
|
172
171
|
end
|
|
173
172
|
|
|
173
|
+
# Extend request_data to expose session and user
|
|
174
|
+
request_data.define_singleton_method(:session) { session_data }
|
|
175
|
+
request_data.define_singleton_method(:user) { auth_data }
|
|
176
|
+
|
|
177
|
+
# Split props into client (serialized) and server (template-only) data
|
|
178
|
+
# By default, all Tilt locals go to client for backward compatibility
|
|
179
|
+
# Frameworks can use :server_data and :client_data keys to override
|
|
180
|
+
client_data = props.delete(:client_data) || props.dup
|
|
181
|
+
server_data = props.delete(:server_data) || {}
|
|
182
|
+
|
|
174
183
|
::Rhales::View.new(
|
|
175
184
|
request_data,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
nil, # locale_override
|
|
179
|
-
props: props,
|
|
185
|
+
client: client_data,
|
|
186
|
+
server: server_data,
|
|
180
187
|
)
|
|
181
188
|
end
|
|
182
189
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# lib/rhales/middleware/json_responder.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../utils/json_serializer'
|
|
6
|
+
|
|
7
|
+
module Rhales
|
|
8
|
+
module Middleware
|
|
9
|
+
# Rack middleware that returns hydration data as JSON when Accept: application/json
|
|
10
|
+
#
|
|
11
|
+
# When a request has Accept: application/json header, this middleware
|
|
12
|
+
# intercepts the response and returns just the hydration data as JSON
|
|
13
|
+
# instead of rendering the full HTML template.
|
|
14
|
+
#
|
|
15
|
+
# This enables:
|
|
16
|
+
# - API clients to fetch data from the same endpoints
|
|
17
|
+
# - Testing hydration data without parsing HTML
|
|
18
|
+
# - Development inspection of data flow
|
|
19
|
+
# - Mobile/native clients using the same routes
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage with Rack
|
|
22
|
+
# use Rhales::Middleware::JsonResponder,
|
|
23
|
+
# enabled: true,
|
|
24
|
+
# include_metadata: false
|
|
25
|
+
#
|
|
26
|
+
# @example With Roda
|
|
27
|
+
# use Rhales::Middleware::JsonResponder,
|
|
28
|
+
# enabled: ENV['RACK_ENV'] != 'production',
|
|
29
|
+
# include_metadata: ENV['RACK_ENV'] == 'development'
|
|
30
|
+
#
|
|
31
|
+
# @example Response format (single window)
|
|
32
|
+
# GET /dashboard
|
|
33
|
+
# Accept: application/json
|
|
34
|
+
#
|
|
35
|
+
# {
|
|
36
|
+
# "user": {"id": 1, "name": "Alice"},
|
|
37
|
+
# "authenticated": true
|
|
38
|
+
# }
|
|
39
|
+
#
|
|
40
|
+
# @example Response format (multiple windows)
|
|
41
|
+
# {
|
|
42
|
+
# "appData": {"user": {...}},
|
|
43
|
+
# "config": {"theme": "dark"}
|
|
44
|
+
# }
|
|
45
|
+
#
|
|
46
|
+
# @example Response with metadata (development)
|
|
47
|
+
# {
|
|
48
|
+
# "template": "dashboard",
|
|
49
|
+
# "data": {"user": {...}}
|
|
50
|
+
# }
|
|
51
|
+
class JsonResponder
|
|
52
|
+
# Initialize the middleware
|
|
53
|
+
#
|
|
54
|
+
# @param app [#call] The Rack application
|
|
55
|
+
# @param options [Hash] Configuration options
|
|
56
|
+
# @option options [Boolean] :enabled Whether JSON responses are enabled (default: true)
|
|
57
|
+
# @option options [Boolean] :include_metadata Whether to include metadata in responses (default: false)
|
|
58
|
+
def initialize(app, options = {})
|
|
59
|
+
@app = app
|
|
60
|
+
@enabled = options.fetch(:enabled, true)
|
|
61
|
+
@include_metadata = options.fetch(:include_metadata, false)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Process the Rack request
|
|
65
|
+
#
|
|
66
|
+
# @param env [Hash] The Rack environment
|
|
67
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
68
|
+
def call(env)
|
|
69
|
+
return @app.call(env) unless @enabled
|
|
70
|
+
return @app.call(env) unless accepts_json?(env)
|
|
71
|
+
|
|
72
|
+
# Get the response from the app
|
|
73
|
+
status, headers, body = @app.call(env)
|
|
74
|
+
|
|
75
|
+
# Only process successful HTML responses
|
|
76
|
+
return [status, headers, body] unless status == 200
|
|
77
|
+
return [status, headers, body] unless html_response?(headers)
|
|
78
|
+
|
|
79
|
+
# Extract hydration data from HTML
|
|
80
|
+
html_body = extract_body(body)
|
|
81
|
+
hydration_data = extract_hydration_data(html_body)
|
|
82
|
+
|
|
83
|
+
# Return empty object if no hydration data found
|
|
84
|
+
if hydration_data.empty?
|
|
85
|
+
return json_response({}, env)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build response data
|
|
89
|
+
response_data = if @include_metadata
|
|
90
|
+
{
|
|
91
|
+
template: env['rhales.template_name'],
|
|
92
|
+
data: hydration_data
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
# Flatten if single window, or return all windows
|
|
96
|
+
hydration_data.size == 1 ? hydration_data.values.first : hydration_data
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
json_response(response_data, env)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Check if request accepts JSON
|
|
105
|
+
#
|
|
106
|
+
# Parses Accept header and checks for application/json.
|
|
107
|
+
# Handles weighted preferences (e.g., "application/json;q=0.9")
|
|
108
|
+
#
|
|
109
|
+
# @param env [Hash] Rack environment
|
|
110
|
+
# @return [Boolean] true if application/json is accepted
|
|
111
|
+
def accepts_json?(env)
|
|
112
|
+
accept = env['HTTP_ACCEPT']
|
|
113
|
+
return false unless accept
|
|
114
|
+
|
|
115
|
+
# Check if application/json is requested
|
|
116
|
+
# Handle weighted preferences (e.g., "application/json;q=0.9")
|
|
117
|
+
accept.split(',').any? do |type|
|
|
118
|
+
type.strip.start_with?('application/json')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if response is HTML
|
|
123
|
+
#
|
|
124
|
+
# @param headers [Hash] Response headers
|
|
125
|
+
# @return [Boolean] true if Content-Type is text/html
|
|
126
|
+
def html_response?(headers)
|
|
127
|
+
# Support both uppercase and lowercase header names for compatibility
|
|
128
|
+
content_type = headers['content-type'] || headers['Content-Type']
|
|
129
|
+
content_type && content_type.include?('text/html')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extract response body as string
|
|
133
|
+
#
|
|
134
|
+
# Handles different Rack body types (Array, IO, String)
|
|
135
|
+
#
|
|
136
|
+
# @param body [Array, IO, String] Rack response body
|
|
137
|
+
# @return [String] Body content as string
|
|
138
|
+
def extract_body(body)
|
|
139
|
+
if body.respond_to?(:each)
|
|
140
|
+
body.each.to_a.join
|
|
141
|
+
elsif body.respond_to?(:read)
|
|
142
|
+
body.read
|
|
143
|
+
else
|
|
144
|
+
body.to_s
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Extract hydration JSON blocks from HTML
|
|
149
|
+
#
|
|
150
|
+
# Looks for <script type="application/json" data-window="varName"> tags
|
|
151
|
+
# and parses their JSON content. Returns a hash keyed by window variable name.
|
|
152
|
+
#
|
|
153
|
+
# @param html [String] HTML response body
|
|
154
|
+
# @return [Hash] Hydration data keyed by window variable name
|
|
155
|
+
def extract_hydration_data(html)
|
|
156
|
+
hydration_blocks = {}
|
|
157
|
+
|
|
158
|
+
# Match script tags with data-window attribute
|
|
159
|
+
html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
|
|
160
|
+
begin
|
|
161
|
+
hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
|
|
162
|
+
rescue JSON::ParserError => e
|
|
163
|
+
# Skip malformed JSON blocks
|
|
164
|
+
warn "Rhales::JsonResponder: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
hydration_blocks
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Build JSON response
|
|
172
|
+
#
|
|
173
|
+
# @param data [Hash, Array, Object] Response data
|
|
174
|
+
# @param env [Hash] Rack environment
|
|
175
|
+
# @return [Array] Rack response tuple
|
|
176
|
+
def json_response(data, env)
|
|
177
|
+
json_body = JSONSerializer.dump(data)
|
|
178
|
+
|
|
179
|
+
[
|
|
180
|
+
200,
|
|
181
|
+
{
|
|
182
|
+
'content-type' => 'application/json',
|
|
183
|
+
'content-length' => json_body.bytesize.to_s,
|
|
184
|
+
'cache-control' => 'no-cache'
|
|
185
|
+
},
|
|
186
|
+
[json_body]
|
|
187
|
+
]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# lib/rhales/middleware/schema_validator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json_schemer'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
module Middleware
|
|
10
|
+
# Rack middleware that validates hydration data against JSON Schemas
|
|
11
|
+
#
|
|
12
|
+
# This middleware extracts hydration JSON from HTML responses and validates
|
|
13
|
+
# it against the JSON Schema for the template. In development, it fails
|
|
14
|
+
# loudly on mismatches. In production, it logs warnings but continues serving.
|
|
15
|
+
#
|
|
16
|
+
# @example Basic usage with Rack
|
|
17
|
+
# use Rhales::Middleware::SchemaValidator,
|
|
18
|
+
# schemas_dir: './public/schemas',
|
|
19
|
+
# fail_on_error: ENV['RACK_ENV'] == 'development'
|
|
20
|
+
#
|
|
21
|
+
# @example With Roda
|
|
22
|
+
# use Rhales::Middleware::SchemaValidator,
|
|
23
|
+
# schemas_dir: File.expand_path('../public/schemas', __dir__),
|
|
24
|
+
# fail_on_error: ENV['RACK_ENV'] == 'development',
|
|
25
|
+
# enabled: true
|
|
26
|
+
#
|
|
27
|
+
# @example Accessing statistics
|
|
28
|
+
# validator = app.middleware.find { |m| m.is_a?(Rhales::Middleware::SchemaValidator) }
|
|
29
|
+
# puts validator.stats
|
|
30
|
+
class SchemaValidator
|
|
31
|
+
# Raised when schema validation fails in development mode
|
|
32
|
+
class ValidationError < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Initialize the middleware
|
|
35
|
+
#
|
|
36
|
+
# @param app [#call] The Rack application
|
|
37
|
+
# @param options [Hash] Configuration options
|
|
38
|
+
# @option options [String] :schemas_dir Path to JSON schemas directory
|
|
39
|
+
# @option options [Boolean] :fail_on_error Whether to raise on validation errors
|
|
40
|
+
# @option options [Boolean] :enabled Whether validation is enabled
|
|
41
|
+
# @option options [Array<String>] :skip_paths Additional paths to skip validation
|
|
42
|
+
def initialize(app, options = {})
|
|
43
|
+
@app = app
|
|
44
|
+
# Default to public/schemas in implementing project's directory
|
|
45
|
+
@schemas_dir = options.fetch(:schemas_dir, './public/schemas')
|
|
46
|
+
@fail_on_error = options.fetch(:fail_on_error, false)
|
|
47
|
+
@enabled = options.fetch(:enabled, true)
|
|
48
|
+
@skip_paths = options.fetch(:skip_paths, [])
|
|
49
|
+
@schema_cache = {}
|
|
50
|
+
@stats = {
|
|
51
|
+
total_validations: 0,
|
|
52
|
+
total_time_ms: 0,
|
|
53
|
+
failures: 0
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Process the Rack request
|
|
58
|
+
#
|
|
59
|
+
# @param env [Hash] The Rack environment
|
|
60
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
61
|
+
def call(env)
|
|
62
|
+
return @app.call(env) unless @enabled
|
|
63
|
+
return @app.call(env) if skip_validation?(env)
|
|
64
|
+
|
|
65
|
+
status, headers, body = @app.call(env)
|
|
66
|
+
|
|
67
|
+
# Only validate HTML responses
|
|
68
|
+
content_type = headers['Content-Type']
|
|
69
|
+
return [status, headers, body] unless content_type&.include?('text/html')
|
|
70
|
+
|
|
71
|
+
# Get template name from env (set by View)
|
|
72
|
+
template_name = env['rhales.template_name']
|
|
73
|
+
return [status, headers, body] unless template_name
|
|
74
|
+
|
|
75
|
+
# Get template path if available (for better error messages)
|
|
76
|
+
template_path = env['rhales.template_path']
|
|
77
|
+
|
|
78
|
+
# Load schema for template
|
|
79
|
+
schema = load_schema_cached(template_name)
|
|
80
|
+
return [status, headers, body] unless schema
|
|
81
|
+
|
|
82
|
+
# Extract hydration data from response
|
|
83
|
+
html_body = extract_body(body)
|
|
84
|
+
hydration_data = extract_hydration_data(html_body)
|
|
85
|
+
return [status, headers, body] if hydration_data.empty?
|
|
86
|
+
|
|
87
|
+
# Validate each hydration block
|
|
88
|
+
start_time = Time.now
|
|
89
|
+
errors = validate_hydration_data(hydration_data, schema, template_name)
|
|
90
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round(2)
|
|
91
|
+
|
|
92
|
+
# Update stats
|
|
93
|
+
@stats[:total_validations] += 1
|
|
94
|
+
@stats[:total_time_ms] += elapsed_ms
|
|
95
|
+
@stats[:failures] += 1 if errors.any?
|
|
96
|
+
|
|
97
|
+
# Handle errors
|
|
98
|
+
handle_errors(errors, template_name, template_path, elapsed_ms) if errors.any?
|
|
99
|
+
|
|
100
|
+
[status, headers, body]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get validation statistics
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] Statistics including avg_time_ms and success_rate
|
|
106
|
+
def stats
|
|
107
|
+
avg_time = @stats[:total_validations] > 0 ?
|
|
108
|
+
(@stats[:total_time_ms] / @stats[:total_validations]).round(2) : 0
|
|
109
|
+
|
|
110
|
+
@stats.merge(
|
|
111
|
+
avg_time_ms: avg_time,
|
|
112
|
+
success_rate: @stats[:total_validations] > 0 ?
|
|
113
|
+
((@stats[:total_validations] - @stats[:failures]).to_f / @stats[:total_validations] * 100).round(2) : 0
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Check if validation should be skipped for this request
|
|
120
|
+
def skip_validation?(env)
|
|
121
|
+
path = env['PATH_INFO']
|
|
122
|
+
|
|
123
|
+
# Skip static assets, APIs, public files
|
|
124
|
+
return true if path.start_with?('/assets', '/api', '/public')
|
|
125
|
+
|
|
126
|
+
# Skip configured custom paths
|
|
127
|
+
return true if @skip_paths.any? { |skip_path| path.start_with?(skip_path) }
|
|
128
|
+
|
|
129
|
+
# Skip files with extensions typically not rendered by templates
|
|
130
|
+
return true if path.match?(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)\z/i)
|
|
131
|
+
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Load and cache JSON schema for template
|
|
136
|
+
def load_schema_cached(template_name)
|
|
137
|
+
@schema_cache[template_name] ||= begin
|
|
138
|
+
schema_path = File.join(@schemas_dir, "#{template_name}.json")
|
|
139
|
+
|
|
140
|
+
return nil unless File.exist?(schema_path)
|
|
141
|
+
|
|
142
|
+
schema_json = File.read(schema_path)
|
|
143
|
+
schema_hash = JSONSerializer.parse(schema_json)
|
|
144
|
+
|
|
145
|
+
# Create JSONSchemer validator
|
|
146
|
+
# Note: json_schemer handles $schema and $id properly
|
|
147
|
+
JSONSchemer.schema(schema_hash)
|
|
148
|
+
rescue JSON::ParserError => e
|
|
149
|
+
warn "Rhales::SchemaValidator: Failed to parse schema for #{template_name}: #{e.message}"
|
|
150
|
+
nil
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.message}"
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Extract response body as string
|
|
158
|
+
def extract_body(body)
|
|
159
|
+
if body.respond_to?(:each)
|
|
160
|
+
body.each.to_a.join
|
|
161
|
+
elsif body.respond_to?(:read)
|
|
162
|
+
body.read
|
|
163
|
+
else
|
|
164
|
+
body.to_s
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Extract hydration JSON blocks from HTML
|
|
169
|
+
#
|
|
170
|
+
# Looks for <script type="application/json" data-window="varName"> tags
|
|
171
|
+
def extract_hydration_data(html)
|
|
172
|
+
hydration_blocks = {}
|
|
173
|
+
|
|
174
|
+
# Match script tags with data-window attribute
|
|
175
|
+
html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
|
|
176
|
+
begin
|
|
177
|
+
hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
|
|
178
|
+
rescue JSON::ParserError => e
|
|
179
|
+
warn "Rhales::SchemaValidator: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
hydration_blocks
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Validate hydration data against schema
|
|
187
|
+
def validate_hydration_data(hydration_data, schema, template_name)
|
|
188
|
+
errors = []
|
|
189
|
+
|
|
190
|
+
hydration_data.each do |window_var, data|
|
|
191
|
+
# Validate data against schema using json_schemer
|
|
192
|
+
begin
|
|
193
|
+
validation_errors = schema.validate(data).to_a
|
|
194
|
+
|
|
195
|
+
if validation_errors.any?
|
|
196
|
+
errors << {
|
|
197
|
+
window: window_var,
|
|
198
|
+
template: template_name,
|
|
199
|
+
errors: format_errors(validation_errors)
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn "Rhales::SchemaValidator: Schema validation error for #{template_name}: #{e.message}"
|
|
204
|
+
# Don't add to errors array - this is a schema definition problem, not data problem
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
errors
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Format json_schemer errors for display
|
|
212
|
+
def format_errors(validation_errors)
|
|
213
|
+
validation_errors.map do |error|
|
|
214
|
+
# json_schemer provides detailed error hash
|
|
215
|
+
# Example: { "data" => value, "data_pointer" => "/user/id", "schema" => {...}, "type" => "required", "error" => "..." }
|
|
216
|
+
|
|
217
|
+
path = error['data_pointer'] || '/'
|
|
218
|
+
type = error['type']
|
|
219
|
+
schema = error['schema'] || {}
|
|
220
|
+
data = error['data']
|
|
221
|
+
|
|
222
|
+
# For type validation errors, format like json-schema did
|
|
223
|
+
# "The property '#/count' of type string did not match the following type: number"
|
|
224
|
+
if schema['type'] && data
|
|
225
|
+
expected = schema['type']
|
|
226
|
+
actual = case data
|
|
227
|
+
when String then 'string'
|
|
228
|
+
when Integer, Float then 'number'
|
|
229
|
+
when TrueClass, FalseClass then 'boolean'
|
|
230
|
+
when Array then 'array'
|
|
231
|
+
when Hash then 'object'
|
|
232
|
+
when NilClass then 'null'
|
|
233
|
+
else data.class.name.downcase
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
"The property '#{path}' of type #{actual} did not match the following type: #{expected}"
|
|
237
|
+
elsif type == 'required'
|
|
238
|
+
details = error['details'] || {}
|
|
239
|
+
missing = details['missing_keys']&.join(', ') || 'unknown'
|
|
240
|
+
"The property '#{path}' is missing required field(s): #{missing}"
|
|
241
|
+
elsif schema['enum']
|
|
242
|
+
expected = schema['enum'].join(', ')
|
|
243
|
+
"The property '#{path}' must be one of: #{expected}"
|
|
244
|
+
elsif schema['minimum']
|
|
245
|
+
min = schema['minimum']
|
|
246
|
+
"The property '#{path}' must be >= #{min}"
|
|
247
|
+
elsif schema['maximum']
|
|
248
|
+
max = schema['maximum']
|
|
249
|
+
"The property '#{path}' must be <= #{max}"
|
|
250
|
+
elsif type == 'additionalProperties'
|
|
251
|
+
"The property '#{path}' is not defined in the schema and the schema does not allow additional properties"
|
|
252
|
+
else
|
|
253
|
+
# Fallback: use json_schemer's built-in error message
|
|
254
|
+
error['error'] || "The property '#{path}' failed '#{type}' validation"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Handle validation errors
|
|
260
|
+
def handle_errors(errors, template_name, template_path, elapsed_ms)
|
|
261
|
+
error_message = build_error_message(errors, template_name, template_path, elapsed_ms)
|
|
262
|
+
|
|
263
|
+
if @fail_on_error
|
|
264
|
+
# Development: Fail loudly
|
|
265
|
+
raise ValidationError, error_message
|
|
266
|
+
else
|
|
267
|
+
# Production: Log warning
|
|
268
|
+
warn error_message
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Build detailed error message
|
|
273
|
+
def build_error_message(errors, template_name, template_path, elapsed_ms)
|
|
274
|
+
msg = ["Schema validation failed for template: #{template_name}"]
|
|
275
|
+
msg << "Template path: #{template_path}" if template_path
|
|
276
|
+
msg << "Validation time: #{elapsed_ms}ms"
|
|
277
|
+
msg << ""
|
|
278
|
+
|
|
279
|
+
errors.each do |error|
|
|
280
|
+
msg << "Window variable: #{error[:window]}"
|
|
281
|
+
msg << "Errors:"
|
|
282
|
+
error[:errors].each do |err|
|
|
283
|
+
msg << " - #{err}"
|
|
284
|
+
end
|
|
285
|
+
msg << ""
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
msg << "This means your backend is sending data that doesn't match the contract"
|
|
289
|
+
msg << "defined in the <schema> section of #{template_name}.rue"
|
|
290
|
+
msg << ""
|
|
291
|
+
msg << "To fix:"
|
|
292
|
+
msg << "1. Check the schema definition in #{template_name}.rue"
|
|
293
|
+
msg << "2. Verify the data passed to render('#{template_name}', ...)"
|
|
294
|
+
msg << "3. Ensure types match (string vs number, required fields, etc.)"
|
|
295
|
+
|
|
296
|
+
msg.join("\n")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|