Skip to content

Commit

Permalink
feat(validation): make class-validator truly optional dependency
Browse files Browse the repository at this point in the history
  • Loading branch information
MichalLytek committed May 10, 2023
1 parent ba7f056 commit 1f2efa4
Show file tree
Hide file tree
Showing 14 changed files with 113 additions and 32 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- **Breaking Change**: `AuthChecker` type is now "function or class" - update to `AuthCheckerFn` if the function form is needed in the code
- **Breaking Change**: update `graphql-js` peer dependency to `^16.6.0`
- **Breaking Change**: `buildSchemaSync` is now also checking the generated schema for errors
- **Breaking Change**: `validate` option of `buildSchema` is set to `false` by default - integration with `class-validator` has to be turned on explicitly
- **Breaking Change**: `validate` option of `buildSchema` doesn't accept anymore a custom validation function - use `validateFn` option instead
- support class-based auth checker, which allows for dependency injection
- allow defining directives for interface types and theirs fields, with inheritance for object types fields (#744)
- allow deprecating input fields and args (#794)
Expand Down
4 changes: 2 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Before getting started with TypeGraphQL we need to install some additional depen
## Packages installation

First, we have to install the main package, as well as [`graphql-js`](https://github.com/graphql/graphql-js) and [`class-validator`](https://github.com/typestack/class-validator) which are peer dependencies of TypeGraphQL:
First, we have to install the main package, as well as [`graphql-js`](https://github.com/graphql/graphql-js) which is a peer dependency of TypeGraphQL:

```sh
npm i graphql class-validator type-graphql
npm i graphql type-graphql
```

Also, the `reflect-metadata` shim is required to make the type reflection work:
Expand Down
36 changes: 22 additions & 14 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ We can also use other libraries or our own custom solution, as described in [cus

### How to use

First we decorate the input/arguments class with the appropriate decorators from `class-validator`.
First, we need to install the `class-validator` package:

```sh
npm i class-validator
```

Then we decorate the input/arguments class with the appropriate decorators from `class-validator`.
So we take this:

```typescript
Expand Down Expand Up @@ -47,6 +53,15 @@ export class RecipeInput {
}
```

Then we need to enable the auto-validate feature (as it's disabled by default) by simply setting `validate: true` in `buildSchema` options, e.g.:

```typescript
const schema = await buildSchema({
resolvers: [RecipeResolver],
validate: true, // enable `class-validator` integration
});
```

And that's it! 😉

TypeGraphQL will automatically validate our inputs and arguments based on the definitions:
Expand All @@ -66,16 +81,8 @@ export class RecipeResolver {

Of course, [there are many more decorators](https://github.com/typestack/class-validator#validation-decorators) we have access to, not just the simple `@Length` decorator used in the example above, so take a look at the `class-validator` documentation.

This feature is enabled by default. However, we can disable it if we must:

```typescript
const schema = await buildSchema({
resolvers: [RecipeResolver],
validate: false, // disable automatic validation or pass the default config object
});
```

And we can still enable it per resolver's argument if we need to:
We don't have to enable this feature for all resolvers.
It's also possible to keep `validate: false` in `buildSchema` options but still enable it per resolver's argument if we need to:

```typescript
class RecipeResolver {
Expand All @@ -101,6 +108,7 @@ class RecipeResolver {
```

Note that by default, the `skipMissingProperties` setting of the `class-validator` is set to `true` because GraphQL will independently check whether the params/fields exist or not.
Same goes to `forbidUnknownValues` setting which is set to `false` because the GraphQL runtime checks for additional data, not described in schema.

GraphQL will also check whether the fields have correct types (String, Int, Float, Boolean, etc.) so we don't have to use the `@IsOptional`, `@Allow`, `@IsString` or the `@IsInt` decorators at all!

Expand Down Expand Up @@ -193,15 +201,15 @@ It receives two parameters:
- `argValue` which is the injected value of `@Arg()` or `@Args()`
- `argType` which is a runtime type information (e.g. `String` or `RecipeInput`).

The `validate` function can be async and should return nothing (`void`) when validation passes or throw an error when validation fails.
So be aware of this while trying to wrap another library in `validate` function for TypeGraphQL.
The `validateFn` option can be an async function and should return nothing (`void`) when validation passes or throw an error when validation fails.
So be aware of this while trying to wrap another library in `validateFn` function for TypeGraphQL.

Example using [decorators library for Joi validators (`joiful`)](https://github.com/joiful-ts/joiful):

```ts
const schema = await buildSchema({
// ...other options
validate: argValue => {
validateFn: argValue => {
// call joiful validate
const { error } = joiful.validate(argValue);
if (error) {
Expand Down
2 changes: 2 additions & 0 deletions examples/automatic-validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ async function bootstrap() {
const schema = await buildSchema({
resolvers: [RecipeResolver],
emitSchemaFile: path.resolve(__dirname, "schema.gql"),
// remember to turn on validation!
validate: true,
});

// Create GraphQL server
Expand Down
2 changes: 1 addition & 1 deletion examples/custom-validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async function bootstrap() {
resolvers: [RecipeResolver],
emitSchemaFile: path.resolve(__dirname, "schema.gql"),
// custom validate function
validate: (argValue, argType) => {
validateFn: (argValue, argType) => {
// call joiful validate
const { error } = joiful.validate(argValue);
if (error) {
Expand Down
1 change: 1 addition & 0 deletions examples/mixin-classes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ async function bootstrap() {
resolvers: [UserResolver],
emitSchemaFile: path.resolve(__dirname, "schema.gql"),
skipCheck: true,
validate: true,
});

// Create GraphQL server
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@
"postgenerate:sponsorkit": "npx shx cp ./img/github-sponsors.svg ./website/static/img/github-sponsors.svg"
},
"peerDependencies": {
"class-validator": ">=0.14.0",
"graphql": "^16.6.0"
"graphql": "^16.6.0",
"class-validator": ">=0.14.0"
},
"peerDependenciesMeta": {
"class-validator": {
"optional": true
}
},
"dependencies": {
"@types/node": "*",
Expand Down
1 change: 1 addition & 0 deletions src/errors/ArgumentValidationError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-ignore `class-validator` might not be installed by user
import type { ValidationError } from "class-validator";

export class ArgumentValidationError extends Error {
Expand Down
5 changes: 5 additions & 0 deletions src/resolvers/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function createHandlerResolver(
): GraphQLFieldResolver<any, any, any> {
const {
validate: globalValidate,
validateFn,
authChecker,
authMode,
pubSub,
Expand All @@ -40,6 +41,7 @@ export function createHandlerResolver(
resolverMetadata.params!,
resolverData,
globalValidate,
validateFn,
pubSub,
);
if (isPromiseLike(params)) {
Expand All @@ -57,6 +59,7 @@ export function createHandlerResolver(
resolverMetadata.params!,
resolverData,
globalValidate,
validateFn,
pubSub,
);
const targetInstance = targetInstanceOrPromise;
Expand All @@ -81,6 +84,7 @@ export function createAdvancedFieldResolver(
const targetType = fieldResolverMetadata.getObjectType!();
const {
validate: globalValidate,
validateFn: validateFn,
authChecker,
authMode,
pubSub,
Expand All @@ -104,6 +108,7 @@ export function createAdvancedFieldResolver(
fieldResolverMetadata.params!,
resolverData,
globalValidate,
validateFn,
pubSub,
);
if (isPromiseLike(params)) {
Expand Down
4 changes: 4 additions & 0 deletions src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { AuthMiddleware } from "../helpers/auth-middleware";
import { convertArgsToInstance, convertArgToInstance } from "./convert-args";
import isPromiseLike from "../utils/isPromiseLike";
import { ValidateSettings } from "../schema/build-context";
import { ValidatorFn } from "../interfaces/ValidatorFn";

export function getParams(
params: ParamMetadata[],
resolverData: ResolverData<any>,
globalValidate: ValidateSettings,
validateFn: ValidatorFn<object> | undefined,
pubSub: PubSubEngine,
): Promise<any[]> | any[] {
const paramValues = params
Expand All @@ -27,13 +29,15 @@ export function getParams(
paramInfo.getType(),
globalValidate,
paramInfo.validate,
validateFn,
);
case "arg":
return validateArg(
convertArgToInstance(paramInfo, resolverData.args),
paramInfo.getType(),
globalValidate,
paramInfo.validate,
validateFn,
);
case "context":
if (paramInfo.propertyName) {
Expand Down
14 changes: 9 additions & 5 deletions src/resolvers/validate-arg.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// @ts-ignore `class-validator` might not be installed by user
import type { ValidatorOptions } from "class-validator";
import { TypeValue } from "../decorators/types";

import { TypeValue } from "../decorators/types";
import { ArgumentValidationError } from "../errors/ArgumentValidationError";
import { ValidateSettings } from "../schema/build-context";
import { ValidatorFn } from "../interfaces/ValidatorFn";

const shouldArgBeValidated = (argValue: unknown): boolean =>
argValue !== null && typeof argValue === "object";
Expand All @@ -12,14 +14,15 @@ export async function validateArg<T extends object>(
argType: TypeValue,
globalValidate: ValidateSettings,
argValidate: ValidateSettings | undefined,
validateFn: ValidatorFn<object> | undefined,
): Promise<T | undefined> {
const validate = argValidate !== undefined ? argValidate : globalValidate;
if (validate === false || !shouldArgBeValidated(argValue)) {
if (typeof validateFn === "function") {
await validateFn(argValue, argType);
return argValue;
}

if (typeof validate === "function") {
await validate(argValue, argType);
const validate = argValidate !== undefined ? argValidate : globalValidate;
if (validate === false || !shouldArgBeValidated(argValue)) {
return argValue;
}

Expand All @@ -35,6 +38,7 @@ export async function validateArg<T extends object>(
validatorOptions.forbidUnknownValues = false;
}

// dynamic import to avoid making `class-validator` a peer dependency when `validate: true` is not set
const { validateOrReject } = await import("class-validator");
try {
if (Array.isArray(argValue)) {
Expand Down
16 changes: 13 additions & 3 deletions src/schema/build-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GraphQLScalarType } from "graphql";
// @ts-ignore `class-validator` might not be installed by user
import type { ValidatorOptions } from "class-validator";
import { PubSubEngine, PubSub, PubSubOptions } from "graphql-subscriptions";

Expand All @@ -14,17 +15,20 @@ export interface ScalarsTypeMap {
scalar: GraphQLScalarType;
}

export type ValidateSettings = boolean | ValidatorOptions | ValidatorFn<object>;
export type ValidateSettings = boolean | ValidatorOptions;

export interface BuildContextOptions {
dateScalarMode?: DateScalarMode;
scalarsMap?: ScalarsTypeMap[];
/**
* Indicates if class-validator should be used to auto validate objects injected into params.
* You can directly pass validator options to enable validator with a given options.
* Also, you can provide your own validation function to check the args.
*/
validate?: ValidateSettings;
/**
* Own validation function to check the args and inputs.
*/
validateFn?: ValidatorFn<object>;
authChecker?: AuthChecker<any, any>;
authMode?: AuthMode;
pubSub?: PubSubEngine | PubSubOptions;
Expand All @@ -44,6 +48,7 @@ export abstract class BuildContext {
static dateScalarMode: DateScalarMode;
static scalarsMaps: ScalarsTypeMap[];
static validate: ValidateSettings;
static validateFn?: ValidatorFn<object>;
static authChecker?: AuthChecker<any, any>;
static authMode: AuthMode;
static pubSub: PubSubEngine;
Expand All @@ -68,6 +73,10 @@ export abstract class BuildContext {
this.validate = options.validate;
}

if (options.validateFn !== undefined) {
this.validateFn = options.validateFn;
}

if (options.authChecker !== undefined) {
this.authChecker = options.authChecker;
}
Expand Down Expand Up @@ -105,7 +114,8 @@ export abstract class BuildContext {
static reset() {
this.dateScalarMode = "isoDate";
this.scalarsMaps = [];
this.validate = true;
this.validate = false;
this.validateFn = undefined;
this.authChecker = undefined;
this.authMode = "error";
this.pubSub = new PubSub();
Expand Down
1 change: 1 addition & 0 deletions tests/functional/typedefs-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ describe("typeDefs and resolvers", () => {
pubSub,
container: Container,
orphanedTypes: [SampleType1],
validate: true,
}));
schema = makeExecutableSchema({
typeDefs,
Expand Down
48 changes: 43 additions & 5 deletions tests/functional/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,45 @@ describe("Validation", () => {
localArgsData = undefined;
});

it("should pass incorrect args when validation is turned off", async () => {
it("should pass incorrect args when validation is turned off by default", async () => {
getMetadataStorage().clear();

@ObjectType()
class SampleObject {
@Field({ nullable: true })
field?: string;
}
@ArgsType()
class SampleArguments {
@Field()
@MaxLength(5)
field: string;
}
@Resolver(of => SampleObject)
class SampleResolver {
@Query()
sampleQuery(@Args() args: SampleArguments): SampleObject {
localArgsData = args;
return {};
}
}
const localSchema = await buildSchema({
resolvers: [SampleResolver],
// default - `validate: false,`
});

const query = `query {
sampleQuery(
field: "12345678",
) {
field
}
}`;
await graphql({ schema: localSchema, source: query });
expect(localArgsData).toEqual({ field: "12345678" });
});

it("should pass incorrect args when validation is turned off explicitly", async () => {
getMetadataStorage().clear();

@ObjectType()
Expand Down Expand Up @@ -667,10 +705,10 @@ describe("Custom validation", () => {
sampleQueryArgs = [];
});

it("should call `validate` function provided in option with proper params", async () => {
it("should call `validateFn` function provided in option with proper params", async () => {
schema = await buildSchema({
resolvers: [sampleResolverCls],
validate: (arg, type) => {
validateFn: (arg, type) => {
validateArgs.push(arg);
validateTypes.push(type);
},
Expand All @@ -686,7 +724,7 @@ describe("Custom validation", () => {
it("should inject validated arg as resolver param", async () => {
schema = await buildSchema({
resolvers: [sampleResolverCls],
validate: () => {
validateFn: () => {
// do nothing
},
});
Expand All @@ -699,7 +737,7 @@ describe("Custom validation", () => {
it("should rethrow wrapped error when error thrown in `validate`", async () => {
schema = await buildSchema({
resolvers: [sampleResolverCls],
validate: () => {
validateFn: () => {
throw new Error("Test validate error");
},
});
Expand Down

0 comments on commit 1f2efa4

Please sign in to comment.