Show last authors
1 {{box cssClass="floatinginfobox" title="**Contents**"}}
2 {{toc depth="1"/}}
3 {{/box}}
4
5 = Use Case =
6
7 In this tutorial for the [[Job Module>>extensions:Extension.Job Module]] we implement a space-rename-function taking into account that:
8
9 * a space can have many pages
10 * each page can have many back-links
11 * some pages can have large content and we want to update the relative links inside that content
12 * hence this operation can take a lot of time so we need to display the progress which means we cannot block the HTTP request that triggers the operation; in other words the operation should be asynchronous.
13
14 = API Design =
15
16 Before we start doing the implementation we need to decide what the rename API would look like. There are two main ways to implement asynchronous tasks:
17
18 1. **push**: you start the task and then you wait to be notified of the task progress, success or failure. In order to be notified you:
19 1*. either pass a **callback** to the API
20 1*. or the API returns a **promise** that you can use to register a callback
21 1. **pull**: you start the task and then you **ask for updates** regularly until the task is done (with success or failure). In this case the API needs to provide some method to access the status of the task
22
23 The first option (push) is nice but it requires a two-way connection between the code that triggers the task and the code that executes the task. This is not the case with (standard) HTTP where the server (normally) doesn't push data to the client. It's the client who pulls the data from the server. So we're going to use the second option.
24
25 {{code language="none"}}
26 ## Start the task.
27 #set ($taskId = $services.space.rename($spaceReference, $newSpaceName))
28 ...
29 ## Pull the task status.
30 #set ($taskStatus = $services.space.getRenameStatus($taskId))
31 {{/code}}
32
33 Let's see how we can implement this API using the [[Job Module>>extensions:Extension.Job Module]].
34
35 = Request =
36
37 The request represents the input for the task. It includes:
38
39 * the data needed by the task (e.g. the space reference and the new space name)
40 * context information (e.g. the user that triggered the task)
41 * configuration options for the task. For instance:
42 ** whether to check access rights or not
43 ** whether the task is interactive (may require user input during the task execution) or not
44
45 Each request has an identifier that is used to access the task status.
46
47 {{code language="java"}}
48 public class RenameRequest extends org.xwiki.job.AbstractRequest
49 {
50 private static final String PROPERTY_SPACE_REFERENCE = "spaceReference";
51
52 private static final String PROPERTY_NEW_SPACE_NAME = "newSpaceName";
53
54 private static final String PROPERTY_CHECK_RIGHTS = "checkrights";
55
56 private static final String PROPERTY_USER_REFERENCE = "user.reference";
57
58 public SpaceReference getSpaceReference()
59 {
60 return getProperty(PROPERTY_SPACE_REFERENCE);
61 }
62
63 public void setSpaceReference(SpaceReference spaceReference)
64 {
65 setProperty(PROPERTY_SPACE_REFERENCE, spaceReference);
66 }
67
68 public String getNewSpaceName()
69 {
70 return getProperty(PROPERTY_NEW_SPACE_NAME);
71 }
72
73 public void setNewSpaceName(String newSpaceName)
74 {
75 setProperty(PROPERTY_NEW_SPACE_NAME, newSpaceName);
76 }
77
78 public boolean isCheckRights()
79 {
80 return getProperty(PROPERTY_CHECK_RIGHTS, true);
81 }
82
83 public void setCheckRights(boolean checkRights)
84 {
85 setProperty(PROPERTY_CHECK_RIGHTS, checkRights);
86 }
87
88 public DocumentReference getUserReference()
89 {
90 return getProperty(PROPERTY_USER_REFERENCE);
91 }
92
93 public void setUserReference(DocumentReference userReference)
94 {
95 setProperty(PROPERTY_USER_REFERENCE, userReference);
96 }
97 }
98 {{/code}}
99
100 = Questions =
101
102 As we mentioned, jobs can be interactive by asking questions during the job execution. For instance, if there is already a space with the specified new name then we have to decide whether to:
103
104 * stop the rename
105 * or merge the two spaces
106
107 If we decide to merge the two spaces then there may be documents with the same name in both spaces, in which case we have to decide whether to overwrite the destination document or not.
108
109 To keep the example simple we're going to always merge the two spaces but we'll ask the user to confirm the overwrite.
110
111 {{code language="java"}}
112 public class OverwriteQuestion
113 {
114 private final DocumentReference source;
115
116 private final DocumentReference destination;
117
118 private boolean overwrite = true;
119
120 private boolean askAgain = true;
121
122 public OverwriteQuestion(DocumentReference source, DocumentReference destination)
123 {
124 this.source = source;
125 this.destination = destination;
126 }
127
128 public EntityReference getSource()
129 {
130 return source;
131 }
132
133 public EntityReference getDestination()
134 {
135 return destination;
136 }
137
138 public boolean isOverwrite()
139 {
140 return overwrite;
141 }
142
143 public void setOverwrite(boolean overwrite)
144 {
145 this.overwrite = overwrite;
146 }
147
148 public boolean isAskAgain()
149 {
150 return askAgain;
151 }
152
153 public void setAskAgain(boolean askAgain)
154 {
155 this.askAgain = askAgain;
156 }
157 }
158 {{/code}}
159
160 = Job Status =
161
162 The job status provides, by default, access to:
163
164 * the job **state** (e.g. NONE, RUNNING, WAITING, FINISHED)
165 * the job **request**
166 * the job **log** ("INFO: Document X.Y has been renamed to A.B")
167 * the job **progress** (72% completed)
168
169 Most of the time you don't need to extend the ##DefaultJobStatus## provided by the Job Module, unless you want to store:
170
171 * more progress information (e.g. the list of documents that have been renamed so far)
172 * the task result / output
173
174 Note that both the request and the job status must be **serializable** so be careful with what kind of information your store in your custom job status. For instance, for the task output, it's probably better to store a reference, path or URL to the output instead of storing the output itself.
175
176 The job status is also your communication channel with the job:
177
178 * if the job asks a question we
179 ** access the question from the job status
180 ** answer the question through the job status
181 * if you want to cancel the job you have to do it through the job status
182
183 {{code language="java"}}
184 public class RenameJobStatus extends DefaultJobStatus<RenameRequest>
185 {
186 private boolean canceled;
187
188 private List<DocumentReference> renamedDocumentReferences = new ArrayList<>();
189
190 public RenameJobStatus(RenameRequest request, ObservationManager observationManager,
191 LoggerManager loggerManager, JobStatus parentJobStatus)
192 {
193 super(request, observationManager, loggerManager, parentJobStatus);
194 }
195
196 public void cancel()
197 {
198 this.canceled = true;
199 }
200
201 public boolean isCanceled()
202 {
203 return this.canceled;
204 }
205
206 public List<DocumentReference> getRenamedDocumentReferences()
207 {
208 return this.renamedDocumentReferences;
209 }
210 }
211 {{/code}}
212
213 = Script Service =
214
215 We now have everything we need to implement a ##ScriptService## that will allow us to trigger the rename from Velocity and to get the rename status.
216
217 {{code language="java"}}
218 @Component
219 @Named(SpaceScriptService.ROLE_HINT)
220 @Singleton
221 public class SpaceScriptService implements ScriptService
222 {
223 public static final String ROLE_HINT = "space";
224
225 public static final String RENAME = "rename";
226
227 @Inject
228 private JobExecutor jobExecutor;
229
230 @Inject
231 private JobStatusStore jobStatusStore;
232
233 @Inject
234 private DocumentAccessBridge documentAccessBridge;
235
236 public String rename(SpaceReference spaceReference, String newSpaceName)
237 {
238 setError(null);
239
240 RenameRequest renameRequest = createRenameRequest(spaceReference, newSpaceName);
241
242 try {
243 this.jobExecutor.execute(RENAME, renameRequest);
244
245 List<String> renameId = renameRequest.getId();
246 return renameId.get(renameId.size() - 1);
247 } catch (Exception e) {
248 setError(e);
249 return null;
250 }
251 }
252
253 public RenameJobStatus getRenameStatus(String renameJobId)
254 {
255 return (RenameJobStatus) this.jobStatusStore.getJobStatus(getJobId(renameJobId));
256 }
257
258 private RenameRequest createRenameRequest(SpaceReference spaceReference, String newSpaceName)
259 {
260 RenameRequest renameRequest = new RenameRequest();
261 renameRequest.setId(getNewJobId());
262 renameRequest.setSpaceReference(spaceReference);
263 renameRequest.setNewSpaceName(newSpaceName);
264 renameRequest.setInteractive(true);
265 renameRequest.setCheckRights(true);
266 renameRequest.setUserReference(this.documentAccessBridge.getCurrentUserReference());
267 return renameRequest;
268 }
269
270 private List<String> getNewJobId()
271 {
272 return getJobId(UUID.randomUUID().toString());
273 }
274
275 private List<String> getJobId(String suffix)
276 {
277 return Arrays.asList(ROLE_HINT, RENAME, suffix);
278 }
279 }
280 {{/code}}
281
282 = Job Implementation =
283
284 Jobs are components. Let's see how we can implement them.
285
286 {{code language="java"}}
287 @Component
288 @Named(SpaceScriptService.RENAME)
289 public class RenameJob extends AbstractJob<RenameRequest, RenameJobStatus> implements GroupedJob
290 {
291 @Inject
292 private AuthorizationManager authorization;
293
294 @Inject
295 private DocumentAccessBridge documentAccessBridge;
296
297 private Boolean overwriteAll;
298
299 @Override
300 public String getType()
301 {
302 return SpaceScriptService.RENAME;
303 }
304
305 @Override
306 public JobGroupPath getGroupPath()
307 {
308 String wiki = this.request.getSpaceReference().getWikiReference().getName();
309 return new JobGroupPath(Arrays.asList(SpaceScriptService.RENAME, wiki));
310 }
311
312 @Override
313 protected void runInternal() throws Exception
314 {
315 List<DocumentReference> documentReferences = getDocumentReferences(this.request.getSpaceReference());
316 this.progressManager.pushLevelProgress(documentReferences.size(), this);
317
318 try {
319 for (DocumentReference documentReference : documentReferences) {
320 this.progressManager.startStep(this);
321
322 if (this.status.isCanceled()) {
323 break;
324 }
325
326 if (hasAccess(Right.DELETE, documentReference)) {
327 move(documentReference, this.request.getNewSpaceName());
328 this.status.getRenamedDocumentReferences().add(documentReference);
329 this.logger.info("Document [{}] has been moved to [{}].", documentReference,
330 this.request.getNewSpaceName());
331 }
332 }
333 } finally {
334 this.progressManager.popLevelProgress(this);
335 }
336 }
337
338 private boolean hasAccess(Right right, EntityReference reference)
339 {
340 return !this.request.isCheckRights()
341 || this.authorization.hasAccess(right, this.request.getUserReference(), reference);
342 }
343
344 private void move(DocumentReference documentReference, String newSpaceName)
345 {
346 SpaceReference newSpaceReference = new SpaceReference(newSpaceName, documentReference.getWikiReference());
347 DocumentReference newDocumentReference =
348 documentReference.replaceParent(documentReference.getLastSpaceReference(), newSpaceReference);
349 if (!this.documentAccessBridge.exists(newDocumentReference)
350 || confirmOverwrite(documentReference, newDocumentReference)) {
351 move(documentReference, newDocumentReference);
352 }
353 }
354
355 private boolean confirmOverwrite(DocumentReference source, DocumentReference destination)
356 {
357 if (this.overwriteAll == null) {
358 OverwriteQuestion question = new OverwriteQuestion(source, destination);
359 try {
360 this.status.ask(question);
361 if (!question.isAskAgain()) {
362 // Use the same answer for the following overwrite questions.
363 this.overwriteAll = question.isOverwrite();
364 }
365 return question.isOverwrite();
366 } catch (InterruptedException e) {
367 this.logger.warn("Overwrite question has been interrupted.");
368 return false;
369 }
370 } else {
371 return this.overwriteAll;
372 }
373 }
374 }
375 {{/code}}
376
377 = Server Controller =
378
379 We need to be able to trigger the rename operation and to get status updates remotely, from JavaScript. This means the rename API should be accessible through some URLs:
380
381 * ##?action=rename## -> redirects to ##?data=jobStatus##
382 * ##?data=jobStatus&jobId=xyz## -> return the job status serialized as JSON
383 * ##?action=continue&jobId=xyz## -> redirects to ##?data=jobStatus##
384 * ##?action=cancel&jobId=xyz## -> redirects to ##?data=jobStatus##
385
386 {{code language="none"}}
387 {{velocity}}
388 #if ($request.action == 'rename')
389 #set ($spaceReference = $services.model.resolveSpace($request.spaceReference))
390 #set ($renameJobId = $services.space.rename($spaceReference, $request.newSpaceName))
391 $response.sendRedirect($doc.getURL('get', $escapetool.url({
392 'outputSyntax': 'plain',
393 'jobId': $renameJobId
394 })))
395 #elseif ($request.action == 'continue')
396 #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId))
397 #set ($overwrite = $request.overwrite == 'true')
398 #set ($discard = $renameJobStatus.question.setOverwrite($overwrite))
399 #set ($discard = $renameJobStatus..answered())
400 #elseif ($request.action == 'cancel')
401 #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId))
402 #set ($discard = $renameJobStatus.cancel())
403 $response.sendRedirect($doc.getURL('get', $escapetool.url({
404 'outputSyntax': 'plain',
405 'jobId': $renameJobId
406 })))
407 #elseif ($request.data == 'jobStatus')
408 #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId))
409 #buildRenameStatusJSON($renameJobStatus)
410 $response.setContentType('application/json')
411 $jsontool.serialize($renameJobStatusJSON)
412 #end
413 {{/velocity}}
414 {{/code}}
415
416 = Client Controller =
417
418 On the client side, the JavaScript code is responsible for:
419
420 * triggering the task with an AJAX request to the server controller
421 * retrieve task status updates regularly and update the displayed progress
422 * pass the job questions to the user and pass the user answers to the server controller
423
424 {{code language="js"}}
425 var onStatusUpdate = function(status) {
426 updateProgressBar(status);
427 if (status.state == 'WAITING') {
428 // Display the question to the user.
429 displayQuestion(status);
430 } else if (status.state != 'FINISHED') {
431 // Pull task status update.
432 setTimeout(function() {
433 requestStatusUpdate(status.request.id).success(onStatusUpdate);
434 }, 1000);
435 }
436 };
437
438 // Trigger the rename task.
439 rename(parameters).success(onStatusUpdate);
440
441 // Continue the rename after the user answers the question.
442 continueRename(parameters).success(onStatusUpdate);
443
444 // Cancel the rename.
445 cancelRename(parameters).success(onStatusUpdate);
446 {{/code}}
447
448 = General Flow =
449
450 [[image:jobFlow.png||style="max-width:100%"]]

Get Connected