Files
moltbot/scripts/protocol-gen-swift.ts

561 lines
18 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
ErrorCodes,
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
ProtocolSchemas,
} from "../src/gateway/protocol/schema.js";
type JsonSchema = {
type?: string | string[];
const?: boolean | number | string | null;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
patternProperties?: Record<string, JsonSchema>;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
additionalProperties?: boolean | JsonSchema;
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const outPaths = [
path.join(
repoRoot,
"apps",
"shared",
"OpenClawKit",
"Sources",
"OpenClawProtocol",
"GatewayModels.swift",
),
];
const STRICT_LITERAL_STRUCTS = new Set([
"PluginsSessionActionSuccessResult",
"PluginsSessionActionFailureResult",
]);
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\nprivate struct GatewayAnyCodingKey: CodingKey, Hashable {\n let stringValue: String\n let intValue: Int?\n\n init?(stringValue: String) {\n self.stringValue = stringValue\n self.intValue = nil\n }\n\n init?(intValue: Int) {\n self.stringValue = String(intValue)\n self.intValue = intValue\n }\n}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(
ErrorCodes,
)
.map((c) => ` case ${camelCase(c)} = "${c}"`)
.join("\n")}\n}\n`;
const reserved = new Set([
"associatedtype",
"class",
"deinit",
"enum",
"extension",
"fileprivate",
"func",
"import",
"init",
"inout",
"internal",
"let",
"open",
"operator",
"private",
"precedencegroup",
"protocol",
"public",
"rethrows",
"static",
"struct",
"subscript",
"typealias",
"var",
]);
function camelCase(input: string) {
return input
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase()
.split(/\s+/)
.map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
.join("");
}
function safeName(name: string) {
const cc = camelCase(name.replace(/-/g, "_"));
if (/^\d/.test(cc)) {
return `_${cc}`;
}
if (reserved.has(cc)) {
return `_${cc}`;
}
return cc;
}
// filled later once schemas are loaded
const schemaNameByObject = new Map<object, string>();
const schemaNameBySignature = new Map<string, string>();
const duplicateSchemaSignatures = new Set<string>();
function stableJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stableJson);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.toSorted()
.map((key) => [key, stableJson(record[key])]),
);
}
return value;
}
function schemaSignature(schema: JsonSchema): string {
return JSON.stringify(stableJson(schema));
}
function registerNamedSchema(name: string, schema: JsonSchema): void {
schemaNameByObject.set(schema as object, name);
const signature = schemaSignature(schema);
if (duplicateSchemaSignatures.has(signature)) {
return;
}
if (schemaNameBySignature.has(signature)) {
schemaNameBySignature.delete(signature);
duplicateSchemaSignatures.add(signature);
return;
}
schemaNameBySignature.set(signature, name);
}
function namedSchema(schema: JsonSchema, allowStructuralFallback = false): string | undefined {
return (
schemaNameByObject.get(schema as object) ??
(allowStructuralFallback ? schemaNameBySignature.get(schemaSignature(schema)) : undefined)
);
}
function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed = false): string {
const t = schema.type;
const isOptional = !required;
let base: string;
const named = namedSchema(schema, allowStructuralNamed);
if (named) {
base = named;
} else if (t === "string") {
base = "String";
} else if (t === "integer") {
base = "Int";
} else if (t === "number") {
base = "Double";
} else if (t === "boolean") {
base = "Bool";
} else if (t === "array") {
base = `[${swiftType(schema.items ?? { type: "Any" }, true, true)}]`;
} else if (schema.enum) {
base = "String";
} else if (schema.patternProperties) {
base = "[String: AnyCodable]";
} else if (t === "object") {
base = "[String: AnyCodable]";
} else {
base = "AnyCodable";
}
return isOptional ? `${base}?` : base;
}
function emitEnum(name: string, schema: JsonSchema): string {
const cases = schema.enum ?? [];
return [
`public enum ${name}: String, Codable, Sendable {`,
...cases.map((value) => ` case ${safeName(value)} = "${value}"`),
"}",
"",
].join("\n");
}
function emitStruct(name: string, schema: JsonSchema): string {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const literalProps = Object.entries(props)
.map(([key, propSchema]) => ({
key,
propSchema,
literal: literalSchemaValue(propSchema),
}))
.filter(
(
entry,
): entry is {
key: string;
propSchema: JsonSchema;
literal: boolean | number | string | null;
} => entry.literal !== undefined,
);
const lines: string[] = [];
if (Object.keys(props).length === 0) {
return `public struct ${name}: Codable, Sendable {}\n`;
}
if (STRICT_LITERAL_STRUCTS.has(name) && literalProps.length > 0) {
const literalPropByKey = new Map(literalProps.map((entry) => [entry.key, entry.literal]));
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
const propName = safeName(key);
const propType = swiftType(propSchema, required.has(key), true);
lines.push(` public let ${propName}: ${propType}`);
if (propName !== key) {
codingKeys.push(` case ${propName} = "${key}"`);
} else {
codingKeys.push(` case ${propName}`);
}
}
const initializerParams = Object.entries(props)
.filter(([key]) => !literalPropByKey.has(key))
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}`;
});
lines.push(
"\n public init(\n" +
(initializerParams.length > 0 ? initializerParams.join(",\n") : " ") +
"\n )\n" +
" {\n" +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
if (literalPropByKey.has(key)) {
return ` self.${propName} = ${swiftLiteralSource(literalPropByKey.get(key)!)}`;
}
return ` self.${propName} = ${propName}`;
})
.join("\n") +
"\n }\n\n" +
" private enum CodingKeys: String, CodingKey {\n" +
codingKeys.join("\n") +
"\n }\n\n" +
" public init(from decoder: Decoder) throws {\n" +
(schema.additionalProperties === false
? ` let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)\n let unexpectedKeys = rawContainer.allKeys\n .map(\\.stringValue)\n .filter { !Set([${Object.keys(
props,
)
.map((key) => JSON.stringify(key))
.join(
", ",
)}]).contains($0) }\n if !unexpectedKeys.isEmpty {\n throw DecodingError.dataCorrupted(\n .init(\n codingPath: rawContainer.codingPath,\n debugDescription: "Unexpected keys for ${name}: \\(unexpectedKeys.sorted().joined(separator: ", "))"\n )\n )\n }\n`
: "") +
" let container = try decoder.container(keyedBy: CodingKeys.self)\n" +
Object.entries(props)
.map(([key, propSchema]) => {
const propName = safeName(key);
const capitalizedPropName = propName.slice(0, 1).toUpperCase() + propName.slice(1);
const literal = literalPropByKey.get(key);
if (literal !== undefined) {
const literalType = swiftType(propSchema, true, true);
return ` let decoded${capitalizedPropName} = try container.decode(${literalType}.self, forKey: .${propName})\n guard decoded${capitalizedPropName} == ${swiftLiteralSource(literal)} else {\n throw DecodingError.dataCorruptedError(\n forKey: .${propName},\n in: container,\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n }\n self.${propName} = ${swiftLiteralSource(literal)}`;
}
if (required.has(key)) {
return ` self.${propName} = try container.decode(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
}
return ` self.${propName} = try container.decodeIfPresent(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
})
.join("\n") +
"\n }\n\n" +
" public func encode(to encoder: Encoder) throws {\n" +
" var container = encoder.container(keyedBy: CodingKeys.self)\n" +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
const literal = literalPropByKey.get(key);
if (literal !== undefined) {
return ` try container.encode(${swiftLiteralSource(literal)}, forKey: .${propName})`;
}
if (required.has(key)) {
return ` try container.encode(${propName}, forKey: .${propName})`;
}
return ` try container.encodeIfPresent(${propName}, forKey: .${propName})`;
})
.join("\n") +
"\n }\n}",
);
lines.push("");
return lines.join("\n");
}
lines.push(`public struct ${name}: Codable, Sendable {`);
const codingKeys: string[] = [];
for (const [key, propSchema] of Object.entries(props)) {
const propName = safeName(key);
const propType = swiftType(propSchema, required.has(key), true);
lines.push(` public let ${propName}: ${propType}`);
if (propName !== key) {
codingKeys.push(` case ${propName} = "${key}"`);
} else {
codingKeys.push(` case ${propName}`);
}
}
lines.push(
"\n public init(\n" +
Object.entries(props)
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}`;
})
.join(",\n") +
")\n" +
" {\n" +
Object.entries(props)
.map(([key]) => {
const propName = safeName(key);
return ` self.${propName} = ${propName}`;
})
.join("\n") +
"\n }\n\n" +
" private enum CodingKeys: String, CodingKey {\n" +
codingKeys.join("\n") +
"\n }\n}",
);
lines.push("");
return lines.join("\n");
}
function literalSchemaValue(schema: JsonSchema): boolean | number | string | null | undefined {
if ("const" in schema) {
return schema.const;
}
if (schema.enum?.length === 1) {
return schema.enum[0] ?? undefined;
}
return undefined;
}
function swiftLiteralTypeName(value: boolean | number | string | null): string {
if (typeof value === "boolean") {
return "Bool";
}
if (typeof value === "number") {
return Number.isInteger(value) ? "Int" : "Double";
}
if (value === null) {
return "AnyCodable";
}
return "String";
}
function swiftLiteralSource(value: boolean | number | string | null): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (value === null) {
return "AnyCodable(nil)";
}
return String(value);
}
function swiftUnionCaseName(value: boolean | number | string | null, fallback: string): string {
if (typeof value === "boolean") {
return value ? "success" : "failure";
}
if (value === null) {
return fallback;
}
return safeName(String(value));
}
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
const branches = schema.oneOf ?? schema.anyOf;
if (!branches || branches.length < 2) {
return undefined;
}
const objectBranches = branches.filter((branch) => branch.type === "object");
if (objectBranches.length !== branches.length) {
return undefined;
}
const discriminatorCandidates = Object.keys(objectBranches[0]?.properties ?? {});
for (const discriminator of discriminatorCandidates) {
const cases = objectBranches.map((branch, index) => {
const discriminatorSchema = branch.properties?.[discriminator];
const literal = discriminatorSchema ? literalSchemaValue(discriminatorSchema) : undefined;
const branchName = namedSchema(branch, true);
if (literal === undefined || !branchName) {
return undefined;
}
return {
branchName,
caseName: swiftUnionCaseName(literal, `case${index + 1}`),
literal,
};
});
if (cases.some((entry) => !entry)) {
continue;
}
const resolvedCases: Array<{
branchName: string;
caseName: string;
literal: boolean | number | string | null;
}> = cases;
const [firstCase] = resolvedCases;
if (!firstCase) {
continue;
}
const literalType = swiftLiteralTypeName(firstCase.literal);
if (
resolvedCases.some((entry) => swiftLiteralTypeName(entry.literal) !== literalType) ||
new Set(resolvedCases.map((entry) => String(entry.literal))).size !== resolvedCases.length
) {
continue;
}
return [
`public enum ${name}: Codable, Sendable {`,
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
"",
" private enum CodingKeys: String, CodingKey {",
` case discriminator = "${discriminator}"`,
" }",
"",
" public init(from decoder: Decoder) throws {",
" let container = try decoder.container(keyedBy: CodingKeys.self)",
` let discriminator = try container.decode(${literalType}.self, forKey: .discriminator)`,
" switch discriminator {",
...resolvedCases.map(
(entry) =>
` case ${swiftLiteralSource(entry.literal)}: self = try .${entry.caseName}(${entry.branchName}(from: decoder))`,
),
" default:",
" throw DecodingError.dataCorruptedError(",
" forKey: .discriminator,",
" in: container,",
` debugDescription: "Unknown ${name} discriminator value"`,
" )",
" }",
" }",
"",
" public func encode(to encoder: Encoder) throws {",
" switch self {",
...resolvedCases.map(
(entry) => ` case .${entry.caseName}(let value): try value.encode(to: encoder)`,
),
" }",
" }",
"}",
"",
].join("\n");
}
return undefined;
}
function emitGatewayFrame(): string {
const cases = ["req", "res", "event"];
const associated: Record<string, string> = {
req: "RequestFrame",
res: "ResponseFrame",
event: "EventFrame",
};
const caseLines = cases.map((c) => ` case ${safeName(c)}(${associated[c]})`);
const initLines = `
private enum CodingKeys: String, CodingKey {
case type
}
public init(from decoder: Decoder) throws {
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
let type = try typeContainer.decode(String.self, forKey: .type)
switch type {
case "req":
self = try .req(RequestFrame(from: decoder))
case "res":
self = try .res(ResponseFrame(from: decoder))
case "event":
self = try .event(EventFrame(from: decoder))
default:
let container = try decoder.singleValueContainer()
let raw = try container.decode([String: AnyCodable].self)
self = .unknown(type: type, raw: raw)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case let .req(v):
try v.encode(to: encoder)
case let .res(v):
try v.encode(to: encoder)
case let .event(v):
try v.encode(to: encoder)
case let .unknown(_, raw):
var container = encoder.singleValueContainer()
try container.encode(raw)
}
}
`;
return [
"public enum GatewayFrame: Codable, Sendable {",
...caseLines,
" case unknown(type: String, raw: [String: AnyCodable])",
initLines.trimEnd(),
"}",
"",
].join("\n");
}
async function generate() {
const definitions = Object.entries(ProtocolSchemas) as Array<[string, JsonSchema]>;
for (const [name, schema] of definitions) {
registerNamedSchema(name, schema);
}
const parts: string[] = [];
parts.push(header);
// Named enums and value structs
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
if (schema.type === "string" && schema.enum) {
parts.push(emitEnum(name, schema));
}
}
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
if (schema.type === "object") {
parts.push(emitStruct(name, schema));
}
}
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
const union = emitDiscriminatedUnion(name, schema);
if (union) {
parts.push(union);
}
}
// Frame enum must come after payload structs
parts.push(emitGatewayFrame());
const content = parts.join("\n");
for (const outPath of outPaths) {
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, content);
console.log(`wrote ${outPath}`);
}
}
generate().catch((err) => {
console.error(err);
process.exit(1);
});