openclaw/scripts/protocol-gen-swift.ts
Peter Steinberger d6e4bd48c3
build(apps): generate native protocol models at build time (#162251)
* build(apps): generate native protocol models at build time

* test(apps): include native preparation in CI build contract

* fix(apps): share cached protocol generation across Apple destinations

* fix(build): type native protocol naming helpers

* fix(build): preserve native protocol tooling consumers

* fix(tooling): scope native protocol build audit roots

* fix(build): prepare native protocol in runtime-only builds

* fix(build): preserve native preparation package and workflow contracts
2026-10-01 11:43:36 -07:00

891 lines
32 KiB
TypeScript

import { ErrorCodes } from "../packages/gateway-protocol/src/schema/error-codes.js";
import { ProtocolSchemas } from "../packages/gateway-protocol/src/schema/protocol-schemas.js";
import {
MIN_CLIENT_PROTOCOL_VERSION,
MIN_NODE_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../packages/gateway-protocol/src/version.js";
import { type JsonSchema, schemaSignature } from "./lib/protocol-codegen-schema.js";
const STRICT_LITERAL_STRUCTS = new Set([
"PluginsSessionActionSuccessResult",
"PluginsSessionActionFailureResult",
"SessionMoveGatewayTarget",
"SessionMoveProfileTarget",
"SessionMoveDeviceTarget",
]);
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}\npublic let GATEWAY_MIN_NODE_PROTOCOL_VERSION = ${MIN_NODE_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.charAt(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;
}
// Canonical initializer labels must match stored properties; compatibility initializers
// declare legacy labels separately.
function swiftStoredPropertyName(structName: string, key: string): string {
if (structName === "WizardStartParams" && key === "installDaemon") {
return "installDaemon";
}
if (structName === "ChatSendParams" && key === "fastMode") {
return "fastmodevalue";
}
if (structName === "AgentsUpdateParams" && key === "model") {
return "modelvalue";
}
if (structName === "ChatSendParams" && key === "fast_seconds") {
return "fastseconds";
}
return safeName(key);
}
function swiftCompatibilityPropertyLines(structName: string, key: string): string[] {
if (structName === "ChatSendParams" && key === "fastMode") {
return [" public var fastmode: Bool? { fastmodevalue?.value as? Bool }"];
}
if (structName === "AgentsUpdateParams" && key === "model") {
return [" public var model: String? { modelvalue?.value as? String }"];
}
return [];
}
// filled later once schemas are loaded
const schemaNameByObject = new Map<object, string>();
const schemaNameBySignature = new Map<string, string | undefined>();
const schemaNamesByIdentity = new Map<symbol, Map<string, string | undefined>>();
// These names already appear in generated public field types. Registry ordering
// must not choose a different nominal type when schemas share the same object.
const CANONICAL_SCHEMA_ALIASES = new Set([
"ArtifactsDownloadParams",
"DevicePairSetupDeliveryUncertainEvent",
"GatewaySuspendResumeParams",
"ProgressCardPutResult",
"ProjectsAddResult",
"SessionDiscussionOpenResult",
"SessionMemberRemoveParams",
"UsersAuthConnectCancelParams",
"WizardStartResult",
"WizardStatusParams",
]);
function resolveSchemaObjectAliases(
definitions: Array<[string, JsonSchema]>,
): Map<JsonSchema, string> {
const aliases = new Map<JsonSchema, string[]>();
for (const [name, schema] of definitions) {
const names = aliases.get(schema) ?? [];
names.push(name);
aliases.set(schema, names);
}
const result = new Map<JsonSchema, string>();
for (const [schema, names] of aliases) {
if (names.length === 1) {
continue;
}
const [preferred, duplicate] = names.filter((name) => CANONICAL_SCHEMA_ALIASES.has(name));
if (preferred === undefined || duplicate !== undefined) {
throw new Error(`Choose one canonical Swift schema name for aliases: ${names.join(", ")}`);
}
result.set(schema, preferred);
}
return result;
}
function registerNamedSchema(name: string, schema: JsonSchema, objectName: string): void {
schemaNameByObject.set(schema, objectName);
const signature = schemaSignature(schema);
registerUniqueName(schemaNameBySignature, signature, name);
const identity = schema["~openclawClosedObjectIdentity"];
if (identity) {
const names = schemaNamesByIdentity.get(identity) ?? new Map<string, string | undefined>();
registerUniqueName(names, signature, name);
schemaNamesByIdentity.set(identity, names);
}
}
function registerUniqueName(
names: Map<string, string | undefined>,
signature: string,
name: string,
) {
names.set(signature, names.has(signature) ? undefined : name);
}
function namedSchema(
schema: JsonSchema,
allowStructuralFallback = false,
identity?: symbol,
): string | undefined {
return (
schemaNameByObject.get(schema) ??
(identity
? schemaNamesByIdentity.get(identity)?.get(schemaSignature(schema))
: allowStructuralFallback
? schemaNameBySignature.get(schemaSignature(schema))
: undefined)
);
}
function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed = false): string {
const schemaTypes = schema.type;
const normalizedSchema =
!required &&
Array.isArray(schemaTypes) &&
schemaTypes.length === 2 &&
schemaTypes.includes("null")
? {
...schema,
type: schemaTypes.find((type) => type !== "null"),
enum: schema.enum?.filter((value) => value !== null),
anyOf: schema.anyOf?.filter((branch) => branch.type !== "null"),
oneOf: schema.oneOf?.filter((branch) => branch.type !== "null"),
}
: schema;
const nullableTypeArray = normalizedSchema !== schema;
const t = normalizedSchema.type;
const isOptional = !required;
let base: string;
// Normalization spreads the schema, so retain its hidden identity before copying.
const identity = schema["~openclawClosedObjectIdentity"];
let named = namedSchema(normalizedSchema, allowStructuralNamed, identity);
if (!named && nullableTypeArray && (normalizedSchema.anyOf || normalizedSchema.oneOf)) {
const { type: _normalizedType, ...normalizedStructuralSchema } = normalizedSchema;
named = namedSchema(normalizedStructuralSchema, allowStructuralNamed, identity);
}
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(normalizedSchema.items ?? { type: "Any" }, true, true)}]`;
} else if (normalizedSchema.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 stringEnumCases(schema: JsonSchema): string[] | undefined {
if (schema.type === "string" && schema.enum) {
const cases = schema.enum.filter((value): value is string => typeof value === "string");
return cases.length === schema.enum.length ? cases : undefined;
}
const variants = schema.oneOf ?? schema.anyOf;
if (!variants?.length) {
return undefined;
}
const cases = variants
.map((variant) => literalSchemaValue(variant))
.filter((value): value is string => typeof value === "string");
return cases.length === variants.length ? cases : undefined;
}
function swiftInitializerParam(params: {
name: string;
schema: JsonSchema;
required: boolean;
allowStructuralNamed?: boolean;
}): string {
const type = swiftType(params.schema, params.required, params.allowStructuralNamed ?? true);
return params.required ? `${params.name}: ${type}` : `${params.name}: ${type} = nil`;
}
function emitEnum(name: string, schema: JsonSchema): string {
const cases = stringEnumCases(schema) ?? [];
return [
`public enum ${name}: String, Codable, Sendable {`,
...cases.map((value) => ` case ${safeName(value)} = "${value}"`),
"}",
"",
].join("\n");
}
function stringLiteralUnionValues(schema: JsonSchema): string[] | undefined {
const branches = schema.oneOf ?? schema.anyOf;
if (!branches || branches.length < 2) {
return undefined;
}
const values = branches.map((branch) => literalSchemaValue(branch));
if (values.some((value) => typeof value !== "string")) {
return undefined;
}
const stringValues = values as string[];
return new Set(stringValues).size === stringValues.length ? stringValues : undefined;
}
function emitStruct(
name: string,
schema: JsonSchema,
strictLiterals = STRICT_LITERAL_STRUCTS.has(name),
): string {
const props = schema.properties ?? {};
const required = new Set(schema.required ?? []);
const literalPropByKey = new Map(
Object.entries(props).flatMap(([key, propSchema]) => {
const literal = literalSchemaValue(propSchema);
return literal === undefined ? [] : [[key, literal] as const];
}),
);
if (Object.keys(props).length === 0) {
return `public struct ${name}: Codable, Sendable {}\n`;
}
const strict = strictLiterals && literalPropByKey.size > 0;
const properties = Object.entries(props).map(([key, propertySchema]) => ({
key,
schema: propertySchema,
name: strict ? safeName(key) : swiftStoredPropertyName(name, key),
required: required.has(key),
}));
const lines = [`public struct ${name}: Codable, Sendable {`];
const codingKeys = properties.map((property) =>
property.name === property.key
? ` case ${property.name}`
: ` case ${property.name} = "${property.key}"`,
);
for (const property of properties) {
lines.push(
` public let ${property.name}: ${swiftType(property.schema, property.required, true)}`,
);
if (!strict) {
lines.push(...swiftCompatibilityPropertyLines(name, property.key));
}
}
if (strict) {
const initializerParams = properties
.filter((property) => !literalPropByKey.has(property.key) || !property.required)
.map((property) => ` ${swiftInitializerParam(property)}`);
const initializerDeclaration =
initializerParams.length > 0
? `\n public init(\n${initializerParams.join(",\n")}\n )\n {\n`
: "\n public init()\n {\n";
lines.push(
initializerDeclaration +
properties
.map(({ key, name: propName, required: isRequired }) => {
if (literalPropByKey.has(key) && isRequired) {
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);
const decodedName = `decoded${capitalizedPropName}`;
const decode = `try container.decode(${literalType}.self, forKey: .${propName})`;
// Optional literals retain presence; decodeIfPresent would silently accept forbidden null.
const decodedValue = required.has(key)
? decode
: `container.contains(.${propName})\n ? ${decode}\n : nil`;
const absent = required.has(key) ? "" : `${decodedName} == nil || `;
return ` let ${decodedName} = ${decodedValue}\n guard ${absent}${decodedName} == ${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} = ${required.has(key) ? swiftLiteralSource(literal) : decodedName}`;
}
const decode = required.has(key) ? "decode" : "decodeIfPresent";
return ` self.${propName} = try container.${decode}(${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 && required.has(key)) {
return ` try container.encode(${swiftLiteralSource(literal)}, forKey: .${propName})`;
}
if (required.has(key)) {
return ` try container.encode(${propName}, forKey: .${propName})`;
}
const validation =
literal === undefined
? ""
: ` if let ${propName}, ${propName} != ${swiftLiteralSource(literal)} {\n throw EncodingError.invalidValue(\n ${propName},\n .init(\n codingPath: container.codingPath + [CodingKeys.${propName}],\n debugDescription: "Expected ${key} to equal ${String(literal)}"\n )\n )\n }\n`;
return `${validation} try container.encodeIfPresent(${propName}, forKey: .${propName})`;
})
.join("\n") +
"\n }\n}",
);
lines.push("");
return lines.join("\n");
}
const needsCodingKeys = properties.some((property) => property.name !== property.key);
const customCodable = emitStructCustomCodable(name, props, required);
lines.push(
"\n public init(\n" +
properties
.map((property) => {
if (name === "AgentsUpdateParams" && property.key === "model") {
// Keep the raw nullable value explicit so the source-compatible initializer stays
// unambiguous when callers omit model.
return " modelvalue: AnyCodable?";
}
return ` ${swiftInitializerParam(property)}`;
})
.join(",\n") +
")\n" +
" {\n" +
properties.map((property) => ` self.${property.name} = ${property.name}`).join("\n") +
"\n }" +
emitStructCompatibilityInitializer(name, props, required) +
(needsCodingKeys || customCodable.length > 0
? "\n\n private enum CodingKeys: String, CodingKey {\n" +
codingKeys.join("\n") +
"\n }"
: "") +
customCodable +
"\n}",
);
lines.push("");
return lines.join("\n");
}
function emitBoardEventParamsModels(schema: JsonSchema): string {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const legacy = variants.find(
(variant) =>
variant.type === "object" &&
variant.properties?.sessionKey &&
variant.properties.widget &&
variant.properties.payload,
);
const ticket = variants.find(
(variant) =>
variant.type === "object" && variant.properties?.ticket && variant.properties.payload,
);
if (!legacy || !ticket) {
throw new Error("BoardEventParams must retain legacy and ticket object variants");
}
// BoardEventParams shipped as the legacy struct before the schema became a
// union. Preserve that source API and expose the ticket form separately.
return `${emitStruct("BoardEventParams", legacy)}\n${emitStruct("BoardTicketEventParams", ticket)}`;
}
function emitStructCustomCodable(
name: string,
props: Record<string, JsonSchema>,
required: Set<string>,
): string {
if (name !== "AgentsUpdateParams" || !props.model) {
return "";
}
const decodedProperties = Object.entries(props).map(([key, propSchema]) => {
const propName = swiftStoredPropertyName(name, key);
if (key === "model") {
// decodeIfPresent collapses an explicit JSON null into nil. Presence-aware decoding
// preserves the Gateway patch distinction between clearing and omitting the model.
return ` self.${propName} = container.contains(.${propName})\n ? try container.decode(AnyCodable.self, forKey: .${propName})\n : nil`;
}
const decode = required.has(key) ? "decode" : "decodeIfPresent";
return ` self.${propName} = try container.${decode}(${swiftType(propSchema, true, true)}.self, forKey: .${propName})`;
});
const encodedProperties = Object.keys(props).map((key) => {
const propName = swiftStoredPropertyName(name, key);
const encode = required.has(key) ? "encode" : "encodeIfPresent";
return ` try container.${encode}(${propName}, forKey: .${propName})`;
});
return (
"\n\n public init(from decoder: Decoder) throws {\n" +
" let container = try decoder.container(keyedBy: CodingKeys.self)\n" +
decodedProperties.join("\n") +
"\n }\n\n" +
" public func encode(to encoder: Encoder) throws {\n" +
" var container = encoder.container(keyedBy: CodingKeys.self)\n" +
encodedProperties.join("\n") +
"\n }"
);
}
function emitStructCompatibilityInitializer(
name: string,
props: Record<string, JsonSchema>,
required: Set<string>,
): string {
const compatibility =
name === "AgentsUpdateParams" && props.model
? { key: "model", parameter: "model: String? = nil", value: "model", omitted: [] }
: name === "ChatSendParams" && props.fastMode
? {
key: "fastMode",
parameter: "fastmode: Bool?",
value: "fastmode",
omitted: ["fastAutoOnSeconds", "fast_seconds"],
}
: undefined;
if (!compatibility) {
return "";
}
const initializerParams = Object.entries(props)
.filter(([key]) => !compatibility.omitted.includes(key))
.map(
([key, schema]) =>
` ${
key === compatibility.key
? compatibility.parameter
: swiftInitializerParam({
name: swiftStoredPropertyName(name, key),
schema,
required: required.has(key),
})
}`,
);
const delegatedArgs = Object.keys(props).map((key) => {
const propName = swiftStoredPropertyName(name, key);
const value =
key === compatibility.key
? `${compatibility.value}.map { AnyCodable($0) }`
: compatibility.omitted.includes(key)
? "nil"
: propName;
return ` ${propName}: ${value}`;
});
return (
"\n\n public init(\n" +
initializerParams.join(",\n") +
")\n" +
" {\n" +
" self.init(\n" +
delegatedArgs.join(",\n") +
")\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 emitDiscriminatedUnionCompatibility(
name: string,
cases: readonly { caseName: string }[],
): string[] {
if (name !== "GatewayErrorDetails") {
return [];
}
// GatewayErrorDetails shipped as the missing-scope struct before it gained an expiry variant.
// Keep its initializer and property access source-compatible while the enum carries both cases.
return [
" public init(code: String, missingscope: String, requiredscopes: [String]) {",
" self = .missingScope(",
" MissingScopeErrorDetails(",
" code: code,",
" missingscope: missingscope,",
" requiredscopes: requiredscopes",
" )",
" )",
" }",
"",
" public var code: String {",
" switch self {",
...cases.map((entry) => ` case .${entry.caseName}(let value): value.code`),
" }",
" }",
"",
" public var missingscope: String {",
" if case .missingScope(let value) = self { return value.missingscope }",
' return ""',
" }",
"",
" public var requiredscopes: [String] {",
" if case .missingScope(let value) = self { return value.requiredscopes }",
" return []",
" }",
"",
];
}
function objectUnionBranches(schema: JsonSchema): JsonSchema[] {
if (schema.type === "object") {
return [schema];
}
const branches = (schema.oneOf ?? schema.anyOf)?.map(objectUnionBranches);
return branches?.length && branches.every((branch) => branch.length > 0) ? branches.flat() : [];
}
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
const branches = schema.oneOf ?? schema.anyOf;
if (!branches || branches.length < 2) {
return undefined;
}
const objectBranches = branches.map(objectUnionBranches);
if (objectBranches.some((branch) => branch.length === 0)) {
return undefined;
}
const discriminatorCandidates = Object.keys(objectBranches[0]?.[0]?.properties ?? {});
for (const discriminator of discriminatorCandidates) {
const caseCounts = new Map<string, number>();
const cases = branches.map((branch, index) => {
const literals = objectBranches[index]!.map((object) => {
const property = object.properties?.[discriminator];
return property ? literalSchemaValue(property) : undefined;
});
const literal = literals[0];
// A named nested union can share one outer tag while selecting its own variants.
if (literal === undefined || literals.some((value) => value !== literal)) {
return undefined;
}
const baseCaseName = swiftUnionCaseName(literal, `case${index + 1}`);
const occurrence = (caseCounts.get(baseCaseName) ?? 0) + 1;
caseCounts.set(baseCaseName, occurrence);
const caseName = occurrence === 1 ? baseCaseName : `${baseCaseName}${occurrence}`;
// Union cases retain their established structural names; properties use nominal identity.
const registeredName = namedSchema(branch, true);
const branchName =
registeredName ?? `${name}${caseName.charAt(0).toUpperCase()}${caseName.slice(1)}`;
return {
branch,
branchName,
caseName,
registeredName,
literal,
};
});
if (cases.some((entry) => !entry)) {
continue;
}
const resolvedCases = cases.filter(
(entry): entry is NonNullable<typeof entry> => entry !== undefined,
);
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 < 2
) {
continue;
}
const groups = new Map<string, typeof resolvedCases>();
for (const entry of resolvedCases) {
const key = swiftLiteralSource(entry.literal);
const group = groups.get(key) ?? [];
group.push(entry);
groups.set(key, group);
}
const repeatedCases = [...groups.values()].filter((group) => group.length > 1).flat();
if (
repeatedCases.some(
(entry) => entry.branch.type !== "object" || entry.branch.additionalProperties !== false,
)
) {
continue;
}
// Shared tags are safe only with strict branch decoders: synthesized Codable
// would otherwise accept extra fields and silently choose a different variant.
for (const entry of repeatedCases) {
entry.registeredName = undefined;
entry.branchName = `${name}${entry.caseName.charAt(0).toUpperCase()}${entry.caseName.slice(1)}`;
}
const coversAllBoolCases =
literalType === "Bool" &&
resolvedCases.some((entry) => entry.literal === true) &&
resolvedCases.some((entry) => entry.literal === false);
const unknownDiscriminatorLines = coversAllBoolCases
? []
: [
" default:",
" throw DecodingError.dataCorruptedError(",
" forKey: .discriminator,",
" in: container,",
` debugDescription: "Unknown ${name} discriminator value"`,
" )",
];
const decodeCases: string[] = [];
for (const [literal, entries] of groups) {
if (entries.length === 1) {
const entry = entries[0]!;
decodeCases.push(
` case ${literal}: self = try .${entry.caseName}(${entry.branchName}(from: decoder))`,
);
continue;
}
decodeCases.push(` case ${literal}:`);
for (const entry of entries) {
decodeCases.push(
` if let value = try? ${entry.branchName}(from: decoder) {\n self = .${entry.caseName}(value)\n return\n }`,
);
}
decodeCases.push(
` throw DecodingError.dataCorruptedError(\n forKey: .discriminator,\n in: container,\n debugDescription: "No matching ${name} variant"\n )`,
);
}
return [
// Inline union branches need declarations too; only registered schemas have an external owner.
...resolvedCases.flatMap((entry) => {
if (entry.registeredName) {
return [];
}
const declaration =
entry.branch.type === "object"
? emitStruct(entry.branchName, entry.branch, true)
: emitDiscriminatedUnion(entry.branchName, entry.branch);
if (!declaration) {
throw new Error(`Cannot emit nested Swift union ${entry.branchName}`);
}
return [declaration];
}),
`public enum ${name}: Codable, Sendable {`,
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
"",
...emitDiscriminatedUnionCompatibility(name, resolvedCases),
" 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 {",
...decodeCases,
...unknownDiscriminatorLines,
" }",
" }",
"",
" 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");
}
export function generateSwiftProtocol(): string {
schemaNameByObject.clear();
schemaNameBySignature.clear();
schemaNamesByIdentity.clear();
const definitions = Object.entries(ProtocolSchemas) as Array<[string, JsonSchema]>;
const objectAliases = resolveSchemaObjectAliases(definitions);
for (const [name, schema] of definitions) {
registerNamedSchema(name, schema, objectAliases.get(schema) ?? name);
}
const parts: string[] = [];
parts.push(header);
// Named enums and value structs
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
if (stringEnumCases(schema)) {
parts.push(emitEnum(name, schema));
continue;
}
const literalUnionValues = stringLiteralUnionValues(schema);
if (literalUnionValues) {
parts.push(emitEnum(name, { enum: literalUnionValues }));
}
}
for (const [name, schema] of definitions) {
if (name === "GatewayFrame") {
continue;
}
if (name === "BoardEventParams") {
parts.push(emitBoardEventParamsModels(schema));
continue;
}
if (schema.type === "object") {
parts.push(emitStruct(name, schema));
}
}
for (const [name, schema] of definitions) {
if (name === "GatewayFrame" || name === "BoardEventParams") {
continue;
}
const union = emitDiscriminatedUnion(name, schema);
if (union) {
parts.push(union);
}
}
// Frame enum must come after payload structs
parts.push(emitGatewayFrame());
return parts.join("\n");
}