From c20c53d6d950d34807dca19572240393b81e1fd1 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Tue, 5 Nov 2024 17:40:23 +0200 Subject: [PATCH] Add the ability to trigger a Quartz job on-demand through an Actuator endpoint Before this commit, triggering a Quartz job on demand was not possible. This commit introduces a new @WriteOperation endpoint at /actuator/quartz/jobs/{groupName}/{jobName}/trigger, allowing a job to be triggered by specifying the jobName, groupName, and an optional JobDataMap See gh-42530 --- .../api/pages/rest/actuator/quartz.adoc | 34 ++++++ ...dFoundryWebFluxEndpointHandlerMapping.java | 6 +- ...undryWebEndpointServletHandlerMapping.java | 4 +- .../QuartzEndpointDocumentationTests.java | 29 +++++ ...AbstractWebFluxEndpointHandlerMapping.java | 8 +- .../AbstractWebMvcEndpointHandlerMapping.java | 8 +- .../boot/actuate/quartz/QuartzEndpoint.java | 66 ++++++++++ .../quartz/QuartzEndpointWebExtension.java | 18 ++- .../actuate/quartz/QuartzEndpointTests.java | 55 ++++++++- .../QuartzEndpointWebIntegrationTests.java | 113 +++++++++++++++++- 10 files changed, 324 insertions(+), 17 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc index 9bb74a5f26fe..293d75b05688 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc @@ -157,6 +157,40 @@ include::partial$rest/actuator/quartz/job-details/response-fields.adoc[] +[[quartz.trigger-job]] +== Trigger Quartz Job On Demand + +To trigger a particular Quartz job, make a `POST` request to `/actuator/quartz/jobs/\{groupName}/\{jobName}/trigger`, as shown in the following curl-based example: + +include::partial$rest/actuator/quartz/trigger-job/curl-request.adoc[] + +The above example shows the triggering of the job, identified by the `samples` group and `jobOne` name, and with the specified `jobData`. The Quartz `jobData` is optional key-value data, that can be passed to the job when it's triggered. + +The response will look similar to the following: + +include::partial$rest/actuator/quartz/trigger-job/http-response.adoc[] + + +[[quartz.trigger-job.request-structure]] +=== Request Structure + +The request specifies an optional `jobData` associated with a particular job. The following table describes the structure of the request: + +[cols="2,1,3"] +include::partial$rest/actuator/quartz/trigger-job/request-fields.adoc[] + + +[[quartz.trigger-job.response-structure]] +=== Response Structure + +The response contains the details of a triggered job. +The following table describes the structure of the response: + +[cols="2,1,3"] +include::partial$rest/actuator/quartz/trigger-job/response-fields.adoc[] + + + [[quartz.trigger]] == Retrieving Details of a Trigger diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java index 144f93a65f89..f0099f044436 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -147,12 +147,12 @@ private static class SecureReactiveWebOperation implements ReactiveWebOperation } @Override - public Mono> handle(ServerWebExchange exchange, Map body) { + public Mono> handle(ServerWebExchange exchange, Map body) { return this.securityInterceptor.preHandle(exchange, this.endpointId.toLowerCaseString()) .flatMap((securityResponse) -> flatMapResponse(exchange, body, securityResponse)); } - private Mono> flatMapResponse(ServerWebExchange exchange, Map body, + private Mono> flatMapResponse(ServerWebExchange exchange, Map body, SecurityResponse securityResponse) { if (!securityResponse.getStatus().equals(HttpStatus.OK)) { return Mono.just(new ResponseEntity<>(securityResponse.getStatus())); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java index 4b3b498a279c..4ef007ba9335 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -155,7 +155,7 @@ private static class SecureServletWebOperation implements ServletWebOperation { } @Override - public Object handle(HttpServletRequest request, Map body) { + public Object handle(HttpServletRequest request, Map body) { SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId); if (!securityResponse.getStatus().equals(HttpStatus.OK)) { return new ResponseEntity(securityResponse.getMessage(), securityResponse.getStatus()); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java index 4f8a04060040..6ea1303c06ed 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java @@ -24,6 +24,7 @@ import java.util.Date; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.TimeZone; @@ -54,9 +55,11 @@ import org.springframework.boot.actuate.endpoint.Show; import org.springframework.boot.actuate.quartz.QuartzEndpoint; import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension; +import org.springframework.boot.json.JsonWriter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.scheduling.quartz.DelegatingJob; @@ -68,7 +71,11 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedRequestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; @@ -385,6 +392,28 @@ void quartzTriggerCustom() throws Exception { .andWithPrefix("custom.", customTriggerSummary))); } + @Test + void quartzTriggerJob() throws Exception { + mockJobs(jobOne); + String json = JsonWriter.>of((members) -> members.addMapEntries(Map::copyOf)) + .write(Map.of("jobData", Map.of("key", "value", "keyN", "valueN"))) + .toJsonString(); + assertThat(this.mvc.post() + .uri("/actuator/quartz/jobs/samples/jobOne/trigger") + .content(json) + .contentType(MediaType.APPLICATION_JSON)) + .hasStatusOk() + .apply(document("quartz/trigger-job", preprocessRequest(), preprocessResponse(prettyPrint()), + relaxedRequestFields(fieldWithPath("jobData").description( + "A Quartz key-value JobDataMap, that will be associated with the trigger, that fires the job immediately."), + fieldWithPath("jobData.key") + .description("An arbitrary name that will be used as a key in JobDataMap.")), + responseFields(fieldWithPath("group").description("Name of the group."), + fieldWithPath("name").description("Name of the job."), + fieldWithPath("className").description("Fully qualified name of the job implementation."), + fieldWithPath("triggerTime").description("Time the job is triggered.")))); + } + private void setupTriggerDetails(TriggerBuilder builder, TriggerState state) throws SchedulerException { T trigger = builder.withIdentity("example", "samples") diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java index 8162d33155c4..f2da1ccba7ee 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java @@ -298,7 +298,7 @@ protected interface LinksHandler { @FunctionalInterface protected interface ReactiveWebOperation { - Mono> handle(ServerWebExchange exchange, Map body); + Mono> handle(ServerWebExchange exchange, Map body); } @@ -349,7 +349,7 @@ Mono emptySecurityContext() { } @Override - public Mono> handle(ServerWebExchange exchange, Map body) { + public Mono> handle(ServerWebExchange exchange, Map body) { Map arguments = getArguments(exchange, body); OperationArgumentResolver serverNamespaceArgumentResolver = OperationArgumentResolver .of(WebServerNamespace.class, () -> WebServerNamespace @@ -363,7 +363,7 @@ public Mono> handle(ServerWebExchange exchange, Map getArguments(ServerWebExchange exchange, Map body) { + private Map getArguments(ServerWebExchange exchange, Map body) { Map arguments = new LinkedHashMap<>(getTemplateVariables(exchange)); String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate() .getMatchAllRemainingPathSegmentsVariable(); @@ -448,7 +448,7 @@ private static final class WriteOperationHandler { @ResponseBody @Reflective Publisher> handle(ServerWebExchange exchange, - @RequestBody(required = false) Map body) { + @RequestBody(required = false) Map body) { return this.operation.handle(exchange, body); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java index fefac91cd87f..1ce210b835a9 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java @@ -278,7 +278,7 @@ protected interface LinksHandler { @FunctionalInterface protected interface ServletWebOperation { - Object handle(HttpServletRequest request, Map body); + Object handle(HttpServletRequest request, Map body); } @@ -308,7 +308,7 @@ private static class ServletWebOperationAdapter implements ServletWebOperation { } @Override - public Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { + public Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders(); Map arguments = getArguments(request, body); try { @@ -336,7 +336,7 @@ public String toString() { return "Actuator web endpoint '" + this.operation.getId() + "'"; } - private Map getArguments(HttpServletRequest request, Map body) { + private Map getArguments(HttpServletRequest request, Map body) { Map arguments = new LinkedHashMap<>(getTemplateVariables(request)); String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate() .getMatchAllRemainingPathSegmentsVariable(); @@ -430,7 +430,7 @@ private static final class OperationHandler { @ResponseBody @Reflective - Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { + Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { return this.operation.handle(request, body); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java index e6cdde6920d2..c63ab3d8ea05 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.quartz; import java.time.Duration; +import java.time.Instant; import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; @@ -212,6 +213,33 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo return null; } + /** + * Trigger (execute it now) the job identified with the given group name and job name. + * @param groupName the name of the group + * @param jobName the name of the job + * @param jobData the job jobData, or {@code null} + * @return the description of the job or {@code null} if such job does not exist + * @throws SchedulerException if either triggering job or retrieving the information + * from the scheduler failed + * @since 3.5.0 + */ + public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName, Map jobData) + throws SchedulerException { + JobKey jobKey = JobKey.jobKey(jobName, groupName); + JobDetail jobDetail = this.scheduler.getJobDetail(jobKey); + if (jobDetail == null) { + return null; + } + if (jobData != null) { + this.scheduler.triggerJob(jobKey, new JobDataMap(jobData)); + } + else { + this.scheduler.triggerJob(jobKey); + } + return new QuartzJobTriggerDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(), + jobDetail.getJobClass().getName(), Instant.now()); + } + private static List> extractTriggersSummary(List triggers) { List triggersToSort = new ArrayList<>(triggers); triggersToSort.sort(TRIGGER_COMPARATOR); @@ -387,6 +415,44 @@ public String getClassName() { } + /** + * Description of a triggered on demand {@link Job Quartz Job}. + */ + public static final class QuartzJobTriggerDescriptor { + + private final String group; + + private final String name; + + private final String className; + + private final Instant triggerTime; + + private QuartzJobTriggerDescriptor(String group, String name, String className, Instant triggerTime) { + this.group = group; + this.name = name; + this.className = className; + this.triggerTime = triggerTime; + } + + public String getGroup() { + return this.group; + } + + public String getName() { + return this.name; + } + + public String getClassName() { + return this.className; + } + + public Instant getTriggerTime() { + return this.triggerTime; + } + + } + /** * Description of a {@link Job Quartz Job}. */ diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java index c5d3ac3e0d0f..e45d87f77cb0 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.quartz; +import java.util.Map; import java.util.Set; import org.quartz.SchedulerException; @@ -27,6 +28,7 @@ import org.springframework.boot.actuate.endpoint.Show; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroupsDescriptor; @@ -35,6 +37,7 @@ import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor; import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension.QuartzEndpointWebExtensionRuntimeHints; import org.springframework.context.annotation.ImportRuntimeHints; +import org.springframework.lang.Nullable; /** * {@link EndpointWebExtension @EndpointWebExtension} for the {@link QuartzEndpoint}. @@ -79,6 +82,19 @@ public WebEndpointResponse quartzJobOrTrigger(SecurityContext securityCo () -> this.delegate.quartzTrigger(group, name, showUnsanitized)); } + @WriteOperation + public WebEndpointResponse triggerQuartzJob(@Selector String jobs, @Selector String group, + @Selector String name, @Selector String action, @Nullable Map jobData) + throws SchedulerException { + if (!"jobs".equals(jobs)) { + return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST); + } + if (!"trigger".equals(action)) { + return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST); + } + return handleNull(this.delegate.triggerQuartzJob(group, name, jobData)); + } + private WebEndpointResponse handle(String jobsOrTriggers, ResponseSupplier jobAction, ResponseSupplier triggerAction) throws SchedulerException { if ("jobs".equals(jobsOrTriggers)) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java index 9fa95ed84ff0..2849efbba059 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ import org.quartz.DateBuilder.IntervalUnit; import org.quartz.Job; import org.quartz.JobBuilder; +import org.quartz.JobDataMap; import org.quartz.JobDetail; import org.quartz.JobKey; import org.quartz.Scheduler; @@ -66,6 +67,7 @@ import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobDetailsDescriptor; import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobGroupSummaryDescriptor; import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobSummaryDescriptor; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobTriggerDescriptor; import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor; import org.springframework.scheduling.quartz.DelegatingJob; import org.springframework.util.LinkedMultiValueMap; @@ -73,9 +75,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; /** * Tests for {@link QuartzEndpoint}. @@ -755,6 +760,54 @@ void quartzJobWithDataMapAndShowUnsanitizedFalse() throws SchedulerException { entry("url", "******")); } + @Test + void quartzJobShouldBeTriggered() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class) + .withIdentity("hello", "samples") + .withDescription("A sample job") + .storeDurably() + .requestRecovery(false) + .build(); + mockJobs(job); + QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello", + null); + assertThat(quartzJobTriggerDescriptor).isNotNull(); + assertThat(quartzJobTriggerDescriptor.getName()).isEqualTo("hello"); + assertThat(quartzJobTriggerDescriptor.getGroup()).isEqualTo("samples"); + assertThat(quartzJobTriggerDescriptor.getClassName()).isEqualTo("org.quartz.Job"); + assertThat(quartzJobTriggerDescriptor.getTriggerTime()).isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS)); + then(this.scheduler).should().triggerJob(new JobKey("hello", "samples")); + } + + @Test + void quartzJobShouldBeTriggeredWithJobData() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class) + .withIdentity("hello", "samples") + .withDescription("A sample job") + .storeDurably() + .requestRecovery(false) + .build(); + mockJobs(job); + Map data = Map.of("key", "value"); + QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello", + data); + assertThat(quartzJobTriggerDescriptor).isNotNull(); + assertThat(quartzJobTriggerDescriptor.getName()).isEqualTo("hello"); + assertThat(quartzJobTriggerDescriptor.getGroup()).isEqualTo("samples"); + assertThat(quartzJobTriggerDescriptor.getClassName()).isEqualTo("org.quartz.Job"); + assertThat(quartzJobTriggerDescriptor.getTriggerTime()).isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS)); + then(this.scheduler).should().triggerJob(new JobKey("hello", "samples"), new JobDataMap(data)); + } + + @Test + void quartzJobShouldNotBeTriggeredJobDoesNotExist() throws SchedulerException { + QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello", + null); + assertThat(quartzJobTriggerDescriptor).isNull(); + then(this.scheduler).should(never()).triggerJob(any(), any()); + then(this.scheduler).should(never()).triggerJob(any()); + } + private void mockJobs(JobDetail... jobs) throws SchedulerException { MultiValueMap jobKeys = new LinkedMultiValueMap<>(); for (JobDetail jobDetail : jobs) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java index 907224e33cc3..a4dfc47991b6 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import net.minidev.json.JSONArray; @@ -42,17 +43,19 @@ import org.quartz.TriggerKey; import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.boot.actuate.endpoint.ApiVersion; import org.springframework.boot.actuate.endpoint.Show; import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; import org.springframework.scheduling.quartz.DelegatingJob; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; +import static org.mockito.BDDMockito.mock; /** * Integration tests for {@link QuartzEndpoint} exposed by Jersey, Spring MVC, and @@ -62,6 +65,10 @@ */ class QuartzEndpointWebIntegrationTests { + private static final String V2_JSON = ApiVersion.V2.getProducedMimeType().toString(); + + private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString(); + private static final JobDetail jobOne = JobBuilder.newJob(Job.class) .withIdentity("jobOne", "samples") .usingJobData(new JobDataMap(Collections.singletonMap("name", "test"))) @@ -249,6 +256,108 @@ void quartzTriggerDetailWithUnknownKey(WebTestClient client) { client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound(); } + @WebEndpointTest + void quartzTriggerJob(WebTestClient client) { + client.post() + .uri("/actuator/quartz/jobs/samples/jobOne/trigger") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("group") + .isEqualTo("samples") + .jsonPath("name") + .isEqualTo("jobOne") + .jsonPath("className") + .isEqualTo("org.quartz.Job") + .jsonPath("triggerTime") + .isNotEmpty(); + } + + @WebEndpointTest + void quartzTriggerJobWithCustomData(WebTestClient client) { + client.post() + .uri("/actuator/quartz/jobs/samples/jobOne/trigger") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("jobData", Map.of("name", "my-job"))) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("group") + .isEqualTo("samples") + .jsonPath("name") + .isEqualTo("jobOne") + .jsonPath("className") + .isEqualTo("org.quartz.Job") + .jsonPath("triggerTime") + .isNotEmpty(); + } + + @WebEndpointTest + void quartzTriggerJobWithCustomDataV2(WebTestClient client) { + client.post() + .uri("/actuator/quartz/jobs/samples/jobOne/trigger") + .contentType(MediaType.parseMediaType(V2_JSON)) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("jobData", Map.of("name", "my-job"))) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("group") + .isEqualTo("samples") + .jsonPath("name") + .isEqualTo("jobOne") + .jsonPath("className") + .isEqualTo("org.quartz.Job") + .jsonPath("triggerTime") + .isNotEmpty(); + } + + @WebEndpointTest + void quartzTriggerJobWithCustomDataV3(WebTestClient client) { + client.post() + .uri("/actuator/quartz/jobs/samples/jobOne/trigger") + .contentType(MediaType.parseMediaType(V3_JSON)) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("jobData", Map.of("name", "my-job"))) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("group") + .isEqualTo("samples") + .jsonPath("name") + .isEqualTo("jobOne") + .jsonPath("className") + .isEqualTo("org.quartz.Job") + .jsonPath("triggerTime") + .isNotEmpty(); + } + + @WebEndpointTest + void quartzTriggerJobWithUnknownKey(WebTestClient client) throws SchedulerException { + client.post() + .uri("/actuator/quartz/jobs/samples/does-not-exist/trigger") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isNotFound(); + } + + @WebEndpointTest + void quartzTriggerJobWithInvalidAction(WebTestClient client) { + client.post() + .uri("/actuator/quartz/jobs/samples/jobOne/invalid-action") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isBadRequest(); + } + @Configuration(proxyBeanMethods = false) static class TestConfiguration {