diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp new file mode 100644 index 00000000000..9991233a2c3 --- /dev/null +++ b/packages/graphql/lib/main.tsp @@ -0,0 +1 @@ +import "./schema.tsp"; diff --git a/packages/graphql/lib/schema.tsp b/packages/graphql/lib/schema.tsp new file mode 100644 index 00000000000..4aab6e2c2bb --- /dev/null +++ b/packages/graphql/lib/schema.tsp @@ -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); diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 5bbc496c381..6ff7d236046 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -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" }, @@ -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", diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index d274d306f8a..db6fe94e5fb 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -1,2 +1,3 @@ export { $onEmit } from "./emitter.js"; export { $lib } from "./lib.js"; +export { $decorators } from "./tsp-index.js"; diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index f9216283c20..27d3acf1799 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -1,5 +1,7 @@ import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler"; +export const NAMESPACE = "TypeSpec.GraphQL"; + export interface GraphQLEmitterOptions { /** * Name of the output file. @@ -95,8 +97,11 @@ export const libDef = { emitter: { options: EmitterOptionsSchema as JSONSchemaType, }, + 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; diff --git a/packages/graphql/src/lib/schema.ts b/packages/graphql/src/lib/schema.ts new file mode 100644 index 00000000000..7c083fe5c77 --- /dev/null +++ b/packages/graphql/src/lib/schema.ts @@ -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(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); +}; diff --git a/packages/graphql/src/lib/state-map.ts b/packages/graphql/src/lib/state-map.ts new file mode 100644 index 00000000000..5859e46c808 --- /dev/null +++ b/packages/graphql/src/lib/state-map.ts @@ -0,0 +1,10 @@ +import type { Type } from "@typespec/compiler"; +import { unsafe_useStateMap, unsafe_useStateSet } from "@typespec/compiler/experimental"; + +export function useStateMap(key: symbol) { + return unsafe_useStateMap(key); +} + +export function useStateSet(key: symbol) { + return unsafe_useStateSet(key); +} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 8c145f53b54..111b933d1a8 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -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, }); } diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts new file mode 100644 index 00000000000..dec5cda6d81 --- /dev/null +++ b/packages/graphql/src/tsp-index.ts @@ -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, + }, +}; diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts new file mode 100644 index 00000000000..f9a3daf932e --- /dev/null +++ b/packages/graphql/src/types.d.ts @@ -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[]; +} diff --git a/packages/graphql/test/hello.test.ts b/packages/graphql/test/hello.test.ts deleted file mode 100644 index 3a41332b322..00000000000 --- a/packages/graphql/test/hello.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { strictEqual } from "node:assert"; -import { describe, it } from "vitest"; -import { emit } from "./test-host.js"; - -describe("hello", () => { - it("emit output file with content hello world", async () => { - const emitterContent = await emit(`op test(): void;`); - strictEqual(emitterContent, "Hello world"); - }); -}); diff --git a/packages/graphql/test/schema.test.ts b/packages/graphql/test/schema.test.ts new file mode 100644 index 00000000000..121ba7e8809 --- /dev/null +++ b/packages/graphql/test/schema.test.ts @@ -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"); + }); +}); diff --git a/packages/graphql/test/test-host.ts b/packages/graphql/test/test-host.ts index bfb7af8943a..6e7568dcb47 100644 --- a/packages/graphql/test/test-host.ts +++ b/packages/graphql/test/test-host.ts @@ -1,4 +1,4 @@ -import type { Diagnostic } from "@typespec/compiler"; +import type { Diagnostic, Program, Type } from "@typespec/compiler"; import { createTestHost, createTestWrapper, @@ -6,6 +6,9 @@ import { 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"; @@ -15,10 +18,17 @@ 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"], @@ -26,10 +36,23 @@ export async function createGraphqlTestRunner() { }); } +export async function diagnose(code: string): Promise { + const runner = await createGraphqlTestRunner(); + return runner.diagnose(code); +} + +export async function compileAndDiagnose>( + 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 { const runner = await createGraphqlTestRunner(); const outputFile = resolveVirtualPath("schema.graphql"); const compilerOptions = { ...options, "output-file": outputFile }; @@ -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 { - const [result, diagnostics] = await emitWithDiagnostics(code, options); - expectDiagnosticEmpty(diagnostics); - return result; +export async function emitSingleSchemaWithDiagnostics( + code: string, + options: GraphQLEmitterOptions = {}, +): Promise { + const schemaRecords = await emitWithDiagnostics(code, options); + expect(schemaRecords.length).toBe(1); + return schemaRecords[0]; +} + +export async function emitSingleSchema( + code: string, + options: GraphQLEmitterOptions = {}, +): Promise { + const schemaRecord = await emitSingleSchemaWithDiagnostics(code, options); + expectDiagnosticEmpty(schemaRecord.diagnostics); + ok(schemaRecord.graphQLOutput, "Expected to have found graphql output"); + return schemaRecord.graphQLOutput; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57126b3f47d..855ab3746ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,6 +452,10 @@ importers: version: 2.1.2(@types/node@22.7.5)(@vitest/ui@2.1.2)(happy-dom@15.10.2)(jsdom@25.0.1)(terser@5.34.1) packages/graphql: + dependencies: + graphql: + specifier: ^16.9.0 + version: 16.9.0 devDependencies: '@types/node': specifier: ~22.7.5 @@ -7957,6 +7961,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.9.0: + resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -19645,6 +19653,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.9.0: {} + gray-matter@4.0.3: dependencies: js-yaml: 3.14.1