Skip to content

Commit

Permalink
feat(decorators): support registering arg by `createParameterDecorato…
Browse files Browse the repository at this point in the history
…r` (#1680)

* Support registering arg in createParameterDecorator

* Update docs

* Update examples

* Update changelog

* Rename to RandomIdArg
  • Loading branch information
MichalLytek authored May 30, 2024
1 parent 79d216f commit bbcea46
Show file tree
Hide file tree
Showing 17 changed files with 246 additions and 42 deletions.
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

## Unreleased

<!-- Here goes all the unreleased changes descriptions -->

## Features

- support declaring middlewares on resolver class level (#620)
- support declaring auth roles on resolver class level (#620)
- make possible creating custom decorators on resolver class level - `createResolverClassMiddlewareDecorator`
- support registering custom arg decorator via `createParameterDecorator` and its second argument `CustomParameterOptions` - `arg` (#1325)

### Others

- **Breaking Change**: update `graphql-scalars` peer dependency to `^1.23.0`
- **Breaking Change**: rename `createMethodDecorator` into `createMethodMiddlewareDecorator`

<!-- Here goes all the unreleased changes descriptions -->
- **Breaking Change**: rename `createParamDecorator` to `createParameterDecorator`

## v2.0.0-rc.1

Expand Down
48 changes: 46 additions & 2 deletions docs/custom-decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ They might be just a simple data extractor function, that makes our resolver mor

```ts
function CurrentUser() {
return createParamDecorator<MyContextType>(({ context }) => context.currentUser);
return createParameterDecorator<MyContextType>(({ context }) => context.currentUser);
}
```

Or might be a more advanced one that performs some calculations and encapsulates some logic. Compared to middlewares, they allow for a more granular control on executing the code, like calculating fields map based on GraphQL info only when it's really needed (requested by using the `@Fields()` decorator):

```ts
function Fields(level = 1): ParameterDecorator {
return createParamDecorator(async ({ info }) => {
return createParameterDecorator(async ({ info }) => {
const fieldsMap: FieldsMap = {};
// Calculate an object with info about requested fields
// based on GraphQL 'info' parameter of the resolver and the level parameter
Expand Down Expand Up @@ -135,6 +135,50 @@ export class RecipeResolver {
}
```

### Custom `@Arg` decorator

In some cases we might want to create a custom decorator that will also register/expose an argument in the GraphQL schema.
Calling both `Arg()` and `createParameterDecorator()` inside a custom decorator does not play well with the internals of TypeGraphQL.

Hence, the `createParameterDecorator()` function supports second argument, `CustomParameterOptions` which allows to set decorator metadata for `@Arg` under the `arg` key:

```ts
function RandomIdArg(argName = "id") {
return createParameterDecorator(
// here we do the logic of getting provided argument or generating a random one
({ args }) => args[argName] ?? Math.round(Math.random() * MAX_ID_VALUE),
{
// here we provide the metadata to register the parameter as a GraphQL argument
arg: {
name: argName,
typeFunc: () => Int,
options: {
nullable: true,
description: "Accepts provided id or generates a random one.",
},
},
},
);
}
```

The usage of that custom decorator is very similar to the previous one and `@Arg` decorator itself:

```ts
@Resolver()
export class RecipeResolver {
constructor(private readonly recipesRepository: Repository<Recipe>) {}

@Query(returns => Recipe, { nullable: true })
async recipe(
// custom decorator that will expose an arg in the schema
@RandomIdArg("id") id: number,
) {
return await this.recipesRepository.findById(id);
}
}
```

## Example

See how different kinds of custom decorators work in the [custom decorators and middlewares example](https://github.com/MichalLytek/type-graphql/tree/master/examples/middlewares-custom-decorators).
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createParamDecorator } from "type-graphql";
import { createParameterDecorator } from "type-graphql";
import { type Context } from "../context.type";

export function CurrentUser() {
return createParamDecorator<Context>(({ context }) => context.currentUser);
return createParameterDecorator<Context>(({ context }) => context.currentUser);
}
24 changes: 24 additions & 0 deletions examples/middlewares-custom-decorators/decorators/random-id-arg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Int, createParameterDecorator } from "type-graphql";

const MAX_ID_VALUE = 3; // Number.MAX_SAFE_INTEGER

export function RandomIdArg(argName = "id") {
return createParameterDecorator(
({ args }) => args[argName] ?? Math.round(Math.random() * MAX_ID_VALUE),
{
arg: {
name: argName,
typeFunc: () => Int,
options: {
nullable: true,
description: "Accepts provided id or generates a random one.",
validateFn: (value: number): void => {
if (value < 0 || value > MAX_ID_VALUE) {
throw new Error(`Invalid value for ${argName}`);
}
},
},
},
},
);
}
18 changes: 18 additions & 0 deletions examples/middlewares-custom-decorators/examples.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,21 @@ query InterceptorsQuery {
averageRating
}
}

query RandomIdQuery {
recipe {
id
title
averageRating
description
}
}

query SelectedIdQuery {
recipe(id: 2) {
id
title
averageRating
description
}
}
8 changes: 7 additions & 1 deletion examples/middlewares-custom-decorators/recipe/recipe.data.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { Recipe } from "./recipe.type";

let lastRecipeId = 0;

function createRecipe(recipeData: Partial<Recipe>): Recipe {
return Object.assign(new Recipe(), recipeData);
return Object.assign(new Recipe(), {
// eslint-disable-next-line no-plusplus

Check warning on line 7 in examples/middlewares-custom-decorators/recipe/recipe.data.ts

View workflow job for this annotation

GitHub Actions / Build & Lint & Test (18.x)

Unknown word: "plusplus"

Check warning on line 7 in examples/middlewares-custom-decorators/recipe/recipe.data.ts

View workflow job for this annotation

GitHub Actions / Build & Lint & Test (20.x)

Unknown word: "plusplus"
id: lastRecipeId++,
...recipeData,
});
}

export const recipes = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RecipesArgs } from "./recipe.args";
import { recipes as recipesData } from "./recipe.data";
import { Recipe } from "./recipe.type";
import { CurrentUser, ValidateArgs } from "../decorators";
import { RandomIdArg } from "../decorators/random-id-arg";
import { ResolveTimeMiddleware } from "../middlewares";
import { User } from "../user.type";

Expand All @@ -13,6 +14,12 @@ import { User } from "../user.type";
export class RecipeResolver {
private readonly items: Recipe[] = recipesData;

@Query(_returns => Recipe, { nullable: true })
async recipe(@RandomIdArg("id") id: number) {
console.log(`Queried for recipe with id: ${id}`);
return this.items.find(item => item.id === id);
}

@Query(_returns => [Recipe])
@ValidateArgs(RecipesArgs)
async recipes(
Expand Down
3 changes: 3 additions & 0 deletions examples/middlewares-custom-decorators/recipe/recipe.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { LogAccessMiddleware, NumberInterceptor } from "../middlewares";

@ObjectType()
export class Recipe {
@Field(_type => Int)
id!: number;

@Field()
title!: string;

Expand Down
7 changes: 7 additions & 0 deletions examples/middlewares-custom-decorators/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
# -----------------------------------------------

type Query {
recipe(
"""
Accepts provided id or generates a random one.
"""
id: Int
): Recipe
recipes(skip: Int! = 0, take: Int! = 10): [Recipe!]!
}

type Recipe {
averageRating: Float
description: String
id: Int!
ratings: [Int!]!
title: String!
}
20 changes: 0 additions & 20 deletions src/decorators/createParamDecorator.ts

This file was deleted.

57 changes: 57 additions & 0 deletions src/decorators/createParameterDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { SymbolKeysNotSupportedError } from "@/errors";
import { getMetadataStorage } from "@/metadata/getMetadataStorage";
import { type ParameterDecorator, type ResolverData } from "@/typings";
import { type ArgOptions } from "./Arg";
import { type ReturnTypeFunc } from "./types";
import { getParamInfo } from "../helpers/params";
import { type CustomParamOptions } from "../metadata/definitions";

export interface CustomParameterOptions {
arg?: {
name: string;
typeFunc: ReturnTypeFunc;
options?: ArgOptions;
};
}

export type ParameterResolver<TContextType extends object = object> = (
resolverData: ResolverData<TContextType>,
) => any;

export function createParameterDecorator<TContextType extends object = object>(
resolver: ParameterResolver<TContextType>,
paramOptions: CustomParameterOptions = {},
): ParameterDecorator {
return (prototype, propertyKey, parameterIndex) => {
if (typeof propertyKey === "symbol") {
throw new SymbolKeysNotSupportedError();
}

const options: CustomParamOptions = {};
if (paramOptions.arg) {
options.arg = {
kind: "arg",
name: paramOptions.arg.name,
description: paramOptions.arg.options?.description,
deprecationReason: paramOptions.arg.options?.deprecationReason,
...getParamInfo({
prototype,
propertyKey,
parameterIndex,
returnTypeFunc: paramOptions.arg.typeFunc,
options: paramOptions.arg.options,
argName: paramOptions.arg.name,
}),
};
}

getMetadataStorage().collectHandlerParamMetadata({
kind: "custom",
target: prototype.constructor,
methodName: propertyKey,
index: parameterIndex,
resolver,
options,
});
};
}
2 changes: 1 addition & 1 deletion src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { Arg } from "./Arg";
export { Args } from "./Args";
export { ArgsType } from "./ArgsType";
export { Authorized } from "./Authorized";
export { createParamDecorator } from "./createParamDecorator";
export { createParameterDecorator } from "./createParameterDecorator";
export { createMethodMiddlewareDecorator } from "./createMethodMiddlewareDecorator";
export { createResolverClassMiddlewareDecorator } from "./createResolverClassMiddlewareDecorator";
export { Ctx } from "./Ctx";
Expand Down
5 changes: 5 additions & 0 deletions src/metadata/definitions/param-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ export type ArgsParamMetadata = {
kind: "args";
} & CommonArgMetadata;

export interface CustomParamOptions {
arg?: ArgParamMetadata;
}

export type CustomParamMetadata = {
kind: "custom";
resolver: (resolverData: ResolverData<any>) => any;
options: CustomParamOptions;
} & BasicParamMetadata;

export type ParamMetadata =
Expand Down
12 changes: 12 additions & 0 deletions src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export function getParams(
return resolverData.info;

case "custom":
if (paramInfo.options.arg) {
const arg = paramInfo.options.arg!;
return validateArg(
convertArgToInstance(arg, resolverData.args),
arg.getType(),
resolverData,
globalValidate,
arg.validateSettings,
globalValidateFn,
arg.validateFn,
).then(() => paramInfo.resolver(resolverData));
}
return paramInfo.resolver(resolverData);

// no default
Expand Down
22 changes: 12 additions & 10 deletions src/schema/schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,14 +724,16 @@ export abstract class SchemaGenerator {
params: ParamMetadata[],
): GraphQLFieldConfigArgumentMap {
return params!.reduce<GraphQLFieldConfigArgumentMap>((args, param) => {
if (param.kind === "arg") {
if (param.kind === "arg" || (param.kind === "custom" && param.options?.arg)) {
const input = param.kind === "arg" ? param : param.options.arg!;

const type = this.getGraphQLInputType(
target,
propertyName,
param.getType(),
param.typeOptions,
param.index,
param.name,
input.getType(),
input.typeOptions,
input.index,
input.name,
);
const argDirectives = getMetadataStorage()
.argumentDirectives.filter(
Expand All @@ -742,12 +744,12 @@ export abstract class SchemaGenerator {
)
.map(it => it.directive);
// eslint-disable-next-line no-param-reassign
args[param.name] = {
description: param.description,
args[input.name] = {
description: input.description,
type,
defaultValue: param.typeOptions.defaultValue,
deprecationReason: param.deprecationReason,
astNode: getInputValueDefinitionNode(param.name, type, argDirectives),
defaultValue: input.typeOptions.defaultValue,
deprecationReason: input.deprecationReason,
astNode: getInputValueDefinitionNode(input.name, type, argDirectives),
};
} else if (param.kind === "args") {
const argumentType = getMetadataStorage().argumentTypes.find(
Expand Down
2 changes: 1 addition & 1 deletion src/shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const Arg: typeof src.Arg = dummyDecorator;
export const Args: typeof src.Args = dummyDecorator;
export const ArgsType: typeof src.ArgsType = dummyDecorator;
export const Authorized: typeof src.Authorized = dummyDecorator;
export const createParamDecorator: typeof src.createParamDecorator = dummyFn as any;
export const createParameterDecorator: typeof src.createParameterDecorator = dummyFn as any;
export const createMethodMiddlewareDecorator: typeof src.createMethodMiddlewareDecorator =
dummyFn as any;
export const createResolverClassMiddlewareDecorator: typeof src.createResolverClassMiddlewareDecorator =
Expand Down
Loading

0 comments on commit bbcea46

Please sign in to comment.