Skip to content
32 changes: 32 additions & 0 deletions spec/rest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1758,4 +1758,36 @@ describe('rest context', () => {

expect(called).toBe(true);
});

it('should support dependency injection on nested directAccess ops', async () => {
const ParseServerRESTController = require('../lib/ParseServerRESTController')
.ParseServerRESTController;
const ParseServer = require('../lib/ParseServer').default;
const requestContextMiddleware = (req, res, next) => {
req.config.aCustomController = 'aCustomController';
next();
};
await reconfigureServer({ requestContextMiddleware, directAccess: true });
// reconfigureServer restores the HTTP RESTController; re-enable directAccess
// so nested Cloud Code SDK ops go through ParseServerRESTController.
Parse.CoreManager.setRESTController(
ParseServerRESTController(
Parse.applicationId,
ParseServer.promiseRouter({ appId: Parse.applicationId })
)
);

let nestedCalled = false;
Parse.Cloud.beforeSave('Child', request => {
expect(request.config.aCustomController).toEqual('aCustomController');
nestedCalled = true;
});
Parse.Cloud.afterSave('Parent', async () => {
const child = new Parse.Object('Child');
await child.save(null, { useMasterKey: true });
});

await new Parse.Object('Parent').save(null, { useMasterKey: true });
expect(nestedCalled).toBe(true);
});
});
3 changes: 2 additions & 1 deletion src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,8 @@ module.exports.ParseServerOptions = {
},
requestContextMiddleware: {
env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
help: 'Options to customize the request context using inversion of control/dependency injection.',
help:
'Options to customize the request context using inversion of control/dependency injection. Applied on Express HTTP requests and on internal directAccess requests via ParseServerRESTController (synthetic request; headers may be empty).',
},
requestKeywordDenylist: {
env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST',
Expand Down
2 changes: 1 addition & 1 deletion src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

295 changes: 172 additions & 123 deletions src/ParseServerRESTController.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,140 +29,189 @@ function getAuth(options = {}, config) {
});
}

