From 8ddde71fc79ee7e992d9e2f807eadb048f0d7757 Mon Sep 17 00:00:00 2001 From: Steve Rice Date: Fri, 22 Nov 2024 11:16:36 -0800 Subject: [PATCH] Implement `@Interface` and `@compose` decorators The `@compose` decorator is used to indicate that a GraphQL `type` or `interface` implements one or more interfaces. As [defined by the GraphQL spec](https://spec.graphql.org/October2021/#sec-Interfaces), a `type` or `interface` implementing an interface must contain all the properties defined by that interface, and so we require that the TypeSpec model implement the interface's properties as well. There is no restriction on how this is accomplished (via spread, via composition, via manual copying of properties, etc). To reduce confusion, we do not allow a TypeSpec model to define both a `type` and an `interface`. In order to define an `interface`, the model must be decorated with the `@Interface` decorator. --- packages/graphql/lib/interface.tsp | 34 ++++ packages/graphql/lib/main.tsp | 1 + packages/graphql/src/lib.ts | 31 +++- packages/graphql/src/lib/interface.ts | 151 +++++++++++++++ packages/graphql/src/tsp-index.ts | 3 + packages/graphql/src/types.d.ts | 4 + packages/graphql/test/interface.test.ts | 234 ++++++++++++++++++++++++ 7 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 packages/graphql/lib/interface.tsp create mode 100644 packages/graphql/src/lib/interface.ts create mode 100644 packages/graphql/test/interface.test.ts diff --git a/packages/graphql/lib/interface.tsp b/packages/graphql/lib/interface.tsp new file mode 100644 index 0000000000..c0eac45de0 --- /dev/null +++ b/packages/graphql/lib/interface.tsp @@ -0,0 +1,34 @@ +import "../dist/src/lib/interface.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark this model as a GraphQL Interface. Interfaces can be implemented by other models. + * + * @example + * + * ```typespec + * @Interface + * model Person { + * name: string; + * } + */ +extern dec Interface(target: Model); + +/** + * Specify the GraphQL interfaces that should be implemented by a model. + * The interfaces must be decorated with the @Interface decorator, + * and all of the interfaces' properties must be present and compatible. + * + * @example + * + * ```typespec + * @compose(Influencer, Person) + * model User { + * ... Influencer; + * ... Person; + * } + */ +extern dec compose(target: Model, ...implements: Model[]); diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp index 9991233a2c..37fd542fec 100644 --- a/packages/graphql/lib/main.tsp +++ b/packages/graphql/lib/main.tsp @@ -1 +1,2 @@ +import "./interface.tsp"; import "./schema.tsp"; diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 27d3acf179..3f66b1c64c 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -1,4 +1,4 @@ -import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler"; +import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler"; export const NAMESPACE = "TypeSpec.GraphQL"; @@ -93,11 +93,38 @@ const EmitterOptionsSchema: JSONSchemaType = { export const libDef = { name: "@typespec/graphql", - diagnostics: {}, + diagnostics: { + "invalid-interface": { + severity: "error", + messages: { + default: paramMessage`All models used with \`@compose\` must be marked as an \`@Interface\`, but ${"interface"} is not.`, + }, + }, + "circular-interface": { + severity: "error", + messages: { + default: "An interface cannot implement itself.", + }, + }, + "missing-interface-property": { + severity: "error", + messages: { + default: paramMessage`Model must contain property \`${"property"}\` from \`${"interface"}\` in order to implement it in GraphQL.`, + }, + }, + "incompatible-interface-property": { + severity: "error", + messages: { + default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`, + }, + }, + }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, }, state: { + compose: { description: "State for the @compose decorator." }, + interface: { description: "State for the @Interface decorator." }, schema: { description: "State for the @schema decorator." }, }, } as const; diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts new file mode 100644 index 0000000000..eb91abb710 --- /dev/null +++ b/packages/graphql/src/lib/interface.ts @@ -0,0 +1,151 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Model, + type ModelProperty, + type Program, + validateDecoratorTarget, + validateDecoratorUniqueOnNode, + walkPropertiesInherited, +} from "@typespec/compiler"; + +import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js"; +import type { Tagged } from "../types.d.ts"; +import { useStateMap, useStateSet } from "./state-map.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** An Interface is a model that has been marked as an Interface */ +type Interface = Tagged; + +const [getInterface, setInterface] = useStateSet(GraphQLKeys.interface); +const [getComposition, setComposition, _getCompositionMap] = useStateMap( + GraphQLKeys.compose, +); + +export { + /** + * Get the implemented interfaces for a given model + * @param program Program + * @param model Model + * @returns Composed interfaces or undefined if no interfaces are composed. + */ + getComposition, +}; + +/** + * Check if the model is defined as a schema. + * @param program Program + * @param model Model + * @returns Boolean + */ +export function isInterface(program: Program, model: Model | Interface): model is Interface { + return !!getInterface(program, model as Interface); +} + +function validateImplementedsAreInterfaces(context: DecoratorContext, interfaces: Model[]) { + let valid = true; + + for (const iface of interfaces) { + if (!isInterface(context.program, iface)) { + valid = false; + reportDiagnostic(context.program, { + code: "invalid-interface", + format: { interface: iface.name }, + target: context.decoratorTarget, + }); + } + } + + return valid; +} + +function validateNoCircularImplementation( + context: DecoratorContext, + target: Model, + interfaces: Interface[], +) { + const valid = !isInterface(context.program, target) || !interfaces.includes(target); + if (!valid) { + reportDiagnostic(context.program, { + code: "circular-interface", + target: context.decoratorTarget, + }); + } + return valid; +} + +function propertiesEqual(prop1: ModelProperty, prop2: ModelProperty): boolean { + // TODO is there some canonical way to do this? + return ( + prop1.name === prop2.name && prop1.type === prop2.type && prop1.optional === prop2.optional + ); +} + +function validateImplementsInterfaceProperties( + context: DecoratorContext, + modelProperties: Map, + iface: Interface, +) { + let valid = true; + + for (const prop of walkPropertiesInherited(iface)) { + if (!modelProperties.has(prop.name)) { + valid = false; + reportDiagnostic(context.program, { + code: "missing-interface-property", + format: { interface: iface.name, property: prop.name }, + target: context.decoratorTarget, + }); + } else if (!propertiesEqual(modelProperties.get(prop.name)!, prop)) { + valid = false; + reportDiagnostic(context.program, { + code: "incompatible-interface-property", + format: { interface: iface.name, property: prop.name }, + target: context.decoratorTarget, + }); + } + } + + return valid; +} + +function validateImplementsInterfacesProperties( + context: DecoratorContext, + target: Model, + interfaces: Interface[], +) { + let valid = true; + const allModelProperties = new Map( + [...walkPropertiesInherited(target)].map((prop) => [prop.name, prop]), + ); + for (const iface of interfaces) { + if (!validateImplementsInterfaceProperties(context, allModelProperties, iface)) { + valid = false; + } + } + return valid; +} + +export const $Interface: DecoratorFunction = (context: DecoratorContext, target: Model) => { + validateDecoratorTarget(context, target, "@Interface", "Model"); // TODO: Is this needed? https://github.com/Azure/cadl-azure/issues/1022 + validateDecoratorUniqueOnNode(context, target, $Interface); + setInterface(context.program, target as Interface); +}; + +export const $compose: DecoratorFunction = ( + context: DecoratorContext, + target: Model, + ...interfaces: Interface[] +) => { + validateDecoratorTarget(context, target, "@compose", "Model"); // TODO: Is this needed? https://github.com/Azure/cadl-azure/issues/1022 + validateImplementedsAreInterfaces(context, interfaces); + validateNoCircularImplementation(context, target, interfaces); + validateImplementsInterfacesProperties(context, target, interfaces); + const existingCompose = getComposition(context.program, target); + if (existingCompose) { + interfaces = [...existingCompose, ...interfaces]; + } + setComposition(context.program, target, interfaces); +}; diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts index dec5cda6d8..1f3b87992d 100644 --- a/packages/graphql/src/tsp-index.ts +++ b/packages/graphql/src/tsp-index.ts @@ -1,9 +1,12 @@ import type { DecoratorImplementations } from "@typespec/compiler"; import { NAMESPACE } from "./lib.js"; +import { $compose, $Interface } from "./lib/interface.js"; import { $schema } from "./lib/schema.js"; export const $decorators: DecoratorImplementations = { [NAMESPACE]: { + compose: $compose, + Interface: $Interface, schema: $schema, }, }; diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts index f9a3daf932..651cc1bb81 100644 --- a/packages/graphql/src/types.d.ts +++ b/packages/graphql/src/types.d.ts @@ -16,3 +16,7 @@ export interface GraphQLSchemaRecord { /** The diagnostics created for this schema */ readonly diagnostics: readonly Diagnostic[]; } + +declare const tags: unique symbol; + +type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; diff --git a/packages/graphql/test/interface.test.ts b/packages/graphql/test/interface.test.ts new file mode 100644 index 0000000000..c680498c49 --- /dev/null +++ b/packages/graphql/test/interface.test.ts @@ -0,0 +1,234 @@ +import type { Interface, Model } from "@typespec/compiler"; +import { + expectDiagnosticEmpty, + expectDiagnostics, + expectIdenticalTypes, +} from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getComposition, isInterface } from "../src/lib/interface.js"; +import { compileAndDiagnose, diagnose } from "./test-host.js"; + +describe("@Interface", () => { + it("Marks the model as an interface", async () => { + const [program, { TestModel }, diagnostics] = await compileAndDiagnose<{ + TestModel: Model; + }>(` + @Interface + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + expect(isInterface(program, TestModel)).toBe(true); + }); +}); + +describe("@compose", () => { + it("Can compose and store the composition", async () => { + const [program, { TestModel, AnInterface }, diagnostics] = await compileAndDiagnose<{ + TestModel: Model; + AnInterface: Interface; + }>(` + @Interface + @test model AnInterface {} + + @compose(AnInterface) + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + const composition = getComposition(program, TestModel); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(1); + expectIdenticalTypes(composition![0], AnInterface); + }); + + it("Can compose multiple interfaces", async () => { + const [program, { TestModel, FirstInterface, SecondInterface }, diagnostics] = + await compileAndDiagnose<{ + TestModel: Model; + FirstInterface: Interface; + SecondInterface: Interface; + }>(` + @Interface + @test model FirstInterface {} + @Interface + @test model SecondInterface {} + + @compose(FirstInterface, SecondInterface) + @test model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + + const composition = getComposition(program, TestModel); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(2); + expectIdenticalTypes(composition![0], FirstInterface); + expectIdenticalTypes(composition![1], SecondInterface); + }); + + it("Can spread properties from the interface", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + ...AnInterface; + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can extend properties from the interface", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel extends AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can copy the interface", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel is AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can receive properties from a template", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + model Template { + prop: string; + extraProp: ExtraProp; + } + + @compose(AnInterface) + model TestModel { + ...Template; + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Requires that an implemented model is an Interface", async () => { + const diagnostics = await diagnose(` + model NotAnInterface {} + + @compose(NotAnInterface) + @test model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/invalid-interface", + message: + "All models used with `@compose` must be marked as an `@Interface`, but NotAnInterface is not.", + }); + }); + + it("Requires that all implemented models are Interfaces", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface {} + model NotAnInterface {} + + @compose(AnInterface, NotAnInterface) + @test model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/invalid-interface", + message: + "All models used with `@compose` must be marked as an `@Interface`, but NotAnInterface is not.", + }); + }); + + it("Allows Interfaces to implement other Interfaces", async () => { + const [program, { AnInterface, AnotherInterface }, diagnostics] = await compileAndDiagnose<{ + AnInterface: Model; + AnotherInterface: Interface; + }>(` + @Interface + @test model AnotherInterface {} + + @compose(AnotherInterface) + @Interface + @test model AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + + const composition = getComposition(program, AnInterface); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(1); + expectIdenticalTypes(composition![0], AnotherInterface); + }); + + it("Does not allow an interface to implement itself", async () => { + const diagnostics = await diagnose(` + @compose(AnInterface) + @Interface + @test model AnInterface {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/circular-interface", + message: "An interface cannot implement itself.", + }); + }); + + it("Requires that all Interface properties are implemented", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/missing-interface-property", + message: + "Model must contain property `prop` from `AnInterface` in order to implement it in GraphQL.", + }); + }); + + it("Requires that all Interface properties are compatible", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + prop: integer; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/incompatible-interface-property", + message: "Property `prop` is incompatible with `AnInterface`.", + }); + }); + + it("Allows additional properties", async () => { + const diagnostics = await diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + prop: string; + anotherProp: integer; + } + `); + expectDiagnosticEmpty(diagnostics); + }); +});