-
-
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 5 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
| 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). |
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,43 @@ | ||
| import { GraphQLResolveInfo, GraphQLObjectType } from "graphql"; | ||
|
|
||
| import { MiddlewareFn } from "../../src"; | ||
| import { Context } from "./context.interface"; | ||
| import { UnauthorizedError } from "../../src/errors"; | ||
|
|
||
| const extractAuthorizationExtensions = (info: GraphQLResolveInfo) => { | ||
| 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.
Outdated
Show resolved
Hide resolved
|
||
| { context: { user }, info }: { context: Context; info: GraphQLResolveInfo }, | ||
hihuz marked this conversation as resolved.
Outdated
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.
Outdated
Show resolved
Hide resolved
|
||
| }; | ||
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,9 @@ | ||
| import { Extensions } from "../../src"; | ||
|
|
||
| export const CustomAuthorized = (roles: string | string[] = []) => | ||
| Extensions({ | ||
| authorization: { | ||
| restricted: true, | ||
| roles: typeof roles === "string" ? [roles] : roles, | ||
| }, | ||
| }); |
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,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}`); | ||
| })(); |
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,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(); | ||
| } | ||
| } |
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,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; | ||
| } | ||
| } |
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 { 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; | ||
| } | ||
| } |
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.