From 0640e9ec2de5a3899043ae912b756af61f8ac63a Mon Sep 17 00:00:00 2001 From: Sylvain Boulade Date: Wed, 8 Jan 2020 17:27:14 +0100 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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).