feat(plugins): onInstall hook (#236)

This commit is contained in:
Jason Kuhrt 2019-10-16 22:55:53 +02:00 committed by GitHub
parent 62a6006c64
commit bac64560e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 538 additions and 18 deletions

View File

@ -11,6 +11,7 @@
"test:ci": "jest -i --coverage",
"build": "tsc",
"dev": "tsc -w",
"dev:test": "jest --watch",
"dev:examples": "yarn -s link-examples && tsc -w",
"lint": "tslint -p tsconfig.json",
"clean": "rm -rf dist",

View File

@ -128,9 +128,19 @@ import {
import { DynamicInputMethodDef, DynamicOutputMethodDef } from "./dynamicMethod";
import { DynamicOutputPropertyDef } from "./dynamicProperty";
import { decorateType } from "./definitions/decorateType";
import * as Plugins from "./plugins";
export type Maybe<T> = T | null;
export type NexusAcceptedTypeDef =
| AllNexusNamedTypeDefs
| NexusExtendInputTypeDef<string>
| NexusExtendTypeDef<string>
| GraphQLNamedType
| DynamicInputMethodDef<string>
| DynamicOutputMethodDef<string>
| DynamicOutputPropertyDef<string>;
type NexusShapedOutput = {
name: string;
definition: (t: ObjectDefinitionBlock<string>) => void;
@ -173,6 +183,7 @@ export const UNKNOWN_TYPE_SCALAR = decorateType(
);
export interface BuilderConfig {
plugins?: Plugins.PluginDef[];
/**
* Generated artifact settings. Set to false to disable all.
* Set to true to enable all and use default paths. Leave
@ -396,6 +407,12 @@ export type DynamicOutputProperties = Record<
* circular references at this step, while fields will guard for it during lazy evaluation.
*/
export class SchemaBuilder {
/**
* Used to track all _GraphQL_ types that have been added to the builder.
* This supports hasType method which permits asking the question "Will
* the GraphQL schema have _this_ type (name)".
*/
protected allTypeDefs: Record<string, NexusAcceptedTypeDef> = {};
/**
* Used to check for circular references.
*/
@ -481,25 +498,17 @@ export class SchemaBuilder {
return this.config;
}
hasType = (typeName: string): boolean => {
return Boolean(this.allTypeDefs[typeName]);
};
/**
* Add type takes a Nexus type, or a GraphQL type and pulls
* it into an internal "type registry". It also does an initial pass
* on any types that are referenced on the "types" field and pulls
* those in too, so you can define types anonymously, without
* exporting them.
*
* @param typeDef
* Add type takes a Nexus type, or a GraphQL type and pulls it into an
* internal "type registry". It also does an initial pass on any types that
* are referenced on the "types" field and pulls those in too, so you can
* define types anonymously, without exporting them.
*/
addType(
typeDef:
| AllNexusNamedTypeDefs
| NexusExtendInputTypeDef<string>
| NexusExtendTypeDef<string>
| GraphQLNamedType
| DynamicInputMethodDef<string>
| DynamicOutputMethodDef<string>
| DynamicOutputPropertyDef<string>
) {
addType = (typeDef: NexusAcceptedTypeDef) => {
if (isNexusDynamicInputMethod(typeDef)) {
this.dynamicInputFields[typeDef.name] = typeDef;
return;
@ -540,6 +549,10 @@ export class SchemaBuilder {
throw extendError(typeDef.name);
}
if (!this.allTypeDefs[typeDef.name]) {
this.allTypeDefs[typeDef.name] = typeDef;
}
if (isNexusScalarTypeDef(typeDef) && typeDef.value.asNexusMethod) {
this.dynamicInputFields[typeDef.value.asNexusMethod] = typeDef.name;
this.dynamicOutputFields[typeDef.value.asNexusMethod] = typeDef.name;
@ -579,7 +592,7 @@ export class SchemaBuilder {
if (isNexusInterfaceTypeDef(typeDef)) {
this.typesToWalk.push({ type: "interface", value: typeDef.value });
}
}
};
walkTypes() {
let obj;
@ -1459,7 +1472,14 @@ export function buildTypesInternal<
schemaBuilder?: SchemaBuilder
): BuildTypes<TypeMapDefs> {
const builder = schemaBuilder || new SchemaBuilder(config);
const plugins = config.plugins || [];
const pluginControllers = plugins.map((plugin) =>
Plugins.initialize(builder, plugin)
);
addTypes(builder, types);
pluginControllers.forEach((pluginController) =>
pluginController.triggerOnInstall()
);
return builder.getFinalTypeMap();
}

View File

@ -29,6 +29,7 @@ export enum NexusTypes {
DynamicInput = "DynamicInput",
DynamicOutputMethod = "DynamicOutputMethod",
DynamicOutputProperty = "DynamicOutputProperty",
Plugin = "Plugin",
}
export interface DeprecationInfo {

View File

@ -1,4 +1,10 @@
// All of the Public API definitions
export {
createPlugin,
PluginConfig,
PluginBuilderLens,
PluginOnInstallHandler,
} from "./plugins";
export { buildTypes, makeSchema } from "./builder";
export {
arg,

197
src/plugins.ts Normal file
View File

@ -0,0 +1,197 @@
import { SchemaBuilder, NexusAcceptedTypeDef } from "./builder";
import { withNexusSymbol, NexusTypes } from "./definitions/_types";
import { venn } from "./utils";
/**
* A read-only builder api exposed to plugins in the onInstall hook which
* proxies very limited functionality into the internal Nexus Builder.
*/
export type PluginBuilderLens = {
hasType: (typeName: string) => boolean;
};
/**
* This is the Neuxs Plugin interface that allows users to extend Nexus at
* particular extension points. Plugins are just functions that receive hooks
* which can then be registered upon with callbacks.
*/
export type PluginConfig = {
name: string;
onInstall?: PluginOnInstallHandler;
};
/**
* The plugin callback to execute when onInstall lifecycle event occurs.
* OnInstall event occurs before type walking which means inline types are not
* visible at this point yet. `builderLens.hasType` will only return true
* for types the user has defined top level in their app, and any types added by
* upstream plugins.
*/
export type PluginOnInstallHandler = (
builder: PluginBuilderLens
) => { types: NexusAcceptedTypeDef[] };
/**
* The processed version of a plugin config. This lower level version has
* defaults provided for optionals etc.
*/
export type InternalPluginConfig = Required<PluginConfig>;
/**
* A plugin defines configuration which can document additional metadata options
* for a type definition. This metadata can be used to decorate the "resolve" function
* to provide custom functionality, such as logging, error handling, additional type
* validation.
*
* You can specify options which can be defined on the schema,
* the type or the plugin. The config from each of these will be
* passed in during schema construction time, and used to augment the field as necessary.
*
* You can either return a function, with the new defintion of a resolver implementation,
* or you can return an "enter" / "leave" pairing which will wrap the pre-execution of the
* resolver and the "result" of the resolver, respectively.
*/
export function createPlugin(config: PluginConfig): PluginDef {
validatePluginConfig(config);
const internalConfig = { ...configDefaults, ...config };
return new PluginDef(internalConfig);
}
const configDefaults = {
onInstall: () => ({ types: [] }),
};
/**
* A definition for a plugin. Should be passed to the `plugins: []` option
* on makeSchema. Refer to `createPlugin` factory for full doc.
*/
export class PluginDef {
constructor(readonly config: InternalPluginConfig) {}
}
withNexusSymbol(PluginDef, NexusTypes.Plugin);
/**
* The interface used to drive plugin execution via lifecycle event triggering.
*/
type PluginController = {
triggerOnInstall: () => void;
};
/**
* This will gather the hook handlers (aka. callbacks, event handlers) a the
* plugin has registered for and return a controller to trigger said hooks,
* thus controlling execution of the plugins' hook handlers.
*/
export function initialize(
builder: SchemaBuilder,
plugin: PluginDef
): PluginController {
const state = {
onInstallTriggered: false,
};
const builderLens: PluginBuilderLens = {
hasType: builder.hasType,
};
return {
triggerOnInstall: () => {
// Enforce the invariant that a lifecycle hook will only ever be called once.
if (state.onInstallTriggered) {
throw new Error(
"Multiple triggers of onInstall hook detected. This should never happen. This is an internal error."
);
} else {
state.onInstallTriggered = true;
}
// By doing addType on the types returned by a plugin right after it has
// done so we make it possible for downstream plugins to see types added
// by upstream plugins.
let hookResult: ReturnType<PluginOnInstallHandler>;
try {
hookResult = plugin.config.onInstall(builderLens);
} catch (error) {
throw new Error(
`Plugin ${plugin.config.name} failed on "onInstall" hook:\n\n${error.stack}`
);
}
validateOnInstallHookResult(plugin, hookResult);
hookResult.types.forEach(builder.addType);
},
};
}
/**
* Validate that the configuration given by a plugin is valid.
*/
function validatePluginConfig(plugin: PluginConfig): void {
const validRequiredProps = ["name"];
const validOptionalProps = ["onInstall"];
const validProps = [...validRequiredProps, ...validOptionalProps];
const givenProps = Object.keys(plugin);
const printProps = (props: Iterable<string>): string => {
return [...props].join(", ");
};
const [missingRequiredProps, ,] = venn(validRequiredProps, givenProps);
if (missingRequiredProps.size > 0) {
throw new Error(
`Plugin "${plugin.name}" is missing required properties: ${printProps(
missingRequiredProps
)}`
);
}
const nameType = typeof plugin.name;
if (nameType !== "string") {
throw new Error(
`Plugin "${plugin.name}" is giving an invalid value for property name: expected "string" type, got ${nameType} type`
);
}
if (plugin.name === "") {
throw new Error(
`Plugin "${plugin.name}" is giving an invalid value for property name: empty string`
);
}
const [, , invalidGivenProps] = venn(validProps, givenProps);
if (invalidGivenProps.size > 0) {
throw new Error(
`Plugin "${plugin.name}" is giving unexpected properties: ${printProps(
invalidGivenProps
)}`
);
}
if (plugin.onInstall) {
const onInstallType = typeof plugin.onInstall;
if (onInstallType !== "function") {
throw new Error(
`Plugin "${plugin.name}" is giving an invalid value for onInstall hook: expected "function" type, got ${onInstallType} type`
);
}
}
}
/**
* Validate that the data returned from a plugin from the `onInstall` hook is valid.
*/
function validateOnInstallHookResult(
plugin: PluginDef,
hookResult: ReturnType<PluginOnInstallHandler>
): void {
if (
hookResult === null ||
typeof hookResult !== "object" ||
!Array.isArray(hookResult.types)
) {
throw new Error(
`Plugin "${plugin.config.name}" returned invalid data for "onInstall" hook:\n\nexpected structure:\n\n { types: NexusAcceptedTypeDef[] }\n\ngot:\n\n ${hookResult}`
);
}
// TODO we should validate that the array members all fall under NexusAcceptedTypeDef
}

View File

@ -236,3 +236,37 @@ export function relativePathTo(
}
return path.join(relative, filename);
}
/**
* Calculate the venn diagram between two iterables based on reference equality
* checks. The returned tripple contains items thusly:
*
* * items only in arg 1 --> first tripple slot
* * items in args 1 & 2 --> second tripple slot
* * items only in arg 2 --> third tripple slot
*/
export function venn<T>(
xs: Iterable<T>,
ys: Iterable<T>
): [Set<T>, Set<T>, Set<T>] {
const lefts: Set<T> = new Set(xs);
const boths: Set<T> = new Set();
const rights: Set<T> = new Set(ys);
for (const l of lefts) {
if (rights.has(l)) {
boths.add(l);
lefts.delete(l);
rights.delete(l);
}
}
for (const r of rights) {
if (lefts.has(r)) {
boths.add(r);
lefts.delete(r);
rights.delete(r);
}
}
return [lefts, boths, rights];
}

View File

@ -22,6 +22,7 @@ export const testSchema = (name: string) => {
expect([appFilePath]).toTypeCheck({
sourceMap: false,
downlevelIteration: true,
noEmitOnError: true,
esModuleInterop: true,
strict: true,

259
tests/plugins.spec.ts Normal file
View File

@ -0,0 +1,259 @@
import {
createPlugin,
makeSchema,
PluginConfig,
queryType,
objectType,
PluginOnInstallHandler,
} from "../src";
import { printSchema } from "graphql";
import {
NexusAcceptedTypeDef,
buildTypes,
inputObjectType,
extendType,
buildTypesInternal,
} from "../src/core";
const fooObject = objectType({
name: "foo",
definition(t) {
t.string("bar");
},
});
const queryField = extendType({
type: "Query",
definition(t) {
t.string("something");
},
});
describe("runtime config validation", () => {
const whenGiven = (config: any) => () => createPlugin(config);
it("checks name present", () => {
expect(whenGiven({})).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"undefined\\" is missing required properties: name"`
);
});
it("checks name is string", () => {
expect(whenGiven({ name: 1 })).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"1\\" is giving an invalid value for property name: expected \\"string\\" type, got number type"`
);
});
it("checks name is not empty", () => {
expect(whenGiven({ name: "" })).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"\\" is giving an invalid value for property name: empty string"`
);
});
it("checks onInstall is a function if defined", () => {
expect(
whenGiven({ name: "x", onInstall: "foo" })
).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"x\\" is giving an invalid value for onInstall hook: expected \\"function\\" type, got string type"`
);
expect(
whenGiven({ name: "x", onInstall: {} })
).toThrowErrorMatchingInlineSnapshot(
`"Plugin \\"x\\" is giving an invalid value for onInstall hook: expected \\"function\\" type, got object type"`
);
});
});
describe("runtime onInstall hook handler", () => {
const whenGiven = (onInstall: any) => () =>
makeSchema({
types: [],
plugins: [createPlugin({ name: "x", onInstall })],
});
it("validates return value against shallow schema", () => {
expect(whenGiven(() => null)).toThrowErrorMatchingInlineSnapshot(`
"Plugin \\"x\\" returned invalid data for \\"onInstall\\" hook:
expected structure:
{ types: NexusAcceptedTypeDef[] }
got:
null"
`);
expect(whenGiven(() => ({ types: null })))
.toThrowErrorMatchingInlineSnapshot(`
"Plugin \\"x\\" returned invalid data for \\"onInstall\\" hook:
expected structure:
{ types: NexusAcceptedTypeDef[] }
got:
[object Object]"
`);
expect(whenGiven(() => ({}))).toThrowErrorMatchingInlineSnapshot(`
"Plugin \\"x\\" returned invalid data for \\"onInstall\\" hook:
expected structure:
{ types: NexusAcceptedTypeDef[] }
got:
[object Object]"
`);
});
it("gracefully handles thrown errors", () => {
expect(
whenGiven(() => {
throw new Error("plugin failed somehow oops");
})
).toThrow(
/Plugin x failed on "onInstall" hook:\n\nError: plugin failed somehow oops\n at.*/
);
});
it("does not validate types array members yet", () => {
expect(
whenGiven(() => ({ types: [null, 1, "bad"] }))
).toThrowErrorMatchingInlineSnapshot(
`"Cannot read property 'name' of null"`
);
});
});
describe("a plugin may", () => {
const whenGiven = (pluginConfig: PluginConfig) => () =>
makeSchema({
types: [],
plugins: [createPlugin(pluginConfig)],
});
it("do nothing", () => {
expect(whenGiven({ name: "x" }));
});
});
describe("onInstall plugins", () => {
const whenGiven = ({
onInstall,
plugin,
appTypes,
}: {
onInstall?: PluginOnInstallHandler;
plugin?: Omit<PluginConfig, "name">;
appTypes?: NexusAcceptedTypeDef[];
}) => {
const xPluginConfig = plugin || { onInstall };
return printSchema(
makeSchema({
types: appTypes || [],
plugins: [createPlugin({ name: "x", ...xPluginConfig })],
})
);
};
it("is an optional hook", () => {
expect(whenGiven({ plugin: {} }));
});
it("may return an empty array of types", () => {
expect(whenGiven({ onInstall: () => ({ types: [] }) }));
});
it("may contribute types", () => {
expect(
whenGiven({
onInstall: () => ({
types: [queryField],
}),
})
).toMatchInlineSnapshot(`
"type Query {
something: String!
}
"
`);
});
it("has access to top-level types", () => {
expect(
whenGiven({
onInstall: (builder) => ({
types: builder.hasType("foo") ? [] : [queryField],
}),
appTypes: [fooObject],
})
).toMatchInlineSnapshot(`
"type foo {
bar: String!
}
type Query {
ok: Boolean!
}
"
`);
});
it("does not see fallback ok-query", () => {
expect(
whenGiven({
onInstall(builder) {
return {
types: builder.hasType("Query") ? [queryField] : [],
};
},
})
).toMatchInlineSnapshot(`
"type Query {
ok: Boolean!
}
"
`);
});
it("does not have access to inline types", () => {
expect(
whenGiven({
onInstall: (builder) => ({
types: builder.hasType("Inline") ? [queryField] : [],
}),
appTypes: [
queryType({
definition(t) {
t.string("bar", {
args: {
inline: inputObjectType({
name: "Inline",
definition(t2) {
t2.string("hidden");
},
}),
},
});
},
}),
],
})
).toMatchInlineSnapshot(`
"input Inline {
hidden: String
}
type Query {
bar(inline: Inline): String!
}
"
`);
});
});

View File

@ -1,5 +1,6 @@
{
"compilerOptions": {
"downlevelIteration": true,
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",