feat: Switch GraphQL server from Yoga v2 to Apollo v4 (#8959)

This commit is contained in:
Onur
2024-03-02 04:06:47 +03:00
committed by GitHub
parent 01c97f7ab7
commit 105ae7c8a5
7 changed files with 1033 additions and 564 deletions

1361
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,11 @@
], ],
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@apollo/server": "4.9.2",
"@babel/eslint-parser": "7.21.8", "@babel/eslint-parser": "7.21.8",
"@graphql-tools/merge": "8.4.1", "@graphql-tools/merge": "8.4.1",
"@graphql-tools/schema": "9.0.4", "@graphql-tools/schema": "9.0.4",
"@graphql-tools/utils": "8.12.0", "@graphql-tools/utils": "8.12.0",
"@graphql-yoga/node": "2.6.0",
"@parse/fs-files-adapter": "2.0.1", "@parse/fs-files-adapter": "2.0.1",
"@parse/push-adapter": "5.0.2", "@parse/push-adapter": "5.0.2",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
@@ -38,6 +38,7 @@
"graphql-list-fields": "2.0.2", "graphql-list-fields": "2.0.2",
"graphql-relay": "0.10.0", "graphql-relay": "0.10.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"graphql-upload": "15.0.2",
"intersect": "1.0.1", "intersect": "1.0.1",
"jsonwebtoken": "9.0.0", "jsonwebtoken": "9.0.0",
"jwks-rsa": "3.1.0", "jwks-rsa": "3.1.0",

View File

