Skip to content

Commit 5ef2ace

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 `GraphQLTestResult` 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 73e3fd0 commit 5ef2ace

14 files changed

+265
-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?: valueof 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

+6-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.
@@ -95,8 +97,11 @@ export const libDef = {
9597
emitter: {
9698
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
9799
},
100+
state: {
101+
schema: { description: "State for the @schema decorator." },
102+
},
98103
} as const;
99104

100105
export const $lib = createTypeSpecLibrary(libDef);
101106

102-
export const { reportDiagnostic, createDiagnostic } = $lib;
107+
export const { reportDiagnostic, createDiagnostic, stateKeys: GraphQLKeys } = $lib;

packages/graphql/src/lib/schema.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
type DecoratorContext,
3+
type DecoratorFunction,
4+
type Namespace,
5+
type Program,
6+
validateDecoratorUniqueOnNode,
7+
} from "@typespec/compiler";
8+
9+
import { GraphQLKeys, NAMESPACE } from "../lib.js";
10+
import { useStateMap } from "./state-map.js";
11+
12+
// This will set the namespace for decorators implemented in this file
13+
export const namespace = NAMESPACE;
14+
15+
export interface SchemaDetails {
16+
name?: string;
17+
}
18+
19+
export interface Schema extends SchemaDetails {
20+
type: Namespace;
21+
}
22+
23+
const [getSchema, setSchema, getSchemaMap] = useStateMap<Namespace, Schema>(GraphQLKeys.schema);
24+
25+
/**
26+
* List all the schemas defined in the TypeSpec program
27+
* @param program Program
28+
* @returns List of schemas.
29+
*/
30+
export function listSchemas(program: Program): Schema[] {
31+
return [...getSchemaMap(program).values()];
32+
}
33+
34+
export {
35+
/**
36+
* Get the schema information for the given namespace.
37+
* @param program Program
38+
* @param namespace Schema namespace
39+
* @returns Schema information or undefined if namespace is not a schema namespace.
40+
*/
41+
getSchema,
42+
};
43+
44+
/**
45+
* Check if the namespace is defined as a schema.
46+
* @param program Program
47+
* @param namespace Namespace
48+
* @returns Boolean
49+
*/
50+
export function isSchema(program: Program, namespace: Namespace): boolean {
51+
return getSchemaMap(program).has(namespace);
52+
}
53+
54+
/**
55+
* Mark the given namespace as a schema.
56+
* @param program Program
57+
* @param namespace Namespace
58+
* @param details Schema details
59+
*/
60+
export function addSchema(
61+
program: Program,
62+
namespace: Namespace,
63+
details: SchemaDetails = {},
64+
): void {
65+
const schemaMap = getSchemaMap(program);
66+
const existing = schemaMap.get(namespace) ?? {};
67+
setSchema(program, namespace, { ...existing, ...details, type: namespace });
68+
}
69+
70+
export const $schema: DecoratorFunction = (
71+
context: DecoratorContext,
72+
target: Namespace,
73+
options: SchemaDetails = {},
74+
) => {
75+
validateDecoratorUniqueOnNode(context, target, $schema);
76+
addSchema(context.program, target, options);
77+
};

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

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Type } from "@typespec/compiler";
2+
import { unsafe_useStateMap, unsafe_useStateSet } from "@typespec/compiler/experimental";
3+
4+
export function useStateMap<K extends Type, V>(key: symbol) {
5+
return unsafe_useStateMap<K, V>(key);
6+
}
7+
8+
export function useStateSet<K extends Type>(key: symbol) {
9+
return unsafe_useStateSet<K>(key);
10+
}

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/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

+65-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 };
@@ -40,14 +63,47 @@ export async function emitWithDiagnostics(
4063
"@typespec/graphql": compilerOptions,
4164
},
4265
});
66+
67+
/**
68+
* There doesn't appear to be a good way to hook into the emit process and get the GraphQLSchema
69+
* that's produced by the emitter. So we're going to read the file that was emitted and parse it.
70+
*
71+
* This is the same way it's done in @typespec/openapi3:
72+
* https://github.com/microsoft/typespec/blame/1cf8601d0f65f707926d58d56566fb0cb4d4f4ff/packages/openapi3/test/test-host.ts#L105
73+
*/
74+
4375
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];
76+
const schema = content
77+
? buildSchema(content, {
78+
assumeValidSDL: true,
79+
noLocation: true,
80+
})
81+
: undefined;
82+
83+
return [
84+
{
85+
graphQLSchema: schema,
86+
graphQLOutput: content,
87+
diagnostics,
88+
},
89+
];
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 emitSingleSchemaWithDiagnostics(
93+
code: string,
94+
options: GraphQLEmitterOptions = {},
95+
): Promise<GraphQLTestResult> {
96+
const schemaRecords = await emitWithDiagnostics(code, options);
97+
expect(schemaRecords.length).toBe(1);
98+
return schemaRecords[0];
99+
}
100+
101+
export async function emitSingleSchema(
102+
code: string,
103+
options: GraphQLEmitterOptions = {},
104+
): Promise<string> {
105+
const schemaRecord = await emitSingleSchemaWithDiagnostics(code, options);
106+
expectDiagnosticEmpty(schemaRecord.diagnostics);
107+
ok(schemaRecord.graphQLOutput, "Expected to have found graphql output");
108+
return schemaRecord.graphQLOutput;
53109
}

pnpm-lock.yaml

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)