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); + }); +});