Skip to content

Commit 21d5a98

Browse files
committed
Add GraphQL spec validations to $onValidate
Add two new early validations per GraphQL spec: - empty-enum: Enums must define at least one value - reserved-name: Names must not begin with "__" (reserved for introspection) The reserved-name check validates: - Type names (models, enums, unions) - Field/property names - Operation parameter names - Enum member names Spec references: - https://spec.graphql.org/September2025/#sec-Enums - https://spec.graphql.org/September2025/#sec-Names.Reserved-Names
1 parent 72a4d71 commit 21d5a98

3 files changed

Lines changed: 273 additions & 1 deletion

File tree

packages/graphql/src/lib.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ export const libDef = {
163163
"GraphQL schema has no operations. At minimum a Query root type is required.",
164164
},
165165
},
166+
"empty-enum": {
167+
severity: "error",
168+
messages: {
169+
default: paramMessage`Enum "${"name"}" must define at least one value. GraphQL enums cannot be empty.`,
170+
},
171+
},
172+
"reserved-name": {
173+
severity: "error",
174+
messages: {
175+
default: paramMessage`Name "${"name"}" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.`,
176+
},
177+
},
166178
},
167179
emitter: {
168180
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,

packages/graphql/src/validate.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { type Namespace, navigateTypesInNamespace, type Program } from "@typespec/compiler";
1+
import {
2+
type DiagnosticTarget,
3+
type Enum,
4+
type Model,
5+
type Namespace,
6+
type Operation,
7+
navigateTypesInNamespace,
8+
type Program,
9+
type Union,
10+
} from "@typespec/compiler";
211
import { reportDiagnostic } from "./lib.js";
312
import { getOperationKind } from "./lib/operation-kind.js";
413
import { listSchemas } from "./lib/schema.js";
@@ -25,6 +34,16 @@ function validateSchema(program: Program, ns: Namespace) {
2534
if (getOperationKind(program, op) !== undefined) {
2635
hasGraphQLOps = true;
2736
}
37+
validateOperation(program, op);
38+
},
39+
model(model) {
40+
validateModel(program, model);
41+
},
42+
enum(enumType) {
43+
validateEnum(program, enumType);
44+
},
45+
union(unionType) {
46+
validateUnion(program, unionType);
2847
},
2948
});
3049

@@ -35,3 +54,80 @@ function validateSchema(program: Program, ns: Namespace) {
3554
});
3655
}
3756
}
57+
58+
/**
59+
* GraphQL spec: Names must not begin with "__" (two underscores).
60+
* https://spec.graphql.org/September2025/#sec-Names.Reserved-Names
61+
*/
62+
function validateReservedName(program: Program, name: string, target: DiagnosticTarget) {
63+
if (name.startsWith("__")) {
64+
reportDiagnostic(program, {
65+
code: "reserved-name",
66+
format: { name },
67+
target,
68+
});
69+
}
70+
}
71+
72+
/**
73+
* Validate model: check type name and property names for reserved prefix.
74+
*/
75+
function validateModel(program: Program, model: Model) {
76+
// Check model name
77+
if (model.name) {
78+
validateReservedName(program, model.name, model);
79+
}
80+
81+
// Check property names
82+
for (const prop of model.properties.values()) {
83+
validateReservedName(program, prop.name, prop);
84+
}
85+
}
86+
87+
/**
88+
* Validate operation: check parameter names for reserved prefix.
89+
*/
90+
function validateOperation(program: Program, op: Operation) {
91+
for (const param of op.parameters.properties.values()) {
92+
validateReservedName(program, param.name, param);
93+
}
94+
}
95+
96+
/**
97+
* Validate union: check type name for reserved prefix.
98+
*/
99+
function validateUnion(program: Program, unionType: Union) {
100+
if (unionType.name) {
101+
validateReservedName(program, unionType.name, unionType);
102+
}
103+
}
104+
105+
/**
106+
* GraphQL spec: Enums must define at least one value.
107+
* https://spec.graphql.org/September2025/#sec-Enums
108+
*/
109+
function validateEnum(program: Program, enumType: Enum) {
110+
// Check enum name
111+
if (enumType.name) {
112+
validateReservedName(program, enumType.name, enumType);
113+
}
114+
115+
// Check enum member names
116+
for (const member of enumType.members.values()) {
117+
validateReservedName(program, member.name, member);
118+
}
119+
120+
// Check for empty enum
121+
if (enumType.members.size === 0) {
122+
reportDiagnostic(program, {
123+
code: "empty-enum",
124+
format: { name: enumType.name },
125+
target: enumType,
126+
});
127+
}
128+
}
129+
130+
// Note: Union member type validation is NOT done here because the mutation
131+
// engine automatically wraps non-object types (scalars, enums) in generated
132+
// wrapper models. For example, `union Foo { text: string }` becomes
133+
// `union Foo = FooTextUnionVariant` with a generated wrapper type.

