Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @Extensions decorator #521

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
title: Extensions
---

It is sometimes desired to be able to annotate schema entities (fields, object types, or even queries and mutations...) with custom metadata that can be used at runtime by middlewares or resolvers.

For such use cases, **TypeGraphQL** provides the `@Extensions` decorator, which will add the data you defined to the `extensions` field of your executable schema for the decorated entity.

_Note:_ This is a low-level decorator and you will generally have to provide your own logic to make use of the `extensions` data.

## How to use

### Using the @Extensions decorator

Adding extensions to your schema entity is as simple as using the `@Extensions` decorator and passing it an object of the custom data you want:

```typescript
@Extensions({ complexity: 2 })
```

You can pass several fields to the decorator:

```typescript
@Extensions({ logMessage: "Restricted access", logLevel: 1 })
```

And you can also decorate an entity several times, this will attach the exact same extensions data to your schema entity than the example above:

```typescript
@Extensions({ logMessage: "Restricted access" })
@Extensions({ logLevel: 1 })
```

If you decorate the same entity several times with the same extensions key, the one defined at the bottom will take precedence:

```typescript
@Extensions({ logMessage: "Restricted access" })
@Extensions({ logMessage: "Another message" })
```

The above will result in your entity having `logmessage: "Another message"` in its extensions.

The following entities can be decorated with extensions:

- @Field
- @ObjectType
- @InputType
- @Query
- @Mutation
- @FieldResolver

So the `@Extensions` decorator can be placed over the class property/method or over the type class itself, and multiple times if necessary, depending on what you want to do with the extensions data:

```typescript
@Extensions({ roles: ["USER"] })
@ObjectType()
class Foo {
@Field()
field: string;
}

@ObjectType()
class Bar {
@Extensions({ roles: ["USER"] })
@Field()
field: string;
}

@ObjectType()
class Bar {
@Extensions({ roles: ["USER"] })
@Extensions({ visible: false, logMessage: "User accessed restricted field" })
@Field()
field: string;
}

@Resolver(of => Foo)
class FooBarResolver {
@Extensions({ roles: ["USER"] })
@Query()
foobar(@Arg("baz") baz: string): string {
return "foobar";
}

@Extensions({ roles: ["ADMIN"] })
@FieldResolver()
bar(): string {
return "foobar";
}
}
```

### Using the extensions data

Once you have decorated the necessary entities with extensions, your executable schema will contain the extensions data, and you can make use of it in any way you choose.

The most common use will be to read it at runtime in resolvers or middlewares and perform some custom logic there.

Here is a simple example of a global middleware logging a message whenever a field is decorated appropriately:

```typescript
export class LoggerMiddleware implements MiddlewareInterface<Context> {
constructor(private readonly logger: Logger) {}

async use({ info }, next: NextFn) {
const { logMessage } = info.parentType.getFields()[info.fieldName].extensions || {};

if (logMessage) {
this.logger.log(logMessage);
}

return next();
}
}

// build the schema and register the global middleware
const schema = buildSchemaSync({
resolvers: [SampleResolver],
globalMiddlewares: [LoggerMiddleware],
});

// declare your type and decorate the appropriate field with "logMessage" extensions
@ObjectType()
class Bar {
@Extensions({ logMessage: "Restricted field was accessed" })
@Field()
field: string;
}
```

## Examples

You can see more detailed examples of usage [here](https://github.com/MichalLytek/type-graphql/tree/master/examples/extensions).
43 changes: 43 additions & 0 deletions examples/extensions/authorizer.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { GraphQLResolveInfo, GraphQLObjectType } from "graphql";

import { MiddlewareFn } from "../../src";
import { Context } from "./context.interface";
import { UnauthorizedError } from "../../src/errors";

const extractAuthorizationExtensions = (info: GraphQLResolveInfo) => {
MichalLytek marked this conversation as resolved.
Show resolved Hide resolved
const parentAuthorizationExtensions =
(info.parentType.extensions && info.parentType.extensions.authorization) || {};
const returnType = info.returnType as GraphQLObjectType;
const returnTypeAuthorizationExtensions =
(returnType.extensions && returnType.extensions.authorization) || {};
const field = info.parentType.getFields()[info.fieldName];
const fieldAuthorizationExtensions = (field.extensions && field.extensions.authorization) || {};

return {
...parentAuthorizationExtensions,
...returnTypeAuthorizationExtensions,
...fieldAuthorizationExtensions,
};
};

export const AuthorizerMiddleware: MiddlewareFn = async (
MichalLytek marked this conversation as resolved.
Show resolved Hide resolved
{ context: { user }, info }: { context: Context; info: GraphQLResolveInfo },
hihuz marked this conversation as resolved.
Show resolved Hide resolved
next,
) => {
const { restricted = false, roles = [] } = extractAuthorizationExtensions(info);

if (restricted) {
if (!user) {
// if no user, restrict access
throw new UnauthorizedError();
}

if (roles.length > 0 && !user.roles.some(role => roles.includes(role))) {
// if the roles don't overlap, restrict access
throw new UnauthorizedError();
}
}

// grant access in other cases
await next();
hihuz marked this conversation as resolved.
Show resolved Hide resolved
};
5 changes: 5 additions & 0 deletions examples/extensions/context.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { User } from "./user.interface";

export interface Context {
user?: User;
}
9 changes: 9 additions & 0 deletions examples/extensions/custom.authorized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Extensions } from "../../src";

