Skip to content

Commit c0fff8a

Browse files
committed
Add @schema decorator to mark namespaces as GraphQL schemas
Using the `TypeSpec.GraphQL.@schema` decorator on a namespace indicates that the decorated namespace represents a GraphQL schema that should be generated by the GraphQL emitter. Because this allows for multiple schemas to be specified in a TypeSpec source, our test host is reworked to provide a `GraphQLSchemaRecord` corresponding to each schema produced. This commit does not actually implement any emitter functionality, but populates a state map that will be used by the emitter in the future.
1 parent fee344a commit c0fff8a

15 files changed

+301
-21
lines changed

packages/graphql/lib/main.tsp

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "./schema.tsp";

packages/graphql/lib/schema.tsp

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import "../dist/src/lib/schema.js";
2+
3+
using TypeSpec.Reflection;
4+
5+
namespace TypeSpec.GraphQL;
6+
7+
namespace Schema {
8+
model SchemaOptions {
9+
name?: string;
10+
}
11+
}
12+
13+
/**
14+
* Mark this namespace as describing a GraphQL schema and configure schema properties.
15+
*
16+
* @example
17+
*
18+
* ```typespec
19+
* @schema(name: "MySchema")
20+
* namespace MySchema {};
21+
* ```
22+
*/
23+
extern dec schema(target: Namespace, options?: Schema.SchemaOptions);