packages/graphql/test/validate.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,168 @@ describe("$onValidate", () => {
6363
expectDiagnosticEmpty(diagnostics);
6464
});
6565
});
66+
67+
describe("empty-enum", () => {
68+
it("reports error for enum with no values", async () => {
69+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
70+
@schema
71+
namespace TestNamespace {
72+
enum Status {}
73+
model Book { status: Status; }
74+
@query op getBooks(): Book[];
75+
}
76+
`);
77+
78+
expectDiagnostics(diagnostics, {
79+
code: "@typespec/graphql/empty-enum",
80+
severity: "error",
81+
message: 'Enum "Status" must define at least one value. GraphQL enums cannot be empty.',
82+
});
83+
});
84+
85+
it("does not report error for enum with values", async () => {
86+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
87+
@schema
88+
namespace TestNamespace {
89+
enum Status { Active, Inactive }
90+
model Book { status: Status; }
91+
@query op getBooks(): Book[];
92+
}
93+
`);
94+
95+
expectDiagnosticEmpty(diagnostics);
96+
});
97+
});
98+
99+
describe("reserved-name", () => {
100+
it("reports error for model name starting with __", async () => {
101+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
102+
@schema
103+
namespace TestNamespace {
104+
model __Reserved { title: string; }
105+
@query op get(): __Reserved;
106+
}
107+
`);
108+
109+
expectDiagnostics(diagnostics, {
110+
code: "@typespec/graphql/reserved-name",
111+
severity: "error",
112+
message:
113+
'Name "__Reserved" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
114+
});
115+
});
116+
117+
it("reports error for property name starting with __", async () => {
118+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
119+
@schema
120+
namespace TestNamespace {
121+
model Book { __internal: string; }
122+
@query op getBooks(): Book[];
123+
}
124+
`);
125+
126+
expectDiagnostics(diagnostics, {
127+
code: "@typespec/graphql/reserved-name",
128+
severity: "error",
129+
message:
130+
'Name "__internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
131+
});
132+
});
133+
134+
it("reports error for operation parameter starting with __", async () => {
135+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
136+
@schema
137+
namespace TestNamespace {
138+
model Book { title: string; }
139+
@query op getBook(__id: string): Book;
140+
}
141+
`);
142+
143+
expectDiagnostics(diagnostics, {
144+
code: "@typespec/graphql/reserved-name",
145+
severity: "error",
146+
message:
147+
'Name "__id" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
148+
});
149+
});
150+
151+
it("reports error for enum name starting with __", async () => {
152+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
153+
@schema
154+
namespace TestNamespace {
155+
enum __Status { Active }
156+
model Book { status: __Status; }
157+
@query op getBooks(): Book[];
158+
}
159+
`);
160+
161+
expectDiagnostics(diagnostics, {
162+
code: "@typespec/graphql/reserved-name",
163+
severity: "error",
164+
message:
165+
'Name "__Status" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
166+
});
167+
});
168+
169+
it("reports error for enum member starting with __", async () => {
170+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
171+
@schema
172+
namespace TestNamespace {
173+
enum Status { __Internal, Active }
174+
model Book { status: Status; }
175+
@query op getBooks(): Book[];
176+
}
177+
`);
178+
179+
expectDiagnostics(diagnostics, {
180+
code: "@typespec/graphql/reserved-name",
181+
severity: "error",
182+
message:
183+
'Name "__Internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
184+
});
185+
});
186+
187+
it("reports error for union name starting with __", async () => {
188+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
189+
@schema
190+
namespace TestNamespace {
191+
model Cat { meow: string; }
192+
model Dog { bark: string; }
193+
union __Pet { cat: Cat, dog: Dog }
194+
@query op getPet(): __Pet;
195+
}
196+
`);
197+
198+
expectDiagnostics(diagnostics, {
199+
code: "@typespec/graphql/reserved-name",
200+
severity: "error",
201+
message:
202+
'Name "__Pet" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.',
203+
});
204+
});
205+
206+
it("does not report error for names with single underscore prefix", async () => {
207+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
208+
@schema
209+
namespace TestNamespace {
210+
model _Book { _title: string; }
211+
@query op _getBooks(_filter: string): _Book[];
212+
}
213+
`);
214+
215+
expectDiagnosticEmpty(diagnostics);
216+
});
217+
218+
it("does not report error for names with underscore in middle", async () => {
219+
const [_, diagnostics] = await Tester.compileAndDiagnose(t.code`
220+
@schema
221+
namespace TestNamespace {
222+
model My__Book { my__title: string; }
223+
@query op get__Books(): My__Book[];
224+
}
225+
`);
226+
227+
expectDiagnosticEmpty(diagnostics);
228+
});
229+
});
66230
});

0 commit comments

Comments
 (0)