From 5c48a1fff24abd865be84441c6371f15d87a832d Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Fri, 10 Jan 2020 17:17:12 +0100 Subject: [PATCH] Add extensions examples --- examples/extensions/authorizer.middleware.ts | 43 ++++++++++++++++++++ examples/extensions/context.interface.ts | 5 +++ examples/extensions/custom.authorized.ts | 9 ++++ examples/extensions/examples.gql | 36 ++++++++++++++++ examples/extensions/index.ts | 37 +++++++++++++++++ examples/extensions/logger.middleware.ts | 20 +++++++++ examples/extensions/logger.ts | 9 ++++ examples/extensions/recipe.helpers.ts | 27 ++++++++++++ examples/extensions/recipe.type.ts | 26 ++++++++++++ examples/extensions/resolver.ts | 42 +++++++++++++++++++ examples/extensions/user.interface.ts | 5 +++ 11 files changed, 259 insertions(+) create mode 100644 examples/extensions/authorizer.middleware.ts create mode 100644 examples/extensions/context.interface.ts create mode 100644 examples/extensions/custom.authorized.ts create mode 100644 examples/extensions/examples.gql create mode 100644 examples/extensions/index.ts create mode 100644 examples/extensions/logger.middleware.ts create mode 100644 examples/extensions/logger.ts create mode 100644 examples/extensions/recipe.helpers.ts create mode 100644 examples/extensions/recipe.type.ts create mode 100644 examples/extensions/resolver.ts create mode 100644 examples/extensions/user.interface.ts diff --git a/examples/extensions/authorizer.middleware.ts b/examples/extensions/authorizer.middleware.ts new file mode 100644 index 000000000..6c4f62ccd --- /dev/null +++ b/examples/extensions/authorizer.middleware.ts @@ -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 ( + { context: { user }, info }: { context: Context; info: GraphQLResolveInfo }, + 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(); +}; diff --git a/examples/extensions/context.interface.ts b/examples/extensions/context.interface.ts new file mode 100644 index 000000000..095b5cf51 --- /dev/null +++ b/examples/extensions/context.interface.ts @@ -0,0 +1,5 @@ +import { User } from "./user.interface"; + +export interface Context { + user?: User; +} diff --git a/examples/extensions/custom.authorized.ts b/examples/extensions/custom.authorized.ts new file mode 100644 index 000000000..fc466efec --- /dev/null +++ b/examples/extensions/custom.authorized.ts @@ -0,0 +1,9 @@ +import { Extensions } from "../../src"; + +export const CustomAuthorized = (roles: string | string[] = []) => + Extensions({ + authorization: { + restricted: true, + roles: typeof roles === "string" ? [roles] : roles, + }, + }); diff --git a/examples/extensions/examples.gql b/examples/extensions/examples.gql new file mode 100644 index 000000000..f57a92c28 --- /dev/null +++ b/examples/extensions/examples.gql @@ -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") +} diff --git a/examples/extensions/index.ts b/examples/extensions/index.ts new file mode 100644 index 000000000..b88d8e859 --- /dev/null +++ b/examples/extensions/index.ts @@ -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}`); +})(); diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts new file mode 100644 index 000000000..5984f6893 --- /dev/null +++ b/examples/extensions/logger.middleware.ts @@ -0,0 +1,20 @@ +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 { + constructor(private readonly logger: Logger) {} + + async use({ context: { user }, info }: ResolverData, next: NextFn) { + const { logData, logLevel = 0 } = info.parentType.getFields()[info.fieldName].extensions || {}; + + if (logData) { + this.logger.log(`${logData}${user ? ` (user: ${user.id})` : ""}`, logLevel); + } + + return next(); + } +} diff --git a/examples/extensions/logger.ts b/examples/extensions/logger.ts new file mode 100644 index 000000000..8b7623a7b --- /dev/null +++ b/examples/extensions/logger.ts @@ -0,0 +1,9 @@ +import { Service } from "typedi"; + +@Service() +export class Logger { + log(...args: any[]) { + // replace with more sophisticated solution :) + console.log(...args); + } +} diff --git a/examples/extensions/recipe.helpers.ts b/examples/extensions/recipe.helpers.ts new file mode 100644 index 000000000..7a311e355 --- /dev/null +++ b/examples/extensions/recipe.helpers.ts @@ -0,0 +1,27 @@ +import { plainToClass } from "class-transformer"; + +import { Recipe } from "./recipe.type"; + +export function createRecipe(recipeData: Partial): 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], + }), +]; diff --git a/examples/extensions/recipe.type.ts b/examples/extensions/recipe.type.ts new file mode 100644 index 000000000..d5b1f3018 --- /dev/null +++ b/examples/extensions/recipe.type.ts @@ -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({ logData: "potatoes" }) + @Extensions({ logLevel: 4 }) + ingredients: string[]; + + @CustomAuthorized("ADMIN") // restrict access to rates details for admin only, this will override the object type CustomAuthorization + @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; + } +} diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts new file mode 100644 index 000000000..cd81c20b7 --- /dev/null +++ b/examples/extensions/resolver.ts @@ -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 { + 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; + } +} diff --git a/examples/extensions/user.interface.ts b/examples/extensions/user.interface.ts new file mode 100644 index 000000000..60f143472 --- /dev/null +++ b/examples/extensions/user.interface.ts @@ -0,0 +1,5 @@ +export interface User { + id: number; + name: string; + roles: string[]; +}