From 880d7acf3a6e164811386ddf6b18686e3d121e5a Mon Sep 17 00:00:00 2001 From: Steve Rice Date: Mon, 18 Nov 2024 15:25:04 -0800 Subject: [PATCH] Set up GraphQL emitter skeleton This commit sets up the basic skeleton for the GraphQL emitter, capable of handling multiple defined GraphQL schemas. The approach here is slightly different from before. Here, we are limiting the functionality of `GraphQLEmitter` to handling the schema definitions, and not participating in any direct parsing of the TSP program. Instead, the `GraphQLTypeRegistry` is responsible for handling its own interpretation of the TSP program in order to provide the types in its registry. This primarily allows for two things: 1. The `GraphQLTypeRegistry` can encapsulate its own functionality, instead of being a "bucket of state" that must be modified and managed externally. 2. The "bucket of state" responsibility can be primarily handled by the StateMap library, with the `GraphQLTypeRegistry` being the orchestrator of that state The `GraphQLTypeRegistry` uses a `Proxy` object to ensure that the program navigation has taken place before any of its public properties are accessed. --- packages/graphql/src/registry.ts | 46 +++++++++ packages/graphql/src/schema-emitter.ts | 138 +++++++++++++++++++++++-- packages/graphql/test/assertions.ts | 37 +++++++ packages/graphql/test/emitter.test.ts | 21 ++++ packages/graphql/test/registry.test.ts | 20 ++++ 5 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 packages/graphql/src/registry.ts create mode 100644 packages/graphql/test/assertions.ts create mode 100644 packages/graphql/test/emitter.test.ts create mode 100644 packages/graphql/test/registry.test.ts diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts new file mode 100644 index 00000000000..271630231e4 --- /dev/null +++ b/packages/graphql/src/registry.ts @@ -0,0 +1,46 @@ +import { navigateProgram, type Program, type SemanticNodeListener } from "@typespec/compiler"; +import type { GraphQLObjectType } from "graphql"; + +type Mutable = { + -readonly [k in keyof T]: T[k]; +}; + +// This class contains the registry of all the GraphQL types that are being used in the program +export class GraphQLTypeRegistry { + program: Program; + readonly programNavigated: boolean = false; + + constructor(program: Program) { + this.program = program; + return new Proxy(this, { + get(target: GraphQLTypeRegistry, prop: string, receiver) { + if (GraphQLTypeRegistry.#publicGetters.includes(prop)) { + if (!target.programNavigated) { + const mutableThis = target as Mutable; + navigateProgram(target.program, target.#semanticNodeListener); + mutableThis.programNavigated = true; + } + } + return Reflect.get(target, prop, receiver); + }, + }); + } + + static get #publicGetters() { + return Object.entries(Object.getOwnPropertyDescriptors(GraphQLTypeRegistry.prototype)) + .filter(([key, descriptor]) => { + return typeof descriptor.get === "function" && key !== "constructor"; + }) + .map(([key]) => key); + } + + get rootQueryType(): GraphQLObjectType | undefined { + return; + } + + // This is the listener based on navigateProgram that will walk the TSP AST and register the types, + // deferred in some cases, and then materialize them in exitXXX functions + get #semanticNodeListener(): SemanticNodeListener { + return {}; + } +} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 111b933d1a8..55b4725e74e 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -1,6 +1,29 @@ -import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; +import { + emitFile, + getNamespaceFullName, + interpolatePath, + type EmitContext, + type Program, +} from "@typespec/compiler"; +import { + GraphQLBoolean, + GraphQLObjectType, + GraphQLSchema, + printSchema, + validateSchema, + type GraphQLSchemaConfig, +} from "graphql"; import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; +import { createDiagnostic, type GraphQLEmitterOptions } from "./lib.js"; +import { listSchemas, type Schema } from "./lib/schema.js"; +import { GraphQLTypeRegistry } from "./registry.js"; +import type { GraphQLSchemaRecord } from "./types.js"; + +export const PLACEHOLDER_FIELD = { + type: GraphQLBoolean, + description: + "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.", +}; export function createGraphQLEmitter( context: EmitContext, @@ -12,15 +35,116 @@ export function createGraphQLEmitter( emitGraphQL, }; + function resolveOutputFile(schema: Schema, multipleSchema: boolean): string { + return interpolatePath(options.outputFile, { + "schema-name": multipleSchema ? schema.name || getNamespaceFullName(schema.type) : "schema", + }); + } + async function emitGraphQL() { - // replace this with the real emitter code - if (!program.compilerOptions.noEmit) { - const filePath = interpolatePath(options.outputFile, { "schema-name": "schema" }); + const emitter = new GraphQLEmitter(program, options); + + for (const schemaRecord of emitter.schemaRecords) { + program.reportDiagnostics(schemaRecord.diagnostics); + } + + if (program.compilerOptions.noEmit || program.hasError()) { + return; + } + + const multipleSchema = emitter.schemaRecords.length > 1; + + for (const schemaRecord of emitter.schemaRecords) { await emitFile(program, { - path: filePath, - content: "", + path: resolveOutputFile(schemaRecord.schema, multipleSchema), + content: serializeDocument(schemaRecord.graphQLSchema), newLine: options.newLine, }); } } } + +function serializeDocument(schema: GraphQLSchema): string { + return printSchema(schema); +} + +export class GraphQLEmitter { + private options: ResolvedGraphQLEmitterOptions; + program: Program; + + constructor(program: Program, options: ResolvedGraphQLEmitterOptions) { + this.options = options; + this.program = program; + } + + private _schemaDefinitions?: Schema[]; + get schemaDefinitions(): Schema[] { + if (!this._schemaDefinitions) { + const schemas = listSchemas(this.program); + if (schemas.length === 0) { + schemas.push({ type: this.program.getGlobalNamespaceType() }); + } + this._schemaDefinitions = schemas; + } + return this._schemaDefinitions; + } + + private _registry?: GraphQLTypeRegistry; + get registry() { + if (!this._registry) { + this._registry = new GraphQLTypeRegistry(this.program); + } + return this._registry; + } + + private _schemaRecords?: GraphQLSchemaRecord[]; + get schemaRecords(): GraphQLSchemaRecord[] { + if (!this._schemaRecords) { + this._schemaRecords = this.buildGraphQLSchemas(); + } + return this._schemaRecords; + } + + static get placeholderQuery(): GraphQLObjectType { + return new GraphQLObjectType({ + name: "Query", + fields: { + // An Object type must define one or more fields. + // https://spec.graphql.org/October2021/#sec-Objects.Type-Validation + _: PLACEHOLDER_FIELD, + }, + }); + } + + private buildGraphQLSchemas(): GraphQLSchemaRecord[] { + const schemaRecords: GraphQLSchemaRecord[] = []; + + for (const schema of this.schemaDefinitions) { + const schemaConfig: GraphQLSchemaConfig = {}; + if (!("query" in schemaConfig)) { + // The query root operation type must be provided and must be an Object type. + // https://spec.graphql.org/draft/#sec-Root-Operation-Types + schemaConfig.query = GraphQLEmitter.placeholderQuery; + } + // Build schema + const graphQLSchema = new GraphQLSchema(schemaConfig); + // Validate schema + const validationErrors = validateSchema(graphQLSchema); + const diagnostics = validationErrors.map((error) => { + const locations = error.locations?.map((loc) => `line ${loc.line}, column ${loc.column}`); + return createDiagnostic({ + code: "graphql-validation-error", + format: { + message: error.message, + locations: locations ? locations.join(", ") : "none", + }, + target: schema.type, + }); + }); + + schemaRecords.push({ schema, graphQLSchema, diagnostics }); + } + + return schemaRecords; + } +} diff --git a/packages/graphql/test/assertions.ts b/packages/graphql/test/assertions.ts new file mode 100644 index 00000000000..e09af071db9 --- /dev/null +++ b/packages/graphql/test/assertions.ts @@ -0,0 +1,37 @@ +import { isType, type GraphQLType } from "graphql"; +import { expect } from "vitest"; + +interface GraphQLAssertions { + toEqualType: (expected: GraphQLType) => R; +} + +declare module "vitest" { + interface Assertion extends GraphQLAssertions {} + interface AsymmetricMatchersContaining extends GraphQLAssertions {} +} + +expect.extend({ + toEqualType(received: GraphQLType, expected: GraphQLType) { + if (!isType(expected)) { + return { + pass: false, + message: () => `Expected value ${expected} is not a GraphQLType.`, + }; + } + + if (!isType(received)) { + return { + pass: false, + message: () => `Received value ${received} is not a GraphQLType.`, + }; + } + + const { isNot } = this; + return { + pass: received.toJSON() === expected.toJSON(), + message: () => `${received} is${isNot ? " not" : ""} the same as ${expected}`, + }; + }, +}); + +export { expect }; diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts new file mode 100644 index 00000000000..70e66ce64b9 --- /dev/null +++ b/packages/graphql/test/emitter.test.ts @@ -0,0 +1,21 @@ +import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { GraphQLSchema, printSchema } from "graphql"; +import { describe, it } from "vitest"; +import { GraphQLEmitter } from "../src/schema-emitter.js"; +import { expect } from "./assertions.js"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +describe("GraphQL emitter", () => { + it("Can produce a placeholder GraphQL schema", async () => { + const result = await emitSingleSchemaWithDiagnostics(""); + expectDiagnosticEmpty(result.diagnostics); + expect(result.graphQLSchema).toBeInstanceOf(GraphQLSchema); + expect(result.graphQLSchema?.getQueryType()).toEqualType(GraphQLEmitter.placeholderQuery); + }); + + it("Can produce an SDL output", async () => { + const result = await emitSingleSchemaWithDiagnostics(""); + expectDiagnosticEmpty(result.diagnostics); + expect(result.graphQLOutput).toEqual(printSchema(result.graphQLSchema!)); + }); +}); diff --git a/packages/graphql/test/registry.test.ts b/packages/graphql/test/registry.test.ts new file mode 100644 index 00000000000..2eec540ae05 --- /dev/null +++ b/packages/graphql/test/registry.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, it } from "vitest"; +import { GraphQLTypeRegistry } from "../src/registry.js"; +import { expect } from "./assertions.js"; +import { createGraphqlTestRunner } from "./test-host.js"; + +describe("GraphQL Type Registry", () => { + let registry: GraphQLTypeRegistry; + + beforeEach(async () => { + const runner = await createGraphqlTestRunner(); + await runner.diagnose(""); + registry = new GraphQLTypeRegistry(runner.program); + }); + + it("Will navigate program when state is accessed", () => { + expect(registry.programNavigated).toBe(false); + expect(registry.rootQueryType).toBeUndefined(); + expect(registry.programNavigated).toBe(true); + }); +});