/**
* Apply requestContextMiddleware on a synthetic request so directAccess ops
* get the same per-request DI as Express HTTP requests.
*/
function applyRequestContextMiddleware(config) {
if (typeof config.requestContextMiddleware !== 'function') {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let settled = false;
const done = err => {
if (settled) {
return;
}
settled = true;
if (err) {
reject(err);
} else {
resolve();
}
};
const req = { config, headers: {} };
try {
const maybePromise = config.requestContextMiddleware(req, {}, done);
if (maybePromise && typeof maybePromise.then === 'function') {
maybePromise.then(() => done(), done);
}
} catch (err) {
done(err);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
Moumouls marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function ParseServerRESTController(applicationId, router) {
function handleRequest(method, path, data = {}, options = {}, config) {
// Store the arguments, for later use if internal fails
const args = arguments;
const configWasProvided = !!config;

if (!config) {
config = Config.get(applicationId);
}
const serverURL = new URL(config.serverURL);
if (path.indexOf(serverURL.pathname) === 0) {
path = path.slice(serverURL.pathname.length, path.length);
}

if (path[0] !== '/') {
path = '/' + path;
}
return Promise.resolve()
.then(() => {
if (!configWasProvided) {
config = Config.get(applicationId);
// Fresh config from AppCache has no Express middleware mutations;
// re-apply requestContextMiddleware for DI parity with HTTP.
return applyRequestContextMiddleware(config);
}
})
.then(() => {
const serverURL = new URL(config.serverURL);
if (path.indexOf(serverURL.pathname) === 0) {
path = path.slice(serverURL.pathname.length, path.length);
}

if (path === '/batch') {
const batch = transactionRetries => {
let initialPromise = Promise.resolve();
if (data.transaction === true) {
initialPromise = config.database.createTransactionalSession();
if (path[0] !== '/') {
path = '/' + path;
}
return initialPromise.then(() => {
const promises = data.requests.map(request => {
return handleRequest(request.method, request.path, request.body, options, config).then(
response => {
if (options.returnStatus) {
const status = response._status;
const headers = response._headers;
delete response._status;
delete response._headers;
return { success: response, _status: status, _headers: headers };
}
return { success: response };
},
error => {
return {
error: { code: error.code, error: error.message },
};
}
);
});
return Promise.all(promises)
.then(result => {
if (data.transaction === true) {
if (result.find(resultItem => typeof resultItem.error === 'object')) {
return config.database.abortTransactionalSession().then(() => {
return Promise.reject(result);
});
} else {
return config.database.commitTransactionalSession().then(() => {

if (path === '/batch') {
const batch = transactionRetries => {
let initialPromise = Promise.resolve();
if (data.transaction === true) {
initialPromise = config.database.createTransactionalSession();
}
return initialPromise.then(() => {
const promises = data.requests.map(request => {
return handleRequest(
request.method,
request.path,
request.body,
options,
config
).then(
response => {
if (options.returnStatus) {
const status = response._status;
const headers = response._headers;
delete response._status;
delete response._headers;
return { success: response, _status: status, _headers: headers };
}
return { success: response };
},
error => {
return {
error: { code: error.code, error: error.message },
};
}
);
});
return Promise.all(promises)
.then(result => {
if (data.transaction === true) {
if (result.find(resultItem => typeof resultItem.error === 'object')) {
return config.database.abortTransactionalSession().then(() => {
return Promise.reject(result);
});
} else {
return config.database.commitTransactionalSession().then(() => {
return result;
});
}
} else {
return result;
});
}
} else {
return result;
}
})
.catch(error => {
if (
error &&
error.find(
errorItem => typeof errorItem.error === 'object' && errorItem.error.code === 251
) &&
transactionRetries > 0
) {
return batch(transactionRetries - 1);
}
throw error;
}
})
.catch(error => {
if (
error &&
error.find(
errorItem =>
typeof errorItem.error === 'object' && errorItem.error.code === 251
) &&
transactionRetries > 0
) {
return batch(transactionRetries - 1);
}
throw error;
});
});
});
};
return batch(5);
}
};
return batch(5);
}

let query;
if (method === 'GET') {
query = data;
}
let query;
if (method === 'GET') {
query = data;
}

return new Promise((resolve, reject) => {
let requestContext;
try {
requestContext = structuredClone(options.context || {});
} catch (error) {
reject(
new Parse.Error(
Parse.Error.INVALID_VALUE,
`Context contains non-cloneable values: ${error.message}`
)
);
return;
}
getAuth(options, config).then(auth => {
const request = {
body: data,
config,
auth,
info: {
applicationId: applicationId,
sessionToken: options.sessionToken,
installationId: options.installationId,
context: requestContext,
},
query,
};
return Promise.resolve()
.then(() => {
return router.tryRouteRequest(method, path, request);
})
.then(
resp => {
const { response, status, headers = {} } = resp;
if (options.returnStatus) {
resolve({ ...response, _status: status, _headers: headers });
} else {
resolve(response);
}
},
err => {
if (
err instanceof Parse.Error &&
err.code == Parse.Error.INVALID_JSON &&
err.message == `cannot route ${method} ${path}`
) {
RESTController.request.apply(null, args).then(resolve, reject);
} else {
reject(err);
}
}
);
}, reject);
});
return new Promise((resolve, reject) => {
let requestContext;
try {
requestContext = structuredClone(options.context || {});
} catch (error) {
reject(
new Parse.Error(
Parse.Error.INVALID_VALUE,
`Context contains non-cloneable values: ${error.message}`
)
);
return;
}
getAuth(options, config).then(auth => {
const request = {
body: data,
config,
auth,
info: {
applicationId: applicationId,
sessionToken: options.sessionToken,
installationId: options.installationId,
context: requestContext,
},
query,
};
return Promise.resolve()
.then(() => {
return router.tryRouteRequest(method, path, request);
})
.then(
resp => {
const { response, status, headers = {} } = resp;
if (options.returnStatus) {
resolve({ ...response, _status: status, _headers: headers });
} else {
resolve(response);
}
},
err => {
if (
err instanceof Parse.Error &&
err.code == Parse.Error.INVALID_JSON &&
err.message == `cannot route ${method} ${path}`
) {
RESTController.request.apply(null, args).then(resolve, reject);
} else {
reject(err);
}
}
);
}, reject);
});
});
}

return {
Expand Down
Loading