Skip to content

Commit c614eb4

Browse files
stephentoubCopilot
andcommitted
Add $ref support to all four language code generators
Enable JSON Schema $ref for type deduplication across all SDK code generators (TypeScript, Python, Go, C#). Changes: - utils.ts: Add resolveRef(), refTypeName(), collectDefinitions() helpers; normalize $defs to definitions in postProcessSchema - typescript.ts: Build combined schema with shared definitions and compile once via unreachableDefinitions, instead of per-method compilation - python.ts/go.ts: Include all definitions alongside SessionEvent for quicktype resolution; include shared API defs in RPC combined schema - csharp.ts: Add handling to resolveSessionPropertyType and resolveRpcType; generate classes for referenced types on demand Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 70b7721 commit c614eb4

File tree

5 files changed

+190
-27
lines changed

5 files changed

+190
-27
lines changed

scripts/codegen/csharp.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import { execFile } from "child_process";
1010
import fs from "fs/promises";
1111
import path from "path";
1212
import { promisify } from "util";
13-
import type { JSONSchema7 } from "json-schema";
13+
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
1414
import {
1515
getSessionEventsSchemaPath,
1616
getApiSchemaPath,
1717
writeGeneratedFile,
18+
collectDefinitions,
19+
resolveRef,
20+
refTypeName,
1821
isRpcMethod,
1922
isNodeFullyExperimental,
2023
EXCLUDED_EVENT_TYPES,
@@ -199,6 +202,9 @@ interface EventVariant {
199202

200203
let generatedEnums = new Map<string, { enumName: string; values: string[] }>();
201204

205+
/** Schema definitions available during session event generation (for $ref resolution). */
206+
let sessionDefinitions: Record<string, JSONSchema7Definition> = {};
207+
202208
function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string): string {
203209
const valuesKey = [...values].sort().join("|");
204210
for (const [, existing] of generatedEnums) {
@@ -402,6 +408,21 @@ function resolveSessionPropertyType(
402408
nestedClasses: Map<string, string>,
403409
enumOutput: string[]
404410
): string {
411+
// Handle $ref by resolving against schema definitions
412+
if (propSchema.$ref) {
413+
const typeName = refTypeName(propSchema.$ref);
414+
const className = typeToClassName(typeName);
415+
if (!nestedClasses.has(className)) {
416+
const refSchema = resolveRef(propSchema.$ref, sessionDefinitions);
417+
if (refSchema) {
418+
if (refSchema.enum && Array.isArray(refSchema.enum)) {
419+
return getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput);
420+
}
421+
nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput));
422+
}
423+
}
424+
return isRequired ? className : `${className}?`;
425+
}
405426
if (propSchema.anyOf) {
406427
const hasNull = propSchema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null");
407428
const nonNull = propSchema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null");
@@ -433,6 +454,18 @@ function resolveSessionPropertyType(
433454
}
434455
if (propSchema.type === "array" && propSchema.items) {
435456
const items = propSchema.items as JSONSchema7;
457+
// Handle $ref in array items
458+
if (items.$ref) {
459+
const typeName = refTypeName(items.$ref);
460+
const className = typeToClassName(typeName);
461+
if (!nestedClasses.has(className)) {
462+
const refSchema = resolveRef(items.$ref, sessionDefinitions);
463+
if (refSchema) {
464+
nestedClasses.set(className, generateNestedClass(className, refSchema, knownTypes, nestedClasses, enumOutput));
465+
}
466+
}
467+
return isRequired ? `${className}[]` : `${className}[]?`;
468+
}
436469
// Array of discriminated union (anyOf with shared discriminator)
437470
if (items.anyOf && Array.isArray(items.anyOf)) {
438471
const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object");
@@ -491,6 +524,7 @@ function generateDataClass(variant: EventVariant, knownTypes: Map<string, string
491524

492525
function generateSessionEventsCode(schema: JSONSchema7): string {
493526
generatedEnums.clear();
527+
sessionDefinitions = schema.definitions as Record<string, JSONSchema7Definition> || {};
494528
const variants = extractEventVariants(schema);
495529
const knownTypes = new Map<string, string>();
496530
const nestedClasses = new Map<string, string>();
@@ -600,6 +634,9 @@ let experimentalRpcTypes = new Set<string>();
600634
let rpcKnownTypes = new Map<string, string>();
601635
let rpcEnumOutput: string[] = [];
602636

637+
/** Schema definitions available during RPC generation (for $ref resolution). */
638+
let rpcDefinitions: Record<string, JSONSchema7Definition> = {};
639+
603640
function singularPascal(s: string): string {
604641
const p = toPascalCase(s);
605642
if (p.endsWith("ies")) return `${p.slice(0, -3)}y`;
@@ -617,6 +654,16 @@ function paramsTypeName(rpcMethod: string): string {
617654
}
618655

619656
function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string {
657+
// Handle $ref by resolving against schema definitions and generating the referenced class
658+
if (schema.$ref) {
659+
const typeName = refTypeName(schema.$ref);
660+
const refSchema = resolveRef(schema.$ref, rpcDefinitions);
661+
if (refSchema && !emittedRpcClasses.has(typeName)) {
662+
const cls = emitRpcClass(typeName, refSchema, "public", classes);
663+
if (cls) classes.push(cls);
664+
}
665+
return isRequired ? typeName : `${typeName}?`;
666+
}
620667
// Handle anyOf: [T, null] → T? (nullable typed property)
621668
if (schema.anyOf) {
622669
const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null");
@@ -637,6 +684,16 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam
637684
}
638685
if (schema.type === "array" && schema.items) {
639686
const items = schema.items as JSONSchema7;
687+
// Handle $ref in array items
688+
if (items.$ref) {
689+
const typeName = refTypeName(items.$ref);
690+
const refSchema = resolveRef(items.$ref, rpcDefinitions);
691+
if (refSchema && !emittedRpcClasses.has(typeName)) {
692+
const cls = emitRpcClass(typeName, refSchema, "public", classes);
693+
if (cls) classes.push(cls);
694+
}
695+
return isRequired ? `List<${typeName}>` : `List<${typeName}>?`;
696+
}
640697
if (items.type === "object" && items.properties) {
641698
const itemClass = singularPascal(propName);
642699
if (!emittedRpcClasses.has(itemClass)) classes.push(emitRpcClass(itemClass, items, "public", classes));
@@ -1065,6 +1122,7 @@ function generateRpcCode(schema: ApiSchema): string {
10651122
rpcKnownTypes.clear();
10661123
rpcEnumOutput = [];
10671124
generatedEnums.clear(); // Clear shared enum deduplication map
1125+
rpcDefinitions = collectDefinitions(schema as Record<string, unknown>);
10681126
const classes: string[] = [];
10691127

10701128
let serverRpcParts: string[] = [];

scripts/codegen/go.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { execFile } from "child_process";
1010
import fs from "fs/promises";
11-
import type { JSONSchema7 } from "json-schema";
11+
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
1212
import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core";
1313
import { promisify } from "util";
1414
import {
@@ -19,6 +19,9 @@ import {
1919
isRpcMethod,
2020
postProcessSchema,
2121
writeGeneratedFile,
22+
collectDefinitions,
23+
refTypeName,
24+
resolveRef,
2225
type ApiSchema,
2326
type RpcMethod,
2427
} from "./utils.js";
@@ -152,6 +155,7 @@ interface GoCodegenCtx {
152155
enums: string[];
153156
enumsByValues: Map<string, string>; // sorted-values-key → enumName
154157
generatedNames: Set<string>;
158+
definitions?: Record<string, JSONSchema7Definition>;
155159
}
156160

157161
function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] {
@@ -257,6 +261,21 @@ function resolveGoPropertyType(
257261
): string {
258262
const nestedName = parentTypeName + toGoFieldName(jsonPropName);
259263

264+
// Handle $ref — resolve the reference and generate the referenced type
265+
if (propSchema.$ref && typeof propSchema.$ref === "string") {
266+
const typeName = toGoFieldName(refTypeName(propSchema.$ref));
267+
const resolved = resolveRef(propSchema.$ref, ctx.definitions);
268+
if (resolved) {
269+
if (resolved.enum) {
270+
return getOrCreateGoEnum(typeName, resolved.enum as string[], ctx, resolved.description);
271+
}
272+
emitGoStruct(typeName, resolved, ctx);
273+
return isRequired ? typeName : `*${typeName}`;
274+
}
275+
// Fallback: use the type name directly
276+
return isRequired ? typeName : `*${typeName}`;
277+
}
278+
260279
// Handle anyOf
261280
if (propSchema.anyOf) {
262281
const nonNull = (propSchema.anyOf as JSONSchema7[]).filter((s) => s.type !== "null");
@@ -514,6 +533,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string {
514533
enums: [],
515534
enumsByValues: new Map(),
516535
generatedNames: new Set(),
536+
definitions: schema.definitions as Record<string, JSONSchema7Definition> | undefined,
517537
};
518538

519539
// Generate per-event data structs
@@ -802,10 +822,12 @@ async function generateRpc(schemaPath?: string): Promise<void> {
802822
...collectRpcMethods(schema.clientSession || {}),
803823
];
804824

805-
// Build a combined schema for quicktype - prefix types to avoid conflicts
825+
// Build a combined schema for quicktype — prefix types to avoid conflicts.
826+
// Include shared definitions from the API schema for $ref resolution.
827+
const sharedDefs = collectDefinitions(schema as Record<string, unknown>);
806828
const combinedSchema: JSONSchema7 = {
807829
$schema: "http://json-schema.org/draft-07/schema#",
808-
definitions: {},
830+
definitions: { ...sharedDefs },
809831
};
810832

811833
for (const method of allMethods) {
@@ -832,10 +854,14 @@ async function generateRpc(schemaPath?: string): Promise<void> {
832854
}
833855
}
834856

835-
// Generate types via quicktype
857+
// Generate types via quicktype — include all definitions in each source for $ref resolution
836858
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
837859
for (const [name, def] of Object.entries(combinedSchema.definitions!)) {
838-
await schemaInput.addSource({ name, schema: JSON.stringify(def) });
860+
const schemaWithDefs: JSONSchema7 = {
861+
...(typeof def === "object" ? (def as JSONSchema7) : {}),
862+
definitions: combinedSchema.definitions,
863+
};
864+
await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) });
839865
}
840866

841867
const inputData = new InputData();

scripts/codegen/python.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isRpcMethod,
1616
postProcessSchema,
1717
writeGeneratedFile,
18+
collectDefinitions,
1819
isRpcMethod,
1920
isNodeFullyExperimental,
2021
type ApiSchema,
@@ -151,11 +152,20 @@ async function generateSessionEvents(schemaPath?: string): Promise<void> {
151152

152153
const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath());
153154
const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7;
154-
const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema;
155-
const processed = postProcessSchema(resolvedSchema);
155+
const processed = postProcessSchema(schema);
156+
157+
// Extract SessionEvent as root but keep all other definitions for $ref resolution
158+
const sessionEventDef = (processed.definitions?.SessionEvent as JSONSchema7) || processed;
159+
const otherDefs = Object.fromEntries(
160+
Object.entries(processed.definitions || {}).filter(([key]) => key !== "SessionEvent")
161+
);
162+
const schemaForQuicktype: JSONSchema7 = {
163+
...sessionEventDef,
164+
...(Object.keys(otherDefs).length > 0 ? { definitions: otherDefs } : {}),
165+
};
156166

157167
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
158-
await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) });
168+
await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(schemaForQuicktype) });
159169

160170
const inputData = new InputData();
161171
inputData.addInput(schemaInput);
@@ -214,10 +224,11 @@ async function generateRpc(schemaPath?: string): Promise<void> {
214224
...collectRpcMethods(schema.clientSession || {}),
215225
];
216226

217-
// Build a combined schema for quicktype
227+
// Build a combined schema for quicktype, including shared definitions from the API schema
228+
const sharedDefs = collectDefinitions(schema as Record<string, unknown>);
218229
const combinedSchema: JSONSchema7 = {
219230
$schema: "http://json-schema.org/draft-07/schema#",
220-
definitions: {},
231+
definitions: { ...sharedDefs },
221232
};
222233

223234
for (const method of allMethods) {
@@ -243,10 +254,14 @@ async function generateRpc(schemaPath?: string): Promise<void> {
243254
}
244255
}
245256

246-
// Generate types via quicktype
257+
// Generate types via quicktype — include all definitions in each source for $ref resolution
247258
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
248259
for (const [name, def] of Object.entries(combinedSchema.definitions!)) {
249-
await schemaInput.addSource({ name, schema: JSON.stringify(def) });
260+
const schemaWithDefs: JSONSchema7 = {
261+
...(typeof def === "object" ? (def as JSONSchema7) : {}),
262+
definitions: combinedSchema.definitions,
263+
};
264+
await schemaInput.addSource({ name, schema: JSON.stringify(schemaWithDefs) });
250265
}
251266

252267
const inputData = new InputData();

scripts/codegen/typescript.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getApiSchemaPath,
1515
postProcessSchema,
1616
writeGeneratedFile,
17+
collectDefinitions,
1718
isRpcMethod,
1819
isNodeFullyExperimental,
1920
type ApiSchema,
@@ -88,32 +89,58 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js";
8889
const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})];
8990
const clientSessionMethods = collectRpcMethods(schema.clientSession || {});
9091

92+
// Build a single combined schema with shared definitions and all method types.
93+
// This ensures $ref-referenced types are generated exactly once.
94+
const sharedDefs = collectDefinitions(schema as Record<string, unknown>);
95+
const combinedSchema: JSONSchema7 = {
96+
$schema: "http://json-schema.org/draft-07/schema#",
97+
type: "object",
98+
definitions: { ...sharedDefs },
99+
};
100+
101+
// Track which type names come from experimental methods for JSDoc annotations.
102+
const experimentalTypes = new Set<string>();
103+
91104
for (const method of [...allMethods, ...clientSessionMethods]) {
92105
if (method.result) {
93-
const compiled = await compile(method.result, resultTypeName(method.rpcMethod), {
94-
bannerComment: "",
95-
additionalProperties: false,
96-
});
106+
combinedSchema.definitions![resultTypeName(method.rpcMethod)] = method.result;
97107
if (method.stability === "experimental") {
98-
lines.push("/** @experimental */");
108+
experimentalTypes.add(resultTypeName(method.rpcMethod));
99109
}
100-
lines.push(compiled.trim());
101-
lines.push("");
102110
}
103111

104112
if (method.params?.properties && Object.keys(method.params.properties).length > 0) {
105-
const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), {
106-
bannerComment: "",
107-
additionalProperties: false,
108-
});
113+
combinedSchema.definitions![paramsTypeName(method.rpcMethod)] = method.params;
109114
if (method.stability === "experimental") {
110-
lines.push("/** @experimental */");
115+
experimentalTypes.add(paramsTypeName(method.rpcMethod));
111116
}
112-
lines.push(paramsCompiled.trim());
113-
lines.push("");
114117
}
115118
}
116119

120+
const compiled = await compile(combinedSchema, "_RpcSchemaRoot", {
121+
bannerComment: "",
122+
additionalProperties: false,
123+
unreachableDefinitions: true,
124+
});
125+
126+
// Strip the placeholder root type and keep only the definition-generated types
127+
const strippedTs = compiled
128+
.replace(/export interface _RpcSchemaRoot\s*\{[^}]*\}\s*/g, "")
129+
.trim();
130+
131+
if (strippedTs) {
132+
// Add @experimental JSDoc annotations for types from experimental methods
133+
let annotatedTs = strippedTs;
134+
for (const expType of experimentalTypes) {
135+
annotatedTs = annotatedTs.replace(
136+
new RegExp(`(^|\\n)(export (?:interface|type) ${expType}\\b)`, "m"),
137+
`$1/** @experimental */\n$2`
138+
);
139+
}
140+
lines.push(annotatedTs);
141+
lines.push("");
142+
}
143+
117144
// Generate factory functions
118145
if (schema.server) {
119146
lines.push(`/** Create typed server-scoped RPC methods (no session required). */`);

0 commit comments

Comments
 (0)