From 80f7b845f1a19e33652478865d16ded954e40f4d 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..f4945d9ecec 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 { + #options: ResolvedGraphQLEmitterOptions; + program: Program; + + constructor(program: Program, options: ResolvedGraphQLEmitterOptions) { + this.#options = options; + this.program = program; + } + + #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; + } + + #registry?: GraphQLTypeRegistry; + get registry() { + if (!this.#registry) { + this.#registry = new GraphQLTypeRegistry(this.program); + } + return this.#registry; + } + + #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, + }, + }); + } + + #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); + }); +});