From 0640e9ec2de5a3899043ae912b756af61f8ac63a Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 8 Jan 2020 17:27:14 +0100 Subject: [PATCH 01/23] Add Extensions metadata Decorator --- src/decorators/Extensions.ts | 28 ++ src/decorators/index.ts | 1 + src/metadata/definitions/class-metadata.ts | 1 + .../definitions/extensions-metadata.ts | 10 + src/metadata/definitions/field-metadata.ts | 1 + src/metadata/definitions/index.ts | 1 + src/metadata/definitions/resolver-metadata.ts | 1 + src/metadata/metadata-storage.ts | 35 +++ src/metadata/utils.ts | 9 + src/schema/schema-generator.ts | 5 + tests/functional/extensions.ts | 268 ++++++++++++++++++ 11 files changed, 360 insertions(+) create mode 100644 src/decorators/Extensions.ts create mode 100644 src/metadata/definitions/extensions-metadata.ts create mode 100644 tests/functional/extensions.ts diff --git a/src/decorators/Extensions.ts b/src/decorators/Extensions.ts new file mode 100644 index 000000000..175e3b7db --- /dev/null +++ b/src/decorators/Extensions.ts @@ -0,0 +1,28 @@ +import { MethodAndPropDecorator } from "./types"; +import { SymbolKeysNotSupportedError } from "../errors"; +import { getMetadataStorage } from "../metadata/getMetadataStorage"; + +export function Extensions( + extensions: Record, +): MethodAndPropDecorator & ClassDecorator; +export function Extensions( + extensions: Record, +): MethodDecorator | PropertyDecorator | ClassDecorator { + return (targetOrPrototype, propertyKey, descriptor) => { + if (typeof propertyKey === "symbol") { + throw new SymbolKeysNotSupportedError(); + } + if (propertyKey) { + getMetadataStorage().collectExtensionsFieldMetadata({ + target: targetOrPrototype.constructor, + fieldName: propertyKey, + extensions, + }); + } else { + getMetadataStorage().collectExtensionsClassMetadata({ + target: targetOrPrototype as Function, + extensions, + }); + } + }; +} diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 3d2c77065..720d8f11c 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -6,6 +6,7 @@ export { createParamDecorator } from "./createParamDecorator"; export { createMethodDecorator } from "./createMethodDecorator"; export { Ctx } from "./Ctx"; export { Directive } from "./Directive"; +export { Extensions } from "./Extensions"; export { registerEnumType } from "./enums"; export { Field, FieldOptions } from "./Field"; export { FieldResolver } from "./FieldResolver"; diff --git a/src/metadata/definitions/class-metadata.ts b/src/metadata/definitions/class-metadata.ts index e15fd7b81..3e988b2d7 100644 --- a/src/metadata/definitions/class-metadata.ts +++ b/src/metadata/definitions/class-metadata.ts @@ -8,5 +8,6 @@ export interface ClassMetadata { description?: string; isAbstract?: boolean; directives?: DirectiveMetadata[]; + extensions?: Record; simpleResolvers?: boolean; } diff --git a/src/metadata/definitions/extensions-metadata.ts b/src/metadata/definitions/extensions-metadata.ts new file mode 100644 index 000000000..add06299c --- /dev/null +++ b/src/metadata/definitions/extensions-metadata.ts @@ -0,0 +1,10 @@ +export interface ExtensionsClassMetadata { + target: Function; + extensions: Record; +} + +export interface ExtensionsFieldMetadata { + target: Function; + fieldName: string; + extensions: Record; +} diff --git a/src/metadata/definitions/field-metadata.ts b/src/metadata/definitions/field-metadata.ts index 0c0855cf7..4398860c5 100644 --- a/src/metadata/definitions/field-metadata.ts +++ b/src/metadata/definitions/field-metadata.ts @@ -17,5 +17,6 @@ export interface FieldMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; + extensions?: Record; simple?: boolean; } diff --git a/src/metadata/definitions/index.ts b/src/metadata/definitions/index.ts index 70a7d6ea9..ff67f5906 100644 --- a/src/metadata/definitions/index.ts +++ b/src/metadata/definitions/index.ts @@ -2,6 +2,7 @@ export * from "./authorized-metadata"; export * from "./class-metadata"; export * from "./directive-metadata"; export * from "./enum-metadata"; +export * from "./extensions-metadata"; export * from "./field-metadata"; export * from "./middleware-metadata"; export * from "./param-metadata"; diff --git a/src/metadata/definitions/resolver-metadata.ts b/src/metadata/definitions/resolver-metadata.ts index be349ba34..43c0845a9 100644 --- a/src/metadata/definitions/resolver-metadata.ts +++ b/src/metadata/definitions/resolver-metadata.ts @@ -22,6 +22,7 @@ export interface BaseResolverMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; + extensions?: Record; } export interface ResolverMetadata extends BaseResolverMetadata { diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 74b3c2d0c..ea404f67d 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -1,6 +1,8 @@ import { ResolverMetadata, ClassMetadata, + ExtensionsClassMetadata, + ExtensionsFieldMetadata, FieldMetadata, ParamMetadata, FieldResolverMetadata, @@ -20,6 +22,7 @@ import { mapMiddlewareMetadataToArray, mapSuperFieldResolverHandlers, ensureReflectMetadataExists, + flattenExtensions, } from "./utils"; import { ObjectClassMetadata } from "./definitions/object-class-metdata"; import { InterfaceClassMetadata } from "./definitions/interface-class-metadata"; @@ -40,6 +43,8 @@ export class MetadataStorage { middlewares: MiddlewareMetadata[] = []; classDirectives: DirectiveClassMetadata[] = []; fieldDirectives: DirectiveFieldMetadata[] = []; + classExtensions: ExtensionsClassMetadata[] = []; + fieldExtensions: ExtensionsFieldMetadata[] = []; private resolverClasses: ResolverClassMetadata[] = []; private fields: FieldMetadata[] = []; @@ -108,11 +113,20 @@ export class MetadataStorage { this.fieldDirectives.push(definition); } + collectExtensionsClassMetadata(definition: ExtensionsClassMetadata) { + this.classExtensions.push(definition); + } + collectExtensionsFieldMetadata(definition: ExtensionsFieldMetadata) { + this.fieldExtensions.push(definition); + } + build() { // TODO: disable next build attempts this.classDirectives.reverse(); this.fieldDirectives.reverse(); + this.classExtensions.reverse(); + this.fieldExtensions.reverse(); this.buildClassMetadata(this.objectTypes); this.buildClassMetadata(this.inputTypes); @@ -143,6 +157,8 @@ export class MetadataStorage { this.middlewares = []; this.classDirectives = []; this.fieldDirectives = []; + this.classExtensions = []; + this.fieldExtensions = []; this.resolverClasses = []; this.fields = []; @@ -167,6 +183,7 @@ export class MetadataStorage { field.directives = this.fieldDirectives .filter(it => it.target === field.target && it.fieldName === field.name) .map(it => it.directive); + field.extensions = this.findFieldExtensions(field.target, field.name); }); def.fields = fields; } @@ -175,6 +192,9 @@ export class MetadataStorage { .filter(it => it.target === def.target) .map(it => it.directive); } + if (!def.extensions) { + def.extensions = this.findClassExtensions(def.target); + } }); } @@ -196,6 +216,7 @@ export class MetadataStorage { def.directives = this.fieldDirectives .filter(it => it.target === def.target && it.fieldName === def.methodName) .map(it => it.directive); + def.extensions = this.findFieldExtensions(def.target, def.methodName); }); } @@ -206,6 +227,7 @@ export class MetadataStorage { def.directives = this.fieldDirectives .filter(it => it.target === def.target && it.fieldName === def.methodName) .map(it => it.directive); + def.extensions = this.findFieldExtensions(def.target, def.methodName); def.getObjectType = def.kind === "external" ? this.resolverClasses.find(resolver => resolver.target === def.target)!.getObjectType @@ -236,6 +258,7 @@ export class MetadataStorage { middlewares: def.middlewares!, params: def.params!, directives: def.directives, + extensions: def.extensions, }; this.collectClassFieldMetadata(fieldMetadata); objectType.fields!.push(fieldMetadata); @@ -286,4 +309,16 @@ export class MetadataStorage { } return authorizedField.roles; } + + private findClassExtensions(target: Function): Record { + return this.classExtensions + .filter(entry => entry.target === target) + .reduce(flattenExtensions, {}); + } + + private findFieldExtensions(target: Function, fieldName: string): Record { + return this.fieldExtensions + .filter(entry => entry.target === target && entry.fieldName === fieldName) + .reduce(flattenExtensions, {}); + } } diff --git a/src/metadata/utils.ts b/src/metadata/utils.ts index 5e100d492..14d79f10c 100644 --- a/src/metadata/utils.ts +++ b/src/metadata/utils.ts @@ -3,6 +3,8 @@ import { BaseResolverMetadata, MiddlewareMetadata, FieldResolverMetadata, + ExtensionsClassMetadata, + ExtensionsFieldMetadata, } from "./definitions"; import { Middleware } from "../interfaces/Middleware"; import { isThrowing } from "../helpers/isThrowing"; @@ -57,3 +59,10 @@ export function ensureReflectMetadataExists() { throw new ReflectMetadataMissingError(); } } + +export function flattenExtensions( + extensions: Record, + entry: ExtensionsClassMetadata | ExtensionsFieldMetadata, +): Record { + return { ...extensions, ...entry.extensions }; +} diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index f649d1b33..3afa5655b 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -282,6 +282,7 @@ export abstract class SchemaGenerator { name: objectType.name, description: objectType.description, astNode: getObjectTypeDefinitionNode(objectType.name, objectType.directives), + extensions: objectType.extensions, interfaces: () => { let interfaces = interfaceClasses.map( interfaceClass => @@ -331,6 +332,7 @@ export abstract class SchemaGenerator { astNode: getFieldDefinitionNode(field.name, type, field.directives), extensions: { complexity: field.complexity, + ...field.extensions, }, }; return fieldsMap; @@ -379,6 +381,7 @@ export abstract class SchemaGenerator { type: new GraphQLInputObjectType({ name: inputType.name, description: inputType.description, + extensions: inputType.extensions, fields: () => { let fields = inputType.fields!.reduce( (fieldsMap, field) => { @@ -399,6 +402,7 @@ export abstract class SchemaGenerator { type, defaultValue: field.typeOptions.defaultValue, astNode: getInputValueDefinitionNode(field.name, type, field.directives), + extensions: field.extensions, }; return fieldsMap; }, @@ -498,6 +502,7 @@ export abstract class SchemaGenerator { astNode: getFieldDefinitionNode(handler.schemaName, type, handler.directives), extensions: { complexity: handler.complexity, + ...handler.extensions, }, }; return fields; diff --git a/tests/functional/extensions.ts b/tests/functional/extensions.ts new file mode 100644 index 000000000..15eb37176 --- /dev/null +++ b/tests/functional/extensions.ts @@ -0,0 +1,268 @@ +import "reflect-metadata"; + +import { GraphQLSchema, GraphQLInputObjectType, GraphQLObjectType, GraphQLFieldMap } from "graphql"; +import { + Field, + InputType, + Resolver, + Query, + Arg, + Extensions, + buildSchema, + ObjectType, + Mutation, + FieldResolver, +} from "../../src"; +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; + +describe("Extensions", () => { + let schema: GraphQLSchema; + + describe("Schema", () => { + beforeAll(async () => { + getMetadataStorage().clear(); + + @InputType() + class ExtensionsOnFieldInput { + @Field() + @Extensions({ role: "admin" }) + withExtensions: string; + } + + @InputType() + @Extensions({ roles: ["admin", "user"] }) + class ExtensionsOnClassInput { + @Field() + regularField: string; + } + + @ObjectType() + @Extensions({ id: 1234 }) + class ExtensionsOnClassObjectType { + @Field() + regularField: string; + } + + @ObjectType() + class SampleObjectType { + @Field() + @Extensions({ role: "user" }) + withExtensions: string = "withExtensions"; + + @Field() + @Extensions({ first: "first value", second: "second value" }) + withMultipleExtensions: string = "withMultipleExtensions"; + + @Field() + @Extensions({ first: "first value" }) + @Extensions({ second: "second value", third: "third value" }) + withMultipleExtensionsDecorators: string = "hello"; + + @Field() + @Extensions({ duplicate: "first value" }) + @Extensions({ duplicate: "second value" }) + withConflictingExtensionsKeys: string = "hello"; + + @Field() + withInput(@Arg("input") input: ExtensionsOnFieldInput): string { + return `hello${input.withExtensions}`; + } + + @Field() + @Extensions({ other: "extension" }) + withInputAndField(@Arg("input") input: ExtensionsOnFieldInput): string { + return `hello${input.withExtensions}`; + } + + @Field() + withInputOnClass(@Arg("input") input: ExtensionsOnClassInput): string { + return `hello${input.regularField}`; + } + + @Field() + @Extensions({ other: "extension" }) + withInputAndFieldOnClass(@Arg("input") input: ExtensionsOnClassInput): string { + return `hello${input.regularField}`; + } + } + + @Resolver() + class SampleResolver { + @Query(() => SampleObjectType) + sampleObjectType(): SampleObjectType { + return new SampleObjectType(); + } + + @Query(() => ExtensionsOnClassObjectType) + extensionsOnClassObjectType(): ExtensionsOnClassObjectType { + return new ExtensionsOnClassObjectType(); + } + + @Query() + @Extensions({ mandatory: true }) + queryWithExtensions(): string { + return "queryWithExtensions"; + } + + @Query() + @Extensions({ first: "first query value", second: "second query value" }) + queryWithMultipleExtensions(): string { + return "hello"; + } + + @Query() + @Extensions({ first: "first query value" }) + @Extensions({ second: "second query value", third: "third query value" }) + queryWithMultipleExtensionsDecorators(): string { + return "hello"; + } + + @Mutation() + @Extensions({ mandatory: false }) + mutationWithExtensions(): string { + return "mutationWithExtensions"; + } + + @Mutation() + @Extensions({ first: "first mutation value", second: "second mutation value" }) + mutationWithMultipleExtensions(): string { + return "mutationWithMultipleExtensions"; + } + + @Mutation() + @Extensions({ first: "first mutation value" }) + @Extensions({ second: "second mutation value", third: "third mutation value" }) + mutationWithMultipleExtensionsDecorators(): string { + return "mutationWithMultipleExtensionsDecorators"; + } + } + + @Resolver(of => SampleObjectType) + class SampleObjectTypeResolver { + @FieldResolver() + @Extensions({ some: "extension" }) + fieldResolverWithExtensions(): string { + return "hello"; + } + } + + schema = await buildSchema({ + resolvers: [SampleResolver, SampleObjectTypeResolver], + }); + }); + + it("should generate schema without errors", async () => { + expect(schema).toBeDefined(); + }); + + describe("Fields", () => { + let fields: GraphQLFieldMap; + + beforeAll(async () => { + fields = (schema.getType("SampleObjectType") as GraphQLObjectType).getFields(); + }); + + it("should add simple extensions to object fields", async () => { + expect(fields.withExtensions.extensions).toEqual({ role: "user" }); + }); + + it("should add extensions with multiple properties to object fields", async () => { + expect(fields.withMultipleExtensions.extensions).toEqual({ + first: "first value", + second: "second value", + }); + }); + + it("should allow multiple extensions decorators for object fields", async () => { + expect(fields.withMultipleExtensionsDecorators.extensions).toEqual({ + first: "first value", + second: "second value", + third: "third value", + }); + }); + + it("should override extensions values when duplicate keys are provided", async () => { + expect(fields.withConflictingExtensionsKeys.extensions).toEqual({ + duplicate: "second value", + }); + }); + }); + + describe("Query", () => { + it("should add simple extensions to query types", async () => { + const { queryWithExtensions } = schema.getQueryType()!.getFields(); + expect(queryWithExtensions.extensions).toEqual({ mandatory: true }); + }); + + it("should add extensions with multiple properties to query types", async () => { + const { queryWithMultipleExtensions } = schema.getQueryType()!.getFields(); + expect(queryWithMultipleExtensions.extensions).toEqual({ + first: "first query value", + second: "second query value", + }); + }); + + it("should allow multiple extensions decorators for query types", async () => { + const { queryWithMultipleExtensionsDecorators } = schema.getQueryType()!.getFields(); + expect(queryWithMultipleExtensionsDecorators.extensions).toEqual({ + first: "first query value", + second: "second query value", + third: "third query value", + }); + }); + }); + + describe("Mutation", () => { + it("should add simple extensions to mutation types", async () => { + const { mutationWithExtensions } = schema.getMutationType()!.getFields(); + expect(mutationWithExtensions.extensions).toEqual({ mandatory: false }); + }); + + it("should add extensions with multiple properties to mutation types", async () => { + const { mutationWithMultipleExtensions } = schema.getMutationType()!.getFields(); + expect(mutationWithMultipleExtensions.extensions).toEqual({ + first: "first mutation value", + second: "second mutation value", + }); + }); + + it("should allow multiple extensions decorators for mutation types", async () => { + const { mutationWithMultipleExtensionsDecorators } = schema.getMutationType()!.getFields(); + expect(mutationWithMultipleExtensionsDecorators.extensions).toEqual({ + first: "first mutation value", + second: "second mutation value", + third: "third mutation value", + }); + }); + }); + + describe("ObjectType", () => { + it("should add extensions to object types", async () => { + const objectType = schema.getType("ExtensionsOnClassObjectType") as GraphQLObjectType; + expect(objectType.extensions).toEqual({ id: 1234 }); + }); + }); + + describe("InputType", () => { + it("should add extensions to input types", async () => { + const inputType = schema.getType("ExtensionsOnClassInput") as GraphQLInputObjectType; + expect(inputType.extensions).toEqual({ roles: ["admin", "user"] }); + }); + + it("should add extensions to input type fields", async () => { + const fields = (schema.getType( + "ExtensionsOnFieldInput", + ) as GraphQLInputObjectType).getFields(); + + expect(fields.withExtensions.extensions).toEqual({ role: "admin" }); + }); + }); + + describe("FieldResolver", () => { + it("should add extensions to field resolvers", async () => { + const fields = (schema.getType("SampleObjectType") as GraphQLObjectType).getFields(); + expect(fields.fieldResolverWithExtensions.extensions).toEqual({ some: "extension" }); + }); + }); + }); +}); From fd3b92fc66b1e631220a570d794c6f10a0d2846b Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Fri, 10 Jan 2020 15:27:54 +0100 Subject: [PATCH 02/23] Update extensions metadata to be readonly --- src/decorators/Extensions.ts | 7 +++---- src/metadata/definitions/class-metadata.ts | 3 ++- src/metadata/definitions/extensions-metadata.ts | 6 ++++-- src/metadata/definitions/field-metadata.ts | 3 ++- src/metadata/definitions/resolver-metadata.ts | 3 ++- src/metadata/metadata-storage.ts | 5 +++-- src/metadata/utils.ts | 5 +++-- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/decorators/Extensions.ts b/src/decorators/Extensions.ts index 175e3b7db..276f300df 100644 --- a/src/decorators/Extensions.ts +++ b/src/decorators/Extensions.ts @@ -1,12 +1,11 @@ import { MethodAndPropDecorator } from "./types"; import { SymbolKeysNotSupportedError } from "../errors"; import { getMetadataStorage } from "../metadata/getMetadataStorage"; +import { ExtensionsMetadata } from "../metadata/definitions"; +export function Extensions(extensions: ExtensionsMetadata): MethodAndPropDecorator & ClassDecorator; export function Extensions( - extensions: Record, -): MethodAndPropDecorator & ClassDecorator; -export function Extensions( - extensions: Record, + extensions: ExtensionsMetadata, ): MethodDecorator | PropertyDecorator | ClassDecorator { return (targetOrPrototype, propertyKey, descriptor) => { if (typeof propertyKey === "symbol") { diff --git a/src/metadata/definitions/class-metadata.ts b/src/metadata/definitions/class-metadata.ts index 3e988b2d7..0155622df 100644 --- a/src/metadata/definitions/class-metadata.ts +++ b/src/metadata/definitions/class-metadata.ts @@ -1,5 +1,6 @@ import { FieldMetadata } from "./field-metadata"; import { DirectiveMetadata } from "./directive-metadata"; +import { ExtensionsMetadata } from "./extensions-metadata"; export interface ClassMetadata { name: string; @@ -8,6 +9,6 @@ export interface ClassMetadata { description?: string; isAbstract?: boolean; directives?: DirectiveMetadata[]; - extensions?: Record; + extensions?: ExtensionsMetadata; simpleResolvers?: boolean; } diff --git a/src/metadata/definitions/extensions-metadata.ts b/src/metadata/definitions/extensions-metadata.ts index add06299c..3b1ada97a 100644 --- a/src/metadata/definitions/extensions-metadata.ts +++ b/src/metadata/definitions/extensions-metadata.ts @@ -1,10 +1,12 @@ +export type ExtensionsMetadata = Readonly>; + export interface ExtensionsClassMetadata { target: Function; - extensions: Record; + extensions: ExtensionsMetadata; } export interface ExtensionsFieldMetadata { target: Function; fieldName: string; - extensions: Record; + extensions: ExtensionsMetadata; } diff --git a/src/metadata/definitions/field-metadata.ts b/src/metadata/definitions/field-metadata.ts index 4398860c5..d5ce485c4 100644 --- a/src/metadata/definitions/field-metadata.ts +++ b/src/metadata/definitions/field-metadata.ts @@ -3,6 +3,7 @@ import { TypeValueThunk, TypeOptions } from "../../decorators/types"; import { Middleware } from "../../interfaces/Middleware"; import { Complexity } from "../../interfaces"; import { DirectiveMetadata } from "./directive-metadata"; +import { ExtensionsMetadata } from "./extensions-metadata"; export interface FieldMetadata { target: Function; @@ -17,6 +18,6 @@ export interface FieldMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; - extensions?: Record; + extensions?: ExtensionsMetadata; simple?: boolean; } diff --git a/src/metadata/definitions/resolver-metadata.ts b/src/metadata/definitions/resolver-metadata.ts index 43c0845a9..913039be1 100644 --- a/src/metadata/definitions/resolver-metadata.ts +++ b/src/metadata/definitions/resolver-metadata.ts @@ -11,6 +11,7 @@ import { ParamMetadata } from "./param-metadata"; import { Middleware } from "../../interfaces/Middleware"; import { Complexity } from "../../interfaces"; import { DirectiveMetadata } from "./directive-metadata"; +import { ExtensionsMetadata } from "./extensions-metadata"; export interface BaseResolverMetadata { methodName: string; @@ -22,7 +23,7 @@ export interface BaseResolverMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; - extensions?: Record; + extensions?: ExtensionsMetadata; } export interface ResolverMetadata extends BaseResolverMetadata { diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index ea404f67d..3ee95a47a 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -14,6 +14,7 @@ import { ResolverClassMetadata, SubscriptionResolverMetadata, MiddlewareMetadata, + ExtensionsMetadata, } from "./definitions"; import { ClassType } from "../interfaces"; import { NoExplicitTypeError } from "../errors"; @@ -310,13 +311,13 @@ export class MetadataStorage { return authorizedField.roles; } - private findClassExtensions(target: Function): Record { + private findClassExtensions(target: Function): ExtensionsMetadata { return this.classExtensions .filter(entry => entry.target === target) .reduce(flattenExtensions, {}); } - private findFieldExtensions(target: Function, fieldName: string): Record { + private findFieldExtensions(target: Function, fieldName: string): ExtensionsMetadata { return this.fieldExtensions .filter(entry => entry.target === target && entry.fieldName === fieldName) .reduce(flattenExtensions, {}); diff --git a/src/metadata/utils.ts b/src/metadata/utils.ts index 14d79f10c..be397e256 100644 --- a/src/metadata/utils.ts +++ b/src/metadata/utils.ts @@ -5,6 +5,7 @@ import { FieldResolverMetadata, ExtensionsClassMetadata, ExtensionsFieldMetadata, + ExtensionsMetadata, } from "./definitions"; import { Middleware } from "../interfaces/Middleware"; import { isThrowing } from "../helpers/isThrowing"; @@ -61,8 +62,8 @@ export function ensureReflectMetadataExists() { } export function flattenExtensions( - extensions: Record, + extensions: ExtensionsMetadata, entry: ExtensionsClassMetadata | ExtensionsFieldMetadata, -): Record { +): ExtensionsMetadata { return { ...extensions, ...entry.extensions }; } From 9409d3dfc8043ac2c69c50d7a227a9b7d3cce376 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Fri, 10 Jan 2020 17:17:12 +0100 Subject: [PATCH 03/23] 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 | 21 ++++++++++ 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, 260 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..0d6a0e69b --- /dev/null +++ b/examples/extensions/logger.middleware.ts @@ -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 { + constructor(private readonly logger: Logger) {} + + async use({ context: { user }, info }: ResolverData, 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(); + } +} 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..615e528ae --- /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({ 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; + } +} 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[]; +} From e5ddc29fb345ddc670aa49dad1f5151f6eb09a7b Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Fri, 10 Jan 2020 19:22:23 +0100 Subject: [PATCH 04/23] Add extensions documentation --- docs/extensions.md | 124 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/extensions.md diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 000000000..979fa1947 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,124 @@ +--- +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 }) +``` + +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 { + 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). From de5ee0bee17a0f771c8e08c95b43f1c1ecde1f25 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Fri, 10 Jan 2020 19:27:23 +0100 Subject: [PATCH 05/23] Add extensions key override documentation --- docs/extensions.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/extensions.md b/docs/extensions.md index 979fa1947..448f07252 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -31,6 +31,15 @@ And you can also decorate an entity several times, this will attach the exact sa @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 From 6eac60ee487040f4072de05259e28539afe9bdf5 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Tue, 14 Jan 2020 19:16:24 +0100 Subject: [PATCH 06/23] Inline extensions flatten function --- src/metadata/metadata-storage.ts | 5 ++--- src/metadata/utils.ts | 7 ------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 3ee95a47a..2443d1f8c 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -23,7 +23,6 @@ import { mapMiddlewareMetadataToArray, mapSuperFieldResolverHandlers, ensureReflectMetadataExists, - flattenExtensions, } from "./utils"; import { ObjectClassMetadata } from "./definitions/object-class-metdata"; import { InterfaceClassMetadata } from "./definitions/interface-class-metadata"; @@ -314,12 +313,12 @@ export class MetadataStorage { private findClassExtensions(target: Function): ExtensionsMetadata { return this.classExtensions .filter(entry => entry.target === target) - .reduce(flattenExtensions, {}); + .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); } private findFieldExtensions(target: Function, fieldName: string): ExtensionsMetadata { return this.fieldExtensions .filter(entry => entry.target === target && entry.fieldName === fieldName) - .reduce(flattenExtensions, {}); + .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); } } diff --git a/src/metadata/utils.ts b/src/metadata/utils.ts index be397e256..591951d64 100644 --- a/src/metadata/utils.ts +++ b/src/metadata/utils.ts @@ -60,10 +60,3 @@ export function ensureReflectMetadataExists() { throw new ReflectMetadataMissingError(); } } - -export function flattenExtensions( - extensions: ExtensionsMetadata, - entry: ExtensionsClassMetadata | ExtensionsFieldMetadata, -): ExtensionsMetadata { - return { ...extensions, ...entry.extensions }; -} From b394faa6b56d4218de280c69f7c7da277b14ef31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Tue, 14 Jan 2020 19:34:57 +0100 Subject: [PATCH 07/23] Polish the extensions docs --- docs/extensions.md | 60 ++++++++++++++++--------------------------- website/i18n/en.json | 3 +++ website/sidebars.json | 10 +++++++- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 448f07252..949a3f81d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -2,54 +2,53 @@ 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. +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 will add the data you defined to the `extensions` field of your executable schema for the decorated entity. +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. -_Note:_ This is a low-level decorator and you will generally have to provide your own logic to make use of the `extensions` data. +> 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. -## How to use +## Using the `@Extensions` decorator -### 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: +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 }) ``` -You can pass several fields to the decorator: +We 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: +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 you decorate the same entity several times with the same extensions key, the one defined at the bottom will take precedence: +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 will result in your entity having `logmessage: "Another message"` in its extensions. +The above usage results in your GraphQL type having a `logMessage: "Another message"` property in its extensions. -The following entities can be decorated with extensions: +TypeGraphQL classes with the following decorators can be annotated with `@Extensions` decorator: -- @Field -- @ObjectType -- @InputType -- @Query -- @Mutation -- @FieldResolver +- `@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 you want to do with the extensions data: +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"] }) @@ -90,19 +89,18 @@ class FooBarResolver { } ``` -### 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. +## Using the extensions data in runtime -The most common use will be to read it at runtime in resolvers or middlewares and perform some custom logic there. +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 logging a message whenever a field is decorated appropriately: +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 { 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) { @@ -112,20 +110,6 @@ export class LoggerMiddleware implements MiddlewareInterface { 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 diff --git a/website/i18n/en.json b/website/i18n/en.json index 3ac37bfaf..31acba4cb 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -36,6 +36,9 @@ "title": "Examples", "sidebar_label": "List of examples" }, + "extensions": { + "title": "Extensions" + }, "faq": { "title": "Frequently Asked Questions" }, diff --git a/website/sidebars.json b/website/sidebars.json index 5ac509ed6..281cbeaed 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -8,7 +8,15 @@ "resolvers", "bootstrap" ], - "Advanced guides": ["scalars", "enums", "unions", "interfaces", "subscriptions", "directives"], + "Advanced guides": [ + "scalars", + "enums", + "unions", + "interfaces", + "subscriptions", + "directives", + "extensions" + ], "Features": [ "dependency-injection", "authorization", From 7c3d2705b091fff394afc3b7ce9d12ae8e291a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Tue, 14 Jan 2020 19:48:13 +0100 Subject: [PATCH 08/23] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ae37fa0..7e4e8069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) From b19c4afede1dbc7c5a8de0eda240ee89cc2b4d39 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Tue, 14 Jan 2020 21:01:48 +0100 Subject: [PATCH 09/23] Abstract findExtensions method --- src/metadata/metadata-storage.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 2443d1f8c..671c2616c 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -183,7 +183,7 @@ export class MetadataStorage { field.directives = this.fieldDirectives .filter(it => it.target === field.target && it.fieldName === field.name) .map(it => it.directive); - field.extensions = this.findFieldExtensions(field.target, field.name); + field.extensions = this.findExtensions(field.target, field.name); }); def.fields = fields; } @@ -193,7 +193,7 @@ export class MetadataStorage { .map(it => it.directive); } if (!def.extensions) { - def.extensions = this.findClassExtensions(def.target); + def.extensions = this.findExtensions(def.target); } }); } @@ -216,7 +216,7 @@ export class MetadataStorage { def.directives = this.fieldDirectives .filter(it => it.target === def.target && it.fieldName === def.methodName) .map(it => it.directive); - def.extensions = this.findFieldExtensions(def.target, def.methodName); + def.extensions = this.findExtensions(def.target, def.methodName); }); } @@ -227,7 +227,7 @@ export class MetadataStorage { def.directives = this.fieldDirectives .filter(it => it.target === def.target && it.fieldName === def.methodName) .map(it => it.directive); - def.extensions = this.findFieldExtensions(def.target, def.methodName); + def.extensions = this.findExtensions(def.target, def.methodName); def.getObjectType = def.kind === "external" ? this.resolverClasses.find(resolver => resolver.target === def.target)!.getObjectType @@ -310,15 +310,13 @@ export class MetadataStorage { return authorizedField.roles; } - private findClassExtensions(target: Function): ExtensionsMetadata { - return this.classExtensions - .filter(entry => entry.target === target) - .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); - } - - private findFieldExtensions(target: Function, fieldName: string): ExtensionsMetadata { - return this.fieldExtensions - .filter(entry => entry.target === target && entry.fieldName === fieldName) + private findExtensions(target: Function, fieldName?: string): ExtensionsMetadata { + const storedExtensions = fieldName ? this.fieldExtensions : this.classExtensions; + return storedExtensions + .filter( + (entry: any) => + entry.target === target && (!entry.fieldName || entry.fieldName === fieldName), + ) .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); } } From 622408edf8ec0d163a099bf39d014dae3f4fe29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Tue, 14 Jan 2020 21:31:02 +0100 Subject: [PATCH 10/23] Make findExtensions type-safe --- src/metadata/metadata-storage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 671c2616c..9b6820ee0 100644 --- a/src/metadata/metadata-storage.ts +++ b/src/metadata/metadata-storage.ts @@ -311,11 +311,13 @@ export class MetadataStorage { } private findExtensions(target: Function, fieldName?: string): ExtensionsMetadata { - const storedExtensions = fieldName ? this.fieldExtensions : this.classExtensions; + const storedExtensions: Array = fieldName + ? this.fieldExtensions + : this.classExtensions; return storedExtensions .filter( - (entry: any) => - entry.target === target && (!entry.fieldName || entry.fieldName === fieldName), + entry => + entry.target === target && (!("fieldName" in entry) || entry.fieldName === fieldName), ) .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); } From 363f8d3ab92e29c9960fd14bc017e51e79e30116 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 15 Jan 2020 08:46:26 +0100 Subject: [PATCH 11/23] Properly return in AuthorizerMiddleware --- examples/extensions/authorizer.middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/extensions/authorizer.middleware.ts b/examples/extensions/authorizer.middleware.ts index 6c4f62ccd..167926724 100644 --- a/examples/extensions/authorizer.middleware.ts +++ b/examples/extensions/authorizer.middleware.ts @@ -39,5 +39,5 @@ export const AuthorizerMiddleware: MiddlewareFn = async ( } // grant access in other cases - await next(); + return next(); }; From 45a43d3393c91f34b2ac875ea2fd598bd77ea8f2 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 15 Jan 2020 08:47:22 +0100 Subject: [PATCH 12/23] Simplify AuthorizerMiddleware type declarations --- examples/extensions/authorizer.middleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/extensions/authorizer.middleware.ts b/examples/extensions/authorizer.middleware.ts index 167926724..1df559d53 100644 --- a/examples/extensions/authorizer.middleware.ts +++ b/examples/extensions/authorizer.middleware.ts @@ -20,8 +20,8 @@ const extractAuthorizationExtensions = (info: GraphQLResolveInfo) => { }; }; -export const AuthorizerMiddleware: MiddlewareFn = async ( - { context: { user }, info }: { context: Context; info: GraphQLResolveInfo }, +export const AuthorizerMiddleware: MiddlewareFn = async ( + { context: { user }, info }, next, ) => { const { restricted = false, roles = [] } = extractAuthorizationExtensions(info); From cf7aa50de2207adff88a28b0f54b4411b34b3647 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 15 Jan 2020 10:04:35 +0100 Subject: [PATCH 13/23] Separate config and extensions extraction in examples --- examples/extensions/authorizer.middleware.ts | 39 ++++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/examples/extensions/authorizer.middleware.ts b/examples/extensions/authorizer.middleware.ts index 1df559d53..61a744026 100644 --- a/examples/extensions/authorizer.middleware.ts +++ b/examples/extensions/authorizer.middleware.ts @@ -1,21 +1,38 @@ -import { GraphQLResolveInfo, GraphQLObjectType } from "graphql"; +import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } 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) || {}; +const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig => { + const { type, extensions, description, deprecationReason } = info.parentType.getFields()[ + info.fieldName + ]; + + return { + type, + description, + extensions, + deprecationReason, + }; +}; + +const extractParentConfig = (info: GraphQLResolveInfo): GraphQLObjectTypeConfig => + info.parentType.toConfig(); + +const extractAuthorizationExtensionsFromConfig = ( + config: GraphQLObjectTypeConfig | GraphQLFieldConfig, +) => (config.extensions && config.extensions.authorization) || {}; + +const getAuthorizationExtensions = (info: GraphQLResolveInfo) => { + const fieldConfig = extractFieldConfig(info); + const fieldAuthorizationExtensions = extractAuthorizationExtensionsFromConfig(fieldConfig); + + const parentConfig = extractParentConfig(info); + const parentAuthorizationExtensions = extractAuthorizationExtensionsFromConfig(parentConfig); return { ...parentAuthorizationExtensions, - ...returnTypeAuthorizationExtensions, ...fieldAuthorizationExtensions, }; }; @@ -24,7 +41,7 @@ export const AuthorizerMiddleware: MiddlewareFn = async ( { context: { user }, info }, next, ) => { - const { restricted = false, roles = [] } = extractAuthorizationExtensions(info); + const { restricted = false, roles = [] } = getAuthorizationExtensions(info); if (restricted) { if (!user) { From 6617818ad896965a51fa55500d5a0ca2147112f8 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 15 Jan 2020 10:22:35 +0100 Subject: [PATCH 14/23] Combine examples in one Logger middleware and decorator --- examples/extensions/authorizer.middleware.ts | 60 ------------------- examples/extensions/custom.authorized.ts | 9 --- examples/extensions/index.ts | 9 +-- examples/extensions/logger.decorator.ts | 17 ++++++ examples/extensions/logger.middleware.ts | 44 ++++++++++++-- .../{logger.ts => logger.service.ts} | 0 examples/extensions/recipe.type.ts | 9 ++- examples/extensions/resolver.ts | 6 +- examples/extensions/user.interface.ts | 1 - 9 files changed, 66 insertions(+), 89 deletions(-) delete mode 100644 examples/extensions/authorizer.middleware.ts delete mode 100644 examples/extensions/custom.authorized.ts create mode 100644 examples/extensions/logger.decorator.ts rename examples/extensions/{logger.ts => logger.service.ts} (100%) diff --git a/examples/extensions/authorizer.middleware.ts b/examples/extensions/authorizer.middleware.ts deleted file mode 100644 index 61a744026..000000000 --- a/examples/extensions/authorizer.middleware.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; - -import { MiddlewareFn } from "../../src"; -import { Context } from "./context.interface"; -import { UnauthorizedError } from "../../src/errors"; - -const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig => { - const { type, extensions, description, deprecationReason } = info.parentType.getFields()[ - info.fieldName - ]; - - return { - type, - description, - extensions, - deprecationReason, - }; -}; - -const extractParentConfig = (info: GraphQLResolveInfo): GraphQLObjectTypeConfig => - info.parentType.toConfig(); - -const extractAuthorizationExtensionsFromConfig = ( - config: GraphQLObjectTypeConfig | GraphQLFieldConfig, -) => (config.extensions && config.extensions.authorization) || {}; - -const getAuthorizationExtensions = (info: GraphQLResolveInfo) => { - const fieldConfig = extractFieldConfig(info); - const fieldAuthorizationExtensions = extractAuthorizationExtensionsFromConfig(fieldConfig); - - const parentConfig = extractParentConfig(info); - const parentAuthorizationExtensions = extractAuthorizationExtensionsFromConfig(parentConfig); - - return { - ...parentAuthorizationExtensions, - ...fieldAuthorizationExtensions, - }; -}; - -export const AuthorizerMiddleware: MiddlewareFn = async ( - { context: { user }, info }, - next, -) => { - const { restricted = false, roles = [] } = getAuthorizationExtensions(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 - return next(); -}; diff --git a/examples/extensions/custom.authorized.ts b/examples/extensions/custom.authorized.ts deleted file mode 100644 index fc466efec..000000000 --- a/examples/extensions/custom.authorized.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/index.ts b/examples/extensions/index.ts index b88d8e859..26d047068 100644 --- a/examples/extensions/index.ts +++ b/examples/extensions/index.ts @@ -4,14 +4,13 @@ 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], + globalMiddlewares: [LoggerMiddleware], }); // Create GraphQL server @@ -19,12 +18,10 @@ void (async function bootstrap() { schema, context: () => { const ctx: Context = { - // create mocked user in context - // in real app you would be mapping user from `req.user` or sth + // example user user: { - id: 1, + id: 123, name: "Sample user", - roles: ["REGULAR"], }, }; return ctx; diff --git a/examples/extensions/logger.decorator.ts b/examples/extensions/logger.decorator.ts new file mode 100644 index 000000000..0137b3735 --- /dev/null +++ b/examples/extensions/logger.decorator.ts @@ -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, + }); diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts index 0d6a0e69b..309006647 100644 --- a/examples/extensions/logger.middleware.ts +++ b/examples/extensions/logger.middleware.ts @@ -1,19 +1,53 @@ 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"; +import { Logger } from "./logger.service"; + +const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig => { + const { type, extensions, description, deprecationReason } = info.parentType.getFields()[ + info.fieldName + ]; + + return { + type, + description, + extensions, + deprecationReason, + }; +}; + +const extractParentConfig = (info: GraphQLResolveInfo): GraphQLObjectTypeConfig => + info.parentType.toConfig(); + +const extractLoggerExtensionsFromConfig = ( + config: GraphQLObjectTypeConfig | GraphQLFieldConfig, +) => (config.extensions && config.extensions.log) || {}; + +const getLoggerExtensions = (info: GraphQLResolveInfo) => { + const fieldConfig = extractFieldConfig(info); + const fieldLoggernExtensions = extractLoggerExtensionsFromConfig(fieldConfig); + + const parentConfig = extractParentConfig(info); + const parentLoggernExtensions = extractLoggerExtensionsFromConfig(parentConfig); + + return { + ...parentLoggernExtensions, + ...fieldLoggernExtensions, + }; +}; @Service() export class LoggerMiddleware implements MiddlewareInterface { constructor(private readonly logger: Logger) {} async use({ context: { user }, info }: ResolverData, next: NextFn) { - const { logMessage, logLevel = 0 } = - info.parentType.getFields()[info.fieldName].extensions || {}; + const { message, level = 0 } = getLoggerExtensions(info); - if (logMessage) { - this.logger.log(`${logMessage}${user ? ` (user: ${user.id})` : ""}`, logLevel); + if (message) { + this.logger.log(`${level}${user ? ` (user: ${user.id})` : ""}`, level); } return next(); diff --git a/examples/extensions/logger.ts b/examples/extensions/logger.service.ts similarity index 100% rename from examples/extensions/logger.ts rename to examples/extensions/logger.service.ts diff --git a/examples/extensions/recipe.type.ts b/examples/extensions/recipe.type.ts index 615e528ae..5e9b255c8 100644 --- a/examples/extensions/recipe.type.ts +++ b/examples/extensions/recipe.type.ts @@ -1,8 +1,8 @@ import { ObjectType, Extensions, Field, Int, Float } from "../../src"; -import { CustomAuthorized } from "./custom.authorized"; +import { Logger } from "./logger.decorator"; @ObjectType() -@CustomAuthorized() // restrict access to all receipe fields only for logged users +@Logger("Recipe accessed") // Log a message when any Recipe field is accessed export class Recipe { @Field() title: string; @@ -11,11 +11,10 @@ export class Recipe { description?: string; @Field(type => [String]) - @Extensions({ logMessage: "ingredients accessed" }) - @Extensions({ logLevel: 4 }) + @Extensions({ log: { message: "ingredients accessed", level: 0 } }) // We can use raw Extensions decorator if we want ingredients: string[]; - @CustomAuthorized("ADMIN") // restrict access to rates details for admin only, this will override the object type custom authorization + @Logger("Ratings accessed") // This will override the object type log message @Field(type => [Int]) ratings: number[]; diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts index cd81c20b7..e667adb49 100644 --- a/examples/extensions/resolver.ts +++ b/examples/extensions/resolver.ts @@ -1,5 +1,5 @@ import { Resolver, Query, Mutation, Arg, Extensions } from "../../src"; -import { CustomAuthorized } from "./custom.authorized"; +import { Logger } from "./logger.decorator"; import { Recipe } from "./recipe.type"; import { createRecipe, sampleRecipes } from "./recipe.helpers"; @@ -14,7 +14,6 @@ export class ExampleResolver { return await this.recipesData; } - @CustomAuthorized() // only logged users can add new recipe @Mutation() addRecipe( @Arg("title") title: string, @@ -29,7 +28,8 @@ export class ExampleResolver { return newRecipe; } - @CustomAuthorized("ADMIN") // only admin can remove the published recipe + @Logger("This message will not be logged") + @Logger("It will be overridden by this one") @Mutation() deleteRecipe(@Arg("title") title: string): boolean { const foundRecipeIndex = this.recipesData.findIndex(it => it.title === title); diff --git a/examples/extensions/user.interface.ts b/examples/extensions/user.interface.ts index 60f143472..4a77c7871 100644 --- a/examples/extensions/user.interface.ts +++ b/examples/extensions/user.interface.ts @@ -1,5 +1,4 @@ export interface User { id: number; name: string; - roles: string[]; } From 0cf457941756460f4fa49e2623d33702b7809fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Sat, 18 Jan 2020 19:33:57 +0100 Subject: [PATCH 15/23] Update Logger decorator --- examples/extensions/logger.decorator.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/examples/extensions/logger.decorator.ts b/examples/extensions/logger.decorator.ts index 0137b3735..70eb43d08 100644 --- a/examples/extensions/logger.decorator.ts +++ b/examples/extensions/logger.decorator.ts @@ -5,13 +5,16 @@ interface LogOptions { level?: number; } -export const Logger = (messageOrOptions: string | LogOptions) => - Extensions({ - log: - typeof messageOrOptions === "string" - ? { - level: 4, - message: messageOrOptions, - } - : messageOrOptions, - }); +export function Logger(messageOrOptions: string | LogOptions) { + // parse the parameters of the custom decorator + const log: LogOptions = + typeof messageOrOptions === "string" + ? { + level: 4, + message: messageOrOptions, + } + : messageOrOptions; + + // return the `@Extensions` decorator with a prepared property + return Extensions({ log }); +} From 546fadb4de3d51b26f0d55599cac42cca6ea2a81 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Mon, 20 Jan 2020 12:12:41 +0100 Subject: [PATCH 16/23] Fix typo: Loggern -> Logger --- examples/extensions/logger.middleware.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts index 309006647..33d81e8f4 100644 --- a/examples/extensions/logger.middleware.ts +++ b/examples/extensions/logger.middleware.ts @@ -28,14 +28,14 @@ const extractLoggerExtensionsFromConfig = ( const getLoggerExtensions = (info: GraphQLResolveInfo) => { const fieldConfig = extractFieldConfig(info); - const fieldLoggernExtensions = extractLoggerExtensionsFromConfig(fieldConfig); + const fieldLoggerExtensions = extractLoggerExtensionsFromConfig(fieldConfig); const parentConfig = extractParentConfig(info); - const parentLoggernExtensions = extractLoggerExtensionsFromConfig(parentConfig); + const parentLoggerExtensions = extractLoggerExtensionsFromConfig(parentConfig); return { - ...parentLoggernExtensions, - ...fieldLoggernExtensions, + ...parentLoggerExtensions, + ...fieldLoggerExtensions, }; }; From 531cc23820cf91f4c0ad26fcadb23afe9cf31ffd Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Mon, 20 Jan 2020 12:15:58 +0100 Subject: [PATCH 17/23] Remove extraneous extensions override example --- examples/extensions/resolver.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts index e667adb49..421eae227 100644 --- a/examples/extensions/resolver.ts +++ b/examples/extensions/resolver.ts @@ -28,8 +28,7 @@ export class ExampleResolver { return newRecipe; } - @Logger("This message will not be logged") - @Logger("It will be overridden by this one") + @Logger("Recipe deletion requested") @Mutation() deleteRecipe(@Arg("title") title: string): boolean { const foundRecipeIndex = this.recipesData.findIndex(it => it.title === title); From 1779546e84b9ba1085ea81503fb280f781d87b7c Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Mon, 20 Jan 2020 13:42:01 +0100 Subject: [PATCH 18/23] Extract 'info' parsing helpers --- examples/extensions/logger.middleware.ts | 19 +-- src/helpers/resolveInfo.ts | 18 +++ tests/functional/resolve-info-helpers.ts | 173 +++++++++++++++++++++++ 3 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 src/helpers/resolveInfo.ts create mode 100644 tests/functional/resolve-info-helpers.ts diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts index 33d81e8f4..02b4dc22a 100644 --- a/examples/extensions/logger.middleware.ts +++ b/examples/extensions/logger.middleware.ts @@ -2,26 +2,11 @@ import { Service } from "typedi"; import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; import { MiddlewareInterface, NextFn, ResolverData } from "../../src"; +import { extractFieldConfig, extractParentTypeConfig } from "../../src/helpers/resolveInfo"; import { Context } from "./context.interface"; import { Logger } from "./logger.service"; -const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig => { - const { type, extensions, description, deprecationReason } = info.parentType.getFields()[ - info.fieldName - ]; - - return { - type, - description, - extensions, - deprecationReason, - }; -}; - -const extractParentConfig = (info: GraphQLResolveInfo): GraphQLObjectTypeConfig => - info.parentType.toConfig(); - const extractLoggerExtensionsFromConfig = ( config: GraphQLObjectTypeConfig | GraphQLFieldConfig, ) => (config.extensions && config.extensions.log) || {}; @@ -30,7 +15,7 @@ const getLoggerExtensions = (info: GraphQLResolveInfo) => { const fieldConfig = extractFieldConfig(info); const fieldLoggerExtensions = extractLoggerExtensionsFromConfig(fieldConfig); - const parentConfig = extractParentConfig(info); + const parentConfig = extractParentTypeConfig(info); const parentLoggerExtensions = extractLoggerExtensionsFromConfig(parentConfig); return { diff --git a/src/helpers/resolveInfo.ts b/src/helpers/resolveInfo.ts new file mode 100644 index 000000000..630b277b3 --- /dev/null +++ b/src/helpers/resolveInfo.ts @@ -0,0 +1,18 @@ +import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; + +export const extractFieldConfig = (info: GraphQLResolveInfo): GraphQLFieldConfig => { + const { type, extensions, description, deprecationReason } = info.parentType.getFields()[ + info.fieldName + ]; + + return { + type, + description, + extensions, + deprecationReason, + }; +}; + +export const extractParentTypeConfig = ( + info: GraphQLResolveInfo, +): GraphQLObjectTypeConfig => info.parentType.toConfig(); diff --git a/tests/functional/resolve-info-helpers.ts b/tests/functional/resolve-info-helpers.ts new file mode 100644 index 000000000..3a4562530 --- /dev/null +++ b/tests/functional/resolve-info-helpers.ts @@ -0,0 +1,173 @@ +import "reflect-metadata"; + +import { + GraphQLSchema, + GraphQLResolveInfo, + graphql, + GraphQLNonNull, + GraphQLString, + GraphQLInt, + GraphQLObjectTypeConfig, +} from "graphql"; +import { + Field, + Resolver, + Query, + Extensions, + buildSchema, + ObjectType, + MiddlewareFn, + Int, +} from "../../src"; +import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; +import { extractFieldConfig, extractParentTypeConfig } from "../../src/helpers/resolveInfo"; + +describe("resolveInfo helpers", () => { + let schema: GraphQLSchema; + let infoRecord: Record = {}; + + const SampleMiddleware: MiddlewareFn = async ({ info }, next) => { + infoRecord[info.fieldName] = info; + return next(); + }; + + beforeAll(async () => { + getMetadataStorage().clear(); + + @ObjectType() + class BasicObjectType { + @Field() + basicField: string; + + @Field(type => Int, { + description: "some field description", + deprecationReason: "outdated type", + }) + @Extensions({ data: "123" }) + decoratedField: number; + } + + @ObjectType({ description: "some object type description" }) + @Extensions({ id: 1234 }) + class DecoratedObjectType { + @Field() + basicField: string; + } + + @Resolver() + class SampleResolver { + @Query(() => BasicObjectType) + basicObjectType(): BasicObjectType { + return new BasicObjectType(); + } + + @Query(() => DecoratedObjectType) + decoratedObjectType(): DecoratedObjectType { + return new DecoratedObjectType(); + } + } + + schema = await buildSchema({ + resolvers: [SampleResolver], + globalMiddlewares: [SampleMiddleware], + }); + }); + + beforeEach(async () => { + // Reset "infoRecord" after each test to prevent false positives + infoRecord = {}; + }); + + describe("extractFieldConfig", () => { + it("should extract proper config for basic fields", async () => { + const query = `query { + basicObjectType { + basicField + } + }`; + await graphql(schema, query); + + const fieldConfig = extractFieldConfig(infoRecord.basicField); + expect(fieldConfig).toEqual({ + deprecationReason: undefined, + description: undefined, + extensions: { + complexity: undefined, + }, + type: new GraphQLNonNull(GraphQLString), + }); + }); + + it("should extract proper config for decorated fields", async () => { + const query = `query { + basicObjectType { + decoratedField + } + }`; + await graphql(schema, query); + + const fieldConfig = extractFieldConfig(infoRecord.decoratedField); + expect(fieldConfig).toEqual({ + deprecationReason: "outdated type", + description: "some field description", + extensions: { + complexity: undefined, + data: "123", + }, + type: new GraphQLNonNull(GraphQLInt), + }); + }); + + describe("extractParentTypeConfig", () => { + const baseObjectTypeConfig = { + astNode: undefined, + extensionASTNodes: [], + interfaces: [], + isTypeOf: undefined, + }; + + it("should extract proper config for basic parent types", async () => { + const query = `query { + basicObjectType { + basicField + } + }`; + await graphql(schema, query); + + const parentTypeConfig = extractParentTypeConfig(infoRecord.basicField); + expect(parentTypeConfig).toEqual({ + ...baseObjectTypeConfig, + description: undefined, + extensions: {}, + fields: { + basicField: expect.any(Object), + decoratedField: expect.any(Object), + }, + name: "BasicObjectType", + }); + }); + + it("should extract proper config for decorated parent types", async () => { + const query = `query { + decoratedObjectType { + basicField + } + }`; + await graphql(schema, query); + + const parentTypeConfig = extractParentTypeConfig(infoRecord.basicField); + expect(parentTypeConfig).toEqual({ + ...baseObjectTypeConfig, + description: "some object type description", + extensions: { + id: 1234, + }, + fields: { + basicField: expect.any(Object), + }, + name: "DecoratedObjectType", + }); + }); + }); + }); +}); From c6cb8ed90b1df412e89d6a8245812405a9b5746c Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Mon, 20 Jan 2020 15:59:14 +0100 Subject: [PATCH 19/23] Remove unused import --- tests/functional/resolve-info-helpers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/functional/resolve-info-helpers.ts b/tests/functional/resolve-info-helpers.ts index 3a4562530..0d5658d4f 100644 --- a/tests/functional/resolve-info-helpers.ts +++ b/tests/functional/resolve-info-helpers.ts @@ -7,7 +7,6 @@ import { GraphQLNonNull, GraphQLString, GraphQLInt, - GraphQLObjectTypeConfig, } from "graphql"; import { Field, From b5db6c72b938ac0eb7cdb2c40930993af7b871d2 Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Tue, 21 Jan 2020 10:26:17 +0100 Subject: [PATCH 20/23] Fix comment typo: after -> before --- tests/functional/resolve-info-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/resolve-info-helpers.ts b/tests/functional/resolve-info-helpers.ts index 0d5658d4f..1599928ee 100644 --- a/tests/functional/resolve-info-helpers.ts +++ b/tests/functional/resolve-info-helpers.ts @@ -73,7 +73,7 @@ describe("resolveInfo helpers", () => { }); beforeEach(async () => { - // Reset "infoRecord" after each test to prevent false positives + // Reset "infoRecord" before each test to prevent false positives infoRecord = {}; }); From 19b68eece62205dc6e97b69d4bebc898a6e7a3ad Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Tue, 21 Jan 2020 10:56:46 +0100 Subject: [PATCH 21/23] Extract info helpers into example instead of src --- .../extensions/helpers/config.extractors.ts | 0 .../{recipe.helpers.ts => helpers/recipe.ts} | 0 examples/extensions/logger.middleware.ts | 2 +- examples/extensions/resolver.ts | 2 +- tests/functional/resolve-info-helpers.ts | 172 ------------------ 5 files changed, 2 insertions(+), 174 deletions(-) rename src/helpers/resolveInfo.ts => examples/extensions/helpers/config.extractors.ts (100%) rename examples/extensions/{recipe.helpers.ts => helpers/recipe.ts} (100%) delete mode 100644 tests/functional/resolve-info-helpers.ts diff --git a/src/helpers/resolveInfo.ts b/examples/extensions/helpers/config.extractors.ts similarity index 100% rename from src/helpers/resolveInfo.ts rename to examples/extensions/helpers/config.extractors.ts diff --git a/examples/extensions/recipe.helpers.ts b/examples/extensions/helpers/recipe.ts similarity index 100% rename from examples/extensions/recipe.helpers.ts rename to examples/extensions/helpers/recipe.ts diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts index 02b4dc22a..b19e4d2a7 100644 --- a/examples/extensions/logger.middleware.ts +++ b/examples/extensions/logger.middleware.ts @@ -2,7 +2,7 @@ import { Service } from "typedi"; import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; import { MiddlewareInterface, NextFn, ResolverData } from "../../src"; -import { extractFieldConfig, extractParentTypeConfig } from "../../src/helpers/resolveInfo"; +import { extractFieldConfig, extractParentTypeConfig } from "./helpers/config.extractors"; import { Context } from "./context.interface"; import { Logger } from "./logger.service"; diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts index 421eae227..9ec8f4e47 100644 --- a/examples/extensions/resolver.ts +++ b/examples/extensions/resolver.ts @@ -2,7 +2,7 @@ import { Resolver, Query, Mutation, Arg, Extensions } from "../../src"; import { Logger } from "./logger.decorator"; import { Recipe } from "./recipe.type"; -import { createRecipe, sampleRecipes } from "./recipe.helpers"; +import { createRecipe, sampleRecipes } from "./helpers/recipe"; @Resolver() export class ExampleResolver { diff --git a/tests/functional/resolve-info-helpers.ts b/tests/functional/resolve-info-helpers.ts deleted file mode 100644 index 1599928ee..000000000 --- a/tests/functional/resolve-info-helpers.ts +++ /dev/null @@ -1,172 +0,0 @@ -import "reflect-metadata"; - -import { - GraphQLSchema, - GraphQLResolveInfo, - graphql, - GraphQLNonNull, - GraphQLString, - GraphQLInt, -} from "graphql"; -import { - Field, - Resolver, - Query, - Extensions, - buildSchema, - ObjectType, - MiddlewareFn, - Int, -} from "../../src"; -import { getMetadataStorage } from "../../src/metadata/getMetadataStorage"; -import { extractFieldConfig, extractParentTypeConfig } from "../../src/helpers/resolveInfo"; - -describe("resolveInfo helpers", () => { - let schema: GraphQLSchema; - let infoRecord: Record = {}; - - const SampleMiddleware: MiddlewareFn = async ({ info }, next) => { - infoRecord[info.fieldName] = info; - return next(); - }; - - beforeAll(async () => { - getMetadataStorage().clear(); - - @ObjectType() - class BasicObjectType { - @Field() - basicField: string; - - @Field(type => Int, { - description: "some field description", - deprecationReason: "outdated type", - }) - @Extensions({ data: "123" }) - decoratedField: number; - } - - @ObjectType({ description: "some object type description" }) - @Extensions({ id: 1234 }) - class DecoratedObjectType { - @Field() - basicField: string; - } - - @Resolver() - class SampleResolver { - @Query(() => BasicObjectType) - basicObjectType(): BasicObjectType { - return new BasicObjectType(); - } - - @Query(() => DecoratedObjectType) - decoratedObjectType(): DecoratedObjectType { - return new DecoratedObjectType(); - } - } - - schema = await buildSchema({ - resolvers: [SampleResolver], - globalMiddlewares: [SampleMiddleware], - }); - }); - - beforeEach(async () => { - // Reset "infoRecord" before each test to prevent false positives - infoRecord = {}; - }); - - describe("extractFieldConfig", () => { - it("should extract proper config for basic fields", async () => { - const query = `query { - basicObjectType { - basicField - } - }`; - await graphql(schema, query); - - const fieldConfig = extractFieldConfig(infoRecord.basicField); - expect(fieldConfig).toEqual({ - deprecationReason: undefined, - description: undefined, - extensions: { - complexity: undefined, - }, - type: new GraphQLNonNull(GraphQLString), - }); - }); - - it("should extract proper config for decorated fields", async () => { - const query = `query { - basicObjectType { - decoratedField - } - }`; - await graphql(schema, query); - - const fieldConfig = extractFieldConfig(infoRecord.decoratedField); - expect(fieldConfig).toEqual({ - deprecationReason: "outdated type", - description: "some field description", - extensions: { - complexity: undefined, - data: "123", - }, - type: new GraphQLNonNull(GraphQLInt), - }); - }); - - describe("extractParentTypeConfig", () => { - const baseObjectTypeConfig = { - astNode: undefined, - extensionASTNodes: [], - interfaces: [], - isTypeOf: undefined, - }; - - it("should extract proper config for basic parent types", async () => { - const query = `query { - basicObjectType { - basicField - } - }`; - await graphql(schema, query); - - const parentTypeConfig = extractParentTypeConfig(infoRecord.basicField); - expect(parentTypeConfig).toEqual({ - ...baseObjectTypeConfig, - description: undefined, - extensions: {}, - fields: { - basicField: expect.any(Object), - decoratedField: expect.any(Object), - }, - name: "BasicObjectType", - }); - }); - - it("should extract proper config for decorated parent types", async () => { - const query = `query { - decoratedObjectType { - basicField - } - }`; - await graphql(schema, query); - - const parentTypeConfig = extractParentTypeConfig(infoRecord.basicField); - expect(parentTypeConfig).toEqual({ - ...baseObjectTypeConfig, - description: "some object type description", - extensions: { - id: 1234, - }, - fields: { - basicField: expect.any(Object), - }, - name: "DecoratedObjectType", - }); - }); - }); - }); -}); From d13c3479f3924be031df039b7f9b968a938b324a Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Tue, 21 Jan 2020 10:57:34 +0100 Subject: [PATCH 22/23] Fix import path --- examples/extensions/helpers/recipe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/extensions/helpers/recipe.ts b/examples/extensions/helpers/recipe.ts index 7a311e355..12581c09d 100644 --- a/examples/extensions/helpers/recipe.ts +++ b/examples/extensions/helpers/recipe.ts @@ -1,6 +1,6 @@ import { plainToClass } from "class-transformer"; -import { Recipe } from "./recipe.type"; +import { Recipe } from "../recipe.type"; export function createRecipe(recipeData: Partial): Recipe { return plainToClass(Recipe, recipeData); From 1fe39dc184606064083708d42fb92b8382e47a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Lytek?= Date: Tue, 28 Jan 2020 21:30:03 +0100 Subject: [PATCH 23/23] Tune up extensions examples --- dev.js | 1 + docs/extensions.md | 2 +- examples/extensions/index.ts | 2 ++ ...{logger.decorator.ts => log-message.decorator.ts} | 2 +- examples/extensions/logger.middleware.ts | 7 +++---- examples/extensions/recipe.type.ts | 12 ++++++++---- examples/extensions/resolver.ts | 5 ++--- 7 files changed, 18 insertions(+), 13 deletions(-) rename examples/extensions/{logger.decorator.ts => log-message.decorator.ts} (86%) diff --git a/dev.js b/dev.js index fa9538417..dd8f18300 100644 --- a/dev.js +++ b/dev.js @@ -4,6 +4,7 @@ require("ts-node/register/transpile-only"); // require("./examples/authorization/index.ts"); // require("./examples/automatic-validation/index.ts"); // require("./examples/enums-and-unions/index.ts"); +// require("./examples/extensions/index.ts"); // require("./examples/generic-types/index.ts"); // require("./examples/interfaces-inheritance/index.ts"); // require("./examples/middlewares-custom-decorators/index.ts"); diff --git a/docs/extensions.md b/docs/extensions.md index 949a3f81d..739720ec9 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -99,7 +99,7 @@ Here is a simple example of a global middleware that will be logging a message o export class LoggerMiddleware implements MiddlewareInterface { constructor(private readonly logger: Logger) {} - async use({ info }, next: NextFn) { + use({ info }: ResolverData, next: NextFn) { // extract `extensions` object from GraphQLResolveInfo object to get the `logMessage` value const { logMessage } = info.parentType.getFields()[info.fieldName].extensions || {}; diff --git a/examples/extensions/index.ts b/examples/extensions/index.ts index 26d047068..8d8e01e9e 100644 --- a/examples/extensions/index.ts +++ b/examples/extensions/index.ts @@ -1,5 +1,6 @@ import "reflect-metadata"; import { ApolloServer } from "apollo-server"; +import Container from "typedi"; import { buildSchema } from "../../src"; import { ExampleResolver } from "./resolver"; @@ -9,6 +10,7 @@ import { LoggerMiddleware } from "./logger.middleware"; void (async function bootstrap() { // build TypeGraphQL executable schema const schema = await buildSchema({ + container: Container, resolvers: [ExampleResolver], globalMiddlewares: [LoggerMiddleware], }); diff --git a/examples/extensions/logger.decorator.ts b/examples/extensions/log-message.decorator.ts similarity index 86% rename from examples/extensions/logger.decorator.ts rename to examples/extensions/log-message.decorator.ts index 70eb43d08..b8a66ddb6 100644 --- a/examples/extensions/logger.decorator.ts +++ b/examples/extensions/log-message.decorator.ts @@ -5,7 +5,7 @@ interface LogOptions { level?: number; } -export function Logger(messageOrOptions: string | LogOptions) { +export function LogMessage(messageOrOptions: string | LogOptions) { // parse the parameters of the custom decorator const log: LogOptions = typeof messageOrOptions === "string" diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts index b19e4d2a7..835fda8b0 100644 --- a/examples/extensions/logger.middleware.ts +++ b/examples/extensions/logger.middleware.ts @@ -1,9 +1,8 @@ import { Service } from "typedi"; import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; - import { MiddlewareInterface, NextFn, ResolverData } from "../../src"; -import { extractFieldConfig, extractParentTypeConfig } from "./helpers/config.extractors"; +import { extractFieldConfig, extractParentTypeConfig } from "./helpers/config.extractors"; import { Context } from "./context.interface"; import { Logger } from "./logger.service"; @@ -28,11 +27,11 @@ const getLoggerExtensions = (info: GraphQLResolveInfo) => { export class LoggerMiddleware implements MiddlewareInterface { constructor(private readonly logger: Logger) {} - async use({ context: { user }, info }: ResolverData, next: NextFn) { + use({ context: { user }, info }: ResolverData, next: NextFn) { const { message, level = 0 } = getLoggerExtensions(info); if (message) { - this.logger.log(`${level}${user ? ` (user: ${user.id})` : ""}`, level); + this.logger.log(level, `${user ? ` (user: ${user.id})` : ""}`, message); } return next(); diff --git a/examples/extensions/recipe.type.ts b/examples/extensions/recipe.type.ts index 5e9b255c8..192c72ef4 100644 --- a/examples/extensions/recipe.type.ts +++ b/examples/extensions/recipe.type.ts @@ -1,8 +1,10 @@ import { ObjectType, Extensions, Field, Int, Float } from "../../src"; -import { Logger } from "./logger.decorator"; + +import { LogMessage } from "./log-message.decorator"; @ObjectType() -@Logger("Recipe accessed") // Log a message when any Recipe field is accessed +// log a message when any Recipe field is accessed +@LogMessage("Recipe field accessed") export class Recipe { @Field() title: string; @@ -11,10 +13,12 @@ export class Recipe { description?: string; @Field(type => [String]) - @Extensions({ log: { message: "ingredients accessed", level: 0 } }) // We can use raw Extensions decorator if we want + // We can use raw Extensions decorator if we want + @Extensions({ log: { message: "ingredients field accessed", level: 0 } }) ingredients: string[]; - @Logger("Ratings accessed") // This will override the object type log message + // this will override the object type log message + @LogMessage("Ratings accessed") @Field(type => [Int]) ratings: number[]; diff --git a/examples/extensions/resolver.ts b/examples/extensions/resolver.ts index 9ec8f4e47..4e627fbc0 100644 --- a/examples/extensions/resolver.ts +++ b/examples/extensions/resolver.ts @@ -1,6 +1,6 @@ import { Resolver, Query, Mutation, Arg, Extensions } from "../../src"; -import { Logger } from "./logger.decorator"; +import { LogMessage } from "./log-message.decorator"; import { Recipe } from "./recipe.type"; import { createRecipe, sampleRecipes } from "./helpers/recipe"; @@ -8,7 +8,6 @@ import { createRecipe, sampleRecipes } from "./helpers/recipe"; export class ExampleResolver { private recipesData: Recipe[] = sampleRecipes.slice(); - @Extensions({ some: "data" }) @Query(returns => [Recipe]) async recipes(): Promise { return await this.recipesData; @@ -28,7 +27,7 @@ export class ExampleResolver { return newRecipe; } - @Logger("Recipe deletion requested") + @LogMessage("Recipe deletion requested") @Mutation() deleteRecipe(@Arg("title") title: string): boolean { const foundRecipeIndex = this.recipesData.findIndex(it => it.title === title);