Skip to content

Commit

Permalink
Implement @Interface and @compose decorators
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
steverice committed Nov 22, 2024
1 parent 5ef2ace commit 8ddde71
Show file tree
Hide file tree
Showing 7 changed files with 456 additions and 2 deletions.
34 changes: 34 additions & 0 deletions packages/graphql/lib/interface.tsp
Original file line number Diff line number Diff line change
@@ -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[]);
1 change: 1 addition & 0 deletions packages/graphql/lib/main.tsp
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
import "./interface.tsp";
import "./schema.tsp";
31 changes: 29 additions & 2 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createTypeSpecLibrary, type JSONSchemaType } from "@typespec/compiler";
import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler";

export const NAMESPACE = "TypeSpec.GraphQL";

Expand Down Expand Up @@ -93,11 +93,38 @@ const EmitterOptionsSchema: JSONSchemaType<GraphQLEmitterOptions> = {

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<GraphQLEmitterOptions>,
},
state: {
compose: { description: "State for the @compose decorator." },
interface: { description: "State for the @Interface decorator." },
schema: { description: "State for the @schema decorator." },
},
} as const;
Expand Down
151 changes: 151 additions & 0 deletions packages/graphql/src/lib/interface.ts
Original file line number Diff line number Diff line change
@@ -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<Model, "interface">;

const [getInterface, setInterface] = useStateSet<Interface>(GraphQLKeys.interface);
const [getComposition, setComposition, _getCompositionMap] = useStateMap<Model, Interface[]>(
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<string, ModelProperty>,
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);
};
3 changes: 3 additions & 0 deletions packages/graphql/src/tsp-index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
4 changes: 4 additions & 0 deletions packages/graphql/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Tag extends PropertyKey> = BaseType & { [tags]: { [K in Tag]: void } };
Loading

0 comments on commit 8ddde71

Please sign in to comment.