-
-
Notifications
You must be signed in to change notification settings - Fork 672
Add @Extensions decorator #521
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
Merged
MichalLytek
merged 23 commits into
MichalLytek:master
from
kontist:feature/124-extensions-decorator
Jan 29, 2020
Merged
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
0640e9e
Add Extensions metadata Decorator
hihuz fd3b92f
Update extensions metadata to be readonly
hihuz 9409d3d
Add extensions examples
hihuz e5ddc29
Add extensions documentation
hihuz de5ee0b
Add extensions key override documentation
hihuz 6eac60e
Inline extensions flatten function
hihuz b394faa
Polish the extensions docs
MichalLytek 7c3d270
Update changelog
MichalLytek b19c4af
Abstract findExtensions method
hihuz 622408e
Make findExtensions type-safe
MichalLytek 363f8d3
Properly return in AuthorizerMiddleware
hihuz 45a43d3
Simplify AuthorizerMiddleware type declarations
hihuz cf7aa50
Separate config and extensions extraction in examples
hihuz 6617818
Combine examples in one Logger middleware and decorator
hihuz 0cf4579
Update Logger decorator
MichalLytek 546fadb
Fix typo: Loggern -> Logger
hihuz 531cc23
Remove extraneous extensions override example
hihuz 1779546
Extract 'info' parsing helpers
hihuz c6cb8ed
Remove unused import
hihuz b5db6c7
Fix comment typo: after -> before
hihuz 19b68ee
Extract info helpers into example instead of src
hihuz d13c347
Fix import path
hihuz 1fe39dc
Tune up extensions examples
MichalLytek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { User } from "./user.interface"; | ||
|
|
||
| export interface Context { | ||
| user?: User; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> => { | ||
| 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.
Outdated
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(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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], | ||
| }), | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
Outdated
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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export interface User { | ||
| id: number; | ||
| name: string; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.