View Javadoc
1   /*
2    * Copyright (C) 2024 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller.admin;
7   
8   import static java.net.HttpURLConnection.HTTP_ACCEPTED;
9   
10  import com.fasterxml.jackson.databind.ObjectMapper;
11  import com.fasterxml.jackson.databind.node.ObjectNode;
12  import io.swagger.v3.oas.annotations.Operation;
13  import io.swagger.v3.oas.annotations.media.Content;
14  import io.swagger.v3.oas.annotations.media.Schema;
15  import io.swagger.v3.oas.annotations.responses.ApiResponse;
16  import java.lang.invoke.MethodHandles;
17  import java.util.ArrayList;
18  import java.util.List;
19  import java.util.Objects;
20  import java.util.UUID;
21  import org.quartz.CronTrigger;
22  import org.quartz.JobDataMap;
23  import org.quartz.JobDetail;
24  import org.quartz.JobKey;
25  import org.quartz.Scheduler;
26  import org.quartz.SchedulerException;
27  import org.quartz.Trigger;
28  import org.quartz.TriggerUtils;
29  import org.quartz.impl.matchers.GroupMatcher;
30  import org.quartz.spi.OperableTrigger;
31  import org.slf4j.Logger;
32  import org.slf4j.LoggerFactory;
33  import org.springframework.http.HttpStatus;
34  import org.springframework.http.HttpStatusCode;
35  import org.springframework.http.MediaType;
36  import org.springframework.http.ResponseEntity;
37  import org.springframework.web.bind.annotation.DeleteMapping;
38  import org.springframework.web.bind.annotation.GetMapping;
39  import org.springframework.web.bind.annotation.PathVariable;
40  import org.springframework.web.bind.annotation.PutMapping;
41  import org.springframework.web.bind.annotation.RequestParam;
42  import org.springframework.web.bind.annotation.RestController;
43  import org.springframework.web.server.ResponseStatusException;
44  
45  /**
46   * Admin controller for controlling the task scheduler. Not to be used to create new tasks, adding
47   * tasks belongs in the domain of the specific controller or Spring Data REST API as that requires
48   * specific configuration information.
49   */
50  @RestController
51  public class TaskAdminController {
52    private static final Logger logger =
53        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
54  
55    private final Scheduler scheduler;
56  
57    public TaskAdminController(Scheduler scheduler) {
58      this.scheduler = scheduler;
59    }
60  
61    @Operation(
62        summary = "List all tasks, optionally filtered by type",
63        description = "This will return a list of all tasks, optionally filtered by task type")
64    @GetMapping(
65        path = "${tailormap-api.admin.base-path}/tasks",
66        produces = MediaType.APPLICATION_JSON_VALUE)
67    @ApiResponse(
68        responseCode = "200",
69        description = "List of all tasks, this list may be empty",
70        content =
71            @Content(
72                mediaType = MediaType.APPLICATION_JSON_VALUE,
73                schema =
74                    @Schema(
75                        example =
76                            "{\"tasks\":[{\"uuid\":\"6308d26e-fe1e-4268-bb28-20db2cd06914\",\"type\":\"poc\"},{\"uuid\":\"d5ce9152-e90e-4b5a-b129-3b2366cabca8\",\"type\":\"poc\"}]}")))
77    public ResponseEntity<?> list(@RequestParam(required = false) String type)
78        throws SchedulerException {
79      logger.debug("Listing all tasks (optional type filter: {})", (null == type ? "all" : type));
80      List<ObjectNode> tasks = new ArrayList<>();
81  
82      GroupMatcher<JobKey> groupMatcher =
83          (null == type ? GroupMatcher.anyGroup() : GroupMatcher.groupEquals(type));
84      scheduler.getJobKeys(groupMatcher).stream()
85          .map(
86              jobKey -> {
87                try {
88                  return scheduler.getJobDetail(jobKey);
89                } catch (SchedulerException e) {
90                  logger.error("Error getting task detail", e);
91                  return null;
92                }
93              })
94          .filter(Objects::nonNull)
95          .forEach(
96              jobDetail -> {
97                tasks.add(
98                    new ObjectMapper()
99                        .createObjectNode()
100                       .put("uuid", jobDetail.getKey().getName())
101                       .put("type", jobDetail.getKey().getGroup())
102                       .put("description", jobDetail.getJobDataMap().getString("description")));
103             });
104 
105     return ResponseEntity.ok(
106         new ObjectMapper()
107             .createObjectNode()
108             .set("tasks", new ObjectMapper().createArrayNode().addAll(tasks)));
109   }
110 
111   @Operation(
112       summary = "List all details for a given task",
113       description =
114           "This will return the details of the task, including the status, progress, result and any message")
115   @GetMapping(
116       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
117       produces = MediaType.APPLICATION_JSON_VALUE)
118   @ApiResponse(
119       responseCode = "404",
120       description = "Task does not exist",
121       content =
122           @Content(
123               mediaType = MediaType.APPLICATION_JSON_VALUE,
124               schema = @Schema(example = "{\"message\":\"Task does not exist\",\"code\":404}")))
125   @ApiResponse(
126       responseCode = "200",
127       description = "Details of the task",
128       content =
129           @Content(
130               mediaType = MediaType.APPLICATION_JSON_VALUE,
131               schema =
132                   @Schema(
133                       example =
134                           """
135                           {
136                             "uuid":"6308d26e-fe1e-4268-bb28-20db2cd06914",
137                             "type":"poc",
138                             "description":"This is a poc task",
139                             "startTime":"2024-06-06T12:00:00Z",
140                             "nextTime":"2024-06-06T12:00:00Z",
141                             "jobData":{
142                               "type":"poc",
143                               "description":"This is a poc task"
144                             },
145                             "status":"NORMAL",
146                             "progress":"TODO",
147                             "result":"TODO",
148                             "message":"TODO something is happening"
149                           }
150                           """)))
151   public ResponseEntity<?> details(@PathVariable String type, @PathVariable UUID uuid)
152       throws SchedulerException {
153     logger.debug("Getting task details for {}:{}", type, uuid);
154 
155     JobDetail jobDetail = scheduler.getJobDetail(getJobKey(type, uuid));
156     JobDataMap jobDataMap = jobDetail.getJobDataMap();
157 
158     /* there should be only one */
159     Trigger trigger = scheduler.getTriggersOfJob(jobDetail.getKey()).get(0);
160     CronTrigger cron = ((CronTrigger) trigger);
161 
162     final Object[] result = new Object[1];
163     scheduler.getCurrentlyExecutingJobs().stream()
164         .filter(Objects::nonNull)
165         .forEach(
166             jobExecutionContext -> {
167               logger.debug(
168                   "currently executing job {} with trigger {}.",
169                   jobExecutionContext.getJobDetail().getKey(),
170                   jobExecutionContext.getTrigger().getKey());
171 
172               result[0] = jobExecutionContext.getResult();
173               //
174               // jobDataMap=  jobExecutionContext.getMergedJobDataMap();
175             });
176 
177     return ResponseEntity.ok(
178         new ObjectMapper()
179             .createObjectNode()
180             // immutable uuid, type and description
181             .put("uuid", jobDetail.getKey().getName())
182             .put("type", jobDetail.getKey().getGroup())
183             .put("description", jobDataMap.getString("description"))
184             .put("cronExpression", cron.getCronExpression())
185             // TODO / XXX we could add a human-readable description of the cron expression using eg.
186             //   com.cronutils:cron-utils like:
187             //     CronParser cronParser = new
188             //         CronParser(CronDefinitionBuilder.instanceDefinitionFor(QUARTZ));
189             //     CronDescriptor.instance(locale).describe(cronParser.parse(cronExpression));
190             //   this could also be done front-end using eg. https://www.npmjs.com/package/cronstrue
191             //   which has the advantage of knowing the required locale for the human
192             // .put("cronDescription", cron.getCronExpression())
193             .put("timezone", cron.getTimeZone().getID())
194             .putPOJO("startTime", trigger.getStartTime())
195             .putPOJO("lastTime", trigger.getPreviousFireTime())
196             .putPOJO(
197                 "nextFireTimes", TriggerUtils.computeFireTimes((OperableTrigger) cron, null, 5))
198             .putPOJO("status", scheduler.getTriggerState(trigger.getKey()))
199             .putPOJO("progress", result[0])
200             .put("lastResult", jobDataMap.getString("lastResult"))
201             .putPOJO("jobData", jobDataMap));
202   }
203 
204   @Operation(
205       summary = "Start a task",
206       description = "This will start the task if it is not already running")
207   @PutMapping(
208       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/start",
209       produces = MediaType.APPLICATION_JSON_VALUE)
210   @ApiResponse(
211       responseCode = "404",
212       description = "Task does not exist",
213       content =
214           @Content(
215               mediaType = MediaType.APPLICATION_JSON_VALUE,
216               schema = @Schema(example = "{\"message\":\"Task does not exist\",\"code\":404}")))
217   @ApiResponse(
218       responseCode = "202",
219       description = "Task is started",
220       content =
221           @Content(
222               mediaType = MediaType.APPLICATION_JSON_VALUE,
223               schema = @Schema(example = "{\"message\":\"Task starting accepted\",\"code\":202}")))
224   public ResponseEntity<?> startTask(@PathVariable String type, @PathVariable UUID uuid) {
225     logger.debug("Starting task {}:{}", type, uuid);
226 
227     return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
228         .body(new ObjectMapper().createObjectNode().put("message", "TODO: Task starting accepted"));
229   }
230 
231   @Operation(
232       summary = "Stop a task",
233       description = "This will stop the task, if the task is not running, nothing will happen")
234   @PutMapping(
235       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}/stop",
236       produces = MediaType.APPLICATION_JSON_VALUE)
237   @ApiResponse(
238       responseCode = "404",
239       description = "Task does not exist",
240       content =
241           @Content(
242               mediaType = MediaType.APPLICATION_JSON_VALUE,
243               schema = @Schema(example = "{\"message\":\"Task does not exist\"}")))
244   @ApiResponse(
245       responseCode = "202",
246       description = "Task is stopping",
247       content =
248           @Content(
249               mediaType = MediaType.APPLICATION_JSON_VALUE,
250               schema = @Schema(example = "{\"message\":\"Task stopping accepted\"}")))
251   public ResponseEntity<?> stopTask(@PathVariable String type, @PathVariable UUID uuid) {
252     logger.debug("Stopping task {}:{}", type, uuid);
253 
254     return ResponseEntity.status(HttpStatusCode.valueOf(HTTP_ACCEPTED))
255         .body(new ObjectMapper().createObjectNode().put("message", "TODO: Task stopping accepted"));
256   }
257 
258   @Operation(
259       summary = "Delete a task",
260       description =
261           "This will remove the task from the scheduler and delete all information about the task")
262   @DeleteMapping(
263       path = "${tailormap-api.admin.base-path}/tasks/{type}/{uuid}",
264       produces = MediaType.APPLICATION_JSON_VALUE)
265   @ApiResponse(
266       responseCode = "404",
267       description = "Task does not exist",
268       content =
269           @Content(
270               mediaType = MediaType.APPLICATION_JSON_VALUE,
271               schema = @Schema(example = "{\"message\":\"Task does not exist\"}")))
272   @ApiResponse(responseCode = "204", description = "Task is deleted")
273   public ResponseEntity<?> delete(@PathVariable String type, @PathVariable UUID uuid)
274       throws SchedulerException {
275 
276     boolean succes = scheduler.deleteJob(getJobKey(type, uuid));
277     logger.info("Task {}:{} deletion {}", type, uuid, (succes ? "succeeded" : "failed"));
278 
279     return ResponseEntity.noContent().build();
280   }
281 
282   /**
283    * Get the job key for a given type and uuid.
284    *
285    * @param type the type of the job
286    * @param uuid the uuid of the job
287    * @return the job key
288    * @throws SchedulerException when the scheduler cannot be reached
289    * @throws ResponseStatusException when the job does not exist
290    */
291   private JobKey getJobKey(String type, UUID uuid)
292       throws SchedulerException, ResponseStatusException {
293     logger.debug("Finding job key for task {}:{}", type, uuid);
294     return scheduler.getJobKeys(GroupMatcher.groupEquals(type)).stream()
295         .filter(jobkey -> jobkey.getName().equals(uuid.toString()))
296         .findFirst()
297         .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
298   }
299 }