diff --git a/packages/graphql/lib/decorators.tsp b/packages/graphql/lib/decorators.tsp new file mode 100644 index 00000000000..3a2748243af --- /dev/null +++ b/packages/graphql/lib/decorators.tsp @@ -0,0 +1,107 @@ +import "../dist/src/tsp-index.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Marks an operation as a GraphQL query operation. + * + * @example + * ```typespec + * @query + * op getUser(id: string): User; + * ``` + */ +extern dec query(target: Operation); + +/** + * Marks an operation as a GraphQL mutation operation. + * + * @example + * ```typespec + * @mutation + * op createUser(userData: UserData): User; + * ``` + */ +extern dec mutation(target: Operation); + +/** + * Marks an operation as a GraphQL subscription operation. + * + * @example + * ```typespec + * @subscription + * op onUserUpdated(id: string): User; + * ``` + */ +extern dec subscription(target: Operation); + +/** + * Designates a model as a GraphQL interface type. + * Can only be applied to output models. + * + * @example + * ```typespec + * @Interface + * model Node { + * id: ID; + * } + * ``` + */ +extern dec Interface(target: Model); + +/** + * Specifies which interfaces a model implements. + * All interfaces must be models decorated with @Interface. + * + * @example + * ```typespec + * @compose(Node) + * model User { + * id: ID; + * name: string; + * } + * ``` + */ +extern dec compose(target: Model, ...implements: Interface.target[]); + +/** + * Adds operations as fields with arguments to a model. + * Operations and interfaces listed won't be emitted in the root GraphQL operations. + * + * @example + * ```typespec + * @operationFields(followers, ImageService.urls) + * model User { + * id: string; + * name: string; + * } + * ``` + */ +extern dec operationFields(target: Model, ...onOperations: (Operation | Interface)[]); + +/** + * Marks a model to be used as a custom query type. + * + * @example + * ```typespec + * @useAsQuery + * @operationFields(followers) + * model MyOwnQuery { + * me: User; + * } + * ``` + */ +extern dec useAsQuery(target: Model); + +/** + * Provides a URL to the specification for a custom scalar type. + * + * @example + * ```typespec + * @specifiedBy("https://tools.ietf.org/html/rfc4122") + * scalar UUID extends string; + * ``` + */ +extern dec specifiedBy(target: Scalar, url: valueof string); diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index cb5e690be51..ec578e31d1e 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -149,6 +149,8 @@ export const libDef = { compose: { description: "State for the @compose decorator." }, interface: { description: "State for the @Interface decorator." }, schema: { description: "State for the @schema decorator." }, + useAsQuery: { description: "State for the @useAsQuery decorator." }, + specifiedBy: { description: "State for the @specifiedBy decorator." }, }, } as const; diff --git a/packages/graphql/src/lib/query-model.ts b/packages/graphql/src/lib/query-model.ts new file mode 100644 index 00000000000..4dd4130f360 --- /dev/null +++ b/packages/graphql/src/lib/query-model.ts @@ -0,0 +1,34 @@ +import { + type DecoratorContext, + type Model, + type Program, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; +import { useStateSet } from "@typespec/compiler/utils"; +import { GraphQLKeys, NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +const [getUseAsQueryState, setUseAsQueryState] = useStateSet( + GraphQLKeys.useAsQuery +); + +/** + * Marks a model to be used as a custom query type. + */ +export const $useAsQuery = (context: DecoratorContext, target: Model) => { + validateDecoratorUniqueOnNode(context, target, $useAsQuery); + setUseAsQueryState(context.program, target); +}; + +/** + * Checks if a model is marked to be used as a custom query type. + * + * @param program The TypeSpec program + * @param model The model to check + * @returns True if the model is marked as a custom query model + */ +export function isCustomQueryModel(program: Program, model: Model): boolean { + return !!getUseAsQueryState(program, model); +} diff --git a/packages/graphql/src/lib/scalar-specification.ts b/packages/graphql/src/lib/scalar-specification.ts new file mode 100644 index 00000000000..1223f79bc84 --- /dev/null +++ b/packages/graphql/src/lib/scalar-specification.ts @@ -0,0 +1,39 @@ +import { + type DecoratorContext, + type Program, + type Scalar, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +const [getSpecifiedByUrl, setSpecifiedByUrl] = useStateMap( + GraphQLKeys.specifiedBy +); + +/** + * Sets the specification URL for a custom scalar type. + */ +export const $specifiedBy = ( + context: DecoratorContext, + target: Scalar, + url: string +) => { + validateDecoratorUniqueOnNode(context, target, $specifiedBy); + + setSpecifiedByUrl(context.program, target, url); +}; + +/** + * Gets the specification URL for a custom scalar type. + * + * @param program The TypeSpec program + * @param scalar The scalar to get the specification for + * @returns The specification URL or undefined if not specified + */ +export function getSpecificationUrl(program: Program, scalar: Scalar): string | undefined { + return getSpecifiedByUrl(program, scalar); +} diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts index e96efd58fe8..ece213d4a3c 100644 --- a/packages/graphql/src/tsp-index.ts +++ b/packages/graphql/src/tsp-index.ts @@ -4,6 +4,8 @@ import { $compose, $Interface } from "./lib/interface.js"; import { $mutation, $query, $subscription } from "./lib/operation-kind.js"; import { $operationFields } from "./lib/operation-fields.js"; import { $schema } from "./lib/schema.js"; +import { $useAsQuery } from "./lib/query-model.js"; +import { $specifiedBy } from "./lib/scalar-specification.js"; export const $decorators: DecoratorImplementations = { [NAMESPACE]: { @@ -14,5 +16,7 @@ export const $decorators: DecoratorImplementations = { operationFields: $operationFields, schema: $schema, subscription: $subscription, + useAsQuery: $useAsQuery, + specifiedBy: $specifiedBy, }, }; diff --git a/packages/graphql/test/query-model.test.ts b/packages/graphql/test/query-model.test.ts new file mode 100644 index 00000000000..1afb65d0694 --- /dev/null +++ b/packages/graphql/test/query-model.test.ts @@ -0,0 +1,62 @@ +import type { Model } from "@typespec/compiler"; +import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { isCustomQueryModel } from "../src/lib/query-model.js"; +import { compileAndDiagnose } from "./test-host.js"; + +describe("Query Model", () => { + it("marks a model as a custom query type", async () => { + const [program, { testModel }, diagnostics] = await compileAndDiagnose<{ + testModel: Model; + }>(` + @useAsQuery @test model testModel { + property: string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(isCustomQueryModel(program, testModel)).toBe(true); + }); + + it("allows marking a model with both useAsQuery and operationFields", async () => { + const [program, { customQuery, getUsers }, diagnostics] = await compileAndDiagnose<{ + customQuery: Model; + getUsers: Model; + }>(` + @test op getUsers(): string[]; + + @useAsQuery + @operationFields(getUsers) + @test model customQuery { + me: User; + } + + model User { + id: string; + name: string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(isCustomQueryModel(program, customQuery)).toBe(true); + }); + + it("can be applied to different models", async () => { + const [program, { firstQuery, secondQuery }, diagnostics] = await compileAndDiagnose<{ + firstQuery: Model; + secondQuery: Model; + }>(` + @useAsQuery @test model firstQuery { + property: string; + } + + @useAsQuery @test model secondQuery { + property: string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(isCustomQueryModel(program, firstQuery)).toBe(true); + expect(isCustomQueryModel(program, secondQuery)).toBe(true); + }); +}); diff --git a/packages/graphql/test/scalar-specification.test.ts b/packages/graphql/test/scalar-specification.test.ts new file mode 100644 index 00000000000..1345221afab --- /dev/null +++ b/packages/graphql/test/scalar-specification.test.ts @@ -0,0 +1,60 @@ +import type { Scalar } from "@typespec/compiler"; +import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getSpecificationUrl } from "../src/lib/scalar-specification.js"; +import { compileAndDiagnose } from "./test-host.js"; + +describe("Scalar Specification", () => { + it("sets specification URL for a custom scalar type", async () => { + const [program, { UUID }, diagnostics] = await compileAndDiagnose<{ + UUID: Scalar; + }>(` + @specifiedBy("https://tools.ietf.org/html/rfc4122") + @test scalar UUID extends string; + `); + + expectDiagnosticEmpty(diagnostics); + expect(getSpecificationUrl(program, UUID)).toBe("https://tools.ietf.org/html/rfc4122"); + }); + + it("allows setting specification URL for multiple scalar types", async () => { + const [program, { UUID, Email }, diagnostics] = await compileAndDiagnose<{ + UUID: Scalar; + Email: Scalar; + }>(` + @specifiedBy("https://tools.ietf.org/html/rfc4122") + @test scalar UUID extends string; + + @specifiedBy("https://tools.ietf.org/html/rfc5322") + @test scalar Email extends string; + `); + + expectDiagnosticEmpty(diagnostics); + expect(getSpecificationUrl(program, UUID)).toBe("https://tools.ietf.org/html/rfc4122"); + expect(getSpecificationUrl(program, Email)).toBe("https://tools.ietf.org/html/rfc5322"); + }); + + it("works with custom scalar types that don't extend from a base type", async () => { + const [program, { CustomScalar }, diagnostics] = await compileAndDiagnose<{ + CustomScalar: Scalar; + }>(` + @specifiedBy("https://example.com/custom-scalar-spec") + @test scalar CustomScalar; + `); + + expectDiagnosticEmpty(diagnostics); + expect(getSpecificationUrl(program, CustomScalar)).toBe("https://example.com/custom-scalar-spec"); + }); + + it("can reference specifications with complex URLs", async () => { + const [program, { ComplexURL }, diagnostics] = await compileAndDiagnose<{ + ComplexURL: Scalar; + }>(` + @specifiedBy("https://example.com/spec/v1.2.3?format=full&lang=en#section-3.4") + @test scalar ComplexURL extends string; + `); + + expectDiagnosticEmpty(diagnostics); + expect(getSpecificationUrl(program, ComplexURL)).toBe("https://example.com/spec/v1.2.3?format=full&lang=en#section-3.4"); + }); +});