Skip to content

Commit

Permalink
Add @schema decorator to mark namespaces as GraphQL schemas
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
steverice committed Nov 20, 2024
1 parent 73e3fd0 commit f87595e
Show file tree
Hide file tree
Showing 14 changed files with 265 additions and 21 deletions.
1 change: 1 addition & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./schema.tsp";
23 changes: 23 additions & 0 deletions packages/graphql/lib/schema.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import "../dist/src/lib/schema.js";

using TypeSpec.Reflection;

namespace TypeSpec.GraphQL;

namespace Schema {
model SchemaOptions {
name?: string;
}
}

/**
* Mark this namespace as describing a GraphQL schema and configure schema properties.
*
* @example
*
* ```typespec
* @schema(#{name: "MySchema"})
* namespace MySchema {};
* ```
*/
extern dec schema(target: Namespace, options?: valueof Schema.SchemaOptions);
7 changes: 7 additions & 0 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"main": "dist/src/index.js",
"exports": {
".": {
"typespec": "./lib/main.tsp",
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
},
Expand All @@ -31,6 +32,12 @@
"engines": {
"node": ">=18.0.0"
},
"graphql": {
"documents": "test/**/*.{js,ts}"
},
"dependencies": {
"graphql": "^16.9.0"
},
"scripts": {
"clean": "rimraf ./dist ./temp",
"build": "tsc",
Expand Down
1 change: 1 addition & 0 deletions packages/graphql/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { $onEmit } from "./emitter.js";
export { $lib } from "./lib.js";
export { $decorators } from "./tsp-index.js";
7 changes: 6 additions & 1 deletion packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler";

export const NAMESPACE = "TypeSpec.GraphQL";

export interface GraphQLEmitterOptions {
/**
* Name of the output file.
Expand Down Expand Up @@ -95,8 +97,11 @@ export const libDef = {
emitter: {
options: EmitterOptionsSchema as JSONSchemaType<GraphQLEmitterOptions>,
},
state: {
schema: { description: "State for the @schema decorator." },
},
} as const;

export const $lib = createTypeSpecLibrary(libDef);

export const { reportDiagnostic, createDiagnostic } = $lib;
export const { reportDiagnostic, createDiagnostic, stateKeys: GraphQLKeys } = $lib;
77 changes: 77 additions & 0 deletions packages/graphql/src/lib/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
type DecoratorContext,
type DecoratorFunction,
type Namespace,
type Program,
validateDecoratorUniqueOnNode,
} from "@typespec/compiler";

import { GraphQLKeys, NAMESPACE } from "../lib.js";
import { useStateMap } from "./state-map.js";

// This will set the namespace for decorators implemented in this file
export const namespace = NAMESPACE;

export interface SchemaDetails {
name?: string;
}

export interface Schema extends SchemaDetails {
type: Namespace;
}

const [getSchema, setSchema, getSchemaMap] = useStateMap<Namespace, Schema>(GraphQLKeys.schema);

/**
* List all the schemas defined in the TypeSpec program
* @param program Program
* @returns List of schemas.
*/
export function listSchemas(program: Program): Schema[] {
return [...getSchemaMap(program).values()];
}

export {
/**
* Get the schema information for the given namespace.
* @param program Program
* @param namespace Schema namespace
* @returns Schema information or undefined if namespace is not a schema namespace.
*/
getSchema,
};

/**
* Check if the namespace is defined as a schema.
* @param program Program
* @param namespace Namespace
* @returns Boolean
*/
export function isSchema(program: Program, namespace: Namespace): boolean {
return getSchemaMap(program).has(namespace);
}

/**
* Mark the given namespace as a schema.
* @param program Program
* @param namespace Namespace
* @param details Schema details
*/
export function addSchema(
program: Program,
namespace: Namespace,
details: SchemaDetails = {},
): void {
const schemaMap = getSchemaMap(program);
const existing = schemaMap.get(namespace) ?? {};
setSchema(program, namespace, { ...existing, ...details, type: namespace });
}

export const $schema: DecoratorFunction = (
context: DecoratorContext,
target: Namespace,
options: SchemaDetails = {},
) => {
validateDecoratorUniqueOnNode(context, target, $schema);
addSchema(context.program, target, options);
};
10 changes: 10 additions & 0 deletions packages/graphql/src/lib/state-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Type } from "@typespec/compiler";
import { unsafe_useStateMap, unsafe_useStateSet } from "@typespec/compiler/experimental";

export function useStateMap<K extends Type, V>(key: symbol) {
return unsafe_useStateMap<K, V>(key);
}

export function useStateSet<K extends Type>(key: symbol) {
return unsafe_useStateSet<K>(key);
}
2 changes: 1 addition & 1 deletion packages/graphql/src/schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function createGraphQLEmitter(
const filePath = interpolatePath(options.outputFile, { "schema-name": "schema" });
await emitFile(program, {
path: filePath,
content: "Hello world",
content: "",
newLine: options.newLine,
});
}
Expand Down
9 changes: 9 additions & 0 deletions packages/graphql/src/tsp-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { DecoratorImplementations } from "@typespec/compiler";
import { NAMESPACE } from "./lib.js";
import { $schema } from "./lib/schema.js";

export const $decorators: DecoratorImplementations = {
[NAMESPACE]: {
schema: $schema,
},
};
18 changes: 18 additions & 0 deletions packages/graphql/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Diagnostic } from "@typespec/compiler";
import type { GraphQLSchema } from "graphql";
import type { Schema } from "./lib/schema.ts";

/**
* A record containing the GraphQL schema corresponding to
* a particular schema definition.
*/
export interface GraphQLSchemaRecord {
/** The declared schema that generated this GraphQL schema */
readonly schema: Schema;

/** The GraphQLSchema */
readonly graphQLSchema: GraphQLSchema;

/** The diagnostics created for this schema */
readonly diagnostics: readonly Diagnostic[];
}
10 changes: 0 additions & 10 deletions packages/graphql/test/hello.test.ts

This file was deleted.

37 changes: 37 additions & 0 deletions packages/graphql/test/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Namespace } from "@typespec/compiler";
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
import { describe, expect, it } from "vitest";
import { getSchema } from "../src/lib/schema.js";
import { compileAndDiagnose } from "./test-host.js";