packages/graphql/package.json

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"main": "dist/src/index.js",
2121
"exports": {
2222
".": {
23+
"typespec": "./lib/main.tsp",
2324
"types": "./dist/src/index.d.ts",
2425
"default": "./dist/src/index.js"
2526
},
@@ -31,6 +32,12 @@
3132
"engines": {
3233
"node": ">=18.0.0"
3334
},
35+
"graphql": {
36+
"documents": "test/**/*.{js,ts}"
37+
},
38+
"dependencies": {
39+
"graphql": "^16.9.0"
40+
},
3441
"scripts": {
3542
"clean": "rimraf ./dist ./temp",
3643
"build": "tsc",

packages/graphql/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { $onEmit } from "./emitter.js";
22
export { $lib } from "./lib.js";
3+
export { $decorators } from "./tsp-index.js";

packages/graphql/src/lib.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler";
22

3+
export const NAMESPACE = "TypeSpec.GraphQL";
4+
35
export interface GraphQLEmitterOptions {
46
/**
57
* Name of the output file.
@@ -99,4 +101,4 @@ export const libDef = {
99101

100102
export const $lib = createTypeSpecLibrary(libDef);
101103

102-
export const { reportDiagnostic, createDiagnostic } = $lib;
104+
export const { reportDiagnostic, createDiagnostic, createStateSymbol } = $lib;

packages/graphql/src/lib/schema.ts

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
type DecoratorContext,
3+
type DecoratorFunction,
4+
type Namespace,
5+
type Program,
6+
type Type,
7+
getTypeName,
8+
validateDecoratorUniqueOnNode,
9+
} from "@typespec/compiler";
10+
11+
import { createStateSymbol, NAMESPACE, reportDiagnostic } from "../lib.js";
12+
import { useStateMap } from "./state-map.js";
13+
14+
// This will set the namespace for decorators implemented in this file
15+
export const namespace = NAMESPACE;
16+
17+
export interface SchemaDetails {
18+
name?: string;
19+
}
20+
21+
export interface Schema extends SchemaDetails {
22+
type: Namespace;
23+
}
24+
25+
const [getSchema, setSchema, getSchemaMap] = useStateMap<Namespace, Schema>(
26+
createStateSymbol("schemas"),
27+
);
28+
29+
/**
30+
* List all the schemas defined in the TypeSpec program
31+
* @param program Program
32+
* @returns List of schemas.
33+
*/
34+
export function listSchemas(program: Program): Schema[] {
35+
return [...getSchemaMap(program).values()];
36+
}
37+
38+
export {
39+
/**
40+
* Get the schema information for the given namespace.
41+
* @param program Program
42+
* @param namespace Schema namespace
43+
* @returns Schema information or undefined if namespace is not a schema namespace.
44+
*/
45+
getSchema,
46+
};
47+
48+
/**
49+
* Check if the namespace is defined as a schema.
50+
* @param program Program
51+
* @param namespace Namespace
52+
* @returns Boolean
53+
*/
54+
export function isSchema(program: Program, namespace: Namespace): boolean {
55+
return getSchemaMap(program).has(namespace);
56+
}
57+
58+
/**
59+
* Mark the given namespace as a schema.
60+
* @param program Program
61+
* @param namespace Namespace
62+
* @param details Schema details
63+
*/
64+
export function addSchema(
65+
program: Program,
66+
namespace: Namespace,
67+
details: SchemaDetails = {},
68+
): void {
69+
const schemaMap = getSchemaMap(program);
70+
const existing = schemaMap.get(namespace) ?? {};
71+
setSchema(program, namespace, { ...existing, ...details, type: namespace });
72+
}
73+
74+
export const $schema: DecoratorFunction = (
75+
context: DecoratorContext,
76+
target: Namespace,
77+
options: Type,
78+
) => {
79+
validateDecoratorUniqueOnNode(context, target, $schema);
80+
81+
if (options && options.kind !== "Model") {
82+
reportDiagnostic(context.program, {
83+
code: "invalid-argument",
84+
format: { value: options.kind, expected: "Model" },
85+
target: context.getArgumentTarget(0)!,
86+
});
87+
return;
88+
}
89+
90+
const schemaDetails: SchemaDetails = {};
91+
const name = options?.properties.get("name")?.type;
92+
if (name) {
93+
if (name.kind === "String") {
94+
schemaDetails.name = name.value;
95+
} else {
96+
reportDiagnostic(context.program, {
97+
code: "unassignable",
98+
format: { sourceType: getTypeName(name), targetType: "String" },
99+
target: context.getArgumentTarget(0)!,
100+
});
101+
}
102+
}
103+
104+
addSchema(context.program, target, schemaDetails);
105+
};

packages/graphql/src/lib/state-map.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Type } from "@typespec/compiler";
2+
import { unsafe_useStateMap, unsafe_useStateSet } from "@typespec/compiler/experimental";
3+
4+
/**
5+
* This is a copy of the experimental state-accessor lib from @typespec/compiler
6+
*/
7+
8+
function createStateSymbol(name: string) {
9+
return Symbol.for(`TypeSpec.${name}`);
10+
}
11+
12+
export function useStateMap<K extends Type, V>(key: string | symbol) {
13+
return unsafe_useStateMap<K, V>(typeof key === "string" ? createStateSymbol(key) : key);
14+
}
15+
16+
export function useStateSet<K extends Type>(key: string | symbol) {
17+
return unsafe_useStateSet<K>(typeof key === "string" ? createStateSymbol(key) : key);
18+
}

packages/graphql/src/schema-emitter.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function createGraphQLEmitter(
1818
const filePath = interpolatePath(options.outputFile, { "schema-name": "schema" });
1919
await emitFile(program, {
2020
path: filePath,
21-
content: "Hello world",
21+
content: "",
2222
newLine: options.newLine,
2323
});
2424
}

packages/graphql/src/testing/utils.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { GraphQLSchema } from "graphql";
2+
3+
export const EMPTY_SCHEMA = new GraphQLSchema({});
4+
5+
export const EMPTY_SCHEMA_OUTPUT = `#graphql
6+
type Query {
7+
"""
8+
A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.
9+
"""
10+
_: Boolean
11+
}
12+
`;

packages/graphql/src/tsp-index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { DecoratorImplementations } from "@typespec/compiler";
2+
import { NAMESPACE } from "./lib.js";
3+
import { $schema } from "./lib/schema.js";
4+
5+
export const $decorators: DecoratorImplementations = {
6+
[NAMESPACE]: {
7+
schema: $schema,
8+
},
9+
};

packages/graphql/src/types.d.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Diagnostic } from "@typespec/compiler";
2+
import type { GraphQLSchema } from "graphql";
3+
import type { Schema } from "./lib/schema.ts";
4+
5+
/**
6+
* A record containing the GraphQL schema corresponding to
7+
* a particular schema definition.
8+
*/
9+
export interface GraphQLSchemaRecord {
10+
/** The declared schema that generated this GraphQL schema */
11+
readonly schema: Schema;
12+
13+
/** The GraphQLSchema */
14+
readonly graphQLSchema: GraphQLSchema;
15+
16+
/** The diagnostics created for this schema */
17+
readonly diagnostics: readonly Diagnostic[];
18+
}

packages/graphql/test/hello.test.ts

-10
This file was deleted.

packages/graphql/test/schema.test.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Namespace } from "@typespec/compiler";
2+
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
3+
import { describe, expect, it } from "vitest";
4+
import { getSchema } from "../src/lib/schema.js";
5+
import { compileAndDiagnose } from "./test-host.js";
6+
7+
describe("@schema", () => {
8+
it("Creates a schema with no name", async () => {
9+
const [program, { TestNamespace }, diagnostics] = await compileAndDiagnose<{
10+
TestNamespace: Namespace;
11+
}>(`
12+
@schema
13+
@test namespace TestNamespace {}
14+
`);
15+
expectDiagnosticEmpty(diagnostics);
16+
17+
const schema = getSchema(program, TestNamespace);
18+
19+
expect(schema?.type).toBe(TestNamespace);
20+
expect(schema?.name).toBeUndefined();
21+
});
22+
23+
it("Creates a schema with a specified name", async () => {
24+
const [program, { TestNamespace }, diagnostics] = await compileAndDiagnose<{
25+
TestNamespace: Namespace;
26+
}>(`
27+
@schema({name: "MySchema"})
28+
@test namespace TestNamespace {}
29+
`);
30+
expectDiagnosticEmpty(diagnostics);
31+
32+
const schema = getSchema(program, TestNamespace);
33+
34+
expect(schema?.type).toBe(TestNamespace);
35+
expect(schema?.name).toBe("MySchema");
36+
});
37+
});

