Skip to content

Commit f0949a1

Browse files
authored
feat: Job Scheduling (#3927)
* Adds back _JobSchedule as volatile class * wip * Restores jobs endpoints for creation, update and deletion * Adds tests * Fixes postgres tests * Enforce jobName exists before creating a schedule
1 parent 9256b2d commit f0949a1

File tree

5 files changed

+284
-15
lines changed

5 files changed

+284
-15
lines changed

spec/JobSchedule.spec.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
const rp = require('request-promise');
2+
const defaultHeaders = {
3+
'X-Parse-Application-Id': 'test',
4+
'X-Parse-Rest-API-Key': 'rest'
5+
}
6+
const masterKeyHeaders = {
7+
'X-Parse-Application-Id': 'test',
8+
'X-Parse-Rest-API-Key': 'rest',
9+
'X-Parse-Master-Key': 'test'
10+
}
11+
const defaultOptions = {
12+
headers: defaultHeaders,
13+
json: true
14+
}
15+
const masterKeyOptions = {
16+
headers: masterKeyHeaders,
17+
json: true
18+
}
19+
20+
describe('JobSchedule', () => {
21+
it('should create _JobSchedule with masterKey', (done) => {
22+
const jobSchedule = new Parse.Object('_JobSchedule');
23+
jobSchedule.set({
24+
'jobName': 'MY Cool Job'
25+
});
26+
jobSchedule.save(null, {useMasterKey: true}).then(() => {
27+
done();
28+
})
29+
.catch(done.fail);
30+
});
31+
32+
it('should fail creating _JobSchedule without masterKey', (done) => {
33+
const jobSchedule = new Parse.Object('_JobSchedule');
34+
jobSchedule.set({
35+
'jobName': 'SomeJob'
36+
});
37+
jobSchedule.save(null).then(done.fail)
38+
.catch(done);
39+
});
40+
41+
it('should reject access when not using masterKey (/jobs)', (done) => {
42+
rp.get(Parse.serverURL + '/cloud_code/jobs', defaultOptions).then(done.fail, done);
43+
});
44+
45+
it('should reject access when not using masterKey (/jobs/data)', (done) => {
46+
rp.get(Parse.serverURL + '/cloud_code/jobs/data', defaultOptions).then(done.fail, done);
47+
});
48+
49+
it('should reject access when not using masterKey (PUT /jobs/id)', (done) => {
50+
rp.put(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done);
51+
});
52+
53+
it('should reject access when not using masterKey (PUT /jobs/id)', (done) => {
54+
rp.del(Parse.serverURL + '/cloud_code/jobs/jobId', defaultOptions).then(done.fail, done);
55+
});
56+
57+
it('should allow access when using masterKey (/jobs)', (done) => {
58+
rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions).then(done, done.fail);
59+
});
60+
61+
it('should create a job schedule', (done) => {
62+
Parse.Cloud.job('job', () => {});
63+
const options = Object.assign({}, masterKeyOptions, {
64+
body: {
65+
job_schedule: {
66+
jobName: 'job'
67+
}
68+
}
69+
});
70+
rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => {
71+
expect(res.objectId).not.toBeUndefined();
72+
})
73+
.then(() => {
74+
return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions);
75+
})
76+
.then((res) => {
77+
expect(res.length).toBe(1);
78+
})
79+
.then(done)
80+
.catch(done.fail);
81+
});
82+
83+
it('should fail creating a job with an invalid name', (done) => {
84+
const options = Object.assign({}, masterKeyOptions, {
85+
body: {
86+
job_schedule: {
87+
jobName: 'job'
88+
}
89+
}
90+
});
91+
rp.post(Parse.serverURL + '/cloud_code/jobs', options)
92+
.then(done.fail)
93+
.catch(done);
94+
});
95+
96+
it('should update a job', (done) => {
97+
Parse.Cloud.job('job1', () => {});
98+
Parse.Cloud.job('job2', () => {});
99+
const options = Object.assign({}, masterKeyOptions, {
100+
body: {
101+
job_schedule: {
102+
jobName: 'job1'
103+
}
104+
}
105+
});
106+
rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => {
107+
expect(res.objectId).not.toBeUndefined();
108+
return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, {
109+
body: {
110+
job_schedule: {
111+
jobName: 'job2'
112+
}
113+
}
114+
}));
115+
})
116+
.then(() => {
117+
return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions);
118+
})
119+
.then((res) => {
120+
expect(res.length).toBe(1);
121+
expect(res[0].jobName).toBe('job2');
122+
})
123+
.then(done)
124+
.catch(done.fail);
125+
});
126+
127+
it('should fail updating a job with an invalid name', (done) => {
128+
Parse.Cloud.job('job1', () => {});
129+
const options = Object.assign({}, masterKeyOptions, {
130+
body: {
131+
job_schedule: {
132+
jobName: 'job1'
133+
}
134+
}
135+
});
136+
rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => {
137+
expect(res.objectId).not.toBeUndefined();
138+
return rp.put(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, Object.assign(options, {
139+
body: {
140+
job_schedule: {
141+
jobName: 'job2'
142+
}
143+
}
144+
}));
145+
})
146+
.then(done.fail)
147+
.catch(done);
148+
});
149+
150+
it('should destroy a job', (done) => {
151+
Parse.Cloud.job('job', () => {});
152+
const options = Object.assign({}, masterKeyOptions, {
153+
body: {
154+
job_schedule: {
155+
jobName: 'job'
156+
}
157+
}
158+
});
159+
rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => {
160+
expect(res.objectId).not.toBeUndefined();
161+
return rp.del(Parse.serverURL + '/cloud_code/jobs/' + res.objectId, masterKeyOptions);
162+
})
163+
.then(() => {
164+
return rp.get(Parse.serverURL + '/cloud_code/jobs', masterKeyOptions);
165+
})
166+
.then((res) => {
167+
expect(res.length).toBe(0);
168+
})
169+
.then(done)
170+
.catch(done.fail);
171+
});
172+
173+
it('should properly return job data', (done) => {
174+
Parse.Cloud.job('job1', () => {});
175+
Parse.Cloud.job('job2', () => {});
176+
const options = Object.assign({}, masterKeyOptions, {
177+
body: {
178+
job_schedule: {
179+
jobName: 'job1'
180+
}
181+
}
182+
});
183+
rp.post(Parse.serverURL + '/cloud_code/jobs', options).then((res) => {
184+
expect(res.objectId).not.toBeUndefined();
185+
})
186+
.then(() => {
187+
return rp.get(Parse.serverURL + '/cloud_code/jobs/data', masterKeyOptions);
188+
})
189+
.then((res) => {
190+
expect(res.in_use).toEqual(['job1']);
191+
expect(res.jobs).toContain('job1');
192+
expect(res.jobs).toContain('job2');
193+
expect(res.jobs.length).toBe(2);
194+
})
195+
.then(done)
196+
.catch(done.fail);
197+
});
198+
});

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ export class PostgresStorageAdapter {
667667
const joins = results.reduce((list, schema) => {
668668
return list.concat(joinTablesForSchema(schema.schema));
669669
}, []);
670-
const classes = ['_SCHEMA','_PushStatus','_JobStatus','_Hooks','_GlobalConfig', ...results.map(result => result.className), ...joins];
670+
const classes = ['_SCHEMA','_PushStatus','_JobStatus','_JobSchedule','_Hooks','_GlobalConfig', ...results.map(result => result.className), ...joins];
671671
return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE IF EXISTS $<className:name>', { className }))));
672672
}, error => {
673673
if (error.code === PostgresRelationDoesNotExistError) {

src/Controllers/SchemaController.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ const defaultColumns = Object.freeze({
9595
"params": {type: 'Object'}, // params received when calling the job
9696
"finishedAt": {type: 'Date'}
9797
},
98+
_JobSchedule: {
99+
"jobName": {type:'String'},
100+
"description": {type:'String'},
101+
"params": {type:'String'},
102+
"startAfter": {type:'String'},
103+
"daysOfWeek": {type:'Array'},
104+
"timeOfDay": {type:'String'},
105+
"lastRun": {type:'Number'},
106+
"repeatMinutes":{type:'Number'}
107+
},
98108
_Hooks: {
99109
"functionName": {type:'String'},
100110
"className": {type:'String'},
@@ -112,9 +122,9 @@ const requiredColumns = Object.freeze({
112122
_Role: ["name", "ACL"]
113123
});
114124

115-
const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus']);
125+
const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus', '_JobStatus', '_JobSchedule']);
116126

117-
const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig']);
127+
const volatileClasses = Object.freeze(['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule']);
118128

119129
// 10 alpha numberic chars + uppercase
120130
const userIdRegex = /^[a-zA-Z0-9]{10}$/;
@@ -291,7 +301,12 @@ const _JobStatusSchema = convertSchemaToAdapterSchema(injectDefaultSchema({
291301
fields: {},
292302
classLevelPermissions: {}
293303
}));
294-
const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _PushStatusSchema, _GlobalConfigSchema];
304+
const _JobScheduleSchema = convertSchemaToAdapterSchema(injectDefaultSchema({
305+
className: "_JobSchedule",
306+
fields: {},
307+
classLevelPermissions: {}
308+
}));
309+
const VolatileClassesSchemas = [_HooksSchema, _JobStatusSchema, _JobScheduleSchema, _PushStatusSchema, _GlobalConfigSchema];
295310

296311
const dbTypeMatchesObjectType = (dbType, objectType) => {
297312
if (dbType.type !== objectType.type) return false;

src/Routers/CloudCodeRouter.js

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,76 @@
1-
import PromiseRouter from '../PromiseRouter';
2-
const triggers = require('../triggers');
1+
import PromiseRouter from '../PromiseRouter';
2+
import Parse from 'parse/node';
3+
import rest from '../rest';
4+
const triggers = require('../triggers');
5+
const middleware = require('../middlewares');
6+
7+
function formatJobSchedule(job_schedule) {
8+
if (typeof job_schedule.startAfter === 'undefined') {
9+
job_schedule.startAfter = new Date().toISOString();
10+
}
11+
return job_schedule;
12+
}
13+
14+
function validateJobSchedule(config, job_schedule) {
15+
const jobs = triggers.getJobs(config.applicationId) || {};
16+
if (job_schedule.jobName && !jobs[job_schedule.jobName]) {
17+
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Cannot Schedule a job that is not deployed');
18+
}
19+
}
320

421
export class CloudCodeRouter extends PromiseRouter {
522
mountRoutes() {
6-
this.route('GET',`/cloud_code/jobs`, CloudCodeRouter.getJobs);
23+
this.route('GET', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobs);
24+
this.route('GET', '/cloud_code/jobs/data', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.getJobsData);
25+
this.route('POST', '/cloud_code/jobs', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.createJob);
26+
this.route('PUT', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.editJob);
27+
this.route('DELETE', '/cloud_code/jobs/:objectId', middleware.promiseEnforceMasterKeyAccess, CloudCodeRouter.deleteJob);
728
}
829

930
static getJobs(req) {
31+
return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => {
32+
return {
33+
response: scheduledJobs.results
34+
}
35+
});
36+
}
37+
38+
static getJobsData(req) {
1039
const config = req.config;
1140
const jobs = triggers.getJobs(config.applicationId) || {};
12-
return Promise.resolve({
13-
response: Object.keys(jobs).map((jobName) => {
14-
return {
15-
jobName,
41+
return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then((scheduledJobs) => {
42+
return {
43+
response: {
44+
in_use: scheduledJobs.results.map((job) => job.jobName),
45+
jobs: Object.keys(jobs),
1646
}
17-
})
47+
};
48+
});
49+
}
50+
51+
static createJob(req) {
52+
const { job_schedule } = req.body;
53+
validateJobSchedule(req.config, job_schedule);
54+
return rest.create(req.config, req.auth, '_JobSchedule', formatJobSchedule(job_schedule), req.client);
55+
}
56+
57+
static editJob(req) {
58+
const { objectId } = req.params;
59+
const { job_schedule } = req.body;
60+
validateJobSchedule(req.config, job_schedule);
61+
return rest.update(req.config, req.auth, '_JobSchedule', { objectId }, formatJobSchedule(job_schedule)).then((response) => {
62+
return {
63+
response
64+
}
65+
});
66+
}
67+
68+
static deleteJob(req) {
69+
const { objectId } = req.params;
70+
return rest.del(req.config, req.auth, '_JobSchedule', objectId).then((response) => {
71+
return {
72+
response
73+
}
1874
});
1975
}
2076
}

src/rest.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ function update(config, auth, className, restWhere, restObject, clientSDK) {
134134
});
135135
}
136136

137-
// Disallowing access to the _Role collection except by master key
137+
const classesWithMasterOnlyAccess = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig', '_JobSchedule'];
138+
// Disallowing access to the _Role collection except by master key
138139
function enforceRoleSecurity(method, className, auth) {
139140
if (className === '_Installation' && !auth.isMaster) {
140141
if (method === 'delete' || method === 'find') {
@@ -144,8 +145,7 @@ function enforceRoleSecurity(method, className, auth) {
144145
}
145146

146147
//all volatileClasses are masterKey only
147-
const volatileClasses = ['_JobStatus', '_PushStatus', '_Hooks', '_GlobalConfig'];
148-
if(volatileClasses.includes(className) && !auth.isMaster){
148+
if(classesWithMasterOnlyAccess.includes(className) && !auth.isMaster){
149149
const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`
150150
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error);
151151
}

0 commit comments

Comments
 (0)