Skip to content

Commit 4cdda82

Browse files
committed
feat: add response argument to cloud functions for HTTP status and headers
1 parent 40040fb commit 4cdda82

File tree

3 files changed

+192
-1
lines changed

3 files changed

+192
-1
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,55 @@ Logs are also viewable in Parse Dashboard.
771771
772772
**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
773773
774+
## Cloud Functions HTTP Response
775+
776+
Cloud functions support an Express-like `(req, res)` pattern to customize HTTP response status codes and headers.
777+
778+
### Basic Usage
779+
780+
```js
781+
// Set custom status code
782+
Parse.Cloud.define('createItem', (req, res) => {
783+
res.status(201);
784+
return { id: 'abc123', message: 'Created' };
785+
});
786+
787+
// Set custom headers
788+
Parse.Cloud.define('apiEndpoint', (req, res) => {
789+
res.set('X-Request-Id', 'req-123');
790+
res.set('Cache-Control', 'no-cache');
791+
return { success: true };
792+
});
793+
794+
// Chain methods
795+
Parse.Cloud.define('authenticate', (req, res) => {
796+
if (!isValid(req.params.token)) {
797+
res.status(401).set('WWW-Authenticate', 'Bearer');
798+
return { error: 'Unauthorized' };
799+
}
800+
return { user: 'john' };
801+
});
802+
```
803+
804+
### Response Methods
805+
806+
| Method | Description |
807+
|--------|-------------|
808+
| `res.status(code)` | Set HTTP status code (e.g., 201, 400, 404). Returns `res` for chaining. |
809+
| `res.set(name, value)` | Set HTTP header. Returns `res` for chaining. |
810+
811+
### Backwards Compatibility
812+
813+
The `res` argument is optional. Existing cloud functions using only `(req) => {}` continue to work unchanged.
814+
815+
### Security Considerations
816+
817+
The `set()` method allows setting arbitrary HTTP headers. Be cautious when setting security-sensitive headers such as:
818+
- CORS headers (`Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`)
819+
- `Set-Cookie`
820+
- `Location` (redirects)
821+
- Authentication headers (`WWW-Authenticate`)
822+
774823
# Deprecations
775824
776825
See the [Deprecation Plan](https://github.com/parse-community/parse-server/blob/master/DEPRECATIONS.md) for an overview of deprecations and planned breaking changes.

spec/CloudCode.spec.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,142 @@ describe('Cloud Code', () => {
184184
expect(response.data.result.result).toEqual('this should be the result');
185185
});
186186

187+
it('res.status() called multiple times uses last value', async () => {
188+
Parse.Cloud.define('multipleStatus', (req, res) => {
189+
res.status(201);
190+
res.status(202);
191+
res.status(203);
192+
return { message: 'ok' };
193+
});
194+
195+
const response = await request({
196+
method: 'POST',
197+
url: 'http://localhost:8378/1/functions/multipleStatus',
198+
headers: {
199+
'X-Parse-Application-Id': 'test',
200+
'X-Parse-REST-API-Key': 'rest',
201+
'Content-Type': 'application/json',
202+
},
203+
body: {},
204+
});
205+
206+
expect(response.status).toEqual(203);
207+
});
208+
209+
it('res.set() called multiple times for same header uses last value', async () => {
210+
Parse.Cloud.define('multipleHeaders', (req, res) => {
211+
res.set('X-Custom-Header', 'first');
212+
res.set('X-Custom-Header', 'second');
213+
res.set('X-Custom-Header', 'third');
214+
return { message: 'ok' };
215+
});
216+
217+
const response = await request({
218+
method: 'POST',
219+
url: 'http://localhost:8378/1/functions/multipleHeaders',
220+
headers: {
221+
'X-Parse-Application-Id': 'test',
222+
'X-Parse-REST-API-Key': 'rest',
223+
'Content-Type': 'application/json',
224+
},
225+
body: {},
226+
});
227+
228+
expect(response.headers['x-custom-header']).toEqual('third');
229+
});
230+
231+
it('res.status() throws error for non-number status code', async () => {
232+
Parse.Cloud.define('invalidStatusType', (req, res) => {
233+
res.status('200');
234+
return { message: 'ok' };
235+
});
236+
237+
try {
238+
await request({
239+
method: 'POST',
240+
url: 'http://localhost:8378/1/functions/invalidStatusType',
241+
headers: {
242+
'X-Parse-Application-Id': 'test',
243+
'X-Parse-REST-API-Key': 'rest',
244+
'Content-Type': 'application/json',
245+
},
246+
body: {},
247+
});
248+
fail('Expected request to fail');
249+
} catch (response) {
250+
expect(response.status).toEqual(400);
251+
}
252+
});
253+
254+
it('res.set() throws error for non-string header name', async () => {
255+
Parse.Cloud.define('invalidHeaderName', (req, res) => {
256+
res.set(123, 'value');
257+
return { message: 'ok' };
258+
});
259+
260+
try {
261+
await request({
262+
method: 'POST',
263+
url: 'http://localhost:8378/1/functions/invalidHeaderName',
264+
headers: {
265+
'X-Parse-Application-Id': 'test',
266+
'X-Parse-REST-API-Key': 'rest',
267+
'Content-Type': 'application/json',
268+
},
269+
body: {},
270+
});
271+
fail('Expected request to fail');
272+
} catch (response) {
273+
expect(response.status).toEqual(400);
274+
}
275+
});
276+
277+
it('res.status() throws error for out of range status code', async () => {
278+
Parse.Cloud.define('outOfRangeStatus', (req, res) => {
279+
res.status(50);
280+
return { message: 'ok' };
281+
});
282+
283+
try {
284+
await request({
285+
method: 'POST',
286+
url: 'http://localhost:8378/1/functions/outOfRangeStatus',
287+
headers: {
288+
'X-Parse-Application-Id': 'test',
289+
'X-Parse-REST-API-Key': 'rest',
290+
'Content-Type': 'application/json',
291+
},
292+
body: {},
293+
});
294+
fail('Expected request to fail');
295+
} catch (response) {
296+
expect(response.status).toEqual(400);
297+
}
298+
});
299+
300+
it('res.set() throws error for undefined header value', async () => {
301+
Parse.Cloud.define('undefinedHeaderValue', (req, res) => {
302+
res.set('X-Custom-Header', undefined);
303+
return { message: 'ok' };
304+
});
305+
306+
try {
307+
await request({
308+
method: 'POST',
309+
url: 'http://localhost:8378/1/functions/undefinedHeaderValue',
310+
headers: {
311+
'X-Parse-Application-Id': 'test',
312+
'X-Parse-REST-API-Key': 'rest',
313+
'Content-Type': 'application/json',
314+
},
315+
body: {},
316+
});
317+
fail('Expected request to fail');
318+
} catch (response) {
319+
expect(response.status).toEqual(400);
320+
}
321+
});
322+
187323
it('can get config', () => {
188324
const config = Parse.Server;
189325
let currentConfig = Config.get('test');

src/Routers/FunctionsRouter.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class CloudResponse {
1919
if (typeof code !== 'number') {
2020
throw new Error('Status code must be a number');
2121
}
22+
if (code < 100 || code > 599) {
23+
throw new Error('Status code must be between 100 and 599');
24+
}
2225
this._status = code;
2326
return this;
2427
}
@@ -27,6 +30,9 @@ class CloudResponse {
2730
if (typeof name !== 'string') {
2831
throw new Error('Header name must be a string');
2932
}
33+
if (value === undefined || value === null) {
34+
throw new Error('Header value must be defined');
35+
}
3036
this._headers[name] = value;
3137
return this;
3238
}
@@ -153,7 +159,7 @@ export class FunctionsRouter extends PromiseRouter {
153159
response.status = status;
154160
}
155161
if (Object.keys(headers).length > 0) {
156-
response.headers = headers;
162+
response.headers = { ...headers };
157163
}
158164
}
159165
resolve(response);

0 commit comments

Comments
 (0)