export const CustomAuthorized = (roles: string | string[] = []) =>
Extensions({
authorization: {
restricted: true,
roles: typeof roles === "string" ? [roles] : roles,
},
});
36 changes: 36 additions & 0 deletions examples/extensions/examples.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
query GetPublicRecipes {
recipes {
title
description
averageRating
}
}

query GetRecipesForAuthedUser {
recipes {
title
description
ingredients
averageRating
}
}

query GetRecipesForAdmin {
recipes {
title
description
ingredients
averageRating
ratings
}
}

mutation AddRecipeByAuthedUser {
addRecipe(title: "Sample Recipe") {
averageRating
}
}

mutation DeleteRecipeByAdmin {
deleteRecipe(title: "Recipe 1")
}
37 changes: 37 additions & 0 deletions examples/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import "reflect-metadata";
import { ApolloServer } from "apollo-server";
import { buildSchema } from "../../src";

import { ExampleResolver } from "./resolver";
import { Context } from "./context.interface";
import { AuthorizerMiddleware } from "./authorizer.middleware";
import { LoggerMiddleware } from "./logger.middleware";

void (async function bootstrap() {
// build TypeGraphQL executable schema
const schema = await buildSchema({
resolvers: [ExampleResolver],
globalMiddlewares: [AuthorizerMiddleware, LoggerMiddleware],
});

// Create GraphQL server
const server = new ApolloServer({
schema,
context: () => {
const ctx: Context = {
// create mocked user in context
// in real app you would be mapping user from `req.user` or sth
user: {
id: 1,
name: "Sample user",
roles: ["REGULAR"],
},
};
return ctx;
},
});

// Start the server
const { url } = await server.listen(4000);
console.log(`Server is running, GraphQL Playground available at ${url}`);
})();
21 changes: 21 additions & 0 deletions examples/extensions/logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Service } from "typedi";
import { MiddlewareInterface, NextFn, ResolverData } from "../../src";

import { Context } from "./context.interface";
import { Logger } from "./logger";

@Service()
export class LoggerMiddleware implements MiddlewareInterface<Context> {
constructor(private readonly logger: Logger) {}

async use({ context: { user }, info }: ResolverData<Context>, next: NextFn) {
const { logMessage, logLevel = 0 } =
info.parentType.getFields()[info.fieldName].extensions || {};

if (logMessage) {
this.logger.log(`${logMessage}${user ? ` (user: ${user.id})` : ""}`, logLevel);
}

return next();
}
}
9 changes: 9 additions & 0 deletions examples/extensions/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Service } from "typedi";

@Service()
export class Logger {
log(...args: any[]) {
// replace with more sophisticated solution :)
console.log(...args);
}
}
27 changes: 27 additions & 0 deletions examples/extensions/recipe.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { plainToClass } from "class-transformer";

import { Recipe } from "./recipe.type";

export function createRecipe(recipeData: Partial<Recipe>): Recipe {
return plainToClass(Recipe, recipeData);
}

export const sampleRecipes = [
createRecipe({
title: "Recipe 1",
description: "Desc 1",
ingredients: ["one", "two", "three"],
ratings: [3, 4, 5, 5, 5],
}),
createRecipe({
title: "Recipe 2",
description: "Desc 2",
ingredients: ["four", "five", "six"],
ratings: [3, 4, 5, 3, 2],
}),
createRecipe({
title: "Recipe 3",
ingredients: ["seven", "eight", "nine"],
ratings: [4, 4, 5, 5, 4],
}),
];
26 changes: 26 additions & 0 deletions examples/extensions/recipe.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ObjectType, Extensions, Field, Int, Float } from "../../src";
import { CustomAuthorized } from "./custom.authorized";

@ObjectType()
@CustomAuthorized() // restrict access to all receipe fields only for logged users
export class Recipe {
@Field()
title: string;

@Field({ nullable: true })
description?: string;

@Field(type => [String])
@Extensions({ logMessage: "ingredients accessed" })
@Extensions({ logLevel: 4 })
ingredients: string[];

@CustomAuthorized("ADMIN") // restrict access to rates details for admin only, this will override the object type custom authorization
@Field(type => [Int])
ratings: number[];

@Field(type => Float, { nullable: true })
get averageRating(): number | null {
return this.ratings.reduce((a, b) => a + b, 0) / this.ratings.length;
}
}
42 changes: 42 additions & 0 deletions examples/extensions/resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Resolver, Query, Mutation, Arg, Extensions } from "../../src";
import { CustomAuthorized } from "./custom.authorized";

import { Recipe } from "./recipe.type";
import { createRecipe, sampleRecipes } from "./recipe.helpers";

@Resolver()
export class ExampleResolver {
private recipesData: Recipe[] = sampleRecipes.slice();

@Extensions({ some: "data" })
@Query(returns => [Recipe])
async recipes(): Promise<Recipe[]> {
return await this.recipesData;
}

@CustomAuthorized() // only logged users can add new recipe
@Mutation()
addRecipe(
@Arg("title") title: string,
@Arg("description", { nullable: true }) description?: string,
): Recipe {
const newRecipe = createRecipe({
title,
description,
ratings: [],
});
this.recipesData.push(newRecipe);
return newRecipe;
}

@CustomAuthorized("ADMIN") // only admin can remove the published recipe
@Mutation()
deleteRecipe(@Arg("title") title: string): boolean {
const foundRecipeIndex = this.recipesData.findIndex(it => it.title === title);
if (!foundRecipeIndex) {
return false;
}
this.recipesData.splice(foundRecipeIndex, 1);
return true;
}
}
Loading