Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/graphql/lib/decorators.tsp
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
34 changes: 34 additions & 0 deletions packages/graphql/src/lib/query-model.ts
Original file line number Diff line number Diff line change
@@ -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<Model>(
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);
}
39 changes: 39 additions & 0 deletions packages/graphql/src/lib/scalar-specification.ts
Original file line number Diff line number Diff line change
@@ -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<Scalar, string>(
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);
}
4 changes: 4 additions & 0 deletions packages/graphql/src/tsp-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand All @@ -14,5 +16,7 @@ export const $decorators: DecoratorImplementations = {
operationFields: $operationFields,
schema: $schema,
subscription: $subscription,
useAsQuery: $useAsQuery,
specifiedBy: $specifiedBy,
},
};
62 changes: 62 additions & 0 deletions packages/graphql/test/query-model.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 60 additions & 0 deletions packages/graphql/test/scalar-specification.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});