Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement @Interface and @compose decorators #3

Open
wants to merge 1 commit into
base: feature/graphql
Choose a base branch
from
Open
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
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