Skip to content

Commit

Permalink
feat(graphql): add checks to ensure that mutation & query decorated r…
Browse files Browse the repository at this point in the history
…esolver methods have valid return types
  • Loading branch information
marcus-sa committed Sep 20, 2023
1 parent dd87380 commit 6c90e79
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ exports[`Context 1`] = `
}
`;

exports[`invalid return type for mutation 1`] = `"Only classes and interfaces are supported as return types for methods decorated by @graphql.mutation()"`;

exports[`invalid return type for query 1`] = `"Only classes and interfaces are supported as return types for methods decorated by @graphql.mutation()"`;

exports[`mutation 1`] = `
{
"data": {
Expand Down
24 changes: 24 additions & 0 deletions packages/graphql/src/lib/decorators.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ import { Context, Parent } from './types-builder';
import { buildSchema } from './schema-builder';
import { Resolvers } from './resolvers';

test('invalid return type for mutation', () => {
expect(() => {
@graphql.resolver()
class TestResolver {
@graphql.mutation()
mutation(): string {
return '';
}
}
}).toThrowErrorMatchingSnapshot();
})

test('invalid return type for query', () => {
expect(() => {
@graphql.resolver()
class TestResolver {
@graphql.mutation()
query(): string {
return '';
}
}
}).toThrowErrorMatchingSnapshot();
})

test('mutation', async () => {
interface User {
readonly id: integer & PositiveNoZero;
Expand Down
60 changes: 49 additions & 11 deletions packages/graphql/src/lib/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
/* eslint-disable functional/immutable-data,functional/prefer-readonly-type,@typescript-eslint/typedef */
import { ClassType } from '@deepkit/core';
import {
ClassDecoratorFn,
createClassDecoratorContext,
createPropertyDecoratorContext,
DecoratorAndFetchSignature,
DualDecorator,
ExtractApiDataType,
ExtractClass,
mergeDecorator,
PropertyDecoratorResult,
ReceiveType,
ReflectionClass,
ReflectionKind,
resolveReceiveType,
resolveRuntimeType,
TypeClass,
ExtractClass,
TypeObjectLiteral,
ExtractApiDataType,
UnionToIntersection,
DualDecorator,
ClassDecoratorFn,
DecoratorAndFetchSignature,
resolveRuntimeType,
ReflectionClass,
} from '@deepkit/type';

import { requireTypeName } from './types-builder';
import { requireTypeName, unwrapPromiseLikeType } from './types-builder';

export const typeResolvers = new Map<string, ClassType>();

Expand Down Expand Up @@ -99,6 +99,7 @@ export class GraphQLQueryMetadata implements GraphQLQueryOptions {
classType: ClassType;
description?: string;
deprecationReason?: string;
readonly checks = new Set<(decorator: GraphQLResolverDecorator) => void>();
}

class GraphQLQueryDecorator {
Expand All @@ -109,12 +110,30 @@ class GraphQLQueryDecorator {
this.t.name = property;
this.t.classType = classType;
gqlResolverDecorator.addQuery(property, this.t)(classType);

this.t.checks.forEach(check =>
gqlResolverDecorator.addCheck(check)(classType),
);
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
query(options?: GraphQLQueryOptions) {
this.t.description = options?.description;
this.t.deprecationReason = options?.deprecationReason;

this.t.checks.add(() => {
const resolverType = resolveRuntimeType(this.t.classType);
const reflectionClass = ReflectionClass.from(resolverType);
const method = reflectionClass.getMethod(this.t.name);
let returnType = method.getReturnType();
returnType = unwrapPromiseLikeType(returnType);

if (returnType.kind !== ReflectionKind.objectLiteral && returnType.kind !== ReflectionKind.class) {
throw new Error(
'Only classes and interfaces are supported as return types for methods decorated by @graphql.query()',
);
}
});
}
}

Expand All @@ -132,6 +151,7 @@ export class GraphQLMutationMetadata implements GraphQLMutationOptions {
classType: ClassType;
description?: string;
deprecationReason?: string;
readonly checks = new Set<(decorator: GraphQLResolverDecorator) => void>();
}

class GraphQLMutationDecorator {
Expand All @@ -142,12 +162,30 @@ class GraphQLMutationDecorator {
this.t.name = property;
this.t.classType = classType;
gqlResolverDecorator.addMutation(property, this.t)(classType);

this.t.checks.forEach(check =>
gqlResolverDecorator.addCheck(check)(classType),
);
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
mutation(options?: GraphQLMutationOptions) {
this.t.description = options?.description;
this.t.deprecationReason = options?.deprecationReason;

this.t.checks.add(() => {
const resolverType = resolveRuntimeType(this.t.classType);
const reflectionClass = ReflectionClass.from(resolverType);
const method = reflectionClass.getMethod(this.t.name);
let returnType = method.getReturnType();
returnType = unwrapPromiseLikeType(returnType);

if (returnType.kind !== ReflectionKind.objectLiteral && returnType.kind !== ReflectionKind.class) {
throw new Error(
'Only classes and interfaces are supported as return types for methods decorated by @graphql.mutation()',
);
}
});
}
}

Expand Down Expand Up @@ -184,8 +222,8 @@ class GraphQLFieldDecorator {
this.t.name = name;
}

this.t.checks.add(decorator => {
if (!decorator.t.type) {
this.t.checks.add(resolverDecorator => {
if (!resolverDecorator.t.type) {
throw new Error(
'Can only resolve fields for resolvers with a type @graphql.resolver<T>()',
);
Expand All @@ -195,7 +233,7 @@ class GraphQLFieldDecorator {
const reflectionClass = ReflectionClass.from(resolverType);

if (!reflectionClass.hasMethod(this.t.name)) {
const typeName = requireTypeName(decorator.t.type);
const typeName = requireTypeName(resolverDecorator.t.type);
throw new Error(
`No field ${this.t.name} found on type ${typeName} for field resolver method ${this.t.property} on resolver ${this.t.classType.name}`,
);
Expand Down
7 changes: 3 additions & 4 deletions packages/graphql/src/lib/types-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export type Instance<T = any> = T & { readonly constructor: Function };

export type ID = string | number;

export function unwrapPromiseType(type: TypePromise): Type {
return type.type;
export function unwrapPromiseLikeType(type: Type): Type {
return type.kind === ReflectionKind.promise ? type.type : type;
}

export function getTypeName(
Expand Down Expand Up @@ -458,8 +458,7 @@ export class TypesBuilder {
}

createReturnType(type: Type): GraphQLOutputType {
type =
type.kind === ReflectionKind.promise ? unwrapPromiseType(type) : type;
type = unwrapPromiseLikeType(type);

const isNull =
type.kind === ReflectionKind.union &&
Expand Down

0 comments on commit 6c90e79

Please sign in to comment.