feat: replace GraphQL Apollo with GraphQL Yoga (#7967)

This commit is contained in:
Antoine Cormouls
2022-05-18 19:55:43 +02:00
committed by GitHub
parent b2ae2e1db4
commit 1aa2204aeb
9 changed files with 488 additions and 1143 deletions

1308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,14 +19,11 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@apollo/client": "3.5.10",
"@apollographql/graphql-playground-html": "1.6.29",
"@graphql-tools/links": "8.2.11",
"@graphql-tools/stitch": "6.2.4", "@graphql-tools/stitch": "6.2.4",
"@graphql-tools/utils": "6.2.4", "@graphql-tools/utils": "6.2.4",
"@graphql-yoga/node": "2.6.0",
"@parse/fs-files-adapter": "1.2.2", "@parse/fs-files-adapter": "1.2.2",
"@parse/push-adapter": "4.1.2", "@parse/push-adapter": "4.1.2",
"apollo-server-express": "2.25.2",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"body-parser": "1.20.0", "body-parser": "1.20.0",
"commander": "5.1.0", "commander": "5.1.0",
@@ -38,7 +35,6 @@
"graphql-list-fields": "2.0.2", "graphql-list-fields": "2.0.2",
"graphql-relay": "0.7.0", "graphql-relay": "0.7.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"graphql-upload": "11.0.0",
"intersect": "1.0.1", "intersect": "1.0.1",
"jsonwebtoken": "8.5.1", "jsonwebtoken": "8.5.1",
"jwks-rsa": "2.0.5", "jwks-rsa": "2.0.5",
@@ -63,6 +59,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/core": "1.2.6", "@actions/core": "1.2.6",
"@apollo/client": "3.6.1",
"@babel/cli": "7.10.0", "@babel/cli": "7.10.0",
"@babel/core": "7.10.0", "@babel/core": "7.10.0",
"@babel/plugin-proposal-object-rest-spread": "7.10.0", "@babel/plugin-proposal-object-rest-spread": "7.10.0",
@@ -98,7 +95,7 @@
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
"mongodb-runner": "4.8.1", "mongodb-runner": "4.8.1",
"mongodb-version-list": "1.0.0", "mongodb-version-list": "1.0.0",
"node-fetch": "3.1.1", "node-fetch": "3.2.4",
"nyc": "15.1.0", "nyc": "15.1.0",
"prettier": "2.0.5", "prettier": "2.0.5",
"semantic-release": "17.4.6", "semantic-release": "17.4.6",

View File

@@ -98,6 +98,24 @@ describe('ParseGraphQLServer', () => {
}); });
}); });
describe('_getServer', () => {
it('should only return new server on schema changes', async () => {
parseGraphQLServer.server = undefined;
const server1 = await parseGraphQLServer._getServer();
const server2 = await parseGraphQLServer._getServer();
expect(server1).toBe(server2);
// Trigger a schema change
const obj = new Parse.Object('SomeClass');
await obj.save();
const server3 = await parseGraphQLServer._getServer();
const server4 = await parseGraphQLServer._getServer();
expect(server3).not.toBe(server2);
expect(server3).toBe(server4);
});
});
describe('_getGraphQLOptions', () => { describe('_getGraphQLOptions', () => {
const req = { const req = {
info: new Object(), info: new Object(),
@@ -106,11 +124,15 @@ describe('ParseGraphQLServer', () => {
}; };
it("should return schema and context with req's info, config and auth", async () => { it("should return schema and context with req's info, config and auth", async () => {
const options = await parseGraphQLServer._getGraphQLOptions(req); const options = await parseGraphQLServer._getGraphQLOptions();
expect(options.multipart).toEqual({
fileSize: 20971520,
});
expect(options.schema).toEqual(parseGraphQLServer.parseGraphQLSchema.graphQLSchema); expect(options.schema).toEqual(parseGraphQLServer.parseGraphQLSchema.graphQLSchema);
expect(options.context.info).toEqual(req.info); const contextResponse = options.context({ req });
expect(options.context.config).toEqual(req.config); expect(contextResponse.info).toEqual(req.info);
expect(options.context.auth).toEqual(req.auth); expect(contextResponse.config).toEqual(req.config);
expect(contextResponse.auth).toEqual(req.auth);
}); });
it('should load GraphQL schema in every call', async () => { it('should load GraphQL schema in every call', async () => {
@@ -467,7 +489,7 @@ describe('ParseGraphQLServer', () => {
} }
}); });
it('should be cors enabled', async () => { it('should be cors enabled and scope the response within the source origin', async () => {
let checked = false; let checked = false;
const apolloClient = new ApolloClient({ const apolloClient = new ApolloClient({
link: new ApolloLink((operation, forward) => { link: new ApolloLink((operation, forward) => {
@@ -476,7 +498,7 @@ describe('ParseGraphQLServer', () => {
const { const {
response: { headers }, response: { headers },
} = context; } = context;
expect(headers.get('access-control-allow-origin')).toEqual('*'); expect(headers.get('access-control-allow-origin')).toEqual('http://example.com');
checked = true; checked = true;
return response; return response;
}); });
@@ -486,7 +508,7 @@ describe('ParseGraphQLServer', () => {
fetch, fetch,
headers: { headers: {
...headers, ...headers,
Origin: 'http://someorigin.com', Origin: 'http://example.com',
}, },
}) })
), ),
@@ -504,14 +526,25 @@ describe('ParseGraphQLServer', () => {
}); });
it('should handle Parse headers', async () => { it('should handle Parse headers', async () => {
let checked = false; const test = {
context: ({ req: { info, config, auth } }) => {
expect(req.info).toBeDefined();
expect(req.config).toBeDefined();
expect(req.auth).toBeDefined();
return {
info,
config,
auth,
};
},
};
const contextSpy = spyOn(test, 'context');
const originalGetGraphQLOptions = parseGraphQLServer._getGraphQLOptions; const originalGetGraphQLOptions = parseGraphQLServer._getGraphQLOptions;
parseGraphQLServer._getGraphQLOptions = async req => { parseGraphQLServer._getGraphQLOptions = async () => {
expect(req.info).toBeDefined(); return {
expect(req.config).toBeDefined(); schema: await parseGraphQLServer.parseGraphQLSchema.load(),
expect(req.auth).toBeDefined(); context: test.context,
checked = true; };
return await originalGetGraphQLOptions.bind(parseGraphQLServer)(req);
}; };
const health = ( const health = (
await apolloClient.query({ await apolloClient.query({
@@ -523,7 +556,7 @@ describe('ParseGraphQLServer', () => {
}) })
).data.health; ).data.health;
expect(health).toBeTruthy(); expect(health).toBeTruthy();
expect(checked).toBeTruthy(); expect(contextSpy).toHaveBeenCalledTimes(1);
parseGraphQLServer._getGraphQLOptions = originalGetGraphQLOptions; parseGraphQLServer._getGraphQLOptions = originalGetGraphQLOptions;
}); });
}); });
@@ -6786,7 +6819,7 @@ describe('ParseGraphQLServer', () => {
expect(queryResult.data.customers.edges.length).toEqual(1); expect(queryResult.data.customers.edges.length).toEqual(1);
} catch (e) { } catch (e) {
console.log(JSON.stringify(e)); console.error(JSON.stringify(e));
} }
}); });
}); });
@@ -9107,15 +9140,15 @@ describe('ParseGraphQLServer', () => {
'operations', 'operations',
JSON.stringify({ JSON.stringify({
query: ` query: `
mutation CreateFile($input: CreateFileInput!) { mutation CreateFile($input: CreateFileInput!) {
createFile(input: $input) { createFile(input: $input) {
fileInfo { fileInfo {
name name
url url
}
} }
} }
} `,
`,
variables: { variables: {
input: { input: {
upload: null, upload: null,
@@ -9176,46 +9209,46 @@ describe('ParseGraphQLServer', () => {
'operations', 'operations',
JSON.stringify({ JSON.stringify({
query: ` query: `
mutation CreateSomeObject( mutation CreateSomeObject(
$fields1: CreateSomeClassFieldsInput $fields1: CreateSomeClassFieldsInput
$fields2: CreateSomeClassFieldsInput $fields2: CreateSomeClassFieldsInput
$fields3: CreateSomeClassFieldsInput $fields3: CreateSomeClassFieldsInput
) {
createSomeClass1: createSomeClass(
input: { fields: $fields1 }
) { ) {
someClass { createSomeClass1: createSomeClass(
id input: { fields: $fields1 }
someField { ) {
name someClass {
url id
someField {
name
url
}
}
}
createSomeClass2: createSomeClass(
input: { fields: $fields2 }
) {
someClass {
id
someField {
name
url
}
}
}
createSomeClass3: createSomeClass(
input: { fields: $fields3 }
) {
someClass {
id
someField {
name
url
}
} }
} }
} }
createSomeClass2: createSomeClass( `,
input: { fields: $fields2 }
) {
someClass {
id
someField {
name
url
}
}
}
createSomeClass3: createSomeClass(
input: { fields: $fields3 }
) {
someClass {
id
someField {
name
url
}
}
}
}
`,
variables: { variables: {
fields1: { fields1: {
someField: { file: someFieldValue }, someField: { file: someFieldValue },
@@ -9344,6 +9377,51 @@ describe('ParseGraphQLServer', () => {
} }
}); });
it_only_node_version('<17')('should not upload if file is too large', async () => {
parseGraphQLServer.parseServer.config.maxUploadSize = '1kb';
const body = new FormData();
body.append(
'operations',
JSON.stringify({
query: `
mutation CreateFile($input: CreateFileInput!) {
createFile(input: $input) {
fileInfo {
name
url
}
}
}
`,
variables: {
input: {
upload: null,
},
},
})
);
body.append('map', JSON.stringify({ 1: ['variables.input.upload'] }));
body.append(
'1',
Buffer.alloc(parseGraphQLServer._transformMaxUploadSizeToBytes('2kb'), 1),
{
filename: 'myFileName.txt',
contentType: 'text/plain',
}
);
const res = await fetch('http://localhost:13377/graphql', {
method: 'POST',
headers,
body,
});
const result = JSON.parse(await res.text());
expect(res.status).toEqual(500);
expect(result.errors[0].message).toEqual('File size limit exceeded: 1024 bytes');
});
it('should support object values', async () => { it('should support object values', async () => {
try { try {
const someObjectFieldValue = { const someObjectFieldValue = {

View File

@@ -89,13 +89,14 @@ class ParseGraphQLSchema {
this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs; this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs;
this.appId = params.appId || requiredParameter('You must provide the appId!'); this.appId = params.appId || requiredParameter('You must provide the appId!');
this.schemaCache = SchemaCache; this.schemaCache = SchemaCache;
this.logCache = {};
} }
async load() { async load() {
const { parseGraphQLConfig } = await this._initializeSchemaAndConfig(); const { parseGraphQLConfig } = await this._initializeSchemaAndConfig();
const parseClassesArray = await this._getClassesForSchema(parseGraphQLConfig); const parseClassesArray = await this._getClassesForSchema(parseGraphQLConfig);
const functionNames = await this._getFunctionNames(); const functionNames = await this._getFunctionNames();
const functionNamesString = JSON.stringify(functionNames); const functionNamesString = functionNames.join();
const parseClasses = parseClassesArray.reduce((acc, clazz) => { const parseClasses = parseClassesArray.reduce((acc, clazz) => {
acc[clazz.className] = clazz; acc[clazz.className] = clazz;
@@ -331,6 +332,14 @@ class ParseGraphQLSchema {
return this.graphQLSchema; return this.graphQLSchema;
} }
_logOnce(severity, message) {
if (this.logCache[message]) {
return;
}
this.log[severity](message);
this.logCache[message] = true;
}
addGraphQLType(type, throwError = false, ignoreReserved = false, ignoreConnection = false) { addGraphQLType(type, throwError = false, ignoreReserved = false, ignoreConnection = false) {
if ( if (
(!ignoreReserved && RESERVED_GRAPHQL_TYPE_NAMES.includes(type.name)) || (!ignoreReserved && RESERVED_GRAPHQL_TYPE_NAMES.includes(type.name)) ||
@@ -341,7 +350,7 @@ class ParseGraphQLSchema {
if (throwError) { if (throwError) {
throw new Error(message); throw new Error(message);
} }
this.log.warn(message); this._logOnce('warn', message);
return undefined; return undefined;
} }
this.graphQLTypes.push(type); this.graphQLTypes.push(type);
@@ -357,7 +366,7 @@ class ParseGraphQLSchema {
if (throwError) { if (throwError) {
throw new Error(message); throw new Error(message);
} }
this.log.warn(message); this._logOnce('warn', message);
return undefined; return undefined;
} }
this.graphQLQueries[fieldName] = field; this.graphQLQueries[fieldName] = field;
@@ -373,7 +382,7 @@ class ParseGraphQLSchema {
if (throwError) { if (throwError) {
throw new Error(message); throw new Error(message);
} }
this.log.warn(message); this._logOnce('warn', message);
return undefined; return undefined;
} }
this.graphQLMutations[fieldName] = field; this.graphQLMutations[fieldName] = field;
@@ -482,7 +491,8 @@ class ParseGraphQLSchema {
if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) { if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) {
return true; return true;
} else { } else {
this.log.warn( this._logOnce(
'warn',
`Function ${functionName} could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.` `Function ${functionName} could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.`
); );
return false; return false;

View File

@@ -1,8 +1,5 @@
import corsMiddleware from 'cors'; import corsMiddleware from 'cors';
import bodyParser from 'body-parser'; import { createServer, renderGraphiQL } from '@graphql-yoga/node';
import { graphqlUploadExpress } from 'graphql-upload';
import { graphqlExpress } from 'apollo-server-express/dist/expressApollo';
import { renderPlaygroundPage } from '@apollographql/graphql-playground-html';
import { execute, subscribe } from 'graphql'; import { execute, subscribe } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws'; import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders } from '../middlewares'; import { handleParseErrors, handleParseHeaders } from '../middlewares';
@@ -32,18 +29,19 @@ class ParseGraphQLServer {
}); });
} }
async _getGraphQLOptions(req) { async _getGraphQLOptions() {
try { try {
return { return {
schema: await this.parseGraphQLSchema.load(), schema: await this.parseGraphQLSchema.load(),
context: { context: ({ req: { info, config, auth } }) => ({
info: req.info, info,
config: req.config, config,
auth: req.auth, auth,
}, }),
formatError: error => { multipart: {
// Allow to console.log here to debug fileSize: this._transformMaxUploadSizeToBytes(
return error; this.parseServer.config.maxUploadSize || '20mb'
),
}, },
}; };
} catch (e) { } catch (e) {
@@ -52,6 +50,17 @@ class ParseGraphQLServer {
} }
} }
async _getServer() {
const schemaRef = this.parseGraphQLSchema.graphQLSchema;
const newSchemaRef = await this.parseGraphQLSchema.load();
if (schemaRef === newSchemaRef && this._server) {
return this._server;
}
const options = await this._getGraphQLOptions();
this._server = createServer(options);
return this._server;
}
_transformMaxUploadSizeToBytes(maxUploadSize) { _transformMaxUploadSizeToBytes(maxUploadSize) {
const unitMap = { const unitMap = {
kb: 1, kb: 1,
@@ -70,22 +79,13 @@ class ParseGraphQLServer {
requiredParameter('You must provide an Express.js app instance!'); requiredParameter('You must provide an Express.js app instance!');
} }
app.use(
this.config.graphQLPath,
graphqlUploadExpress({
maxFileSize: this._transformMaxUploadSizeToBytes(
this.parseServer.config.maxUploadSize || '20mb'
),
})
);
app.use(this.config.graphQLPath, corsMiddleware()); app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, bodyParser.json());
app.use(this.config.graphQLPath, handleParseHeaders); app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseErrors); app.use(this.config.graphQLPath, handleParseErrors);
app.use( app.use(this.config.graphQLPath, async (req, res) => {
this.config.graphQLPath, const server = await this._getServer();
graphqlExpress(async req => await this._getGraphQLOptions(req)) return server(req, res);
); });
} }
applyPlayground(app) { applyPlayground(app) {
@@ -98,14 +98,13 @@ class ParseGraphQLServer {
(_req, res) => { (_req, res) => {
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
res.write( res.write(
renderPlaygroundPage({ renderGraphiQL({
endpoint: this.config.graphQLPath, endpoint: this.config.graphQLPath,
version: '1.7.25',
subscriptionEndpoint: this.config.subscriptionsPath, subscriptionEndpoint: this.config.subscriptionsPath,
headers: { headers: JSON.stringify({
'X-Parse-Application-Id': this.parseServer.config.appId, 'X-Parse-Application-Id': this.parseServer.config.appId,
'X-Parse-Master-Key': this.parseServer.config.masterKey, 'X-Parse-Master-Key': this.parseServer.config.masterKey,
}, }),
}) })
); );
res.end(); res.end();

View File

@@ -50,7 +50,7 @@ const getObject = async (
options.keys = keys; options.keys = keys;
} }
} catch (e) { } catch (e) {
console.log(e); console.error(e);
} }
if (include) { if (include) {
options.include = include; options.include = include;

View File

@@ -15,7 +15,6 @@ import {
GraphQLUnionType, GraphQLUnionType,
} from 'graphql'; } from 'graphql';
import { toGlobalId } from 'graphql-relay'; import { toGlobalId } from 'graphql-relay';
import { GraphQLUpload } from '@graphql-tools/links';
class TypeValidationError extends Error { class TypeValidationError extends Error {
constructor(value, type) { constructor(value, type) {
@@ -223,6 +222,11 @@ const DATE = new GraphQLScalarType({
}, },
}); });
const GraphQLUpload = new GraphQLScalarType({
name: 'Upload',
description: 'The Upload scalar type represents a file upload.',
});
const BYTES = new GraphQLScalarType({ const BYTES = new GraphQLScalarType({
name: 'Bytes', name: 'Bytes',
description: description:
@@ -1265,6 +1269,7 @@ const load = parseGraphQLSchema => {
}; };
export { export {
GraphQLUpload,
TypeValidationError, TypeValidationError,
parseStringValue, parseStringValue,
parseIntValue, parseIntValue,

View File

@@ -1,43 +1,33 @@
import { GraphQLNonNull } from 'graphql'; import { GraphQLNonNull } from 'graphql';
import { mutationWithClientMutationId } from 'graphql-relay'; import { mutationWithClientMutationId } from 'graphql-relay';
import { GraphQLUpload } from '@graphql-tools/links';
import Parse from 'parse/node'; import Parse from 'parse/node';
import * as defaultGraphQLTypes from './defaultGraphQLTypes'; import * as defaultGraphQLTypes from './defaultGraphQLTypes';
import logger from '../../logger'; import logger from '../../logger';
const handleUpload = async (upload, config) => { const handleUpload = async (upload, config) => {
const { createReadStream, filename, mimetype } = await upload; const data = Buffer.from(await upload.arrayBuffer());
let data = null; const fileName = upload.name;
if (createReadStream) { const type = upload.type;
const stream = createReadStream();
data = await new Promise((resolve, reject) => {
const chunks = [];
stream
.on('error', reject)
.on('data', chunk => chunks.push(chunk))
.on('end', () => resolve(Buffer.concat(chunks)));
});
}
if (!data || !data.length) { if (!data || !data.length) {
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'); throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
} }
if (filename.length > 128) { if (fileName.length > 128) {
throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.'); throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.');
} }
if (!filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { if (!fileName.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) {
throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.'); throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.');
} }
try { try {
return { return {
fileInfo: await config.filesController.createFile(config, filename, data, mimetype), fileInfo: await config.filesController.createFile(config, fileName, data, type),
}; };
} catch (e) { } catch (e) {
logger.error('Error creating a file: ', e); logger.error('Error creating a file: ', e);
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Could not store file: ${filename}.`); throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Could not store file: ${fileName}.`);
} }
}; };
@@ -48,7 +38,7 @@ const load = parseGraphQLSchema => {
inputFields: { inputFields: {
upload: { upload: {
description: 'This is the new file to be created and uploaded.', description: 'This is the new file to be created and uploaded.',
type: new GraphQLNonNull(GraphQLUpload), type: new GraphQLNonNull(defaultGraphQLTypes.GraphQLUpload),
}, },
}, },
outputFields: { outputFields: {

View File

@@ -1,5 +1,5 @@
import Parse from 'parse/node'; import Parse from 'parse/node';
import { ApolloError } from 'apollo-server-core'; import { GraphQLYogaError } from '@graphql-yoga/node';
export function enforceMasterKeyAccess(auth) { export function enforceMasterKeyAccess(auth) {
if (!auth.isMaster) { if (!auth.isMaster) {
@@ -16,7 +16,7 @@ export function toGraphQLError(error) {
code = Parse.Error.INTERNAL_SERVER_ERROR; code = Parse.Error.INTERNAL_SERVER_ERROR;
message = 'Internal server error'; message = 'Internal server error';
} }
return new ApolloError(message, code); return new GraphQLYogaError(message, { code });
} }
export const extractKeysAndInclude = selectedFields => { export const extractKeysAndInclude = selectedFields => {