Skip to content

Commit

Permalink
feat(decorators): support auth and middlewares on resolver class (#1297)
Browse files Browse the repository at this point in the history
* ✨feat(Authorized): decorator to resolver

* ✨feat(decorators/UseMiddleware): middleware decorator to Resolver

* ✨feat(decorators): createMiddlewareDecorator

* 🐞fix(tests/middlewares): add @resolver() to LocalResolver

* 🐞fix(decorators): export createMiddlewareDecorator

* Rename create decorator method

* Add changelog

* Add comments for cleanup in tests

* Add missing shim for createClassMiddlewareDecorator

* Update docs to reflect new class-defined auth and middlewares

* Rename createResolverClassMiddlewareDecorator and update changelog

* Update examples to use resolver class scoped middleware

---------

Co-authored-by: Michał Lytek <[email protected]>
  • Loading branch information
xcfox and MichalLytek committed May 30, 2024
1 parent 9ae96c0 commit 79d216f
Show file tree
Hide file tree
Showing 20 changed files with 409 additions and 59 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

## Unreleased

## Features

- support declaring middlewares on resolver class level (#620)
- support declaring auth roles on resolver class level (#620)
- make possible creating custom decorators on resolver class level - `createResolverClassMiddlewareDecorator`

### Others

- **Breaking Change**: update `graphql-scalars` peer dependency to `^1.23.0`
- **Breaking Change**: rename `createMethodDecorator` into `createMethodMiddlewareDecorator`

<!-- Here goes all the unreleased changes descriptions -->

Expand Down
29 changes: 27 additions & 2 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ In express.js (and other Node.js frameworks) we use middleware for this, like `p

That's why authorization is a first-class feature in `TypeGraphQL`!

## How to use
## Declaration

First, we need to use the `@Authorized` decorator as a guard on a field, query or mutation.
Example object type field guards:
Expand Down Expand Up @@ -68,7 +68,32 @@ class MyResolver {

Authorized users (regardless of their roles) will be able to read data from the `publicQuery` and the `authedQuery` queries, but will receive an error when trying to perform the `adminMutation` when their roles don't include `ADMIN` or `MODERATOR`.

Next, we need to create our auth checker function. Its implementation may depend on our business logic:
However, declaring `@Authorized()` on all the resolver's class methods would be not only a tedious task but also an error-prone one, as it's easy to forget to put it on some newly added method, etc.
Hence, TypeGraphQL support declaring `@Authorized()` or the resolver class level. This way you can declare it once per resolver's class but you can still overwrite the defaults and narrows the authorization rules:

```ts
@Authorized()
@Resolver()
class MyResolver {
// this will inherit the auth guard defined on the class level
@Query()
authedQuery(): string {
return "Authorized users only!";
}

// this one overwrites the resolver's one
// and registers roles required for this mutation
@Authorized("ADMIN", "MODERATOR")
@Mutation()
adminMutation(): string {
return "You are an admin/moderator, you can safely drop the database ;)";
}
}
```

## Runtime checks

Having all the metadata for authorization set, we need to create our auth checker function. Its implementation may depend on our business logic:

```ts
export const customAuthChecker: AuthChecker<ContextType> = (
Expand Down
39 changes: 36 additions & 3 deletions docs/custom-decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
title: Custom decorators
---

Custom decorators are a great way to reduce the boilerplate and reuse some common logic between different resolvers. TypeGraphQL supports two kinds of custom decorators - method and parameter.
Custom decorators are a great way to reduce the boilerplate and reuse some common logic between different resolvers. TypeGraphQL supports three kinds of custom decorators - method, resolver class and parameter.

## Method decorators

Using [middlewares](./middlewares.md) allows to reuse some code between resolvers. To further reduce the boilerplate and have a nicer API, we can create our own custom method decorators.

They work in the same way as the [reusable middleware function](./middlewares.md#reusable-middleware), however, in this case we need to call `createMethodDecorator` helper function with our middleware logic and return its value:
They work in the same way as the [reusable middleware function](./middlewares.md#reusable-middleware), however, in this case we need to call `createMethodMiddlewareDecorator` helper function with our middleware logic and return its value:

```ts
export function ValidateArgs(schema: JoiSchema) {
return createMethodDecorator(async ({ args }, next) => {
return createMethodMiddlewareDecorator(async ({ args }, next) => {
// Middleware code that uses custom decorator arguments

// e.g. Validation logic based on schema using 'joi'
Expand All @@ -36,6 +36,39 @@ export class RecipeResolver {
}
```

## Resolver class decorators

Similar to method decorators, we can create our own custom resolver class decorators.
In this case we need to call `createResolverClassMiddlewareDecorator` helper function, just like we did for `createMethodMiddlewareDecorator`:

```ts
export function ValidateArgs(schema: JoiSchema) {
return createResolverClassMiddlewareDecorator(async ({ args }, next) => {
// Middleware code that uses custom decorator arguments

// e.g. Validation logic based on schema using 'joi'
await joiValidate(schema, args);
return next();
});
}
```

The usage is then analogue - we just place it above the resolver class and pass the required arguments to it:

```ts
@ValidateArgs(MyArgsSchema) // Custom decorator
@UseMiddleware(ResolveTime) // Explicit middleware
@Resolver()
export class RecipeResolver {
@Query()
randomValue(@Args() { scale }: MyArgs): number {
return Math.random() * scale;
}
}
```

This way, we just need to put it once in the code and our custom decorator will be applied to all the resolver's queries or mutations. As simple as that!

## Parameter decorators

Parameter decorators are just like the custom method decorators or middlewares but with an ability to return some value that will be injected to the method as a parameter. Thanks to this, it reduces the pollution in `context` which was used as a workaround for the communication between reusable middlewares and resolvers.
Expand Down
26 changes: 23 additions & 3 deletions docs/middlewares.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export class LogAccess implements MiddlewareInterface<TContext> {

### Attaching Middleware

To attach middleware to a resolver, place the `@UseMiddleware()` decorator above the field or resolver declaration. It accepts an array of middleware that will be called in the provided order. We can also pass them without an array as it supports [rest parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters):
To attach middleware to a resolver method, place the `@UseMiddleware()` decorator above the method declaration. It accepts an array of middleware that will be called in the provided order. We can also pass them without an array as it supports [rest parameters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters):

```ts
@Resolver()
Expand All @@ -151,6 +151,26 @@ export class RecipeResolver {
}
```

If we want to apply the middlewares to all the resolver's class methods, we can put the decorator on top of the class declaration:

```ts
@UseMiddleware(ResolveTime, LogAccess)
@Resolver()
export class RecipeResolver {
@Query()
randomValue(): number {
return Math.random();
}

@Query()
constantValue(): number {
return 21.37;
}
}
```

> Be aware that resolver's class middlewares are executed first, before the method's ones.
We can also attach the middleware to the `ObjectType` fields, the same way as with the [`@Authorized()` decorator](./authorization.md).

```ts
Expand All @@ -167,9 +187,9 @@ export class Recipe {

### Global Middleware

However, for common middleware like measuring resolve time or catching errors, it might be annoying to place a `@UseMiddleware(ResolveTime)` decorator on every field/resolver.
However, for common middlewares like measuring resolve time or catching errors, it might be annoying to place a `@UseMiddleware(ResolveTime)` decorator on every field, method or resolver class.

Hence, in TypeGraphQL we can also register a global middleware that will be called for each query, mutation, subscription and field resolver. For this, we use the `globalMiddlewares` property of the `buildSchema` configuration object:
Hence, in TypeGraphQL we can also register a global middleware that will be called for each query, mutation, subscription and a field. For this, we use the `globalMiddlewares` property of the `buildSchema` configuration object:

```ts
const schema = await buildSchema({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { validate } from "class-validator";
import { ArgumentValidationError, type ClassType, createMethodDecorator } from "type-graphql";
import {
ArgumentValidationError,
type ClassType,
createMethodMiddlewareDecorator,
} from "type-graphql";

// Sample implementation of custom validation decorator
// This example use 'class-validator' however you can plug-in 'joi' or any other validation library
export function ValidateArgs<T extends object>(Type: ClassType<T>) {
return createMethodDecorator(async ({ args }, next) => {
return createMethodMiddlewareDecorator(async ({ args }, next) => {
const instance = Object.assign(new Type(), args);
const validationErrors = await validate(instance);
if (validationErrors.length > 0) {
Expand Down
4 changes: 2 additions & 2 deletions examples/middlewares-custom-decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { startStandaloneServer } from "@apollo/server/standalone";
import { buildSchema } from "type-graphql";
import Container from "typedi";
import { type Context } from "./context.type";
import { ErrorLoggerMiddleware, ResolveTimeMiddleware } from "./middlewares";
import { ErrorLoggerMiddleware } from "./middlewares";
import { RecipeResolver } from "./recipe";

async function bootstrap() {
Expand All @@ -14,7 +14,7 @@ async function bootstrap() {
// Array of resolvers
resolvers: [RecipeResolver],
// Array of global middlewares
globalMiddlewares: [ErrorLoggerMiddleware, ResolveTimeMiddleware],
globalMiddlewares: [ErrorLoggerMiddleware],
// Create 'schema.graphql' file with schema definition in current directory
emitSchemaFile: path.resolve(__dirname, "schema.graphql"),
// Registry 3rd party IOC container
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Args, Query, Resolver } from "type-graphql";
import { Args, Query, Resolver, UseMiddleware } from "type-graphql";
import { Service } from "typedi";
import { RecipesArgs } from "./recipe.args";
import { recipes as recipesData } from "./recipe.data";
import { Recipe } from "./recipe.type";
import { CurrentUser, ValidateArgs } from "../decorators";
import { ResolveTimeMiddleware } from "../middlewares";
import { User } from "../user.type";

@Service()
@UseMiddleware(ResolveTimeMiddleware)
@Resolver(_of => Recipe)
export class RecipeResolver {
private readonly items: Recipe[] = recipesData;
Expand Down
26 changes: 19 additions & 7 deletions src/decorators/Authorized.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import { SymbolKeysNotSupportedError } from "@/errors";
import { getArrayFromOverloadedRest } from "@/helpers/decorators";
import { getMetadataStorage } from "@/metadata/getMetadataStorage";
import { type MethodAndPropDecorator } from "./types";
import { type MethodPropClassDecorator } from "./types";

export function Authorized(): MethodAndPropDecorator;
export function Authorized<RoleType = string>(roles: readonly RoleType[]): MethodAndPropDecorator;
export function Authorized(): MethodPropClassDecorator;
export function Authorized<RoleType = string>(roles: readonly RoleType[]): MethodPropClassDecorator;
export function Authorized<RoleType = string>(
...roles: readonly RoleType[]
): MethodAndPropDecorator;
): MethodPropClassDecorator;
export function Authorized<RoleType = string>(
...rolesOrRolesArray: Array<RoleType | readonly RoleType[]>
): MethodDecorator | PropertyDecorator {
): MethodPropClassDecorator {
const roles = getArrayFromOverloadedRest(rolesOrRolesArray);

return (prototype, propertyKey, _descriptor) => {
return (
target: Function | Object,
propertyKey?: string | symbol,
_descriptor?: TypedPropertyDescriptor<any>,
) => {
if (propertyKey == null) {
getMetadataStorage().collectAuthorizedResolverMetadata({
target: target as Function,
roles,
});
return;
}

if (typeof propertyKey === "symbol") {
throw new SymbolKeysNotSupportedError();
}

getMetadataStorage().collectAuthorizedFieldMetadata({
target: prototype.constructor,
target: target.constructor,
fieldName: propertyKey,
roles,
});
Expand Down
24 changes: 18 additions & 6 deletions src/decorators/UseMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,34 @@ import { SymbolKeysNotSupportedError } from "@/errors";
import { getArrayFromOverloadedRest } from "@/helpers/decorators";
import { getMetadataStorage } from "@/metadata/getMetadataStorage";
import { type Middleware } from "@/typings/middleware";
import { type MethodAndPropDecorator } from "./types";
import { type MethodPropClassDecorator } from "./types";

export function UseMiddleware(middlewares: Array<Middleware<any>>): MethodAndPropDecorator;
export function UseMiddleware(...middlewares: Array<Middleware<any>>): MethodAndPropDecorator;
export function UseMiddleware(middlewares: Array<Middleware<any>>): MethodPropClassDecorator;
export function UseMiddleware(...middlewares: Array<Middleware<any>>): MethodPropClassDecorator;
export function UseMiddleware(
...middlewaresOrMiddlewareArray: Array<Middleware<any> | Array<Middleware<any>>>
): MethodDecorator | PropertyDecorator {
): MethodPropClassDecorator {
const middlewares = getArrayFromOverloadedRest(middlewaresOrMiddlewareArray);

return (prototype, propertyKey, _descriptor) => {
return (
target: Function | Object,
propertyKey?: string | symbol,
_descriptor?: TypedPropertyDescriptor<any>,
) => {
if (propertyKey == null) {
getMetadataStorage().collectResolverMiddlewareMetadata({
target: target as Function,
middlewares,
});
return;
}

if (typeof propertyKey === "symbol") {
throw new SymbolKeysNotSupportedError();
}

getMetadataStorage().collectMiddlewareMetadata({
target: prototype.constructor,
target: target.constructor,
fieldName: propertyKey,
middlewares,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type MiddlewareFn } from "@/typings/middleware";
import { UseMiddleware } from "./UseMiddleware";

export function createMethodDecorator<TContextType extends object = object>(
export function createMethodMiddlewareDecorator<TContextType extends object = object>(
resolver: MiddlewareFn<TContextType>,
): MethodDecorator {
return UseMiddleware(resolver);
Expand Down
8 changes: 8 additions & 0 deletions src/decorators/createResolverClassMiddlewareDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type MiddlewareFn } from "@/typings";
import { UseMiddleware } from "./UseMiddleware";

export function createResolverClassMiddlewareDecorator<TContextType extends object = object>(
resolver: MiddlewareFn<TContextType>,
): ClassDecorator {
return UseMiddleware(resolver);
}
3 changes: 2 additions & 1 deletion src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export { Args } from "./Args";
export { ArgsType } from "./ArgsType";
export { Authorized } from "./Authorized";
export { createParamDecorator } from "./createParamDecorator";
export { createMethodDecorator } from "./createMethodDecorator";
export { createMethodMiddlewareDecorator } from "./createMethodMiddlewareDecorator";
export { createResolverClassMiddlewareDecorator } from "./createResolverClassMiddlewareDecorator";
export { Ctx } from "./Ctx";
export { Directive } from "./Directive";
export { Extensions } from "./Extensions";
Expand Down
2 changes: 2 additions & 0 deletions src/decorators/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ export type EnumValuesConfig<TEnum extends object> = Partial<
>;

export type MethodAndPropDecorator = PropertyDecorator & MethodDecorator;

export type MethodPropClassDecorator = PropertyDecorator & MethodDecorator & ClassDecorator;
2 changes: 2 additions & 0 deletions src/metadata/definitions/authorized-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export interface AuthorizedMetadata {
fieldName: string;
roles: any[];
}

export type AuthorizedClassMetadata = Omit<AuthorizedMetadata, "fieldName">;
2 changes: 2 additions & 0 deletions src/metadata/definitions/middleware-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export interface MiddlewareMetadata {
fieldName: string;
middlewares: Array<Middleware<any>>;
}

export type ResolverMiddlewareMetadata = Omit<MiddlewareMetadata, "fieldName">;
Loading

0 comments on commit 79d216f

Please sign in to comment.