Skip to content

Commit 0dda7d3

Browse files
FionaBronwenclaude
andcommitted
Add mutation engine for TypeSpec-to-GraphQL type transformation
Introduce a two-pass mutation architecture that transforms TypeSpec types into GraphQL-compatible types before emission: - Name sanitization (camelCase fields, PascalCase types, SCREAMING_SNAKE enums) - GraphQL reserved keyword checking - Scalar type mapping (TypeSpec scalars → GraphQL scalars/custom scalars) - Numeric enum value conversion - Nullable union unwrapping (T | null → nullable T) - Union wrapper model generation for mixed unions - Input type nullability handling Includes shared utilities: - src/lib/type-utils.ts: name helpers, nullable union detection - src/lib/scalar-mappings.ts: TypeSpec-to-GraphQL scalar mapping table 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0f7b9e1 commit 0dda7d3

18 files changed

Lines changed: 1539 additions & 1 deletion

File tree

packages/graphql/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"dependencies": {
3737
"@alloy-js/core": "^0.11.0",
3838
"@alloy-js/typescript": "^0.11.0",
39+
"change-case": "^5.4.4",
3940
"graphql": "^16.9.0"
4041
},
4142
"scripts": {
@@ -56,13 +57,15 @@
5657
"peerDependencies": {
5758
"@typespec/compiler": "workspace:~",
5859
"@typespec/emitter-framework": "workspace:~",
59-
"@typespec/http": "workspace:~"
60+
"@typespec/http": "workspace:~",
61+
"@typespec/mutator-framework": "workspace:~"
6062
},
6163
"devDependencies": {
6264
"@types/node": "~22.13.13",
6365
"@typespec/compiler": "workspace:~",
6466
"@typespec/emitter-framework": "workspace:~",
6567
"@typespec/http": "workspace:~",
68+
"@typespec/mutator-framework": "workspace:~",
6669
"rimraf": "~6.0.1",
6770
"source-map-support": "~0.5.21",
6871
"typescript": "~5.8.2",

packages/graphql/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { $onEmit } from "./emitter.js";
22
export { $lib } from "./lib.js";
33
export { $decorators } from "./tsp-index.js";
4+
5+
export { createGraphQLMutationEngine } from "./mutation-engine/index.js";

packages/graphql/src/lib.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ export const libDef = {
108108
default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`,
109109
},
110110
},
111+
"unrecognized-union": {
112+
severity: "error",
113+
messages: {
114+
default: "Unrecognized union construction. Union must be named, a return type, a model property, or an alias.",
115+
},
116+
},
111117
},
112118
emitter: {
113119
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { type Program, type Scalar } from "@typespec/compiler";
2+
import { getEncode } from "@typespec/compiler";
3+
4+
/**
5+
* Represents a mapping from TypeSpec scalar to GraphQL custom scalar
6+
*/
7+
export interface ScalarMapping {
8+
/** The GraphQL scalar name to emit */
9+
graphqlName: string;
10+
/** The base GraphQL type (String, Int, or Float) */
11+
baseType: "String" | "Int" | "Float";
12+
/** Optional URL to specification for @specifiedBy directive */
13+
specificationUrl?: string;
14+
}
15+
16+
/**
17+
* Mapping table for TypeSpec standard library scalars to GraphQL custom scalars.
18+
* Based on design doc: https://github.com/microsoft/typespec/issues/4933
19+
*/
20+
const SCALAR_MAPPINGS: Record<string, Record<string, ScalarMapping>> = {
21+
// int64 → BigInt (String)
22+
int64: {
23+
default: {
24+
graphqlName: "BigInt",
25+
baseType: "String",
26+
},
27+
},
28+
29+
// numeric → Numeric (String)
30+
numeric: {
31+
default: {
32+
graphqlName: "Numeric",
33+
baseType: "String",
34+
},
35+
},
36+
37+
// decimal, decimal128 → BigDecimal (String)
38+
decimal: {
39+
default: {
40+
graphqlName: "BigDecimal",
41+
baseType: "String",
42+
},
43+
},
44+
decimal128: {
45+
default: {
46+
graphqlName: "BigDecimal",
47+
baseType: "String",
48+
},
49+
},
50+
51+
// bytes with different encodings
52+
bytes: {
53+
base64: {
54+
graphqlName: "Bytes",
55+
baseType: "String",
56+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648",
57+
},
58+
base64url: {
59+
graphqlName: "BytesUrl",
60+
baseType: "String",
61+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648",
62+
},
63+
},
64+
65+
// utcDateTime with different encodings
66+
utcDateTime: {
67+
rfc3339: {
68+
graphqlName: "UTCDateTime",
69+
baseType: "String",
70+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc3339",
71+
},
72+
rfc7231: {
73+
graphqlName: "UTCDateTimeHuman",
74+
baseType: "String",
75+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231",
76+
},
77+
unixTimestamp: {
78+
graphqlName: "UTCDateTimeUnix",
79+
baseType: "Int",
80+
},
81+
},
82+
83+
// offsetDateTime with different encodings
84+
offsetDateTime: {
85+
rfc3339: {
86+
graphqlName: "OffsetDateTime",
87+
baseType: "String",
88+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc3339",
89+
},
90+
rfc7231: {
91+
graphqlName: "OffsetDateTimeHuman",
92+
baseType: "String",
93+
specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231",
94+
},
95+
unixTimestamp: {
96+
graphqlName: "OffsetDateTimeUnix",
97+
baseType: "Int",
98+
},
99+
},
100+
101+
// unixTimestamp32 → OffsetDateTimeUnix (Int)
102+
unixTimestamp32: {
103+
default: {
104+
graphqlName: "OffsetDateTimeUnix",
105+
baseType: "Int",
106+
},
107+
},
108+
109+
// duration with different encodings
110+
duration: {
111+
ISO8601: {
112+
graphqlName: "Duration",
113+
baseType: "String",
114+
specificationUrl: "https://www.iso.org/standard/70907.html",
115+
},
116+
seconds: {
117+
graphqlName: "DurationSeconds",
118+
baseType: "Int", // Could be Float based on context, defaulting to Int
119+
},
120+
},
121+
122+
// plainDate → PlainDate (String)
123+
plainDate: {
124+
default: {
125+
graphqlName: "PlainDate",
126+
baseType: "String",
127+
},
128+
},
129+
130+
// plainTime → PlainTime (String)
131+
plainTime: {
132+
default: {
133+
graphqlName: "PlainTime",
134+
baseType: "String",
135+
},
136+
},
137+
138+
// url → URL (String)
139+
url: {
140+
default: {
141+
graphqlName: "URL",
142+
baseType: "String",
143+
specificationUrl: "https://url.spec.whatwg.org/",
144+
},
145+
},
146+
147+
// unknown → Unknown (String)
148+
unknown: {
149+
default: {
150+
graphqlName: "Unknown",
151+
baseType: "String",
152+
},
153+
},
154+
};
155+
156+
/**
157+
* Get the GraphQL scalar mapping for a TypeSpec scalar.
158+
* Returns undefined if the scalar should be emitted as-is (custom scalar).
159+
*
160+
* @param program The TypeSpec program
161+
* @param scalar The scalar type to map
162+
* @param encoding Optional encoding to use instead of checking @encode on the scalar
163+
* @returns The scalar mapping or undefined if no mapping exists
164+
*/
165+
export function getScalarMapping(
166+
program: Program,
167+
scalar: Scalar,
168+
encoding?: string
169+
): ScalarMapping | undefined {
170+
// Only map standard library scalars, not user-defined ones
171+
if (!program.checker.isStdType(scalar)) {
172+
return undefined;
173+
}
174+
175+
const scalarName = scalar.name;
176+
const mappingTable = SCALAR_MAPPINGS[scalarName];
177+
178+
if (!mappingTable) {
179+
return undefined;
180+
}
181+
182+
// Use provided encoding, or check for @encode decorator on the scalar
183+
const actualEncoding = encoding ?? getEncode(program, scalar)?.encoding;
184+
185+
if (actualEncoding && mappingTable[actualEncoding]) {
186+
return mappingTable[actualEncoding];
187+
}
188+
189+
// Fall back to default mapping
190+
return mappingTable.default;
191+
}

0 commit comments

Comments
 (0)