packages/graphql/test/test-host.ts

+56-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import type { Diagnostic } from "@typespec/compiler";
1+
import type { Diagnostic, Program, Type } from "@typespec/compiler";
22
import {
33
createTestHost,
44
createTestWrapper,
55
expectDiagnosticEmpty,
66
resolveVirtualPath,
77
} from "@typespec/compiler/testing";
88
import { ok } from "assert";
9+
import type { GraphQLSchema } from "graphql";
10+
import { buildSchema } from "graphql";
11+
import { expect } from "vitest";
912
import type { GraphQLEmitterOptions } from "../src/lib.js";
1013
import { GraphqlTestLibrary } from "../src/testing/index.js";
1114

@@ -15,21 +18,41 @@ export async function createGraphqlTestHost() {
1518
});
1619
}
1720

21+
export interface GraphQLTestResult {
22+
readonly graphQLSchema?: GraphQLSchema;
23+
readonly graphQLOutput?: string;
24+
readonly diagnostics: readonly Diagnostic[];
25+
}
26+
1827
export async function createGraphqlTestRunner() {
1928
const host = await createGraphqlTestHost();
2029

2130
return createTestWrapper(host, {
31+
autoUsings: ["TypeSpec.GraphQL"],
2232
compilerOptions: {
2333
noEmit: false,
2434
emit: ["@typespec/graphql"],
2535
},
2636
});
2737
}
2838

39+
export async function diagnose(code: string): Promise<readonly Diagnostic[]> {
40+
const runner = await createGraphqlTestRunner();
41+
return runner.diagnose(code);
42+
}
43+
44+
export async function compileAndDiagnose<T extends Record<string, Type>>(
45+
code: string,
46+
): Promise<[Program, T, readonly Diagnostic[]]> {
47+
const runner = await createGraphqlTestRunner();
48+
const [testTypes, diagnostics] = await runner.compileAndDiagnose(code);
49+
return [runner.program, testTypes as T, diagnostics];
50+
}
51+
2952
export async function emitWithDiagnostics(
3053
code: string,
3154
options: GraphQLEmitterOptions = {},
32-
): Promise<[string, readonly Diagnostic[]]> {
55+
): Promise<readonly GraphQLTestResult[]> {
3356
const runner = await createGraphqlTestRunner();
3457
const outputFile = resolveVirtualPath("schema.graphql");
3558
const compilerOptions = { ...options, "output-file": outputFile };
@@ -41,13 +64,37 @@ export async function emitWithDiagnostics(
4164
},
4265
});
4366
const content = runner.fs.get(outputFile);
44-
ok(content, "Expected to have found graphql output");
45-
// Change this to whatever makes sense for the actual GraphQL emitter, probably a GraphQLSchemaRecord
46-
return [content, diagnostics];
67+
const schema = content
68+
? buildSchema(content, {
69+
assumeValidSDL: true,
70+
noLocation: true,
71+
})
72+
: undefined;
73+
74+
return [
75+
{
76+
graphQLSchema: schema,
77+
graphQLOutput: content,
78+
diagnostics,
79+
},
80+
];
81+
}
82+
83+
export async function emitSingleSchemaWithDiagnostics(
84+
code: string,
85+
options: GraphQLEmitterOptions = {},
86+
): Promise<GraphQLTestResult> {
87+
const schemaRecords = await emitWithDiagnostics(code, options);
88+
expect(schemaRecords.length).toBe(1);
89+
return schemaRecords[0];
4790
}
4891

49-
export async function emit(code: string, options: GraphQLEmitterOptions = {}): Promise<string> {
50-
const [result, diagnostics] = await emitWithDiagnostics(code, options);
51-
expectDiagnosticEmpty(diagnostics);
52-
return result;
92+
export async function emitSingleSchema(
93+
code: string,
94+
options: GraphQLEmitterOptions = {},
95+
): Promise<string> {
96+
const schemaRecord = await emitSingleSchemaWithDiagnostics(code, options);
97+
expectDiagnosticEmpty(schemaRecord.diagnostics);
98+
ok(schemaRecord.graphQLOutput, "Expected to have found graphql output");
99+
return schemaRecord.graphQLOutput;
53100
}

0 commit comments

Comments
 (0)