Skip to content

Commit 4ab36ef

Browse files
committed
Add mutation engine for TypeSpec-to-GraphQL type transformation
Introduce a mutation engine that transforms TypeSpec types into GraphQL-compatible forms using the mutator framework. Includes mutations for enums, models, scalars, unions, and operations. Also includes package hygiene: add tspMain, update node engine to >=20, add api-extractor.json, CHANGELOG.md, fix testing casing, and clean up dead code.
1 parent c2fe95c commit 4ab36ef

25 files changed

Lines changed: 1563 additions & 27 deletions

packages/graphql/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Change Log - @typespec/graphql
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
3+
"extends": "../../api-extractor.base.json"
4+
}

packages/graphql/package.json

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"typespec"
1818
],
1919
"type": "module",
20+
"tspMain": "lib/main.tsp",
2021
"main": "dist/src/index.js",
2122
"exports": {
2223
".": {
@@ -30,24 +31,23 @@
3031
}
3132
},
3233
"engines": {
33-
"node": ">=18.0.0"
34-
},
35-
"graphql": {
36-
"documents": "test/**/*.{js,ts}"
34+
"node": ">=20.0.0"
3735
},
3836
"dependencies": {
3937
"@alloy-js/core": "^0.11.0",
4038
"@alloy-js/typescript": "^0.11.0",
39+
"change-case": "^5.4.4",
4140
"graphql": "^16.9.0"
4241
},
4342
"scripts": {
4443
"clean": "rimraf ./dist ./temp",
4544
"build": "tsc -p .",
4645
"watch": "tsc --watch",
4746
"test": "vitest run",
47+
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
4848
"test:watch": "vitest -w",
49-
"lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0",
50-
"lint:fix": "eslint . --report-unused-disable-directives --fix"
49+
"lint": "eslint . --max-warnings=0",
50+
"lint:fix": "eslint . --fix"
5151
},
5252
"files": [
5353
"lib/*.tsp",
@@ -56,11 +56,16 @@
5656
],
5757
"peerDependencies": {
5858
"@typespec/compiler": "workspace:~",
59+
"@typespec/emitter-framework": "workspace:~",
5960
"@typespec/http": "workspace:~",
60-
"@typespec/emitter-framework": "^0.5.0"
61+
"@typespec/mutator-framework": "workspace:~"
6162
},
6263
"devDependencies": {
6364
"@types/node": "~22.13.13",
65+
"@typespec/compiler": "workspace:~",
66+
"@typespec/emitter-framework": "workspace:~",
67+
"@typespec/http": "workspace:~",
68+
"@typespec/mutator-framework": "workspace:~",
6469
"rimraf": "~6.0.1",
6570
"source-map-support": "~0.5.21",
6671
"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
@@ -136,6 +136,12 @@ export const libDef = {
136136
default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`,
137137
},
138138
},
139+
"unrecognized-union": {
140+
severity: "error",
141+
messages: {
142+
default: "Unrecognized union construction. Union must be named, a return type, a model property, or an alias.",
143+
},
144+
},
139145
},
140146
emitter: {
141147
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,

packages/graphql/src/lib/interface.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { useStateMap, useStateSet } from "@typespec/compiler/utils";
1212
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
1313
import type { Tagged } from "../types.d.ts";
14+
import { propertiesEqual } from "./utils.js";
1415

1516
// This will set the namespace for decorators implemented in this file
1617
export const namespace = NAMESPACE;
@@ -75,13 +76,6 @@ function validateNoCircularImplementation(
7576
return valid;
7677
}
7778

78-
function propertiesEqual(prop1: ModelProperty, prop2: ModelProperty): boolean {
79-
// TODO is there some canonical way to do this?
80-
return (
81-
prop1.name === prop2.name && prop1.type === prop2.type && prop1.optional === prop2.optional
82-
);
83-
}
84-
8579
function validateImplementsInterfaceProperties(
8680
context: DecoratorContext,
8781
modelProperties: Map<string, ModelProperty>,

packages/graphql/src/lib/operation-fields.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ import {
77
type Operation,
88
type Program,
99
} from "@typespec/compiler";
10-
11-
// import { createTypeRelationChecker } from "../../../compiler/dist/src/core/type-relation-checker.js";
12-
1310
import { useStateMap } from "@typespec/compiler/utils";
1411
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
1512
import { operationsEqual } from "./utils.js";
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)