describe("@schema", () => {
it("Creates a schema with no name", async () => {
const [program, { TestNamespace }, diagnostics] = await compileAndDiagnose<{
TestNamespace: Namespace;
}>(`
@schema
@test namespace TestNamespace {}
`);
expectDiagnosticEmpty(diagnostics);

const schema = getSchema(program, TestNamespace);

expect(schema?.type).toBe(TestNamespace);
expect(schema?.name).toBeUndefined();
});

it("Creates a schema with a specified name", async () => {
const [program, { TestNamespace }, diagnostics] = await compileAndDiagnose<{
TestNamespace: Namespace;
}>(`
@schema(#{name: "MySchema"})
@test namespace TestNamespace {}
`);
expectDiagnosticEmpty(diagnostics);

const schema = getSchema(program, TestNamespace);

expect(schema?.type).toBe(TestNamespace);
expect(schema?.name).toBe("MySchema");
});
});
74 changes: 65 additions & 9 deletions packages/graphql/test/test-host.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { Diagnostic } from "@typespec/compiler";
import type { Diagnostic, Program, Type } from "@typespec/compiler";
import {
createTestHost,
createTestWrapper,
expectDiagnosticEmpty,
resolveVirtualPath,
} from "@typespec/compiler/testing";
import { ok } from "assert";
import type { GraphQLSchema } from "graphql";
import { buildSchema } from "graphql";
import { expect } from "vitest";
import type { GraphQLEmitterOptions } from "../src/lib.js";
import { GraphqlTestLibrary } from "../src/testing/index.js";

Expand All @@ -15,21 +18,41 @@ export async function createGraphqlTestHost() {
});
}

export interface GraphQLTestResult {
readonly graphQLSchema?: GraphQLSchema;
readonly graphQLOutput?: string;
readonly diagnostics: readonly Diagnostic[];
}

export async function createGraphqlTestRunner() {
const host = await createGraphqlTestHost();

return createTestWrapper(host, {
autoUsings: ["TypeSpec.GraphQL"],
compilerOptions: {
noEmit: false,
emit: ["@typespec/graphql"],
},
});
}

export async function diagnose(code: string): Promise<readonly Diagnostic[]> {
const runner = await createGraphqlTestRunner();
return runner.diagnose(code);
}

export async function compileAndDiagnose<T extends Record<string, Type>>(
code: string,
): Promise<[Program, T, readonly Diagnostic[]]> {
const runner = await createGraphqlTestRunner();
const [testTypes, diagnostics] = await runner.compileAndDiagnose(code);
return [runner.program, testTypes as T, diagnostics];
}

export async function emitWithDiagnostics(
code: string,
options: GraphQLEmitterOptions = {},
): Promise<[string, readonly Diagnostic[]]> {
): Promise<readonly GraphQLTestResult[]> {
const runner = await createGraphqlTestRunner();
const outputFile = resolveVirtualPath("schema.graphql");
const compilerOptions = { ...options, "output-file": outputFile };
Expand All @@ -40,14 +63,47 @@ export async function emitWithDiagnostics(
"@typespec/graphql": compilerOptions,
},
});

/**
* There doesn't appear to be a good way to hook into the emit process and get the GraphQLSchema
* that's produced by the emitter. So we're going to read the file that was emitted and parse it.
*
* This is the same way it's done in @typespec/openapi3:
* https://github.com/microsoft/typespec/blame/1cf8601d0f65f707926d58d56566fb0cb4d4f4ff/packages/openapi3/test/test-host.ts#L105
*/

const content = runner.fs.get(outputFile);
ok(content, "Expected to have found graphql output");
// Change this to whatever makes sense for the actual GraphQL emitter, probably a GraphQLSchemaRecord
return [content, diagnostics];
const schema = content
? buildSchema(content, {
assumeValidSDL: true,
noLocation: true,
})
: undefined;

return [
{
graphQLSchema: schema,
graphQLOutput: content,
diagnostics,
},
];
}

export async function emit(code: string, options: GraphQLEmitterOptions = {}): Promise<string> {
const [result, diagnostics] = await emitWithDiagnostics(code, options);
expectDiagnosticEmpty(diagnostics);
return result;
export async function emitSingleSchemaWithDiagnostics(
code: string,
options: GraphQLEmitterOptions = {},
): Promise<GraphQLTestResult> {
const schemaRecords = await emitWithDiagnostics(code, options);
expect(schemaRecords.length).toBe(1);
return schemaRecords[0];
}

export async function emitSingleSchema(
code: string,
options: GraphQLEmitterOptions = {},
): Promise<string> {
const schemaRecord = await emitSingleSchemaWithDiagnostics(code, options);
expectDiagnosticEmpty(schemaRecord.diagnostics);
ok(schemaRecord.graphQLOutput, "Expected to have found graphql output");
return schemaRecord.graphQLOutput;
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f87595e

Please sign in to comment.