diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js index 389f9f740a..a8636082b0 100644 --- a/spec/ParseServerRESTController.spec.js +++ b/spec/ParseServerRESTController.spec.js @@ -1,7 +1,8 @@ -const ParseServerRESTController = require('../lib/ParseServerRESTController') - .ParseServerRESTController; +const ParseServerRESTController = + require('../lib/ParseServerRESTController').ParseServerRESTController; const ParseServer = require('../lib/ParseServer').default; const Parse = require('parse/node').Parse; +const request = require('../lib/request'); let RESTController; @@ -163,9 +164,7 @@ describe('ParseServerRESTController', () => { const results = await query.find(); expect(createSpy.calls.count()).toBe(2); for (let i = 0; i + 1 < createSpy.calls.length; i = i + 2) { - expect(createSpy.calls.argsFor(i)[3]).toBe( - createSpy.calls.argsFor(i + 1)[3] - ); + expect(createSpy.calls.argsFor(i)[3]).toBe(createSpy.calls.argsFor(i + 1)[3]); } expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); }); @@ -610,6 +609,129 @@ describe('ParseServerRESTController', () => { } }); + it('should strip undefined values from cloud function responses (with directAccess)', async () => { + Parse.Cloud.define('returnUndefinedValues', () => { + return { + definedKey: 'value', + undefinedKey: undefined, + nested: { a: 1, b: undefined }, + arrayWithUndefined: [1, undefined, 3], + }; + }); + + const res = await RESTController.request( + 'POST', + '/functions/returnUndefinedValues', + {}, + { useMasterKey: true } + ); + + expect(res.result.definedKey).toEqual('value'); + expect(res.result.undefinedKey).toBeUndefined(); + expect(Object.hasOwnProperty.call(res.result, 'undefinedKey')).toBe(false); + expect(res.result.nested.a).toEqual(1); + expect(Object.hasOwnProperty.call(res.result.nested, 'b')).toBe(false); + expect(res.result.arrayWithUndefined).toEqual([1, null, 3]); + }); + + it('should strip undefined values from cloud function responses (without directAccess)', async () => { + Parse.Cloud.define('returnUndefinedValuesHTTP', () => { + return { + definedKey: 'value', + undefinedKey: undefined, + nested: { a: 1, b: undefined }, + arrayWithUndefined: [1, undefined, 3], + }; + }); + + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const res = await request({ + method: 'POST', + headers, + url: `${serverURL}/functions/returnUndefinedValuesHTTP`, + body: {}, + }); + + const result = res.data.result; + + expect(result.definedKey).toEqual('value'); + expect(result.undefinedKey).toBeUndefined(); + expect(Object.hasOwnProperty.call(result, 'undefinedKey')).toBe(false); + expect(result.nested.a).toEqual(1); + expect(Object.hasOwnProperty.call(result.nested, 'b')).toBe(false); + expect(result.arrayWithUndefined).toEqual([1, null, 3]); + }); + + it('should call toJSON() on cloud function results with a custom toJSON (with directAccess)', async () => { + Parse.Cloud.define('returnCustomToJSON', () => { + return { + raw: 'should be replaced', + undefinedKey: undefined, + toJSON() { + return { serialized: true, undefinedKey: undefined, nested: { b: undefined, c: 2 } }; + }, + }; + }); + + const res = await RESTController.request( + 'POST', + '/functions/returnCustomToJSON', + {}, + { useMasterKey: true } + ); + + // toJSON() must be invoked (matching JSON.stringify in HTTP mode), not treated + // as a plain record, and undefined keys inside its result must be stripped. + expect(res.result.serialized).toEqual(true); + expect(res.result.raw).toBeUndefined(); + expect(Object.hasOwnProperty.call(res.result, 'raw')).toBe(false); + expect(Object.hasOwnProperty.call(res.result, 'undefinedKey')).toBe(false); + expect(Object.hasOwnProperty.call(res.result, 'toJSON')).toBe(false); + expect(res.result.nested.c).toEqual(2); + expect(Object.hasOwnProperty.call(res.result.nested, 'b')).toBe(false); + }); + + it('should call toJSON() on cloud function results with a custom toJSON (without directAccess)', async () => { + Parse.Cloud.define('returnCustomToJSONHTTP', () => { + return { + raw: 'should be replaced', + undefinedKey: undefined, + toJSON() { + return { serialized: true, undefinedKey: undefined, nested: { b: undefined, c: 2 } }; + }, + }; + }); + + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const res = await request({ + method: 'POST', + headers, + url: `${serverURL}/functions/returnCustomToJSONHTTP`, + body: {}, + }); + + const result = res.data.result; + expect(result.serialized).toEqual(true); + expect(result.raw).toBeUndefined(); + expect(Object.hasOwnProperty.call(result, 'raw')).toBe(false); + expect(Object.hasOwnProperty.call(result, 'undefinedKey')).toBe(false); + expect(Object.hasOwnProperty.call(result, 'toJSON')).toBe(false); + expect(result.nested.c).toEqual(2); + expect(Object.hasOwnProperty.call(result.nested, 'b')).toBe(false); + }); + it('ensures sessionTokens are properly handled', async () => { const user = await Parse.User.signUp('user', 'pass'); const sessionToken = user.getSessionToken(); @@ -750,16 +872,23 @@ describe('ParseServerRESTController', () => { data: { alert: 'We return status!' }, where: { deviceType: 'ios' }, }; - const res = await RESTController.request('POST', 'batch', { - requests: [{ - method: 'POST', - path: '/push', - body: payload, - }], - }, { - useMasterKey: true, - returnStatus: true, - }); + const res = await RESTController.request( + 'POST', + 'batch', + { + requests: [ + { + method: 'POST', + path: '/push', + body: payload, + }, + ], + }, + { + useMasterKey: true, + returnStatus: true, + } + ); const pushStatusId = res[0]._headers['X-Parse-Push-Status-Id']; expect(pushStatusId).toBeDefined(); @@ -767,6 +896,192 @@ describe('ParseServerRESTController', () => { expect(result.id).toBe(pushStatusId); }); + it('should strip undefined values from GET query params with directAccess', async () => { + let capturedWhere; + Parse.Cloud.beforeFind('QueryStripObject', req => { + capturedWhere = req.query.toJSON().where; + }); + + const uniqueValue = `directAccess_${Date.now()}`; + await RESTController.request('POST', '/classes/QueryStripObject', { + presentField: uniqueValue, + absentField: undefined, + }); + + // If undefined values in "where" are not stripped, they would become null + // and the query would filter for absentField: null, failing to match the object. + const getRes = await RESTController.request('GET', '/classes/QueryStripObject', { + where: { presentField: uniqueValue, absentField: undefined }, + }); + + expect(getRes.results.length).toBe(1); + expect(getRes.results[0].presentField).toBe(uniqueValue); + expect(capturedWhere.presentField).toBe(uniqueValue); + expect('absentField' in capturedWhere).toBe(false); + }); + + it('should strip undefined values from GET query params without directAccess (HTTP mode)', async () => { + let capturedWhere; + Parse.Cloud.beforeFind('QueryStripObjectHTTP', req => { + capturedWhere = req.query.toJSON().where; + }); + + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + const uniqueValue = `httpMode_${Date.now()}`; + + await request({ + method: 'POST', + headers, + url: `${serverURL}/classes/QueryStripObjectHTTP`, + body: { presentField: uniqueValue, absentField: undefined }, + }); + + // In HTTP mode, JSON.stringify naturally strips undefined values from the where clause. + const getRes = await request({ + method: 'GET', + headers, + url: `${serverURL}/classes/QueryStripObjectHTTP?where=${encodeURIComponent(JSON.stringify({ presentField: uniqueValue, absentField: undefined }))}`, + }); + + expect(getRes.data.results.length).toBe(1); + expect(getRes.data.results[0].presentField).toBe(uniqueValue); + expect(capturedWhere.presentField).toBe(uniqueValue); + expect('absentField' in capturedWhere).toBe(false); + }); + + it('should not convert undefined values to null on update with directAccess', async () => { + const createRes = await RESTController.request('POST', '/classes/MyObject', { + presentField: 'hello', + }); + expect(createRes.objectId).toBeDefined(); + + await RESTController.request('PUT', `/classes/MyObject/${createRes.objectId}`, { + presentField: 'updated', + absentField: undefined, + nested: { absentField: undefined, presentField: 'value' }, + }); + + const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); + + expect(getRes.presentField).toBe('updated'); + expect(getRes.absentField).toBeUndefined(); + expect('absentField' in getRes).toBe(false); + expect(getRes.nested).toBeDefined(); + expect(getRes.nested.presentField).toBe('value'); + expect('absentField' in getRes.nested).toBe(false); + }); + + it('should not convert undefined values to null on create with directAccess', async () => { + const createRes = await RESTController.request('POST', '/classes/MyObject', { + presentField: 'hello', + absentField: undefined, + }); + expect(createRes.objectId).toBeDefined(); + + const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); + + expect(getRes.presentField).toBe('hello'); + expect(getRes.absentField).toBeUndefined(); + expect('absentField' in getRes).toBe(false); + }); + + it('should not throw when request body is frozen (directAccess)', async () => { + const createRes = await RESTController.request('POST', '/classes/MyObject', { + presentField: 'hello', + }); + expect(createRes.objectId).toBeDefined(); + + const body = Object.freeze({ + presentField: 'updated', + absentField: undefined, + nested: Object.freeze({ absentField: undefined, presentField: 'value' }), + arrayWithUndefined: Object.freeze([1, undefined, 3]), + }); + + await RESTController.request('PUT', `/classes/MyObject/${createRes.objectId}`, body); + + const getRes = await RESTController.request('GET', `/classes/MyObject/${createRes.objectId}`); + + expect(getRes.presentField).toBe('updated'); + expect('absentField' in getRes).toBe(false); + expect(getRes.nested.presentField).toBe('value'); + expect('absentField' in getRes.nested).toBe(false); + expect(getRes.arrayWithUndefined).toEqual([1, null, 3]); + }); + + it('should not convert undefined values to null on update without directAccess (HTTP mode)', async () => { + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const createRes = await request({ + method: 'POST', + headers, + url: `${serverURL}/classes/MyObject`, + body: { presentField: 'hello' }, + }); + expect(createRes.data.objectId).toBeDefined(); + + await request({ + method: 'PUT', + headers, + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, + body: { + presentField: 'updated', + absentField: undefined, + nested: { absentField: undefined, presentField: 'value' }, + }, + }); + + const getRes = await request({ + method: 'GET', + headers, + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, + }); + + expect(getRes.data.presentField).toBe('updated'); + expect(getRes.data.absentField).toBeUndefined(); + expect('absentField' in getRes.data).toBe(false); + expect(getRes.data.nested).toBeDefined(); + expect(getRes.data.nested.presentField).toBe('value'); + expect('absentField' in getRes.data.nested).toBe(false); + }); + + it('should not convert undefined values to null on create without directAccess (HTTP mode)', async () => { + const serverURL = 'http://localhost:8378/1'; + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }; + + const createRes = await request({ + method: 'POST', + headers, + url: `${serverURL}/classes/MyObject`, + body: { presentField: 'hello', absentField: undefined }, + }); + expect(createRes.data.objectId).toBeDefined(); + + const getRes = await request({ + method: 'GET', + headers, + url: `${serverURL}/classes/MyObject/${createRes.data.objectId}`, + }); + + expect(getRes.data.presentField).toBe('hello'); + expect(getRes.data.absentField).toBeUndefined(); + expect('absentField' in getRes.data).toBe(false); + }); + it('handles an object ID without alphanumeric characters', async () => { await reconfigureServer({ allowCustomObjectId: true }); await RESTController.request('POST', '/classes/TestObject', { objectId: '-', key: 'value' }); diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js index 3f1c4dd4bf..de4bbabdc3 100644 --- a/src/ParseServerRESTController.js +++ b/src/ParseServerRESTController.js @@ -29,6 +29,29 @@ function getAuth(options = {}, config) { }); } +function stripUndefined(obj) { + if (obj === null || obj === undefined || typeof obj !== 'object') { + return obj; + } + // Match JSON.stringify semantics: objects with a toJSON() method serialize + // through it before undefined stripping. Date/RegExp are pre-encoded by the + // SDK/router, but a custom toJSON on a cloud function result reaches here + // (FunctionsRouter uses Parse._encode, not JSON.stringify). + if (typeof obj.toJSON === 'function') { + return stripUndefined(obj.toJSON()); + } + if (Array.isArray(obj)) { + return obj.map(item => (item === undefined ? null : stripUndefined(item))); + } + const result = {}; + for (const key of Object.keys(obj)) { + if (obj[key] !== undefined) { + result[key] = stripUndefined(obj[key]); + } + } + return result; +} + function ParseServerRESTController(applicationId, router) { function handleRequest(method, path, data = {}, options = {}, config) { // Store the arguments, for later use if internal fails @@ -125,7 +148,7 @@ function ParseServerRESTController(applicationId, router) { } getAuth(options, config).then(auth => { const request = { - body: data, + body: stripUndefined(data), config, auth, info: { @@ -134,7 +157,7 @@ function ParseServerRESTController(applicationId, router) { installationId: options.installationId, context: requestContext, }, - query, + query: query ? stripUndefined(query) : query, }; return Promise.resolve() .then(() => { @@ -143,10 +166,11 @@ function ParseServerRESTController(applicationId, router) { .then( resp => { const { response, status, headers = {} } = resp; + const strippedResponse = stripUndefined(response); if (options.returnStatus) { - resolve({ ...response, _status: status, _headers: headers }); + resolve({ ...strippedResponse, _status: status, _headers: headers }); } else { - resolve(response); + resolve(strippedResponse); } }, err => {