feat: Switch GraphQL server from Yoga v2 to Apollo v4 (#8959)
This commit is contained in:
1361
package-lock.json
generated
1361
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user