@@ -49,7 +49,9 @@ describe('ParseGraphQLServer', () => {
let parseGraphQLServer; let parseGraphQLServer;
beforeEach(async () => { beforeEach(async () => {
parseServer = await global.reconfigureServer({}); parseServer = await global.reconfigureServer({
maxUploadSize: '1kb',
});
parseGraphQLServer = new ParseGraphQLServer(parseServer, { parseGraphQLServer = new ParseGraphQLServer(parseServer, {
graphQLPath: '/graphql', graphQLPath: '/graphql',
playgroundPath: '/playground', playgroundPath: '/playground',
@@ -122,15 +124,16 @@ describe('ParseGraphQLServer', () => {
info: new Object(), info: new Object(),
config: new Object(), config: new Object(),
auth: new Object(), auth: new Object(),
get: () => {},
};
const res = {
set: () => {},
}; };
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(); 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);
const contextResponse = options.context({ req }); const contextResponse = await options.context({ req, res });
expect(contextResponse.info).toEqual(req.info); expect(contextResponse.info).toEqual(req.info);
expect(contextResponse.config).toEqual(req.config); expect(contextResponse.config).toEqual(req.config);
expect(contextResponse.auth).toEqual(req.auth); expect(contextResponse.auth).toEqual(req.auth);
@@ -9340,7 +9343,6 @@ describe('ParseGraphQLServer', () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
const result = JSON.parse(await res.text()); const result = JSON.parse(await res.text());
expect(result.data.createFile.fileInfo.name).toEqual( expect(result.data.createFile.fileInfo.name).toEqual(
jasmine.stringMatching(/_myFileName.txt$/) jasmine.stringMatching(/_myFileName.txt$/)
); );
@@ -9654,9 +9656,61 @@ describe('ParseGraphQLServer', () => {
).toEqual(jasmine.stringMatching(/_someRelationField.txt$/)); ).toEqual(jasmine.stringMatching(/_someRelationField.txt$/));
}); });
it('should not upload if file is too large', async () => { it('should support files and add extension from mimetype', async () => {
parseGraphQLServer.parseServer.config.maxUploadSize = '1kb'; try {
parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
});
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', 'My File Content', {
// No extension, the system should add it from mimetype
filename: 'myFileName',
contentType: 'text/plain',
});
const res = await fetch('http://localhost:13377/graphql', {
method: 'POST',
headers,
body,
});
expect(res.status).toEqual(200);
const result = JSON.parse(await res.text());
expect(result.data.createFile.fileInfo.name).toEqual(
jasmine.stringMatching(/_myFileName.txt$/)
);
expect(result.data.createFile.fileInfo.url).toEqual(
jasmine.stringMatching(/_myFileName.txt$/)
);
} catch (e) {
handleError(e);
}
});
it('should not upload if file is too large', async () => {
const body = new FormData(); const body = new FormData();
body.append( body.append(
'operations', 'operations',
@@ -9681,6 +9735,7 @@ describe('ParseGraphQLServer', () => {
body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); body.append('map', JSON.stringify({ 1: ['variables.input.upload'] }));
body.append( body.append(
'1', '1',
// In this test file parse server is setup with 1kb limit
Buffer.alloc(parseGraphQLServer._transformMaxUploadSizeToBytes('2kb'), 1), Buffer.alloc(parseGraphQLServer._transformMaxUploadSizeToBytes('2kb'), 1),
{ {
filename: 'myFileName.txt', filename: 'myFileName.txt',
@@ -9695,8 +9750,10 @@ describe('ParseGraphQLServer', () => {
}); });
const result = JSON.parse(await res.text()); const result = JSON.parse(await res.text());
expect(res.status).toEqual(500); expect(res.status).toEqual(200);
expect(result.errors[0].message).toEqual('File size limit exceeded: 1024 bytes'); expect(result.errors[0].message).toEqual(
'File truncated as it exceeds the 1024 byte size limit.'
);
}); });
it('should support object values', async () => { it('should support object values', async () => {

View File

@@ -1,5 +1,9 @@
import corsMiddleware from 'cors'; import corsMiddleware from 'cors';
import { createServer, renderGraphiQL } from '@graphql-yoga/node'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
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, handleParseSession } from '../middlewares'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
@@ -33,16 +37,13 @@ class ParseGraphQLServer {
try { try {
return { return {
schema: await this.parseGraphQLSchema.load(), schema: await this.parseGraphQLSchema.load(),
context: ({ req: { info, config, auth } }) => ({ context: async ({ req, res }) => {
info, res.set('access-control-allow-origin', req.get('origin') || '*');
config, return {
auth, info: req.info,
}), config: req.config,
maskedErrors: false, auth: req.auth,
multipart: { };
fileSize: this._transformMaxUploadSizeToBytes(
this.parseServer.config.maxUploadSize || '20mb'
),
}, },
}; };
} catch (e) { } catch (e) {
@@ -57,8 +58,21 @@ class ParseGraphQLServer {
if (schemaRef === newSchemaRef && this._server) { if (schemaRef === newSchemaRef && this._server) {
return this._server; return this._server;
} }
const options = await this._getGraphQLOptions(); const { schema, context } = await this._getGraphQLOptions();
this._server = createServer(options); const apollo = new ApolloServer({
csrfPrevention: {
// See https://www.apollographql.com/docs/router/configuration/csrf/
// needed since we use graphql upload
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: true,
plugins: [ApolloServerPluginCacheControlDisabled()],
schema,
});
await apollo.start();
this._server = expressMiddleware(apollo, {
context,
});
return this._server; return this._server;
} }
@@ -79,14 +93,21 @@ class ParseGraphQLServer {
if (!app || !app.use) { if (!app || !app.use) {
requiredParameter('You must provide an Express.js app instance!'); requiredParameter('You must provide an Express.js app instance!');
} }
app.use(this.config.graphQLPath, corsMiddleware()); app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, handleParseHeaders); app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession); app.use(this.config.graphQLPath, handleParseSession);
app.use(this.config.graphQLPath, handleParseErrors); app.use(this.config.graphQLPath, handleParseErrors);
app.use(this.config.graphQLPath, async (req, res) => { app.use(
this.config.graphQLPath,
graphqlUploadExpress({
maxFileSize: this._transformMaxUploadSizeToBytes(
this.parseServer.config.maxUploadSize || '20mb'
),
})
);
app.use(this.config.graphQLPath, express.json(), async (req, res, next) => {
const server = await this._getServer(); const server = await this._getServer();
return server(req, res); return server(req, res, next);
}); });
} }
@@ -94,20 +115,33 @@ class ParseGraphQLServer {
if (!app || !app.get) { if (!app || !app.get) {
requiredParameter('You must provide an Express.js app instance!'); requiredParameter('You must provide an Express.js app instance!');
} }
app.get( app.get(
this.config.playgroundPath || this.config.playgroundPath ||
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'), requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
(_req, res) => { (_req, res) => {
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
res.write( res.write(
renderGraphiQL({ `<div id="sandbox" style="position:absolute;top:0;right:0;bottom:0;left:0"></div>
endpoint: this.config.graphQLPath, <script src="https://embeddable-sandbox.cdn.apollographql.com/_latest/embeddable-sandbox.umd.production.min.js"></script>
subscriptionEndpoint: this.config.subscriptionsPath, <script>
headers: JSON.stringify({ new window.EmbeddedSandbox({
'X-Parse-Application-Id': this.parseServer.config.appId, target: "#sandbox",
'X-Parse-Master-Key': this.parseServer.config.masterKey, endpointIsEditable: false,
}), initialEndpoint: "${JSON.stringify(this.config.graphQLPath)}",
}) handleRequest: (endpointUrl, options) => {
return fetch(endpointUrl, {
...options,
headers: {
...options.headers,
'X-Parse-Application-Id': "${JSON.stringify(this.parseServer.config.appId)}",
'X-Parse-Master-Key': "${JSON.stringify(this.parseServer.config.masterKey)}",
},
})
},
});
// advanced options: https://www.apollographql.com/docs/studio/explorer/sandbox#embedding-sandbox
</script>`
); );
res.end(); res.end();
} }

View File

@@ -15,6 +15,7 @@ import {
GraphQLUnionType, GraphQLUnionType,
} from 'graphql'; } from 'graphql';
import { toGlobalId } from 'graphql-relay'; import { toGlobalId } from 'graphql-relay';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
class TypeValidationError extends Error { class TypeValidationError extends Error {
constructor(value, type) { constructor(value, type) {
@@ -222,11 +223,6 @@ 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:

View File

@@ -1,33 +1,61 @@
import { GraphQLNonNull } from 'graphql'; import { GraphQLNonNull } from 'graphql';
import { request } from 'http';
import { getExtension } from 'mime';
import { mutationWithClientMutationId } from 'graphql-relay'; import { mutationWithClientMutationId } from 'graphql-relay';
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';
// Handle GraphQL file upload and proxy file upload to GraphQL server url specified in config;
// `createFile` is not directly called by Parse Server to leverage standard file upload mechanism
const handleUpload = async (upload, config) => { const handleUpload = async (upload, config) => {
const data = Buffer.from(await upload.arrayBuffer()); const { createReadStream, filename, mimetype } = await upload;
const fileName = upload.name; const headers = { ...config.headers };
const type = upload.type; delete headers['accept-encoding'];
delete headers['accept'];
if (!data || !data.length) { delete headers['connection'];
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'); delete headers['host'];
} delete headers['content-length'];
const stream = createReadStream();
if (fileName.length > 128) {
throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.');
}
if (!fileName.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) {
throw new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.');
}
try { try {
const ext = getExtension(mimetype);
const fullFileName = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`;
const serverUrl = new URL(config.serverURL);
const fileInfo = await new Promise((resolve, reject) => {
const req = request(
{
hostname: serverUrl.hostname,
port: serverUrl.port,
path: `${serverUrl.pathname}/files/${fullFileName}`,
method: 'POST',
headers,
},
res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Parse.Error(Parse.error, data));
}
});
}
);
stream.pipe(req);
stream.on('end', () => {
req.end();
});
});
return { return {
fileInfo: await config.filesController.createFile(config, fileName, data, type), fileInfo,
}; };
} catch (e) { } catch (e) {
stream.destroy();
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}.`);
} }
}; };

View File

@@ -1,5 +1,5 @@
import Parse from 'parse/node'; import Parse from 'parse/node';
import { GraphQLYogaError } from '@graphql-yoga/node'; import { GraphQLError } from 'graphql';
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 GraphQLYogaError(message, { code }); return new GraphQLError(message, { extensions: { code } });
} }
export const extractKeysAndInclude = selectedFields => { export const extractKeysAndInclude = selectedFields => {