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 14 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- add basic support for directives with `@Directive()` decorator (#369)
- add possibility to tune up the performance and disable auth & middlewares stack for simple field resolvers (#479)
- optimize resolvers execution paths to speed up a lot basic scenarios (#488)
- add `@Extensions` decorator for putting metadata into GraphQL types config (#521)
### Fixes
- refactor union types function syntax handling to prevent possible errors with circular refs
- fix transforming and validating nested inputs and arrays (#462)
Expand Down
117 changes: 117 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Extensions
---

The `graphql-js` library allows for putting arbitrary data into GraphQL types config inside the `extensions` property.
Annotating schema types or fields with a custom metadata, that can be then used at runtime by middlewares or resolvers, is a really powerful and useful feature.

For such use cases, **TypeGraphQL** provides the `@Extensions` decorator, which adds the data we defined to the `extensions` property of the executable schema for the decorated classes, methods or properties.

> Be aware that this is a low-level decorator and you generally have to provide your own logic to make use of the `extensions` metadata.

## Using the `@Extensions` decorator

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

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

We can pass several fields to the decorator:

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

And we can also decorate a type several times. The snippet below shows that this attaches the exact same extensions data to the schema type as the snippet above:

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

If we decorate the same type several times with the same extensions key, the one defined at the bottom takes precedence:

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

The above usage results in your GraphQL type having a `logMessage: "Another message"` property in its extensions.

TypeGraphQL classes with the following decorators can be annotated with `@Extensions` decorator:

- `@ObjectType`
- `@InputType`
- `@Field`
- `@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 we 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 in runtime

Once we have decorated the necessary types with extensions, the executable schema will contain the extensions data, and we can make use of it in any way we 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 that will be logging a message on field resolver execution whenever the field is decorated appropriately with `@Extensions`:

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

async use({ info }, next: NextFn) {
// extract `extensions` object from GraphQLResolveInfo object to get the `logMessage` value
const { logMessage } = info.parentType.getFields()[info.fieldName].extensions || {};

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

return next();
}
}
```

## Examples

You can see more detailed examples of usage [here](https://github.com/MichalLytek/type-graphql/tree/master/examples/extensions).
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;
}
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")
}
34 changes: 34 additions & 0 deletions examples/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import "reflect-metadata";
import { ApolloServer } from "apollo-server";
import { buildSchema } from "../../src";

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

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

// Create GraphQL server
const server = new ApolloServer({
schema,
context: () => {
const ctx: Context = {
// example user
user: {
id: 123,
name: "Sample user",
},
};
return ctx;
},
});

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

interface LogOptions {
message: string;
level?: number;
}

export const Logger = (messageOrOptions: string | LogOptions) =>
Extensions({
log:
typeof messageOrOptions === "string"
? {
level: 4,
message: messageOrOptions,
}
: messageOrOptions,
});
55 changes: 55 additions & 0 deletions examples/extensions/logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Service } from "typedi";
import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql";

import { MiddlewareInterface, NextFn, ResolverData } from "../../src";

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

const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig<any, any> => {
MichalLytek marked this conversation as resolved.
Show resolved Hide resolved
const { type, extensions, description, deprecationReason } = info.parentType.getFields()[
info.fieldName
];

return {
type,
description,
extensions,
deprecationReason,
};
};

const extractParentConfig = (info: GraphQLResolveInfo): GraphQLObjectTypeConfig<any, any> =>
info.parentType.toConfig();

const extractLoggerExtensionsFromConfig = (
config: GraphQLObjectTypeConfig<any, any> | GraphQLFieldConfig<any, any>,
) => (config.extensions && config.extensions.log) || {};

const getLoggerExtensions = (info: GraphQLResolveInfo) => {
const fieldConfig = extractFieldConfig(info);
const fieldLoggernExtensions = extractLoggerExtensionsFromConfig(fieldConfig);
hihuz marked this conversation as resolved.
Show resolved Hide resolved

const parentConfig = extractParentConfig(info);
const parentLoggernExtensions = extractLoggerExtensionsFromConfig(parentConfig);

return {
...parentLoggernExtensions,
...fieldLoggernExtensions,
};
};

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

async use({ context: { user }, info }: ResolverData<Context>, next: NextFn) {
const { message, level = 0 } = getLoggerExtensions(info);

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

return next();
}
}
9 changes: 9 additions & 0 deletions examples/extensions/logger.service.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],
}),
];
25 changes: 25 additions & 0 deletions examples/extensions/recipe.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ObjectType, Extensions, Field, Int, Float } from "../../src";
import { Logger } from "./logger.decorator";

@ObjectType()
@Logger("Recipe accessed") // Log a message when any Recipe field is accessed
export class Recipe {
@Field()
title: string;

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

@Field(type => [String])
@Extensions({ log: { message: "ingredients accessed", level: 0 } }) // We can use raw Extensions decorator if we want
ingredients: string[];

@Logger("Ratings accessed") // This will override the object type log message
@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 { Logger } from "./logger.decorator";

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;
}

@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;
}

@Logger("This message will not be logged")
@Logger("It will be overridden by this one")
MichalLytek marked this conversation as resolved.
Show resolved Hide resolved
@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;
}
}
4 changes: 4 additions & 0 deletions examples/extensions/user.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface User {
id: number;
name: string;
}
Loading