import assert from 'node:assert/strict'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { execFileSync } from 'node:child_process'; type Schema = { $ref?: string; type?: string | string[]; const?: string | number; enum?: string[]; properties?: Record; required?: string[]; additionalProperties?: boolean | Schema; items?: Schema; }; const root = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8')) as { $defs: Record }; const defs = root.$defs; const ref = (s: Schema) => s.$ref?.split('/').at(-1); const namedObject = (s: Schema) => ref(s) && !!defs[ref(s)!]?.properties; function type(s: Schema, lang: 'ts' | 'go' | 'dart'): string { if (s.$ref) return ref(s)!; if (Array.isArray(s.type)) { if (s.type.includes('null')) { const inner = type({ ...s, type: s.type.filter(v => v !== 'null')[0] }, lang); return lang === 'ts' ? `${inner} | null` : lang === 'go' ? `*${inner}` : `${inner}?`; } return lang === 'ts' ? s.type.map(t => type({ type: t }, lang)).join(' | ') : lang === 'go' ? 'any' : 'Object'; } if (lang === 'ts' && s.const !== undefined) return JSON.stringify(s.const); if (lang === 'ts' && s.enum) return s.enum.map(v => JSON.stringify(v)).join(' | '); switch (s.type) { case 'string': return lang === 'dart' ? 'String' : 'string'; case 'integer': return lang === 'go' ? 'int64' : lang === 'dart' ? 'int' : 'number'; case 'number': return lang === 'go' ? 'float64' : lang === 'dart' ? 'num' : 'number'; case 'boolean': return lang === 'ts' ? 'boolean' : 'bool'; case 'array': return lang === 'go' ? `[]${type(s.items!, lang)}` : lang === 'dart' ? `List<${type(s.items!, lang)}>` : `Array<${type(s.items!, lang)}>`; case 'object': assert.ok(s.additionalProperties && typeof s.additionalProperties === 'object', 'Unnamed object needs typed additionalProperties'); return lang === 'go' ? `map[string]${type(s.additionalProperties, lang)}` : lang === 'dart' ? `Map` : `Record`; default: throw new Error(`Unsupported schema type ${s.type}`); } } function goName(name: string) { return name.replace(/^id$/, 'ID').replace(/Id$/, 'ID').replace(/^./, c => c.toUpperCase()); } function dartDecode(s: Schema, value: string): string { if (Array.isArray(s.type) && s.type.includes('null')) return `${value} == null ? null : ${dartDecode({ ...s, type: s.type.find(t => t !== 'null') }, value)}`; if (s.$ref) return namedObject(s) ? `${ref(s)}.fromJson(_object(${value}))` : dartDecode(defs[ref(s)!]!, value); if (Array.isArray(s.type)) return `_argument(${value})`; if (s.type === 'array') return `(${value} as List).map((item) => ${dartDecode(s.items!, 'item')}).toList()`; if (s.type === 'object') return `_object(${value}).map((key, item) => MapEntry(key, ${dartDecode(s.additionalProperties as Schema, 'item')}))`; return `${value} as ${type(s, 'dart')}`; } function dartEncode(s: Schema, value: string): string { if (namedObject(s)) return `${value}.toJson()`; if (s.type === 'array' && namedObject(s.items!)) return `${value}.map((item) => item.toJson()).toList()`; return value; } let ts = '// Generated from ../schema.json. Edit the schema and run npm run contracts:generate.\n\n'; let go = '// Code generated from ../schema.json; DO NOT EDIT.\npackage shared\n\n'; let dart = '// Generated from ../schema.json. Do not edit.\n// Structural DTOs; schema and semantic validation are separate.\n\n'; for (const [name, schema] of Object.entries(defs)) { if (!schema.properties) { ts += `export type ${name} = ${type(schema, 'ts')};\n\n`; go += `type ${name} ${type(schema, 'go')}\n\n`; dart += `typedef ${name} = ${type(schema, 'dart')};\n\n`; continue; } const fields = Object.entries(schema.properties); const required = new Set(schema.required); ts += `export interface ${name} {\n${fields.map(([key,s]) => ` ${key}${required.has(key) ? '' : '?'}: ${type(s,'ts')};`).join('\n')}\n}\n\n`; go += `type ${name} struct {\n${fields.map(([key,s]) => `\t${goName(key)} ${required.has(key) ? '' : '*'}${type(s,'go')} \x60json:"${key}${required.has(key) ? '' : ',omitempty'}"\x60`).join('\n')}\n}\n\n`; dart += `class ${name} {\n${fields.map(([key,s]) => ` final ${type(s,'dart')}${required.has(key) ? '' : '?'} ${key};`).join('\n')}\n`; dart += ` const ${name}({${fields.map(([key]) => `${required.has(key) ? 'required ' : ''}this.${key}`).join(', ')}});\n`; dart += ` factory ${name}.fromJson(Map json) => ${name}(\n${fields.map(([key,s]) => ` ${key}: ${required.has(key) ? dartDecode(s, `_required(json, '${key}')`) : `json['${key}'] == null ? null : ${dartDecode(s, `json['${key}']`)}`},`).join('\n')}\n );\n`; dart += ` Map toJson() => {\n${fields.map(([key,s]) => ` ${required.has(key) ? '' : `if (${key} != null) `}'${key}': ${dartEncode(s,key)},`).join('\n')}\n };\n}\n\n`; } dart += `dynamic _required(Map json, String key) {\n if (!json.containsKey(key)) throw FormatException('Missing field: $key');\n return json[key];\n}\nMap _object(dynamic value) => Map.from(value as Map);\nObject _argument(dynamic value) {\n if (value is String || value is bool || (value is num && value.isFinite)) return value as Object;\n throw const FormatException('Invalid message argument');\n}\n`; go = execFileSync('gofmt', { input: go, encoding: 'utf8' }); const outputs = { 'ts/models.generated.ts': ts.trimEnd() + '\n', 'go/models.gen.go': go.trimEnd() + '\n', 'dart/models.g.dart': dart.trimEnd() + '\n' }; if (process.argv.includes('--print')) { console.log(JSON.stringify(outputs)); } else { for (const [file, value] of Object.entries(outputs)) { const url = new URL(file, import.meta.url); if (process.argv.includes('--write')) { await mkdir(new URL('.', url), { recursive: true }); await writeFile(url, value); } assert.equal(await readFile(url, 'utf8'), value, `Generated DTO drift: ${file}`); } console.log(`${Object.keys(defs).length} shared models: TypeScript, Go and Dart bindings match schema.json.`); }