Skip to content

Commit

Permalink
New plugin: useExtendedValidation and implementation of @oneOf di…
Browse files Browse the repository at this point in the history
…rective (#149)

* wip

* Fix implementation of oneOf directive
test fixes

* fixes

* added docs
  • Loading branch information
dotansimha authored May 2, 2021
1 parent 028129a commit ced704e
Show file tree
Hide file tree
Showing 15 changed files with 576 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/poor-eagles-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/extended-validation': patch
---

NEW PLUGIN!
6 changes: 6 additions & 0 deletions .changeset/purple-bugs-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@envelop/core': patch
'@envelop/types': patch
---

Allow plugins to stop execution and return errors
47 changes: 24 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,29 +109,30 @@ Here's a list of integrations and examples:

We provide a few built-in plugins within the `@envelop/core`, and many more plugins as standalone packages.

| Name | Package | Description |
| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| useSchema | [`@envelop/core`](./packages/core#useschema) | Simplest plugin to provide your GraphQL schema. |
| useErrorHandler | [`@envelop/core`](./packages/core#useerrorhandler) | Get notified when any execution error occurs. |
| useExtendContext | [`@envelop/core`](./packages/core#useextendcontext) | Extend execution context based on your needs. |
| useLogger | [`@envelop/core`](./packages/core#uselogger) | Simple, yet powerful logging for GraphQL execution. |
| usePayloadFormatter | [`@envelop/core`](./packages/core#usepayloadformatter) | Format, clean and customize execution result. |
| useTiming | [`@envelop/core`](./packages/core#usetiming) | Simple timing/tracing mechanism for your execution. |
| useGraphQLJit | [`@envelop/graphql-jit`](./packages/plugins/graphql-jit) | Custom executor based on GraphQL-JIT. |
| useParserCache | [`@envelop/parser-cache`](./packages/plugins/parser-cache) | Simple LRU for caching `parse` results. |
| useValidationCache | [`@envelop/validation-cache`](./packages/plugins/validation-cache) | Simple LRU for caching `validate` results. |
| useDepthLimit | [`@envelop/depth-limit`](./packages/plugins/depth-limit) | Limits the depth of your GraphQL selection sets. |
| useDataLoader | [`@envelop/dataloader`](./packages/plugins/dataloader) | Simply injects a DataLoader instance into your context. |
| useApolloTracing | [`@envelop/apollo-tracing`](./packages/plugins/apollo-tracing) | Integrates timing with Apollo-Tracing format (for GraphQL Playground) |
| useSentry | [`@envelop/sentry`](./packages/plugins/sentry) | Tracks performance, timing and errors and reports it to Sentry. |
| useOpenTelemetry | [`@envelop/opentelemetry`](./packages/plugins/opentelemetry) | Tracks performance, timing and errors and reports in OpenTelemetry structure. |
| useGenericAuth | [`@envelop/generic-auth`](./packages/plugins/generic-auth) | Super flexible authentication, also supports `@auth` directive . |
| useAuth0 | [`@envelop/auth0`](./packages/plugins/auth0) | Validates Auth0 JWT tokens and injects the authenticated user to your context. |
| useGraphQLModules | [`@envelop/graphql-modules`](./packages/plugins/graphql-modules) | Integrates the execution lifecycle of GraphQL-Modules. |
| useGraphQLMiddleware | [`@envelop/graphql-middleware`](./packages/plugins/graphql-middleware) | Integrates middlewares written for `graphql-middleware` |
| useRateLimiter | [`@envelop/rate-limiter`](./packages/plugins/rate-limiter) | Limit request rate via `@rateLimit` directive |
| useDisableIntrospection | [`@envelop/disable-introspection`](./packages/plugins/disable-introspection) | Disables introspection by adding a validation rule |
| useFilterAllowedOperations | [`@envelop/filter-operation-type`](./packages/plugins/filter-operation-type) | Only allow execution of specific operation types |
| Name | Package | Description |
| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| useSchema | [`@envelop/core`](./packages/core#useschema) | Simplest plugin to provide your GraphQL schema. |
| useErrorHandler | [`@envelop/core`](./packages/core#useerrorhandler) | Get notified when any execution error occurs. |
| useExtendContext | [`@envelop/core`](./packages/core#useextendcontext) | Extend execution context based on your needs. |
| useLogger | [`@envelop/core`](./packages/core#uselogger) | Simple, yet powerful logging for GraphQL execution. |
| usePayloadFormatter | [`@envelop/core`](./packages/core#usepayloadformatter) | Format, clean and customize execution result. |
| useTiming | [`@envelop/core`](./packages/core#usetiming) | Simple timing/tracing mechanism for your execution. |
| useGraphQLJit | [`@envelop/graphql-jit`](./packages/plugins/graphql-jit) | Custom executor based on GraphQL-JIT. |
| useParserCache | [`@envelop/parser-cache`](./packages/plugins/parser-cache) | Simple LRU for caching `parse` results. |
| useValidationCache | [`@envelop/validation-cache`](./packages/plugins/validation-cache) | Simple LRU for caching `validate` results. |
| useDepthLimit | [`@envelop/depth-limit`](./packages/plugins/depth-limit) | Limits the depth of your GraphQL selection sets. |
| useDataLoader | [`@envelop/dataloader`](./packages/plugins/dataloader) | Simply injects a DataLoader instance into your context. |
| useApolloTracing | [`@envelop/apollo-tracing`](./packages/plugins/apollo-tracing) | Integrates timing with Apollo-Tracing format (for GraphQL Playground) |
| useSentry | [`@envelop/sentry`](./packages/plugins/sentry) | Tracks performance, timing and errors and reports it to Sentry. |
| useOpenTelemetry | [`@envelop/opentelemetry`](./packages/plugins/opentelemetry) | Tracks performance, timing and errors and reports in OpenTelemetry structure. |
| useGenericAuth | [`@envelop/generic-auth`](./packages/plugins/generic-auth) | Super flexible authentication, also supports `@auth` directive . |
| useAuth0 | [`@envelop/auth0`](./packages/plugins/auth0) | Validates Auth0 JWT tokens and injects the authenticated user to your context. |
| useGraphQLModules | [`@envelop/graphql-modules`](./packages/plugins/graphql-modules) | Integrates the execution lifecycle of GraphQL-Modules. |
| useGraphQLMiddleware | [`@envelop/graphql-middleware`](./packages/plugins/graphql-middleware) | Integrates middlewares written for `graphql-middleware` |
| useRateLimiter | [`@envelop/rate-limiter`](./packages/plugins/rate-limiter) | Limit request rate via `@rateLimit` directive |
| useDisableIntrospection | [`@envelop/disable-introspection`](./packages/plugins/disable-introspection) | Disables introspection by adding a validation rule |
| useFilterAllowedOperations | [`@envelop/filter-operation-type`](./packages/plugins/filter-operation-type) | Only allow execution of specific operation types |
| useExtendedValidation | [`@envelop/extended-validation`](./packages/plugins/extended-validation) | Adds custom validations to the execution pipeline, with access to variables. Comes with an implementation for `@oneOf` directibe for input union. |

## Sharing `envelop`s

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,16 +297,23 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi

const onResolversHandlers: OnResolverCalledHooks[] = [];
let executeFn: typeof execute = execute;
let result: ExecutionResult;

const afterCalls: ((options: { result: ExecutionResult; setResult: (newResult: ExecutionResult) => void }) => void)[] = [];
let context = args.contextValue;

for (const plugin of beforeExecuteCalls) {
let stopCalled = false;

const after = plugin.onExecute({
executeFn,
setExecuteFn: newExecuteFn => {
executeFn = newExecuteFn;
},
setResultAndStopExecution: stopResult => {
stopCalled = true;
result = stopResult;
},
extendContext: extension => {
if (typeof extension === 'object') {
context = {
Expand All @@ -322,6 +329,10 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi
args,
});

if (stopCalled) {
return result;
}

if (after) {
if (after.onExecuteDone) {
afterCalls.push(after.onExecuteDone);
Expand All @@ -336,7 +347,7 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi
context[resolversHooksSymbol] = onResolversHandlers;
}

let result = await executeFn({
result = await executeFn({
...args,
contextValue: context,
});
Expand Down
18 changes: 10 additions & 8 deletions packages/core/test/context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('contextFactory', () => {
it('Should set initial `createProxy` arguments as initial context', async () => {
const spiedPlugin = createSpiedPlugin();
const teskit = createTestkit([spiedPlugin.plugin], schema);
await teskit.execute(query, { test: true });
await teskit.execute(query, {}, { test: true });
expect(spiedPlugin.spies.beforeContextBuilding).toHaveBeenCalledTimes(1);
expect(spiedPlugin.spies.beforeContextBuilding).toHaveBeenCalledWith({
context: expect.objectContaining({
Expand Down Expand Up @@ -52,7 +52,7 @@ describe('contextFactory', () => {
schema
);

await teskit.execute(query, {});
await teskit.execute(query, {}, {});
expect(afterContextSpy).toHaveBeenCalledWith({
context: {
test: true,
Expand Down Expand Up @@ -106,12 +106,14 @@ describe('contextFactory', () => {
],
schema
);
await teskit.execute(query, {});
expect(afterContextSpy).toHaveBeenCalledWith({
context: {
test: true,
},
});
await teskit.execute(query, {}, {});
expect(afterContextSpy).toHaveBeenCalledWith(
expect.objectContaining({
context: {
test: true,
},
})
);
expect(onExecuteSpy).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.objectContaining({
Expand Down
7 changes: 4 additions & 3 deletions packages/core/test/execute.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@ describe('execute', () => {
it('Should wrap and trigger events correctly', async () => {
const spiedPlugin = createSpiedPlugin();
const teskit = createTestkit([spiedPlugin.plugin], schema);
await teskit.execute(query, { test: 1 });
await teskit.execute(query, {}, { test: 1 });
expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledTimes(1);
expect(spiedPlugin.spies.beforeResolver).toHaveBeenCalledTimes(3);
expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledWith({
executeFn: expect.any(Function),
setExecuteFn: expect.any(Function),
extendContext: expect.any(Function),
setResultAndStopExecution: expect.any(Function),
args: {
contextValue: expect.objectContaining({ test: 1 }),
rootValue: {},
schema,
operationName: undefined,
fieldResolver: undefined,
typeResolver: undefined,
variableValues: undefined,
variableValues: {},
document: expect.objectContaining({
definitions: expect.any(Array),
}),
Expand Down Expand Up @@ -58,7 +59,7 @@ describe('execute', () => {
expect(altExecute).toHaveBeenCalledTimes(1);
});

it.skip('Should allow to write async functions for before execute', async () => {
it('Should allow to write async functions for before execute', async () => {
const altExecute = jest.fn(execute);
const teskit = createTestkit(
[
Expand Down
89 changes: 89 additions & 0 deletions packages/plugins/extended-validation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
## `@envelop/extended-validation`

Extended validation plugin adds support for writing GraphQL validation rules, that has access to all `execute` parameters, including variables.

While GraphQL supports fair amount of built-in validations, and validations could be extended, it's doesn't expose `variables` to the validation rules, since operation variables are not available during `validate` flow (it's only available through execution of the operation, after input/variables coercion is done).

This plugin runs before `validate` but allow developers to write their validation rules in the same way GraphQL `ValidationRule` is defined (based on a GraphQL visitor).

## Getting Started

Start by installing the plugin:

```
yarn add @envelop/extended-validation
```

Then, use the plugin with your validation rules:

```ts
import { useExtendedValidation } from '@envelop/extended-validation';

const getEnveloped = evelop({
plugins: [
useExtendedValidation({
rules: [ ... ] // your rules here
})
]
})
```

To create your custom rules, implement the `ExtendedValidationRule` interface and return your GraphQL AST visitor.

For example:

```ts
import { ExtendedValidationRule } from '@envelop/extended-validation';

export const MyRule: ExtendedValidationRule = (validationContext, executionArgs) => {
return {
OperationDefinition: node => {
// This will run for every executed Query/Mutation/Subscription
// And now you also have access to the execution params like variables, context and so on.
// If you wish to report an error, use validationContext.reportError or throw an exception.
},
};
};
```

## Built-in Rules

### Union Inputs: `@oneOf`

This directive provides validation for input types and implements the concept of union inputs. You can find the [complete spec RFC here](https://github.com/graphql/graphql-spec/pull/825).

To use that validation rule, make sure to include the following directive in your schema:

```graphql
directive @oneOf on INPUT_OBJECT | FIELD_DEFINITION
```

Then, apply it to field definitions, or to a complete `input` type:

```graphql
## Apply to entire input type
input FindUserInput @oneOf {
id: ID
organizationAndRegistrationNumber: OrganizationAndRegistrationNumberInput
}

## Or, apply to a set of input arguments

type Query {
foo(id: ID, str1: String, str2: String): String @oneOf
}
```

Then, make sure to add that rule to your plugin usage:

```ts
import { useExtendedValidation, OneOfInputObjectsRule } from '@envelop/extended-validation';

const getEnveloped = evelop({
plugins: [
useExtendedValidation({
rules: [OneOfInputObjectsRule],
}),
],
});
```
38 changes: 38 additions & 0 deletions packages/plugins/extended-validation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@envelop/extended-validation",
"version": "0.0.0",
"author": "Dotan Simha <dotansimha@gmail.com>",
"license": "MIT",
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/envelop.git",
"directory": "packages/plugins/extended-validation"
},
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"scripts": {
"test": "jest",
"prepack": "bob prepack"
},
"dependencies": {},
"devDependencies": {
"bob-the-bundler": "1.2.0",
"graphql": "15.5.0",
"typescript": "4.2.4"
},
"peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0"
},
"buildOptions": {
"input": "./src/index.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
}
}
35 changes: 35 additions & 0 deletions packages/plugins/extended-validation/src/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
ASTVisitor,
DirectiveNode,
ExecutionArgs,
GraphQLNamedType,
GraphQLType,
isListType,
isNonNullType,
ValidationContext,
} from 'graphql';

export type ExtendedValidationRule = (context: ValidationContext, executionArgs: ExecutionArgs) => ASTVisitor;

export function getDirectiveFromAstNode(
astNode: { directives?: ReadonlyArray<DirectiveNode> },
names: string | string[]
): null | DirectiveNode {
if (!astNode) {
return null;
}

const directives = astNode.directives || [];
const namesArr = Array.isArray(names) ? names : [names];
const authDirective = directives.find(d => namesArr.includes(d.name.value));

return authDirective || null;
}

export function unwrapType(type: GraphQLType): GraphQLNamedType {
if (isNonNullType(type) || isListType(type)) {
return unwrapType(type.ofType);
}

return type;
}
4 changes: 4 additions & 0 deletions packages/plugins/extended-validation/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './plugin';
export * from './common';

export * from './rules/one-of';
Loading

0 comments on commit ced704e

Please sign in to comment.