Skip to content

Commit

Permalink
Convert hooks system to a middleware system (#2071)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjie authored May 21, 2024
2 parents c5d2361 + 16aff7f commit c734994
Show file tree
Hide file tree
Showing 48 changed files with 1,294 additions and 604 deletions.
70 changes: 70 additions & 0 deletions .changeset/smooth-bats-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"graphile-build": patch
"postgraphile": patch
"graphile-config": patch
"@dataplan/pg": patch
"grafserv": patch
"grafast": patch
---

`GrafastExecutionArgs` now accepts `resolvedPreset` and `requestContext`
directly; passing these through additional arguments is now deprecated and
support will be removed in a future revision. This affects:

- `grafast()`
- `execute()`
- `subscribe()`
- `hookArgs()`

`graphile-config` has gained a middleware system which is more powerful than
it's AsyncHooks system. Old hooks can be emulated through the middleware system
safely since middleware is a superset of hooks' capabilities. `applyHooks` has
been renamed to `orderedApply` (because it applies to more than just hooks),
calling `applyHooks` will still work but is deprecated.

🚨 `grafast` no longer automatically reads your `graphile.config.ts` or similar;
you must do that yourself and pass the `resolvedPreset` to grafast via the
`args`. This is to aid in bundling of grafast since it should not need to read
from filesystem or dynamically load modules.

`grafast` no longer outputs performance warning when you set
`GRAPHILE_ENV=development`.

🚨 `plugin.grafast.hooks.args` is now `plugin.grafast.middleware.prepareArgs`,
and the signature has changed - you must be sure to call the `next()` function
and ctx/resolvedPreset can be extracted directly from `args`:

```diff
const plugin = {
grafast: {
- hooks: {
+ middleware: {
- args({ args, ctx, resolvedPreset }) {
+ prepareArgs(next, { args }) {
+ const { requestContext: ctx, resolvedPreset } = args;
// ...
+ return next();
}
}
}
}
```

Many more middleware have been added; use TypeScript's autocomplete to see
what's available until we have proper documentation for them.

`plugin.grafserv.hooks.*` are still supported but deprecated; instead use
middleware `plugin.grafserv.middleware.*` (note that call signatures have
changed slightly, similar to the diff above):

- `hooks.init` -> `middleware.setPreset`
- `hooks.processGraphQLRequestBody` -> `middleware.processGraphQLRequestBody`
- `hooks.ruruHTMLParts` -> `middleware.ruruHTMLParts`

A few TypeScript types related to Hooks have been renamed, but their old names
are still available, just deprecated. They will be removed in a future update:

- `HookObject` -> `FunctionalityObject`
- `PluginHook` -> `CallbackOrDescriptor`
- `PluginHookObject` -> `CallbackDescriptor`
- `PluginHookCallback` -> `UnwrapCallback`
14 changes: 10 additions & 4 deletions grafast/dataplan-pg/src/plugins/PgContextPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ import type {} from "../interfaces.js";
import { withPgClientFromPgService } from "../pgServices.js";
import { version } from "../version.js";

export const EMPTY_OBJECT: Record<string, never> = Object.freeze(
Object.create(null),
);

export const PgContextPlugin: GraphileConfig.Plugin = {
name: "PgContextPlugin",
description:
"Extends the runtime GraphQL context with details needed to support your configured pgServices",
version: version,

grafast: {
hooks: {
args({ args, ctx, resolvedPreset: config }) {
middleware: {
prepareArgs(next, { args }) {
if (!args.contextValue) {
args.contextValue = Object.create(null);
}
const { resolvedPreset: config, requestContext: ctx } = args;
const contextValue = args.contextValue as Record<string, any>;
if (config.pgServices) {
if (config?.pgServices) {
for (const pgService of config.pgServices) {
const {
pgSettings,
Expand All @@ -44,7 +49,7 @@ export const PgContextPlugin: GraphileConfig.Plugin = {
}
contextValue[pgSettingsKey] =
typeof pgSettings === "function"
? pgSettings(ctx)
? pgSettings(ctx ?? EMPTY_OBJECT)
: pgSettings ?? undefined;
}
if (pgSubscriberKey != null) {
Expand All @@ -66,6 +71,7 @@ export const PgContextPlugin: GraphileConfig.Plugin = {
);
}
}
return next();
},
},
},
Expand Down
105 changes: 66 additions & 39 deletions grafast/grafast/src/args.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,92 @@
import type { ExecutionArgs } from "graphql";

import { hook, NULL_PRESET } from "./config.js";
import type {
GrafastExecutionArgs,
PrepareArgsEvent,
PromiseOrDirect,
} from "./interfaces.js";
import { $$hooked } from "./interfaces.js";
import { getGrafastMiddleware } from "./middleware.js";
import { isPromiseLike } from "./utils.js";
const EMPTY_OBJECT: Record<string, never> = Object.freeze(Object.create(null));

/** @deprecated Pass `resolvedPreset` and `requestContext` via args directly */
export function hookArgs(
rawArgs: ExecutionArgs,
resolvedPreset: GraphileConfig.ResolvedPreset,
ctx: Partial<Grafast.RequestContext>,
): PromiseOrDirect<Grafast.ExecutionArgs>;
export function hookArgs(
rawArgs: GrafastExecutionArgs,
): PromiseOrDirect<Grafast.ExecutionArgs>;
/**
* Applies Graphile Config hooks to your GraphQL request, e.g. to
* populate context or similar.
*
* @experimental
*/
export function hookArgs(
rawArgs: ExecutionArgs,
resolvedPreset: GraphileConfig.ResolvedPreset,
ctx: Partial<Grafast.RequestContext>,
): Grafast.ExecutionArgs | PromiseLike<Grafast.ExecutionArgs> {
rawArgs: GrafastExecutionArgs,
legacyResolvedPreset?: GraphileConfig.ResolvedPreset,
legacyCtx?: Partial<Grafast.RequestContext>,
): PromiseOrDirect<Grafast.ExecutionArgs> {
if (legacyResolvedPreset !== undefined) {
rawArgs.resolvedPreset = legacyResolvedPreset;
}
if (legacyCtx !== undefined) {
rawArgs.requestContext = rawArgs.requestContext ?? legacyCtx;
}
const {
middleware: rawMiddleware,
resolvedPreset,
contextValue: rawContextValue,
} = rawArgs;
// Make context mutable
rawArgs.contextValue = Object.assign(Object.create(null), rawContextValue);
const middleware =
rawMiddleware === undefined && resolvedPreset != null
? getGrafastMiddleware(resolvedPreset)
: rawMiddleware ?? null;
if (rawMiddleware === undefined) {
rawArgs.middleware = middleware;
}
const args = rawArgs as Grafast.ExecutionArgs;
// Assert that args haven't already been hooked
if (args[$$hooked]) {
throw new Error("Must not call hookArgs twice!");
}
args[$$hooked] = true;

// Make context mutable
args.contextValue = Object.assign(Object.create(null), args.contextValue);
if (middleware != null) {
return middleware.run("prepareArgs", { args }, finalizeWithEvent);
} else {
return finalize(args);
}
}

// finalize(args): args is deliberately shadowed
const finalize = (args: Grafast.ExecutionArgs) => {
const userContext = resolvedPreset.grafast?.context;
if (typeof userContext === "function") {
const result = userContext(ctx, args);
if (isPromiseLike(result)) {
// Deliberately shadowed 'result'
return result.then((result) => {
Object.assign(args.contextValue as Partial<Grafast.Context>, result);
return args;
});
} else {
Object.assign(args.contextValue as Partial<Grafast.Context>, result);
function finalize(args: Grafast.ExecutionArgs) {
const userContext = args.resolvedPreset?.grafast?.context;
const contextValue = args.contextValue as Partial<Grafast.Context>;
if (typeof userContext === "function") {
const result = userContext(args.requestContext ?? EMPTY_OBJECT, args);
if (isPromiseLike(result)) {
// Deliberately shadowed 'result'
return result.then((result) => {
Object.assign(contextValue, result);
return args;
}
} else if (typeof userContext === "object" && userContext !== null) {
Object.assign(args.contextValue as Partial<Grafast.Context>, userContext);
return args;
});
} else {
Object.assign(contextValue, result);
return args;
}
};

if (
resolvedPreset !== NULL_PRESET &&
resolvedPreset.plugins &&
resolvedPreset.plugins.length > 0
) {
const event = { args, ctx, resolvedPreset };
const result = hook(resolvedPreset, "args", event);
if (isPromiseLike(result)) {
return result.then(() => finalize(event.args));
} else {
return finalize(event.args);
}
} else if (typeof userContext === "object" && userContext !== null) {
Object.assign(contextValue, userContext);
return args;
} else {
return args;
}
return finalize(args);
}

function finalizeWithEvent(event: PrepareArgsEvent) {
return finalize(event.args);
}
4 changes: 3 additions & 1 deletion grafast/grafast/src/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// import type { GraphQLScalarType } from "graphql";

import type { ExecutableStep } from ".";
import type { ExecutableStep, GrafastExecutionArgs } from ".";
import type { LayerPlan } from "./engine/LayerPlan";
import type { MetaByMetaKey } from "./engine/OperationPlan";
import type {
Expand All @@ -13,6 +13,8 @@ import type {
* @internal
*/
export interface RequestTools {
/** @internal */
args: GrafastExecutionArgs;
/** The `timeSource.now()` at which the request started executing */
startTime: number;
/** The `timeSource.now()` at which the request should stop executing (if a timeout was configured) */
Expand Down
130 changes: 0 additions & 130 deletions grafast/grafast/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,3 @@
import type { AsyncHooks, PluginHook } from "graphile-config";

export const NULL_PRESET: GraphileConfig.ResolvedPreset = Object.freeze(
Object.create(null),
);

// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type GraphileConfigModule = typeof import("graphile-config");

type PromiseOrValue<T> = T | Promise<T>;

let graphileConfig: undefined | PromiseOrValue<null | GraphileConfigModule> =
undefined;
let graphileConfigLoaded = false;

export function withGraphileConfig<T>(
callback: (graphileConfig: GraphileConfigModule | null) => PromiseOrValue<T>,
): PromiseOrValue<T> {
if (graphileConfig === undefined) {
// ESM:
// This should be
// graphileConfig = import("graphile-config").then(
// but that causes a segfault in jest/node when testing third party
// modules. So we had to convert everything to CommonJS.
graphileConfig = new Promise<any>((resolve, reject) => {
try {
resolve(require("graphile-config"));
} catch (e) {
if (e.code === "ERR_REQUIRE_ESM") {
return import("graphile-config").then(resolve, reject);
} else {
reject(e);
}
}
}).then(
(GC) => {
graphileConfig = GC;
graphileConfigLoaded = true;
return GC;
},
() => {
graphileConfig = null;
graphileConfigLoaded = true;
return null;
},
);
}

if (graphileConfigLoaded) {
return callback(graphileConfig as GraphileConfigModule | null);
} else {
return (graphileConfig as Promise<any>).then(callback);
}
}

const $$skipHooks = Symbol("skipHooks");
const $$hooksForPreset = Symbol("grafastHooks");

declare global {
namespace GraphileConfig {
interface ResolvedPreset {
[$$hooksForPreset]?: null | AsyncHooks<GraphileConfig.GrafastHooks>;
[$$skipHooks]?: Record<string, boolean>;
}
}
}

export function withHooks<TResult>(
resolvedPreset: GraphileConfig.ResolvedPreset,
callback: (
hooks: AsyncHooks<GraphileConfig.GrafastHooks> | null,
) => PromiseOrValue<TResult>,
) {
const existing = resolvedPreset[$$hooksForPreset];
if (existing !== undefined) {
return callback(existing);
}
if (!resolvedPreset.plugins || resolvedPreset.plugins.length === 0) {
resolvedPreset[$$hooksForPreset] = null;
return callback(null);
}
const plugins = resolvedPreset.plugins;
return withGraphileConfig((gc) => {
if (gc !== null) {
const hooks = new gc.AsyncHooks<GraphileConfig.GrafastHooks>();
gc.applyHooks(
plugins,
(p) => p.grafast?.hooks,
(name, fn, _plugin) => {
hooks.hook(name, fn);
},
);
resolvedPreset[$$hooksForPreset] = hooks;
return callback(hooks);
} else {
resolvedPreset[$$hooksForPreset] = null;
return callback(null);
}
});
}

export function hook<THookName extends keyof GraphileConfig.GrafastHooks>(
resolvedPreset: GraphileConfig.ResolvedPreset,
hookName: THookName,
...args: Parameters<
GraphileConfig.GrafastHooks[THookName] extends PluginHook<infer U>
? U
: never
>
): PromiseOrValue<void> {
if (resolvedPreset[$$skipHooks]?.[hookName]) {
return;
}
return withHooks(resolvedPreset, (hooks) => {
if (hooks !== null) {
if (hooks.callbacks[hookName] !== undefined) {
return hooks.process(hookName, ...args);
} else {
if (!resolvedPreset[$$skipHooks]) {
resolvedPreset[$$skipHooks] = Object.create(null);
}
resolvedPreset[$$skipHooks]![hookName] = true;
return;
}
} else {
if (!resolvedPreset[$$skipHooks]) {
resolvedPreset[$$skipHooks] = Object.create(null);
}
resolvedPreset[$$skipHooks]![hookName] = true;
return;
}
});
}
Loading

0 comments on commit c734994

Please sign in to comment.