feat: replace deprecated express-graphql dependency (#2484)

This commit is contained in:
Dan Ribbens
2023-04-17 13:48:14 -04:00
committed by GitHub
parent 15442a9cc7
commit cd548a6e2d
11 changed files with 1102 additions and 1667 deletions

View File

@@ -106,7 +106,6 @@
"dotenv": "^8.6.0",
"express": "^4.18.2",
"express-fileupload": "1.4.0",
"express-graphql": "0.12.0",
"express-rate-limit": "^5.5.1",
"file-loader": "^6.2.0",
"file-type": "16.5.4",
@@ -115,6 +114,7 @@
"fs-extra": "^10.1.0",
"get-tsconfig": "^4.4.0",
"graphql": "^16.6.0",
"graphql-http": "^1.17.1",
"graphql-playground-middleware-express": "^1.7.23",
"graphql-query-complexity": "^0.12.0",
"graphql-scalars": "^1.20.1",
@@ -186,7 +186,7 @@
"url-loader": "^4.1.1",
"use-context-selector": "^1.4.1",
"uuid": "^8.3.2",
"webpack": "^5.76.0",
"webpack": "^5.78.0",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^4.10.0",
"webpack-dev-middleware": "6.0.1",
@@ -278,7 +278,7 @@
"node-fetch": "2",
"nodemon": "^2.0.20",
"passport-strategy": "^1.0.0",
"release-it": "^15.6.0",
"release-it": "^15.10.1",
"rimraf": "^3.0.2",
"serve-static": "^1.15.0",
"shelljs": "^0.8.5",

View File

@@ -2,19 +2,12 @@ import { GraphQLFormattedError } from 'graphql';
import { AfterErrorHook } from '../collections/config/types';
import { Payload } from '../payload';
/**
*
* @param info
* @param debug
* @param afterErrorHook
* @returns {Promise<unknown[]>}
*/
const errorHandler = async (
payload: Payload,
info: any,
err: any,
debug: boolean,
afterErrorHook: AfterErrorHook,
): Promise<GraphQLFormattedError[]> => Promise.all(info.result.errors.map(async (err) => {
): Promise<GraphQLFormattedError> => {
payload.logger.error(err.stack);
let response: GraphQLFormattedError = {
@@ -33,6 +26,6 @@ const errorHandler = async (
}
return response;
}));
};
export default errorHandler;

View File

@@ -1,20 +1,35 @@
import { graphqlHTTP } from 'express-graphql';
import { Response } from 'express';
import { createHandler } from 'graphql-http/lib/use/express';
import { GraphQLError } from 'graphql';
import { PayloadRequest } from '../express/types';
import errorHandler from './errorHandler';
const graphQLHandler = (req: PayloadRequest, res: Response) => {
const { payload } = req;
payload.errorResponses = null;
const afterErrorHook = typeof payload.config.hooks.afterError === 'function' ? payload.config.hooks.afterError : null;
return graphqlHTTP(
async (request, response, { variables }) => ({
return createHandler(
{
schema: payload.schema,
customFormatErrorFn: payload.customFormatErrorFn,
extensions: payload.extensions,
onOperation: async (request, args, result) => {
const response = typeof payload.extensions === 'function' ? await payload.extensions({
req: request,
args,
result,
}) : result;
if (response.errors) {
const errors = await Promise.all(result.errors.map((error) => {
return errorHandler(payload, error, payload.config.debug, afterErrorHook);
})) as GraphQLError[];
// errors type should be FormattedGraphQLError[] but onOperation has a return type of ExecutionResult instead of FormattedExecutionResult
return { ...response, errors };
}
return response;
},
context: { req, res },
validationRules: payload.validationRules(variables),
}),
validationRules: (request, args, defaultRules) => defaultRules.concat(payload.validationRules(args)),
},
);
};

View File

@@ -10,7 +10,6 @@ import initGlobals from '../globals/graphql/init';
import initPreferences from '../preferences/graphql/init';
import buildPoliciesType from './schema/buildPoliciesType';
import accessResolver from '../auth/graphql/resolvers/access';
import errorHandler from './errorHandler';
export default function registerSchema(payload: Payload): void {
payload.types = {
@@ -74,26 +73,6 @@ export default function registerSchema(payload: Payload): void {
payload.schema = new GraphQLSchema(schema);
payload.extensions = async (info) => {
const { result } = info;
if (result.errors) {
payload.errorIndex = 0;
const afterErrorHook = typeof payload.config.hooks.afterError === 'function' ? payload.config.hooks.afterError : null;
payload.errorResponses = await errorHandler(payload, info, payload.config.debug, afterErrorHook);
}
return null;
};
payload.customFormatErrorFn = (error) => {
if (payload.errorResponses && payload.errorResponses[payload.errorIndex]) {
const response = payload.errorResponses[payload.errorIndex];
payload.errorIndex += 1;
return response;
}
return error;
};
payload.validationRules = (variables) => ([
queryComplexity({
estimators: [

View File

@@ -18,7 +18,7 @@ import errorHandler from './express/middleware/errorHandler';
import { PayloadRequest } from './express/types';
import { getDataLoader } from './collections/dataloader';
import mountEndpoints from './express/mountEndpoints';
import { Payload, getPayload } from './payload';
import { getPayload, Payload } from './payload';
export const initHTTP = async (options: InitOptions): Promise<Payload> => {
if (typeof options.local === 'undefined') options.local = false;
@@ -64,7 +64,7 @@ export const initHTTP = async (options: InitOptions): Promise<Payload> => {
}
},
identifyAPI('GraphQL'),
(req: PayloadRequest, res: Response) => graphQLHandler(req, res)(req, res),
(req: PayloadRequest, res: Response, next) => graphQLHandler(req, res)(req, res, next),
);
initGraphQLPlayground(payload);
}

View File

@@ -1,10 +1,11 @@
import pino from 'pino';
import type { Express, Router } from 'express';
import { GraphQLError, GraphQLFormattedError, GraphQLSchema } from 'graphql';
import { ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql';
import crypto from 'crypto';
import path from 'path';
import mongoose from 'mongoose';
import { Config as GeneratedTypes } from 'payload/generated-types';
import { OperationArgs, Request as graphQLRequest } from 'graphql-http/lib/handler';
import { BulkOperationResult, Collection, CollectionModel } from './collections/config/types';
import { EmailOptions, InitOptions, SanitizedConfig } from './config/types';
import { TypeWithVersion } from './versions/types';
@@ -122,15 +123,13 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
schema: GraphQLSchema;
extensions: (info: any) => Promise<any>;
extensions: (args: {
req: graphQLRequest<unknown, unknown>,
args: OperationArgs<any>,
result: ExecutionResult
}) => Promise<any>;
customFormatErrorFn: (error: GraphQLError) => GraphQLFormattedError;
validationRules: any;
errorResponses: GraphQLFormattedError[] = [];
errorIndex: number;
validationRules: (args: OperationArgs<any>) => ValidationRule[];
getAdminURL = (): string => `${this.config.serverURL}${this.config.routes.admin}`;

View File

@@ -31,6 +31,12 @@ export const slug = 'posts';
export const relationSlug = 'relation';
export default buildConfig({
collections: [
{
slug: 'users',
auth: true,
access: openAccess,
fields: [],
},
{
slug,
access: openAccess,
@@ -47,6 +53,11 @@ export default buildConfig({
name: 'number',
type: 'number',
},
{
name: 'min',
type: 'number',
min: 10,
},
// Relationship
{
name: 'relationField',

View File

@@ -1,11 +1,10 @@
import mongoose from 'mongoose';
import { GraphQLClient } from 'graphql-request';
import { initPayloadTest } from '../helpers/configHelpers';
import configPromise from './config';
import configPromise, { slug } from './config';
import payload from '../../src';
import type { Post } from './payload-types';
let slug = '';
const title = 'title';
let client: GraphQLClient;
@@ -14,7 +13,6 @@ describe('collections-graphql', () => {
beforeAll(async () => {
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
const config = await configPromise;
slug = config.collections[0]?.slug;
const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`;
client = new GraphQLClient(url);
});
@@ -353,6 +351,80 @@ describe('collections-graphql', () => {
});
});
});
describe('Error Handler', () => {
it('should return have an array of errors when making a bad request', async () => {
let error;
// language=graphQL
const query = `query {
Posts(where: { title: { exists: true }}) {
docs {
badFieldName
}
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(typeof error.response.errors[0].message).toBe('string');
});
it('should return have an array of errors when failing to pass validation', async () => {
let error;
// language=graphQL
const query = `mutation {
createPost(data: {min: 1}) {
id
min
createdAt
updatedAt
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(error.response.errors[0].message).toEqual('The following field is invalid: min');
expect(typeof error.response.errors[0].locations).toBeDefined();
});
it('should return have an array of errors when failing multiple mutations', async () => {
let error;
// language=graphQL
const query = `mutation createTest {
test1:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
test2:createUser(data: { email: "test2@test.com", password: "" }) {
email
}
test3:createUser(data: { email: "test@test.com", password: "test" }) {
email
}
}`;
await client.request(query).catch((err) => {
error = err;
});
expect(Array.isArray(error.response.errors)).toBe(true);
expect(error.response.errors[0].message).toEqual('No password was given');
expect(Array.isArray(error.response.errors[0].locations)).toEqual(true);
expect(error.response.errors[0].path[0]).toEqual('test2');
expect(error.response.errors[0].extensions.name).toEqual('MissingPasswordError');
expect(error.response.errors[1].message).toEqual('The following field is invalid: email');
expect(error.response.errors[1].path[0]).toEqual('test3');
expect(error.response.errors[1].extensions.name).toEqual('ValidationError');
expect(error.response.errors[1].extensions.data[0].message).toEqual('A user with the given username is already registered');
expect(error.response.errors[1].extensions.data[0].field).toEqual('email');
});
});
});
async function createPost(overrides?: Partial<Post>) {

View File

@@ -5,16 +5,21 @@
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Config {
collections: {
posts: Post;
relation: Relation;
dummy: Dummy;
users: User;
};
globals: {};
}
export interface Post {
id: string;
title?: string;
description?: string;
number?: number;
min?: number;
max?: number;
relationField?: string | Relation;
relationHasManyField?: string[] | Relation[];
relationMultiRelationTo?:
@@ -50,30 +55,18 @@ export interface Post {
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relation".
*/
export interface Relation {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "dummy".
*/
export interface Dummy {
id: string;
name?: string;
createdAt: string;
updatedAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
email?: string;
@@ -83,4 +76,5 @@ export interface User {
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

View File

@@ -719,9 +719,9 @@ type User {
}
"""
A field whose value conforms to the standard internet email address format as specified in RFC822: https://www.w3.org/Protocols/rfc822/.
A field whose value conforms to the standard internet email address format as specified in HTML Spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address.
"""
scalar EmailAddress @specifiedBy(url: "https://www.w3.org/Protocols/rfc822/")
scalar EmailAddress @specifiedBy(url: "https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address")
type Users {
docs: [User]

2554
yarn.lock

File diff suppressed because it is too large Load Diff