You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
87 lines
6.1 KiB
87 lines
6.1 KiB
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<string, Schema>; required?: string[];
|
|
additionalProperties?: boolean | Schema; items?: Schema;
|
|
};
|
|
const root = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8')) as { $defs: Record<string, Schema> };
|
|
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<String, ${type(s.additionalProperties, lang)}>` : `Record<string, ${type(s.additionalProperties, lang)}>`;
|
|
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json, String key) {\n if (!json.containsKey(key)) throw FormatException('Missing field: $key');\n return json[key];\n}\nMap<String, dynamic> _object(dynamic value) => Map<String, dynamic>.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.`);
|
|
}
|