diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts new file mode 100644 index 0000000000..271630231e --- /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 111b933d1a..55b4725e74 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 0000000000..e09af071db --- /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 0000000000..70e66ce64b --- /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 0000000000..2eec540ae0 --- /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); + }); +});