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) 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 new file mode 100644 index 000000000..739720ec9 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,117 @@ +--- +title: Extensions +--- + +The `graphql-js` library allows for putting arbitrary data into GraphQL types config inside the `extensions` property. +Annotating schema types or fields with a custom metadata, that can be then used at runtime by middlewares or resolvers, is a really powerful and useful feature. + +For such use cases, **TypeGraphQL** provides the `@Extensions` decorator, which adds the data we defined to the `extensions` property of the executable schema for the decorated classes, methods or properties. + +> Be aware that this is a low-level decorator and you generally have to provide your own logic to make use of the `extensions` metadata. + +## Using the `@Extensions` decorator + +Adding extensions to the schema type is as simple as using the `@Extensions` decorator and passing it an object of the custom data we want: + +```typescript +@Extensions({ complexity: 2 }) +``` + +We can pass several fields to the decorator: + +```typescript +@Extensions({ logMessage: "Restricted access", logLevel: 1 }) +``` + +And we can also decorate a type several times. The snippet below shows that this attaches the exact same extensions data to the schema type as the snippet above: + +```typescript +@Extensions({ logMessage: "Restricted access" }) +@Extensions({ logLevel: 1 }) +``` + +If we decorate the same type several times with the same extensions key, the one defined at the bottom takes precedence: + +```typescript +@Extensions({ logMessage: "Restricted access" }) +@Extensions({ logMessage: "Another message" }) +``` + +The above usage results in your GraphQL type having a `logMessage: "Another message"` property in its extensions. + +TypeGraphQL classes with the following decorators can be annotated with `@Extensions` decorator: + +- `@ObjectType` +- `@InputType` +- `@Field` +- `@Query` +- `@Mutation` +- `@FieldResolver` + +So the `@Extensions` decorator can be placed over the class property/method or over the type class itself, and multiple times if necessary, depending on what we want to do with the extensions data: + +```typescript +@Extensions({ roles: ["USER"] }) +@ObjectType() +class Foo { + @Field() + field: string; +} + +@ObjectType() +class Bar { + @Extensions({ roles: ["USER"] }) + @Field() + field: string; +} + +@ObjectType() +class Bar { + @Extensions({ roles: ["USER"] }) + @Extensions({ visible: false, logMessage: "User accessed restricted field" }) + @Field() + field: string; +} + +@Resolver(of => Foo) +class FooBarResolver { + @Extensions({ roles: ["USER"] }) + @Query() + foobar(@Arg("baz") baz: string): string { + return "foobar"; + } + + @Extensions({ roles: ["ADMIN"] }) + @FieldResolver() + bar(): string { + return "foobar"; + } +} +``` + +## Using the extensions data in runtime + +Once we have decorated the necessary types with extensions, the executable schema will contain the extensions data, and we can make use of it in any way we choose. The most common use will be to read it at runtime in resolvers or middlewares and perform some custom logic there. + +Here is a simple example of a global middleware that will be logging a message on field resolver execution whenever the field is decorated appropriately with `@Extensions`: + +```typescript +export class LoggerMiddleware implements MiddlewareInterface { + constructor(private readonly logger: Logger) {} + + use({ info }: ResolverData, next: NextFn) { + // extract `extensions` object from GraphQLResolveInfo object to get the `logMessage` value + const { logMessage } = info.parentType.getFields()[info.fieldName].extensions || {}; + + if (logMessage) { + this.logger.log(logMessage); + } + + return next(); + } +} +``` + +## Examples + +You can see more detailed examples of usage [here](https://github.com/MichalLytek/type-graphql/tree/master/examples/extensions). 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/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/helpers/config.extractors.ts b/examples/extensions/helpers/config.extractors.ts new file mode 100644 index 000000000..630b277b3 --- /dev/null +++ b/examples/extensions/helpers/config.extractors.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/examples/extensions/helpers/recipe.ts b/examples/extensions/helpers/recipe.ts new file mode 100644 index 000000000..12581c09d --- /dev/null +++ b/examples/extensions/helpers/recipe.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/index.ts b/examples/extensions/index.ts new file mode 100644 index 000000000..8d8e01e9e --- /dev/null +++ b/examples/extensions/index.ts @@ -0,0 +1,36 @@ +import "reflect-metadata"; +import { ApolloServer } from "apollo-server"; +import Container from "typedi"; +import { buildSchema } from "../../src"; + +import { ExampleResolver } from "./resolver"; +import { Context } from "./context.interface"; +import { LoggerMiddleware } from "./logger.middleware"; + +void (async function bootstrap() { + // build TypeGraphQL executable schema + const schema = await buildSchema({ + container: Container, + resolvers: [ExampleResolver], + globalMiddlewares: [LoggerMiddleware], + }); + + // Create GraphQL server + const server = new ApolloServer({ + schema, + context: () => { + const ctx: Context = { + // example user + user: { + id: 123, + name: "Sample user", + }, + }; + return ctx; + }, + }); + + // Start the server + const { url } = await server.listen(4000); + console.log(`Server is running, GraphQL Playground available at ${url}`); +})(); diff --git a/examples/extensions/log-message.decorator.ts b/examples/extensions/log-message.decorator.ts new file mode 100644 index 000000000..b8a66ddb6 --- /dev/null +++ b/examples/extensions/log-message.decorator.ts @@ -0,0 +1,20 @@ +import { Extensions } from "../../src"; + +interface LogOptions { + message: string; + level?: number; +} + +export function LogMessage(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 }); +} diff --git a/examples/extensions/logger.middleware.ts b/examples/extensions/logger.middleware.ts new file mode 100644 index 000000000..835fda8b0 --- /dev/null +++ b/examples/extensions/logger.middleware.ts @@ -0,0 +1,39 @@ +import { Service } from "typedi"; +import { GraphQLResolveInfo, GraphQLFieldConfig, GraphQLObjectTypeConfig } from "graphql"; +import { MiddlewareInterface, NextFn, ResolverData } from "../../src"; + +import { extractFieldConfig, extractParentTypeConfig } from "./helpers/config.extractors"; +import { Context } from "./context.interface"; +import { Logger } from "./logger.service"; + +const extractLoggerExtensionsFromConfig = ( + config: GraphQLObjectTypeConfig | GraphQLFieldConfig, +) => (config.extensions && config.extensions.log) || {}; + +const getLoggerExtensions = (info: GraphQLResolveInfo) => { + const fieldConfig = extractFieldConfig(info); + const fieldLoggerExtensions = extractLoggerExtensionsFromConfig(fieldConfig); + + const parentConfig = extractParentTypeConfig(info); + const parentLoggerExtensions = extractLoggerExtensionsFromConfig(parentConfig); + + return { + ...parentLoggerExtensions, + ...fieldLoggerExtensions, + }; +}; + +@Service() +export class LoggerMiddleware implements MiddlewareInterface { + constructor(private readonly logger: Logger) {} + + use({ context: { user }, info }: ResolverData, next: NextFn) { + const { message, level = 0 } = getLoggerExtensions(info); + + if (message) { + this.logger.log(level, `${user ? ` (user: ${user.id})` : ""}`, message); + } + + return next(); + } +} diff --git a/examples/extensions/logger.service.ts b/examples/extensions/logger.service.ts new file mode 100644 index 000000000..8b7623a7b --- /dev/null +++ b/examples/extensions/logger.service.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.type.ts b/examples/extensions/recipe.type.ts new file mode 100644 index 000000000..192c72ef4 --- /dev/null +++ b/examples/extensions/recipe.type.ts @@ -0,0 +1,29 @@ +import { ObjectType, Extensions, Field, Int, Float } from "../../src"; + +import { LogMessage } from "./log-message.decorator"; + +@ObjectType() +// log a message when any Recipe field is accessed +@LogMessage("Recipe field accessed") +export class Recipe { + @Field() + title: string; + + @Field({ nullable: true }) + description?: string; + + @Field(type => [String]) + // We can use raw Extensions decorator if we want + @Extensions({ log: { message: "ingredients field accessed", level: 0 } }) + ingredients: string[]; + + // this will override the object type log message + @LogMessage("Ratings accessed") + @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..4e627fbc0 --- /dev/null +++ b/examples/extensions/resolver.ts @@ -0,0 +1,40 @@ +import { Resolver, Query, Mutation, Arg, Extensions } from "../../src"; + +import { LogMessage } from "./log-message.decorator"; +import { Recipe } from "./recipe.type"; +import { createRecipe, sampleRecipes } from "./helpers/recipe"; + +@Resolver() +export class ExampleResolver { + private recipesData: Recipe[] = sampleRecipes.slice(); + + @Query(returns => [Recipe]) + async recipes(): Promise { + return await this.recipesData; + } + + @Mutation() + addRecipe( + @Arg("title") title: string, + @Arg("description", { nullable: true }) description?: string, + ): Recipe { + const newRecipe = createRecipe({ + title, + description, + ratings: [], + }); + this.recipesData.push(newRecipe); + return newRecipe; + } + + @LogMessage("Recipe deletion requested") + @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..4a77c7871 --- /dev/null +++ b/examples/extensions/user.interface.ts @@ -0,0 +1,4 @@ +export interface User { + id: number; + name: string; +} diff --git a/src/decorators/Extensions.ts b/src/decorators/Extensions.ts new file mode 100644 index 000000000..276f300df --- /dev/null +++ b/src/decorators/Extensions.ts @@ -0,0 +1,27 @@ +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: ExtensionsMetadata, +): 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..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,5 +9,6 @@ export interface ClassMetadata { description?: string; isAbstract?: boolean; directives?: DirectiveMetadata[]; + extensions?: ExtensionsMetadata; simpleResolvers?: boolean; } diff --git a/src/metadata/definitions/extensions-metadata.ts b/src/metadata/definitions/extensions-metadata.ts new file mode 100644 index 000000000..3b1ada97a --- /dev/null +++ b/src/metadata/definitions/extensions-metadata.ts @@ -0,0 +1,12 @@ +export type ExtensionsMetadata = Readonly>; + +export interface ExtensionsClassMetadata { + target: Function; + extensions: ExtensionsMetadata; +} + +export interface ExtensionsFieldMetadata { + target: Function; + fieldName: string; + extensions: ExtensionsMetadata; +} diff --git a/src/metadata/definitions/field-metadata.ts b/src/metadata/definitions/field-metadata.ts index 0c0855cf7..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,5 +18,6 @@ export interface FieldMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; + extensions?: ExtensionsMetadata; 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..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,6 +23,7 @@ export interface BaseResolverMetadata { roles?: any[]; middlewares?: Array>; directives?: DirectiveMetadata[]; + extensions?: ExtensionsMetadata; } export interface ResolverMetadata extends BaseResolverMetadata { diff --git a/src/metadata/metadata-storage.ts b/src/metadata/metadata-storage.ts index 74b3c2d0c..9b6820ee0 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, @@ -12,6 +14,7 @@ import { ResolverClassMetadata, SubscriptionResolverMetadata, MiddlewareMetadata, + ExtensionsMetadata, } from "./definitions"; import { ClassType } from "../interfaces"; import { NoExplicitTypeError } from "../errors"; @@ -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.findExtensions(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.findExtensions(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.findExtensions(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.findExtensions(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 findExtensions(target: Function, fieldName?: string): ExtensionsMetadata { + const storedExtensions: Array = fieldName + ? this.fieldExtensions + : this.classExtensions; + return storedExtensions + .filter( + entry => + entry.target === target && (!("fieldName" in entry) || entry.fieldName === fieldName), + ) + .reduce((extensions, entry) => ({ ...extensions, ...entry.extensions }), {}); + } } diff --git a/src/metadata/utils.ts b/src/metadata/utils.ts index 5e100d492..591951d64 100644 --- a/src/metadata/utils.ts +++ b/src/metadata/utils.ts @@ -3,6 +3,9 @@ import { BaseResolverMetadata, MiddlewareMetadata, FieldResolverMetadata, + ExtensionsClassMetadata, + ExtensionsFieldMetadata, + ExtensionsMetadata, } from "./definitions"; import { Middleware } from "../interfaces/Middleware"; import { isThrowing } from "../helpers/isThrowing"; 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" }); + }); + }); + }); +}); 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",