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
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# lib/rhales/hydration_data_aggregator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
require_relative '../core/template_engine'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
|
|
9
|
+
module Rhales
|
|
10
|
+
# HydrationDataAggregator traverses the ViewComposition and executes
|
|
11
|
+
# all <schema> sections to produce a single, merged JSON structure.
|
|
12
|
+
#
|
|
13
|
+
# This class implements the server-side data aggregation phase of the
|
|
14
|
+
# two-pass rendering model, handling:
|
|
15
|
+
# - Traversal of the template dependency tree
|
|
16
|
+
# - Direct serialization of props for <schema> sections
|
|
17
|
+
# - Merge strategies (deep, shallow, strict)
|
|
18
|
+
# - Collision detection and error reporting
|
|
19
|
+
#
|
|
20
|
+
# The aggregator replaces the HydrationRegistry by performing all
|
|
21
|
+
# data merging in a single, coordinated pass.
|
|
22
|
+
class HydrationDataAggregator
|
|
23
|
+
include Rhales::Utils::LoggingHelpers
|
|
24
|
+
|
|
25
|
+
class JSONSerializationError < StandardError; end
|
|
26
|
+
|
|
27
|
+
def initialize(context)
|
|
28
|
+
@context = context
|
|
29
|
+
@window_attributes = {}
|
|
30
|
+
@merged_data = {}
|
|
31
|
+
@schema_cache = {}
|
|
32
|
+
@schemas_dir = File.join(Dir.pwd, 'public/schemas')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Aggregate all hydration data from the view composition
|
|
36
|
+
def aggregate(composition)
|
|
37
|
+
log_timed_operation(Rhales.logger, :debug, 'Schema aggregation started',
|
|
38
|
+
template_count: composition.template_names.size
|
|
39
|
+
) do
|
|
40
|
+
composition.each_document_in_render_order do |template_name, parser|
|
|
41
|
+
process_template(template_name, parser)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@merged_data
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Log hydration schema mismatch using configured format
|
|
51
|
+
def log_hydration_mismatch(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
52
|
+
format = Rhales.config.hydration_mismatch_format || :compact
|
|
53
|
+
|
|
54
|
+
formatter = case format
|
|
55
|
+
when :sidebyside
|
|
56
|
+
format_sidebyside(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
57
|
+
when :multiline
|
|
58
|
+
format_multiline(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
59
|
+
when :compact
|
|
60
|
+
format_compact(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
61
|
+
when :json
|
|
62
|
+
format_json(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
63
|
+
else
|
|
64
|
+
raise ArgumentError, "Unknown hydration_mismatch_format: #{format}. Valid: :compact, :multiline, :sidebyside, :json"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Rhales.logger.warn formatter
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Format: Side-by-side comparison (most visual, for development)
|
|
71
|
+
def format_sidebyside(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
72
|
+
# Determine authority: schema (default) or data
|
|
73
|
+
authority = Rhales.config.hydration_authority || :schema
|
|
74
|
+
|
|
75
|
+
lines = []
|
|
76
|
+
lines << 'Hydration schema mismatch'
|
|
77
|
+
lines << " Template: #{template_path}"
|
|
78
|
+
lines << " Window: #{window_attr} (#{data_size} keys)"
|
|
79
|
+
lines << ''
|
|
80
|
+
|
|
81
|
+
if authority == :schema
|
|
82
|
+
# Schema is correct, data needs fixing
|
|
83
|
+
lines << ' Schema (correct) │ Data (fix)'
|
|
84
|
+
lines << ' ─────────────────┼────────────'
|
|
85
|
+
|
|
86
|
+
# Show all expected keys with their status
|
|
87
|
+
expected.each do |key|
|
|
88
|
+
lines << if actual.include?(key)
|
|
89
|
+
" #{key.ljust(17)}│ ✓ #{key}"
|
|
90
|
+
else
|
|
91
|
+
" #{key.ljust(17)}│ ✗ missing ← add to data source"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Show extra keys that shouldn't be in data
|
|
96
|
+
extra.each do |key|
|
|
97
|
+
lines << " (not in schema) │ ✗ #{key} ← remove from data source"
|
|
98
|
+
end
|
|
99
|
+
else
|
|
100
|
+
# Data is correct, schema needs fixing
|
|
101
|
+
lines << ' Schema (fix) │ Data (correct)'
|
|
102
|
+
lines << ' ─────────────────┼───────────────'
|
|
103
|
+
|
|
104
|
+
# Show all actual keys with their status
|
|
105
|
+
actual.each do |key|
|
|
106
|
+
lines << if expected.include?(key)
|
|
107
|
+
" #{key.ljust(17)}│ ✓ #{key}"
|
|
108
|
+
else
|
|
109
|
+
" [missing] │ ✓ #{key} ← add to schema"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Show keys in schema but not in data
|
|
114
|
+
missing.each do |key|
|
|
115
|
+
lines << " #{key.ljust(17)}│ (not in data) ← remove from schema"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
lines.join("\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Format: Multi-line with visual indicators (balanced)
|
|
123
|
+
def format_multiline(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
124
|
+
# Check if order changed (same keys, different positions)
|
|
125
|
+
order_changed = (expected.to_set == actual.to_set) && (expected != actual)
|
|
126
|
+
|
|
127
|
+
# Find keys that moved position
|
|
128
|
+
moved_keys = if order_changed
|
|
129
|
+
(expected & actual).select do |k|
|
|
130
|
+
expected.index(k) != actual.index(k)
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
[]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
lines = []
|
|
137
|
+
lines << 'Hydration schema mismatch'
|
|
138
|
+
lines << " Template: #{template_path}"
|
|
139
|
+
lines << " Window: #{window_attr}"
|
|
140
|
+
lines << " Data size: #{data_size}"
|
|
141
|
+
|
|
142
|
+
if missing.any?
|
|
143
|
+
lines << " ✗ Schema expects (#{missing.size}): #{missing.join(', ')}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if extra.any?
|
|
147
|
+
lines << " + Data provides (#{extra.size}): #{extra.join(', ')}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if moved_keys.any?
|
|
151
|
+
lines << " ↔ Order changed (#{moved_keys.size}): #{moved_keys.join(', ')}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
lines.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Format: Single line compact (for production)
|
|
158
|
+
def format_compact(template_path, window_attr, _expected, _actual, missing, extra, data_size)
|
|
159
|
+
parts = []
|
|
160
|
+
parts << "#{missing.size} missing" if missing.any?
|
|
161
|
+
parts << "#{extra.size} extra" if extra.any?
|
|
162
|
+
|
|
163
|
+
summary = parts.join(', ')
|
|
164
|
+
metadata = {
|
|
165
|
+
template: template_path,
|
|
166
|
+
window_attribute: window_attr,
|
|
167
|
+
missing_keys: missing,
|
|
168
|
+
extra_keys: extra,
|
|
169
|
+
client_data_size: data_size,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Use existing metadata formatter
|
|
173
|
+
metadata_str = metadata.map do |k, v|
|
|
174
|
+
"#{k}=#{format_metadata_value(v)}"
|
|
175
|
+
end.join(' ')
|
|
176
|
+
|
|
177
|
+
"Schema mismatch (#{summary}): #{metadata_str}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Format: JSON (for structured logging systems)
|
|
181
|
+
def format_json(template_path, window_attr, expected, actual, missing, extra, data_size)
|
|
182
|
+
require 'json'
|
|
183
|
+
|
|
184
|
+
authority = Rhales.config.hydration_authority || :schema
|
|
185
|
+
|
|
186
|
+
# Check if order changed
|
|
187
|
+
order_changed = (expected.to_set == actual.to_set) && (expected != actual)
|
|
188
|
+
moved_keys = if order_changed
|
|
189
|
+
(expected & actual).select { |k| expected.index(k) != actual.index(k) }
|
|
190
|
+
else
|
|
191
|
+
[]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
data = {
|
|
195
|
+
event: 'hydration_schema_mismatch',
|
|
196
|
+
template: template_path,
|
|
197
|
+
window_attribute: window_attr,
|
|
198
|
+
authority: authority,
|
|
199
|
+
schema: {
|
|
200
|
+
expected_keys: expected,
|
|
201
|
+
key_count: expected.size,
|
|
202
|
+
},
|
|
203
|
+
data: {
|
|
204
|
+
actual_keys: actual,
|
|
205
|
+
key_count: data_size,
|
|
206
|
+
},
|
|
207
|
+
diff: {
|
|
208
|
+
missing_keys: missing,
|
|
209
|
+
missing_count: missing.size,
|
|
210
|
+
extra_keys: extra,
|
|
211
|
+
extra_count: extra.size,
|
|
212
|
+
order_changed: order_changed,
|
|
213
|
+
moved_keys: moved_keys,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
JSON.generate(data)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Format values for compact metadata output
|
|
221
|
+
def format_metadata_value(value)
|
|
222
|
+
case value
|
|
223
|
+
when Array
|
|
224
|
+
if value.empty?
|
|
225
|
+
'[]'
|
|
226
|
+
else
|
|
227
|
+
"[#{value.join(', ')}]"
|
|
228
|
+
end
|
|
229
|
+
when String
|
|
230
|
+
value.include?(' ') ? "\"#{value}\"" : value
|
|
231
|
+
else
|
|
232
|
+
value.to_s
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def process_template(template_name, parser)
|
|
237
|
+
# Process schema section
|
|
238
|
+
if parser.schema_lang
|
|
239
|
+
log_timed_operation(Rhales.logger, :debug, 'Schema validation',
|
|
240
|
+
template: template_name,
|
|
241
|
+
schema_lang: parser.schema_lang
|
|
242
|
+
) do
|
|
243
|
+
process_schema_section(template_name, parser)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Process schema section: Direct JSON serialization
|
|
249
|
+
def process_schema_section(template_name, parser)
|
|
250
|
+
window_attr = parser.schema_window || 'data'
|
|
251
|
+
merge_strategy = parser.schema_merge_strategy
|
|
252
|
+
|
|
253
|
+
# Extract client data for validation
|
|
254
|
+
client_data = @context.client || {}
|
|
255
|
+
schema_content = parser.section('schema')
|
|
256
|
+
expected_keys = extract_expected_keys(template_name, schema_content) if schema_content
|
|
257
|
+
|
|
258
|
+
# Build template path for error reporting
|
|
259
|
+
template_path = build_template_path_for_schema(parser)
|
|
260
|
+
|
|
261
|
+
# Log schema validation details
|
|
262
|
+
if expected_keys && expected_keys.any?
|
|
263
|
+
actual_keys = client_data.keys.map(&:to_s)
|
|
264
|
+
missing_keys = expected_keys - actual_keys
|
|
265
|
+
extra_keys = actual_keys - expected_keys
|
|
266
|
+
|
|
267
|
+
if missing_keys.any? || extra_keys.any?
|
|
268
|
+
log_hydration_mismatch(
|
|
269
|
+
Rhales.pretty_path(template_path),
|
|
270
|
+
window_attr,
|
|
271
|
+
expected_keys,
|
|
272
|
+
actual_keys,
|
|
273
|
+
missing_keys,
|
|
274
|
+
extra_keys,
|
|
275
|
+
client_data.size,
|
|
276
|
+
)
|
|
277
|
+
else
|
|
278
|
+
log_with_metadata(Rhales.logger, :debug, 'Schema validation passed',
|
|
279
|
+
template: Rhales.pretty_path(template_path),
|
|
280
|
+
window_attribute: window_attr,
|
|
281
|
+
key_count: expected_keys.size,
|
|
282
|
+
client_data_size: client_data.size
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Direct serialization of client data (no template interpolation)
|
|
288
|
+
processed_data = @context.client
|
|
289
|
+
|
|
290
|
+
# Check for collisions only if the data is not empty
|
|
291
|
+
if @window_attributes.key?(window_attr) && merge_strategy.nil? && !empty_data?(processed_data)
|
|
292
|
+
existing = @window_attributes[window_attr]
|
|
293
|
+
existing_data = @merged_data[window_attr]
|
|
294
|
+
|
|
295
|
+
# Only raise collision error if existing data is also not empty
|
|
296
|
+
unless empty_data?(existing_data)
|
|
297
|
+
raise ::Rhales::HydrationCollisionError.new(window_attr, existing[:path], template_path)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Merge or set the data
|
|
302
|
+
@merged_data[window_attr] = if @merged_data.key?(window_attr)
|
|
303
|
+
merge_data(
|
|
304
|
+
@merged_data[window_attr],
|
|
305
|
+
processed_data,
|
|
306
|
+
merge_strategy || 'deep',
|
|
307
|
+
window_attr,
|
|
308
|
+
template_path,
|
|
309
|
+
)
|
|
310
|
+
else
|
|
311
|
+
processed_data
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Track the window attribute
|
|
315
|
+
@window_attributes[window_attr] = {
|
|
316
|
+
path: template_path,
|
|
317
|
+
merge_strategy: merge_strategy,
|
|
318
|
+
section_type: :schema,
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def merge_data(target, source, strategy, window_attr, template_path)
|
|
323
|
+
case strategy
|
|
324
|
+
when 'deep'
|
|
325
|
+
deep_merge(target, source)
|
|
326
|
+
when 'shallow'
|
|
327
|
+
shallow_merge(target, source, window_attr, template_path)
|
|
328
|
+
when 'strict'
|
|
329
|
+
strict_merge(target, source, window_attr, template_path)
|
|
330
|
+
else
|
|
331
|
+
raise ArgumentError, "Unknown merge strategy: #{strategy}"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def deep_merge(target, source)
|
|
336
|
+
result = target.dup
|
|
337
|
+
|
|
338
|
+
source.each do |key, value|
|
|
339
|
+
result[key] = if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
|
|
340
|
+
deep_merge(result[key], value)
|
|
341
|
+
else
|
|
342
|
+
value
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
result
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def shallow_merge(target, source, window_attr, template_path)
|
|
350
|
+
result = target.dup
|
|
351
|
+
|
|
352
|
+
source.each do |key, value|
|
|
353
|
+
if result.key?(key)
|
|
354
|
+
raise ::Rhales::HydrationCollisionError.new(
|
|
355
|
+
"#{window_attr}.#{key}",
|
|
356
|
+
@window_attributes[window_attr][:path],
|
|
357
|
+
template_path,
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
result[key] = value
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
result
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def strict_merge(target, source, window_attr, template_path)
|
|
367
|
+
# In strict mode, any collision is an error
|
|
368
|
+
target.each_key do |key|
|
|
369
|
+
next unless source.key?(key)
|
|
370
|
+
|
|
371
|
+
raise ::Rhales::HydrationCollisionError.new(
|
|
372
|
+
"#{window_attr}.#{key}",
|
|
373
|
+
@window_attributes[window_attr][:path],
|
|
374
|
+
template_path,
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
target.merge(source)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def build_template_path_for_schema(parser)
|
|
382
|
+
schema_node = parser.section_node('schema')
|
|
383
|
+
line_number = schema_node ? schema_node.location.start_line : 1
|
|
384
|
+
|
|
385
|
+
if parser.file_path
|
|
386
|
+
"#{parser.file_path}:#{line_number}"
|
|
387
|
+
else
|
|
388
|
+
"<inline>:#{line_number}"
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Check if data is considered empty for collision detection
|
|
393
|
+
def empty_data?(data)
|
|
394
|
+
return true if data.nil?
|
|
395
|
+
return true if data == {}
|
|
396
|
+
return true if data == []
|
|
397
|
+
return true if data.respond_to?(:empty?) && data.empty?
|
|
398
|
+
|
|
399
|
+
false
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Extract expected keys using hybrid approach
|
|
403
|
+
#
|
|
404
|
+
# Tries to load pre-generated JSON schema first (reliable, handles all Zod patterns).
|
|
405
|
+
# Falls back to regex parsing for development (before schemas are generated).
|
|
406
|
+
#
|
|
407
|
+
# To generate JSON schemas, run: rake rhales:schema:generate
|
|
408
|
+
def extract_expected_keys(template_name, schema_content)
|
|
409
|
+
# Try JSON schema first (reliable, comprehensive)
|
|
410
|
+
keys = extract_keys_from_json_schema(template_name)
|
|
411
|
+
if keys&.any?
|
|
412
|
+
log_with_metadata(Rhales.logger, :debug, 'Schema keys extracted from JSON schema',
|
|
413
|
+
template: template_name, key_count: keys.size, method: 'json_schema'
|
|
414
|
+
)
|
|
415
|
+
return keys
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Fall back to regex (development, before schemas generated)
|
|
419
|
+
keys = extract_keys_from_zod_regex(schema_content)
|
|
420
|
+
if keys.any?
|
|
421
|
+
log_with_metadata(Rhales.logger, :debug, 'Schema keys extracted from Zod regex',
|
|
422
|
+
template: template_name, key_count: keys.size, method: 'regex_fallback',
|
|
423
|
+
note: 'Run rake rhales:schema:generate for reliable validation'
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
keys
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Extract keys from pre-generated JSON schema (preferred method)
|
|
431
|
+
def extract_keys_from_json_schema(template_name)
|
|
432
|
+
schema = load_schema_cached(template_name)
|
|
433
|
+
return nil unless schema
|
|
434
|
+
|
|
435
|
+
# Extract all properties from JSON schema
|
|
436
|
+
properties = schema.dig('properties') || {}
|
|
437
|
+
properties.keys
|
|
438
|
+
rescue StandardError => ex
|
|
439
|
+
log_with_metadata(Rhales.logger, :debug, 'JSON schema loading failed',
|
|
440
|
+
template: template_name, error: ex.message
|
|
441
|
+
)
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Load and cache JSON schema from disk
|
|
446
|
+
def load_schema_cached(template_name)
|
|
447
|
+
@schema_cache[template_name] ||= begin
|
|
448
|
+
schema_path = File.join(@schemas_dir, "#{template_name}.json")
|
|
449
|
+
return nil unless File.exist?(schema_path)
|
|
450
|
+
|
|
451
|
+
JSON.parse(File.read(schema_path))
|
|
452
|
+
rescue JSON::ParserError, Errno::ENOENT => ex
|
|
453
|
+
log_with_metadata(Rhales.logger, :debug, 'Schema file error',
|
|
454
|
+
template: template_name, path: schema_path, error: ex.class.name
|
|
455
|
+
)
|
|
456
|
+
nil
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Extract keys from Zod schema using regex (fallback method)
|
|
461
|
+
#
|
|
462
|
+
# NOTE: This is a basic implementation that only matches simple patterns like:
|
|
463
|
+
# fieldName: z.string()
|
|
464
|
+
#
|
|
465
|
+
# It will miss:
|
|
466
|
+
# - Nested object literals: settings: { theme: z.enum([...]) }
|
|
467
|
+
# - Complex compositions and unions
|
|
468
|
+
# - Multiline definitions
|
|
469
|
+
#
|
|
470
|
+
# For reliable validation, generate JSON schemas with: rake rhales:schema:generate
|
|
471
|
+
def extract_keys_from_zod_regex(schema_content)
|
|
472
|
+
return [] unless schema_content
|
|
473
|
+
|
|
474
|
+
keys = []
|
|
475
|
+
schema_content.scan(/(\w+):\s*z\./) do |match|
|
|
476
|
+
keys << match[0]
|
|
477
|
+
end
|
|
478
|
+
keys
|
|
479
|
+
rescue StandardError => ex
|
|
480
|
+
log_with_metadata(Rhales.logger, :debug, 'Regex key extraction failed',
|
|
481
|
+
error: ex.message,
|
|
482
|
+
schema_preview: schema_content[0..100]
|
|
483
|
+
)
|
|
484
|
+
[]
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
# lib/rhales/hydration/hydration_endpoint.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
2
5
|
require 'digest'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
3
7
|
|
|
4
8
|
module Rhales
|
|
5
9
|
# Handles API endpoint responses for link-based hydration strategies
|
|
@@ -48,7 +52,7 @@ module Rhales
|
|
|
48
52
|
merged_data = process_template_data(template_name, additional_context)
|
|
49
53
|
|
|
50
54
|
{
|
|
51
|
-
content:
|
|
55
|
+
content: JSONSerializer.dump(merged_data),
|
|
52
56
|
content_type: 'application/json',
|
|
53
57
|
headers: json_headers(merged_data)
|
|
54
58
|
}
|
|
@@ -63,7 +67,7 @@ module Rhales
|
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
{
|
|
66
|
-
content:
|
|
70
|
+
content: JSONSerializer.dump(error_data),
|
|
67
71
|
content_type: 'application/json',
|
|
68
72
|
headers: json_headers(error_data)
|
|
69
73
|
}
|
|
@@ -78,7 +82,7 @@ module Rhales
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
{
|
|
81
|
-
content:
|
|
85
|
+
content: JSONSerializer.dump(error_data),
|
|
82
86
|
content_type: 'application/json',
|
|
83
87
|
headers: json_headers(error_data)
|
|
84
88
|
}
|
|
@@ -89,7 +93,7 @@ module Rhales
|
|
|
89
93
|
merged_data = process_template_data(template_name, additional_context)
|
|
90
94
|
|
|
91
95
|
{
|
|
92
|
-
content: "export default #{
|
|
96
|
+
content: "export default #{JSONSerializer.dump(merged_data)};",
|
|
93
97
|
content_type: 'text/javascript',
|
|
94
98
|
headers: module_headers(merged_data)
|
|
95
99
|
}
|
|
@@ -100,7 +104,7 @@ module Rhales
|
|
|
100
104
|
merged_data = process_template_data(template_name, additional_context)
|
|
101
105
|
|
|
102
106
|
{
|
|
103
|
-
content: "#{callback_name}(#{
|
|
107
|
+
content: "#{callback_name}(#{JSONSerializer.dump(merged_data)});",
|
|
104
108
|
content_type: 'application/javascript',
|
|
105
109
|
headers: jsonp_headers(merged_data),
|
|
106
110
|
}
|
|
@@ -116,7 +120,7 @@ module Rhales
|
|
|
116
120
|
def calculate_etag(template_name, additional_context = {})
|
|
117
121
|
merged_data = process_template_data(template_name, additional_context)
|
|
118
122
|
# Simple ETag based on data hash
|
|
119
|
-
Digest::MD5.hexdigest(
|
|
123
|
+
Digest::MD5.hexdigest(JSONSerializer.dump(merged_data))
|
|
120
124
|
end
|
|
121
125
|
|
|
122
126
|
private
|
|
@@ -126,7 +130,7 @@ module Rhales
|
|
|
126
130
|
template_context = create_template_context(additional_context)
|
|
127
131
|
|
|
128
132
|
# Process template to extract hydration data
|
|
129
|
-
view = View.new(@context.req,
|
|
133
|
+
view = View.new(@context.req, client: {})
|
|
130
134
|
aggregator = HydrationDataAggregator.new(template_context)
|
|
131
135
|
|
|
132
136
|
# Build composition to get template dependencies
|
|
@@ -149,11 +153,11 @@ module Rhales
|
|
|
149
153
|
def create_template_context(additional_context)
|
|
150
154
|
if @context
|
|
151
155
|
# Merge additional context into existing context by reconstructing with merged props
|
|
152
|
-
merged_props = @context.
|
|
153
|
-
@context.class.for_view(@context.req, @context.
|
|
156
|
+
merged_props = @context.client.merge(additional_context)
|
|
157
|
+
@context.class.for_view(@context.req, @context.locale, **merged_props)
|
|
154
158
|
else
|
|
155
159
|
# Create minimal context with just the additional data
|
|
156
|
-
Context.minimal(
|
|
160
|
+
Context.minimal(client: additional_context)
|
|
157
161
|
end
|
|
158
162
|
end
|
|
159
163
|
|
|
@@ -170,7 +174,7 @@ module Rhales
|
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
# Add ETag for caching
|
|
173
|
-
headers['ETag'] = %("#{Digest::MD5.hexdigest(
|
|
177
|
+
headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
|
|
174
178
|
|
|
175
179
|
headers
|
|
176
180
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# lib/rhales/hydrator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../utils/json_serializer'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Data Hydrator for RSFC client-side data injection
|
|
10
|
+
#
|
|
11
|
+
# ## RSFC Security Model: Server-to-Client Security Boundary
|
|
12
|
+
#
|
|
13
|
+
# The Hydrator enforces a critical security boundary between server and client:
|
|
14
|
+
#
|
|
15
|
+
# ### Server Side (Template Rendering)
|
|
16
|
+
# - Templates have FULL server context access (like ERB/HAML)
|
|
17
|
+
# - Can access user objects, database connections, internal APIs
|
|
18
|
+
# - Can access secrets, configuration, authentication state
|
|
19
|
+
# - Can process sensitive business logic
|
|
20
|
+
#
|
|
21
|
+
# ### Client Side (Data Hydration)
|
|
22
|
+
# - Only data declared in <schema> sections reaches the browser
|
|
23
|
+
# - Creates explicit allowlist like designing a REST API
|
|
24
|
+
# - For <schema>: Direct props serialization (no interpolation)
|
|
25
|
+
# - JSON serialization validates data structure
|
|
26
|
+
#
|
|
27
|
+
# ### Process Flow (Schema-based, preferred)
|
|
28
|
+
# 1. Backend provides fully-resolved props to render call
|
|
29
|
+
# 2. Props are directly serialized as JSON
|
|
30
|
+
# 3. Client receives only the declared props
|
|
31
|
+
#
|
|
32
|
+
# ### Example (Schema-based)
|
|
33
|
+
# ```rue
|
|
34
|
+
# <schema lang="js-zod" window="appData">
|
|
35
|
+
# const schema = z.object({
|
|
36
|
+
# user_name: z.string(),
|
|
37
|
+
# theme: z.string()
|
|
38
|
+
# });
|
|
39
|
+
# </schema>
|
|
40
|
+
# ```
|
|
41
|
+
# Backend: render('template', user_name: user.name, theme: user.theme_preference)
|
|
42
|
+
#
|
|
43
|
+
#
|
|
44
|
+
# Server template can access {{user.admin?}} and {{internal_config}},
|
|
45
|
+
# but client only gets the declared user_name and theme values.
|
|
46
|
+
#
|
|
47
|
+
# This creates an API-like boundary where data is serialized once and
|
|
48
|
+
# parsed once, enforcing the same security model as REST endpoints.
|
|
49
|
+
#
|
|
50
|
+
# Note: With the new two-pass architecture, the Hydrator's role is
|
|
51
|
+
# greatly simplified. All data merging happens server-side in the
|
|
52
|
+
# HydrationDataAggregator, so this class only handles JSON generation
|
|
53
|
+
# for individual templates (used during the aggregation phase).
|
|
54
|
+
class Hydrator
|
|
55
|
+
class HydrationError < StandardError; end
|
|
56
|
+
class JSONSerializationError < HydrationError; end
|
|
57
|
+
|
|
58
|
+
attr_reader :parser, :context, :window_attribute
|
|
59
|
+
|
|
60
|
+
def initialize(parser, context)
|
|
61
|
+
@parser = parser
|
|
62
|
+
@context = context
|
|
63
|
+
@window_attribute = parser.window_attribute || 'data'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Process <schema> section and return JSON string
|
|
67
|
+
def process_data_section
|
|
68
|
+
# Check for schema section
|
|
69
|
+
if @parser.schema_lang
|
|
70
|
+
# Schema section: Direct props serialization
|
|
71
|
+
JSONSerializer.dump(@context.client)
|
|
72
|
+
else
|
|
73
|
+
# No hydration section
|
|
74
|
+
'{}'
|
|
75
|
+
end
|
|
76
|
+
rescue JSON::ParserError => ex
|
|
77
|
+
raise JSONSerializationError, "Invalid JSON in schema section: #{ex.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get processed data as Ruby hash (for internal use)
|
|
81
|
+
def processed_data_hash
|
|
82
|
+
json_string = process_data_section
|
|
83
|
+
JSONSerializer.parse(json_string)
|
|
84
|
+
rescue JSON::ParserError => ex
|
|
85
|
+
raise JSONSerializationError, "Cannot parse processed data as JSON: #{ex.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
class << self
|
|
91
|
+
# Generate only JSON data (for testing or API endpoints)
|
|
92
|
+
def generate_json(parser, context)
|
|
93
|
+
new(parser, context).process_data_section
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Generate data hash (for internal processing)
|
|
97
|
+
def generate_data_hash(parser, context)
|
|
98
|
+
new(parser, context).processed_data_hash
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|