Class: Rhales::SchemaGenerator
- Inherits:
-
Object
- Object
- Rhales::SchemaGenerator
- Defined in:
- lib/rhales/utils/schema_generator.rb
Overview
Generates JSON Schemas from Zod schemas using TypeScript execution
This class uses pnpm exec tsx to execute Zod schema code and convert it to JSON Schema format. The generated schemas are saved to disk for use by the validation middleware.
Usage: generator = SchemaGenerator.new( templates_dir: ‘./templates’, output_dir: ‘./public/schemas’ ) results = generator.generate_all
Defined Under Namespace
Classes: GenerationError
Instance Attribute Summary collapse
-
#output_dir ⇒ Object
readonly
Returns the value of attribute output_dir.
-
#templates_dir ⇒ Object
readonly
Returns the value of attribute templates_dir.
Instance Method Summary collapse
-
#build_typescript_import_script(schema_info) ⇒ Array<String, String>
private
Build TypeScript script from bundled external schema.
-
#build_typescript_script(schema_info) ⇒ Object
private
-
#ensure_output_directory! ⇒ Object
private
-
#execute_tsx(script_path) ⇒ Array
private
Execute tsx with optional tsconfig.
-
#generate_all ⇒ Hash
Generate JSON Schemas for all templates with
sections. -
#generate_schema(schema_info) ⇒ Hash
Generate JSON Schema for a single template.
-
#initialize(templates_dir:, output_dir: nil) ⇒ SchemaGenerator
constructor
A new instance of SchemaGenerator.
-
#path_to_file_url(path) ⇒ String
private
Convert absolute path to file:// URL for cross-platform ESM imports.
-
#save_schema(template_name, json_schema) ⇒ Object
private
-
#use_tsx_import_mode?(schema_info) ⇒ Boolean
private
Determine if we should use tsx import mode for this schema.
-
#validate_setup! ⇒ Object
private
Constructor Details
#initialize(templates_dir:, output_dir: nil) ⇒ SchemaGenerator
Returns a new instance of SchemaGenerator.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/rhales/utils/schema_generator.rb', line 33 def initialize(templates_dir:, output_dir: nil) @templates_dir = File.(templates_dir) # Smart default: place schemas in public/schemas relative to current working directory # This ensures schemas are generated in the implementing project, not the gem directory @output_dir = if output_dir File.(output_dir) else # Default to public/schemas in current working directory File.('./public/schemas') end validate_setup! ensure_output_directory! end |
Instance Attribute Details
#output_dir ⇒ Object (readonly)
Returns the value of attribute output_dir.
28 29 30 |
# File 'lib/rhales/utils/schema_generator.rb', line 28 def output_dir @output_dir end |
#templates_dir ⇒ Object (readonly)
Returns the value of attribute templates_dir.
28 29 30 |
# File 'lib/rhales/utils/schema_generator.rb', line 28 def templates_dir @templates_dir end |
Instance Method Details
#build_typescript_import_script(schema_info) ⇒ Array<String, String> (private)
Build TypeScript script from bundled external schema
Uses esbuild to bundle the external schema with all imports resolved, writes to a temp file, then imports via default export. This allows external files to name their schema variable anything they want.
External schema files must use default export: const mySchema = z.object({ … }); export default mySchema;
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 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 |
# File 'lib/rhales/utils/schema_generator.rb', line 182 def build_typescript_import_script(schema_info) safe_name = schema_info[:template_name].gsub("'", "\\'") schema_path = schema_info[:resolved_path] # Bundle external schema with esbuild to temp file - resolves all imports # Use SecureRandom for uniqueness across concurrent invocations temp_dir = File.join(Dir.pwd, 'tmp') FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir) unique_suffix = "#{Process.pid}_#{SecureRandom.hex(4)}" bundled_file = File.join(temp_dir, "bundled_#{File.basename(schema_path, '.*')}_#{unique_suffix}.mjs") stdout, stderr, status = Open3.capture3( 'pnpm', 'exec', 'esbuild', schema_path, '--bundle', '--format=esm', '--platform=node', '--external:zod', "--outfile=#{bundled_file}" ) unless status.success? raise GenerationError, "esbuild bundling failed for #{schema_path}: #{stderr}" end # Convert to file:// URL for cross-platform ESM import compatibility bundled_file_url = path_to_file_url(bundled_file) script = <<~TYPESCRIPT // Auto-generated schema generator for #{safe_name} // Source: #{schema_info[:src]} (bundled via esbuild) import { z } from 'zod/v4'; import schema from '#{bundled_file_url}'; // Generate JSON Schema try { const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', cycles: 'ref', reused: 'inline', }); // Add metadata const schemaWithMeta = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: `https://rhales.dev/schemas/#{safe_name}.json`, title: '#{safe_name}', description: 'Schema for #{safe_name} template', ...jsonSchema, }; // Output JSON to stdout console.log(JSON.stringify(schemaWithMeta, null, 2)); } catch (error) { console.error('Schema generation error:', error.message); process.exit(1); } TYPESCRIPT [script, bundled_file] end |
#build_typescript_script(schema_info) ⇒ Object (private)
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/rhales/utils/schema_generator.rb', line 242 def build_typescript_script(schema_info) # Escape single quotes in template name for TypeScript string safe_name = schema_info[:template_name].gsub("'", "\\'") source_comment = if schema_info[:src] "// Source: #{schema_info[:src]} (external)" else "// Source: inline schema" end <<~TYPESCRIPT // Auto-generated schema generator for #{safe_name} #{source_comment} import { z } from 'zod/v4'; // Schema code from .rue template #{schema_info[:schema_code].strip} // Generate JSON Schema try { const jsonSchema = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', cycles: 'ref', reused: 'inline', }); // Add metadata const schemaWithMeta = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: `https://rhales.dev/schemas/#{safe_name}.json`, title: '#{safe_name}', description: 'Schema for #{safe_name} template', ...jsonSchema, }; // Output JSON to stdout console.log(JSON.stringify(schemaWithMeta, null, 2)); } catch (error) { console.error('Schema generation error:', error.message); process.exit(1); } TYPESCRIPT end |
#ensure_output_directory! ⇒ Object (private)
321 322 323 |
# File 'lib/rhales/utils/schema_generator.rb', line 321 def ensure_output_directory! FileUtils.mkdir_p(@output_dir) unless File.directory?(@output_dir) end |
#execute_tsx(script_path) ⇒ Array (private)
Execute tsx with optional tsconfig
155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/rhales/utils/schema_generator.rb', line 155 def execute_tsx(script_path) tsconfig_path = Rhales.configuration.schema_tsconfig_path if tsconfig_path if File.exist?(tsconfig_path) Open3.capture3('pnpm', 'exec', 'tsx', '--tsconfig', tsconfig_path, script_path) else warn "[Rhales] Warning: schema_tsconfig_path '#{tsconfig_path}' does not exist, ignoring" Open3.capture3('pnpm', 'exec', 'tsx', script_path) end else Open3.capture3('pnpm', 'exec', 'tsx', script_path) end end |
#generate_all ⇒ Hash
Generate JSON Schemas for all templates with
52 53 54 55 56 57 58 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 |
# File 'lib/rhales/utils/schema_generator.rb', line 52 def generate_all extractor = SchemaExtractor.new(@templates_dir) schemas = extractor.extract_all if schemas.empty? return { success: true, generated: 0, failed: 0, message: 'No schemas found in templates' } end results = { success: true, generated: 0, failed: 0, errors: [] } schemas.each do |schema_info| begin generate_schema(schema_info) results[:generated] += 1 puts "✓ Generated schema for: #{schema_info[:template_name]}" rescue => e results[:failed] += 1 results[:success] = false source_info = schema_info[:src] ? " (from #{schema_info[:src]})" : "" error_msg = "Failed to generate schema for #{schema_info[:template_name]}#{source_info}: #{e.}" results[:errors] << error_msg warn error_msg end end results end |
#generate_schema(schema_info) ⇒ Hash
Generate JSON Schema for a single template
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/rhales/utils/schema_generator.rb', line 94 def generate_schema(schema_info) # Create temp file in project directory so Node.js can resolve modules temp_dir = File.join(Dir.pwd, 'tmp') FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir) temp_file = Tempfile.new(['schema', '.mts'], temp_dir) bundled_file = nil begin # Write TypeScript script - use import mode for external schemas when configured if use_tsx_import_mode?(schema_info) script, bundled_file = build_typescript_import_script(schema_info) else script = build_typescript_script(schema_info) end temp_file.write(script) temp_file.close # Execute with tsx via pnpm, optionally with tsconfig stdout, stderr, status = execute_tsx(temp_file.path) unless status.success? raise GenerationError, "TypeScript execution failed: #{stderr}" end # Parse JSON Schema from stdout json_schema = JSONSerializer.parse(stdout) # Save to disk save_schema(schema_info[:template_name], json_schema) json_schema ensure temp_file.unlink if temp_file File.unlink(bundled_file) if bundled_file && File.exist?(bundled_file) end end |
#path_to_file_url(path) ⇒ String (private)
Convert absolute path to file:// URL for cross-platform ESM imports
On Windows, paths like C:\foo\bar need to become file:///C:/foo/bar On Unix, paths like /foo/bar become file:///foo/bar
332 333 334 335 336 337 338 339 |
# File 'lib/rhales/utils/schema_generator.rb', line 332 def path_to_file_url(path) normalized = path.tr('\\', '/') if normalized.match?(%r{^[A-Za-z]:}) # Windows drive letter "file:///#{normalized}" else "file://#{normalized}" end end |
#save_schema(template_name, json_schema) ⇒ Object (private)
286 287 288 289 290 291 292 293 |
# File 'lib/rhales/utils/schema_generator.rb', line 286 def save_schema(template_name, json_schema) # Create subdirectories if template name contains paths schema_file = File.join(@output_dir, "#{template_name}.json") schema_dir = File.dirname(schema_file) FileUtils.mkdir_p(schema_dir) unless File.directory?(schema_dir) File.write(schema_file, JSONSerializer.dump(json_schema)) end |
#use_tsx_import_mode?(schema_info) ⇒ Boolean (private)
Determine if we should use tsx import mode for this schema
Import mode is used when: 1. schema_use_tsx_import is enabled in configuration 2. The schema has an external src (not inline) 3. The resolved_path exists
143 144 145 146 147 148 149 |
# File 'lib/rhales/utils/schema_generator.rb', line 143 def use_tsx_import_mode?(schema_info) return false unless Rhales.configuration.schema_use_tsx_import return false unless schema_info[:src] return false unless schema_info[:resolved_path] File.exist?(schema_info[:resolved_path]) end |
#validate_setup! ⇒ Object (private)
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/rhales/utils/schema_generator.rb', line 295 def validate_setup! unless File.directory?(@templates_dir) raise GenerationError, "Templates directory does not exist: #{@templates_dir}" end # Check pnpm is available stdout, stderr, status = Open3.capture3('pnpm', '--version') unless status.success? raise GenerationError, "pnpm not found. Install pnpm to generate schemas: npm install -g pnpm" end # Check tsx is available (will be installed by pnpm if needed) stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'tsx', '--version') unless status.success? raise GenerationError, "tsx not found. Run: pnpm install tsx --save-dev" end # Check esbuild is available when tsx import mode is enabled if Rhales.configuration.schema_use_tsx_import stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'esbuild', '--version') unless status.success? raise GenerationError, "esbuild not found (required for external schema bundling). Run: pnpm install esbuild --save-dev" end end end |