diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 400efbc380..685210b4e1 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -4788,4 +4788,231 @@ describe('beforePasswordResetRequest hook', () => { Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { }); }).not.toThrow(); }); + + describe('Express-style cloud functions with (req, res) parameters', () => { + it('should support express-style cloud function with res.success()', async () => { + Parse.Cloud.define('expressStyleFunction', (req, res) => { + res.success({ message: 'Hello from express style!' }); + }); + + const result = await Parse.Cloud.run('expressStyleFunction', {}); + expect(result.message).toEqual('Hello from express style!'); + }); + + it('should support express-style cloud function with res.error()', async () => { + Parse.Cloud.define('expressStyleError', (req, res) => { + res.error('Custom error message'); + }); + + await expectAsync(Parse.Cloud.run('expressStyleError', {})).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Custom error message') + ); + }); + + it('should support setting custom HTTP status code with res.status().success()', async () => { + Parse.Cloud.define('customStatusCode', (req, res) => { + res.status(201).success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.data.result.created).toBe(true); + }); + + it('should support 401 unauthorized status code with error', async () => { + Parse.Cloud.define('unauthorizedFunction', (req, res) => { + if (!req.user) { + res.status(401).error('Unauthorized access'); + } else { + res.success({ message: 'Authorized' }); + } + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/unauthorizedFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should support 404 not found status code with error', async () => { + Parse.Cloud.define('notFoundFunction', (req, res) => { + res.status(404).error('Resource not found'); + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/notFoundFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should default to 200 status code when not specified', async () => { + Parse.Cloud.define('defaultStatusCode', (req, res) => { + res.success({ message: 'Default status' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/defaultStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.data.result.message).toBe('Default status'); + }); + + it('should maintain backward compatibility with single-parameter functions', async () => { + Parse.Cloud.define('traditionalFunction', (req) => { + return { message: 'Traditional style works!' }; + }); + + const result = await Parse.Cloud.run('traditionalFunction', {}); + expect(result.message).toEqual('Traditional style works!'); + }); + + it('should maintain backward compatibility with implicit return functions', async () => { + Parse.Cloud.define('implicitReturnFunction', () => 'Implicit return works!'); + + const result = await Parse.Cloud.run('implicitReturnFunction', {}); + expect(result).toEqual('Implicit return works!'); + }); + + it('should support async express-style functions', async () => { + Parse.Cloud.define('asyncExpressStyle', async (req, res) => { + await new Promise(resolve => setTimeout(resolve, 10)); + res.success({ async: true }); + }); + + const result = await Parse.Cloud.run('asyncExpressStyle', {}); + expect(result.async).toBe(true); + }); + + it('should access request parameters in express-style functions', async () => { + Parse.Cloud.define('expressWithParams', (req, res) => { + const { name } = req.params; + res.success({ greeting: `Hello, ${name}!` }); + }); + + const result = await Parse.Cloud.run('expressWithParams', { name: 'World' }); + expect(result.greeting).toEqual('Hello, World!'); + }); + + it('should access user in express-style functions', async () => { + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + + Parse.Cloud.define('expressWithUser', (req, res) => { + if (req.user) { + res.success({ username: req.user.get('username') }); + } else { + res.status(401).error('Not authenticated'); + } + }); + + const result = await Parse.Cloud.run('expressWithUser', {}); + expect(result.username).toEqual('testuser'); + + await Parse.User.logOut(); + }); + + it('should support setting custom headers with res.header()', async () => { + Parse.Cloud.define('customHeaderFunction', (req, res) => { + res.header('X-Custom-Header', 'custom-value').success({ message: 'OK' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.data.result.message).toBe('OK'); + }); + + it('should support setting multiple custom headers', async () => { + Parse.Cloud.define('multipleHeadersFunction', (req, res) => { + res.header('X-Header-One', 'value1') + .header('X-Header-Two', 'value2') + .success({ message: 'Multiple headers' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/multipleHeadersFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-header-one']).toBe('value1'); + expect(response.headers['x-header-two']).toBe('value2'); + expect(response.data.result.message).toBe('Multiple headers'); + }); + + it('should support combining status code and custom headers', async () => { + Parse.Cloud.define('statusAndHeaderFunction', (req, res) => { + res.status(201) + .header('X-Resource-Id', '12345') + .success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/statusAndHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.headers['x-resource-id']).toBe('12345'); + expect(response.data.result.created).toBe(true); + }); + }); }); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 9720e4679c..93183f6f76 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -103,20 +103,52 @@ export class FunctionsRouter extends PromiseRouter { }); } - static createResponseObject(resolve, reject) { - return { + static createResponseObject(resolve, reject, statusCode = null) { + let httpStatusCode = statusCode; + const customHeaders = {}; + let responseSent = false; + const responseObject = { success: function (result) { - resolve({ + if (responseSent) { + throw new Error('Cannot call success() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); + } + responseSent = true; + const response = { response: { result: Parse._encode(result), }, - }); + }; + if (httpStatusCode !== null) { + response.status = httpStatusCode; + } + if (Object.keys(customHeaders).length > 0) { + response.headers = customHeaders; + } + resolve(response); }, error: function (message) { + if (responseSent) { + throw new Error('Cannot call error() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); + } + responseSent = true; const error = triggers.resolveError(message); + // If a custom status code was set, attach it to the error + if (httpStatusCode !== null) { + error.status = httpStatusCode; + } reject(error); }, + status: function (code) { + httpStatusCode = code; + return responseObject; + }, + header: function (key, value) { + customHeaders[key] = value; + return responseObject; + }, + _isResponseSent: () => responseSent, }; + return responseObject; } static handleCloudFunction(req) { const functionName = req.params.functionName; @@ -143,7 +175,7 @@ export class FunctionsRouter extends PromiseRouter { return new Promise(function (resolve, reject) { const userString = req.auth && req.auth.user ? req.auth.user.id : undefined; - const { success, error } = FunctionsRouter.createResponseObject( + const responseObject = FunctionsRouter.createResponseObject( result => { try { if (req.config.logLevels.cloudFunctionSuccess !== 'silent') { @@ -184,14 +216,37 @@ export class FunctionsRouter extends PromiseRouter { } } ); + const { success, error } = responseObject; + return Promise.resolve() .then(() => { return triggers.maybeRunValidator(request, functionName, req.auth); }) .then(() => { - return theFunction(request); + // Check if function expects 2 parameters (req, res) - Express style + if (theFunction.length >= 2) { + return theFunction(request, responseObject); + } else { + // Traditional style - single parameter + return theFunction(request); + } }) - .then(success, error); + .then(result => { + // For Express-style functions, only send response if not already sent + if (theFunction.length >= 2) { + if (!responseObject._isResponseSent()) { + // If Express-style function returns a value without calling res.success/error + if (result !== undefined) { + success(result); + } + // If no response sent and no value returned, this is an error in user code + // but we don't handle it here to maintain backward compatibility + } + } else { + // For traditional functions, always call success with the result (even if undefined) + success(result); + } + }, error); }); } } diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 3fc38c3aad..5c1a2b156c 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -107,22 +107,49 @@ var ParseCloud = {}; * * **Available in Cloud Code only.** * + * **Traditional Style:** * ``` * Parse.Cloud.define('functionName', (request) => { * // code here + * return result; * }, (request) => { * // validation code here * }); * * Parse.Cloud.define('functionName', (request) => { * // code here + * return result; * }, { ...validationObject }); * ``` * + * **Express Style with Custom HTTP Status Codes:** + * ``` + * Parse.Cloud.define('functionName', (request, response) => { + * // Set custom HTTP status code and send response + * response.status(201).success({ message: 'Created' }); + * }); + * + * Parse.Cloud.define('unauthorizedFunction', (request, response) => { + * if (!request.user) { + * response.status(401).error('Unauthorized'); + * } else { + * response.success({ data: 'OK' }); + * } + * }); + * + * Parse.Cloud.define('withCustomHeaders', (request, response) => { + * response.header('X-Custom-Header', 'value').success({ data: 'OK' }); + * }); + * + * Parse.Cloud.define('errorFunction', (request, response) => { + * response.error('Something went wrong'); + * }); + * ``` + * * @static * @memberof Parse.Cloud * @param {String} name The name of the Cloud Function - * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}. + * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or two parameters (request, response) for Express-style functions where response is a {@link Parse.Cloud.FunctionResponse}. * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.define = function (functionName, handler, validationHandler) { @@ -788,9 +815,22 @@ module.exports = ParseCloud; * @property {Boolean} master If true, means the master key was used. * @property {Parse.User} user If set, the user that made the request. * @property {Object} params The params passed to the cloud function. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {Object} log The current logger inside Parse Server. + * @property {String} functionName The name of the cloud function. + * @property {Object} context The context of the cloud function call. * @property {Object} config The Parse Server config. */ +/** + * @interface Parse.Cloud.FunctionResponse + * @property {function} success Call this function to return a successful response with an optional result. Usage: `response.success(result)` + * @property {function} error Call this function to return an error response with an error message. Usage: `response.error(message)` + * @property {function} status Call this function to set a custom HTTP status code for the response. Returns the response object for chaining. Usage: `response.status(code).success(result)` or `response.status(code).error(message)` + * @property {function} header Call this function to set a custom HTTP header for the response. Returns the response object for chaining. Usage: `response.header('X-Custom-Header', 'value').success(result)` + */ + /** * @interface Parse.Cloud.JobRequest * @property {Object} params The params passed to the background job.