Commit b11602ae authored by Stephane Nicoll's avatar Stephane Nicoll

Polish "Add Quartz actuator endpoint"

This commit reworks the initial proposal so that jobs and triggers are
treated as first class concepts.

`/actuator/quartz` now returns the group names for jobs and triggers.

`actuator/quartz/jobs` returns the job names, keyed by the available
group names, while `/actuator/quartz/triggers` does the same for
triggers.

`/actuator/jobs/{groupName}` provides an overview of a job group. It
provides a map of job names with the class name of the job.
implementation

`/actuator/triggers/{groupName}` provides an overview of a trigger
group. There are five supported trigger implementations: cron, simple,
daily time interval, calendar interval, and custom for any other
implementation. Given that each implementation has specific settings,
triggers are split in five objects.

`/actuator/jobs/{groupName}/{jobName}` provides the full details of a
particular job. This includes a sanitized data map and a list of
triggers ordered by next fire time.

`/actuator/triggers/{groupName}/{triggerName}` provides the full details
of a particular trigger. This includes the state, its type, and a
dedicate object containing implementation-specific settings.

See gh-10364
parent 97950613
[[quartz]]
= Quartz (`quartz`)
The `quartz` endpoint provides information about jobs and triggers that are managed by the Quartz Scheduler.
[[quartz-report]]
== Retrieving Registered Groups
Jobs and triggers are managed in groups.
To retrieve the list of registered job and trigger groups, make a `GET` request to `/actuator/quartz`, as shown in the following curl-based example:
include::{snippets}/quartz/report/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/report/http-response.adoc[]
[[quartz-report-response-structure]]
=== Response Structure
The response contains the groups names for registered jobs and triggers.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/report/response-fields.adoc[]
[[quartz-job-groups]]
== Retrieving Registered Job Names
To retrieve the list of registered job names, make a `GET` request to `/actuator/quartz/jobs`, as shown in the following curl-based example:
include::{snippets}/quartz/jobs/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/jobs/http-response.adoc[]
[[quartz-job-groups-response-structure]]
=== Response Structure
The response contains the registered job names for each group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/jobs/response-fields.adoc[]
[[quartz-trigger-groups]]
== Retrieving Registered Trigger Names
To retrieve the list of registered trigger names, make a `GET` request to `/actuator/quartz/triggers`, as shown in the following curl-based example:
include::{snippets}/quartz/triggers/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/triggers/http-response.adoc[]
[[quartz-trigger-groups-response-structure]]
=== Response Structure
The response contains the registered trigger names for each group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/triggers/response-fields.adoc[]
[[quartz-job-group]]
== Retrieving Overview of a Job Group
To retrieve an overview of the jobs in a particular group, make a `GET` request to `/actuator/quartz/jobs/\{groupName}`, as shown in the following curl-based example:
include::{snippets}/quartz/job-group/curl-request.adoc[]
The preceding example retrieves the summary for jobs in the `samples` group.
The resulting response is similar to the following:
include::{snippets}/quartz/job-group/http-response.adoc[]
[[quartz-job-group-response-structure]]
=== Response Structure
The response contains an overview of jobs in a particular group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/job-group/response-fields.adoc[]
[[quartz-trigger-group]]
== Retrieving Overview of a Trigger Group
To retrieve an overview of the triggers in a particular group, make a `GET` request to `/actuator/quartz/triggers/\{groupName}`, as shown in the following curl-based example:
include::{snippets}/quartz/trigger-group/curl-request.adoc[]
The preceding example retrieves the summary for triggers in the `tests` group.
The resulting response is similar to the following:
include::{snippets}/quartz/trigger-group/http-response.adoc[]
[[quartz-trigger-group-response-structure]]
=== Response Structure
The response contains an overview of triggers in a particular group.
Trigger implementation specific details are available.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/trigger-group/response-fields.adoc[]
[[quartz-job]]
== Retrieving Details of a Job
To retrieve the details about a particular job, make a `GET` request to `/actuator/quartz/jobs/\{groupName}/\{jobName}`, as shown in the following curl-based example:
include::{snippets}/quartz/job-details/curl-request.adoc[]
The preceding example retrieves the details of the job identified by the `samples` group and `jobOne` name.
The resulting response is similar to the following:
include::{snippets}/quartz/job-details/http-response.adoc[]
If a key in the data map is identified as sensitive, its value is sanitized.
[[quartz-job-response-structure]]
=== Response Structure
The response contains the full details of a job including a summary of the triggers associated with it, if any.
The triggers are sorted by next fire time and priority.
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/job-details/response-fields.adoc[]
[[quartz-trigger]]
== Retrieving Details of a Trigger
To retrieve the details about a particular trigger, make a `GET` request to `/actuator/quartz/triggers/\{groupName}/\{triggerName}`, as shown in the following curl-based example:
include::{snippets}/quartz/trigger-details-cron/curl-request.adoc[]
The preceding example retrieves the details of trigger identified by the `samples` group and `example` name.
The resulting response has a common structure and a specific additional object according to the trigger implementation.
There are five supported types:
* `cron` for `CronTrigger`
* `simple` for `SimpleTrigger`
* `dailyTimeInterval` for `DailyTimeIntervalTrigger`
* `calendarInterval` for `CalendarIntervalTrigger`
* `custom` for any other trigger implementations
[[quartz-trigger-cron]]
=== Cron Trigger Response Structure
A cron trigger defines the cron expression that is used to determine when it has to fire.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-cron/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-cron/response-fields.adoc[]
[[quartz-trigger-simple]]
=== Simple Trigger Response Structure
A simple trigger is used to fire a Job at a given moment in time, and optionally repeated at a specified interval.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-simple/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-simple/response-fields.adoc[]
[[quartz-trigger-daily-time-interval]]
=== Daily Time Interval Trigger Response Structure
A daily time interval trigger is used to fire a Job based upon daily repeating time intervals.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-daily-time-interval/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-daily-time-interval/response-fields.adoc[]
[[quartz-trigger-calendar-interval]]
=== Calendar Interval Trigger Response Structure
A daily time interval trigger is used to fire a Job based upon repeating calendar time intervals.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-calendar-interval/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-calendar-interval/response-fields.adoc[]
[[quartz-trigger-custom]]
=== Custom Trigger Response Structure
A custom trigger is any other implementation.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-custom/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-custom/response-fields.adoc[]
[[quartz]]
= Quartz (`quartz`)
The `quartz` endpoint provides information about the scheduled jobs that are managed by
Quartz Scheduler.
[[quartz-report]]
== Retrieving the Quartz report
To retrieve the Quartz, make a `GET` request to `/application/quartz`,
as shown in the following curl-based example:
include::{snippets}quartz-report/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}quartz-report/http-response.adoc[]
[[quartz-job]]
== Retrieving the Quartz job details
To retrieve the Quartz, make a `GET` request to `/application/quartz/{group}/{name}`,
as shown in the following curl-based example:
include::{snippets}quartz-job/curl-request.adoc[]
The preceding example retrieves the job with the `group` of `groupOne` and `name` of
`jobOne`. The resulting response is similar to the following:
include::{snippets}quartz-job/http-response.adoc[]
[[quartz-job-response-structure]]
=== Response Structure
The response contains details of the scheduled job. The following table describes the
structure of the response:
[cols="2,1,3"]
include::{snippets}quartz-job/response-fields.adoc[]
/* /*
* Copyright 2012-2017 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
...@@ -20,6 +20,7 @@ import org.quartz.Scheduler; ...@@ -20,6 +20,7 @@ import org.quartz.Scheduler;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpoint; import org.springframework.boot.actuate.quartz.QuartzEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension;
import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
...@@ -33,9 +34,10 @@ import org.springframework.context.annotation.Configuration; ...@@ -33,9 +34,10 @@ import org.springframework.context.annotation.Configuration;
* {@link EnableAutoConfiguration Auto-configuration} for {@link QuartzEndpoint}. * {@link EnableAutoConfiguration Auto-configuration} for {@link QuartzEndpoint}.
* *
* @author Vedran Pavic * @author Vedran Pavic
* @since 2.0.0 * @author Stephane Nicoll
* @since 2.5.0
*/ */
@Configuration @Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Scheduler.class) @ConditionalOnClass(Scheduler.class)
@AutoConfigureAfter(QuartzAutoConfiguration.class) @AutoConfigureAfter(QuartzAutoConfiguration.class)
@ConditionalOnAvailableEndpoint(endpoint = QuartzEndpoint.class) @ConditionalOnAvailableEndpoint(endpoint = QuartzEndpoint.class)
...@@ -48,4 +50,11 @@ public class QuartzEndpointAutoConfiguration { ...@@ -48,4 +50,11 @@ public class QuartzEndpointAutoConfiguration {
return new QuartzEndpoint(scheduler); return new QuartzEndpoint(scheduler);
} }
@Bean
@ConditionalOnBean(QuartzEndpoint.class)
@ConditionalOnMissingBean
public QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint);
}
} }
/* /*
* Copyright 2012-2017 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
......
/* /*
* Copyright 2012-2017 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
package org.springframework.boot.actuate.autoconfigure.quartz; package org.springframework.boot.actuate.autoconfigure.quartz;
import org.junit.Test; import org.junit.jupiter.api.Test;
import org.quartz.Scheduler; import org.quartz.Scheduler;
import org.springframework.boot.actuate.quartz.QuartzEndpoint; import org.springframework.boot.actuate.quartz.QuartzEndpoint;
...@@ -32,30 +32,59 @@ import static org.mockito.Mockito.mock; ...@@ -32,30 +32,59 @@ import static org.mockito.Mockito.mock;
* Tests for {@link QuartzEndpointAutoConfiguration}. * Tests for {@link QuartzEndpointAutoConfiguration}.
* *
* @author Vedran Pavic * @author Vedran Pavic
* @author Stephane Nicoll
*/ */
public class QuartzEndpointAutoConfigurationTests { class QuartzEndpointAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class)) .withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class));
.withUserConfiguration(QuartzConfiguration.class);
@Test @Test
public void runShouldHaveEndpointBean() { void endpointIsAutoConfigured() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class)); this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.withPropertyValues("management.endpoints.web.exposure.include=quartz")
.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class));
} }
@Test @Test
public void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() throws Exception { void endpointIsNotAutoConfiguredIfSchedulerIsNotAvailable() {
this.contextRunner.withPropertyValues("management.endpoint.quartz.enabled:false") this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=quartz")
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class)); .run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
} }
@Configuration @Test
static class QuartzConfiguration { void endpointNotAutoConfiguredWhenNotExposed() {
this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
}
@Test
void endpointCanBeDisabled() {
this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.withPropertyValues("management.endpoint.quartz.enabled:false")
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
}
@Test
void endpointBacksOffWhenUserProvidedEndpointIsPresent() {
this.contextRunner.withUserConfiguration(CustomEndpointConfiguration.class)
.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class).hasBean("customEndpoint"));
}
@Configuration(proxyBeanMethods = false)
static class CustomEndpointConfiguration {
@Bean @Bean
public Scheduler scheduler() { CustomEndpoint customEndpoint() {
return mock(Scheduler.class); return new CustomEndpoint();
}
}
private static final class CustomEndpoint extends QuartzEndpoint {
private CustomEndpoint() {
super(mock(Scheduler.class));
} }
} }
......
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.quartz;
import org.quartz.SchedulerException;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroups;
/**
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link QuartzEndpoint}.
*
* @author Stephane Nicoll
* @since 2.5.0
*/
@EndpointWebExtension(endpoint = QuartzEndpoint.class)
public class QuartzEndpointWebExtension {
private final QuartzEndpoint delegate;
public QuartzEndpointWebExtension(QuartzEndpoint delegate) {
this.delegate = delegate;
}
@ReadOperation
public WebEndpointResponse<QuartzGroups> quartzJobOrTriggerGroups(@Selector String jobsOrTriggers)
throws SchedulerException {
return handle(jobsOrTriggers, this.delegate::quartzJobGroups, this.delegate::quartzTriggerGroups);
}
@ReadOperation
public WebEndpointResponse<Object> quartzJobOrTriggerGroup(@Selector String jobsOrTriggers, @Selector String group)
throws SchedulerException {
return handle(jobsOrTriggers, () -> this.delegate.quartzJobGroupSummary(group),
() -> this.delegate.quartzTriggerGroupSummary(group));
}
@ReadOperation
public WebEndpointResponse<Object> quartzJobOrTrigger(@Selector String jobsOrTriggers, @Selector String group,
@Selector String name) throws SchedulerException {
return handle(jobsOrTriggers, () -> this.delegate.quartzJob(group, name),
() -> this.delegate.quartzTrigger(group, name));
}
private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
ResponseSupplier<T> triggerAction) throws SchedulerException {
if ("jobs".equals(jobsOrTriggers)) {
return handleNull(jobAction.get());
}
if ("triggers".equals(jobsOrTriggers)) {
return handleNull(triggerAction.get());
}
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
private <T> WebEndpointResponse<T> handleNull(T value) {
if (value != null) {
return new WebEndpointResponse<>(value);
}
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND);
}
@FunctionalInterface
private interface ResponseSupplier<T> {
T get() throws SchedulerException;
}
}
/* /*
* Copyright 2012-2017 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
......
/*
* Copyright 2012-2019 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.quartz;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map.Entry;
import net.minidev.json.JSONArray;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SimpleTrigger;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
/**
* Integration tests for {@link QuartzEndpoint} exposed by Jersey, Spring MVC, and
* WebFlux.
*
* @author Stephane Nicoll
*/
class QuartzEndpointWebIntegrationTests {
private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne", "samples")
.usingJobData(new JobDataMap(Collections.singletonMap("name", "test"))).withDescription("A sample job")
.build();
private static final JobDetail jobTwo = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobTwo", "samples")
.build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree").build();
private static final CronTrigger triggerOne = TriggerBuilder.newTrigger().withDescription("Once a day 3AM")
.withIdentity("triggerOne").withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
private static final SimpleTrigger triggerTwo = TriggerBuilder.newTrigger().withDescription("Once a day")
.withIdentity("triggerTwo", "tests").withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build();
private static final CalendarIntervalTrigger triggerThree = TriggerBuilder.newTrigger()
.withDescription("Once a week").withIdentity("triggerThree", "tests")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1)).build();
@WebEndpointTest
void quartzReport(WebTestClient client) {
client.get().uri("/actuator/quartz").exchange().expectStatus().isOk().expectBody().jsonPath("jobs.groups")
.isEqualTo(new JSONArray().appendElement("samples").appendElement("DEFAULT"))
.jsonPath("triggers.groups").isEqualTo(new JSONArray().appendElement("DEFAULT").appendElement("tests"));
}
@WebEndpointTest
void quartzJobNames(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs").exchange().expectStatus().isOk().expectBody()
.jsonPath("groups.samples.jobs")
.isEqualTo(new JSONArray().appendElement("jobOne").appendElement("jobTwo"))
.jsonPath("groups.DEFAULT.jobs").isEqualTo(new JSONArray().appendElement("jobThree"));
}
@WebEndpointTest
void quartzTriggerNames(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers").exchange().expectStatus().isOk().expectBody()
.jsonPath("groups.DEFAULT.paused").isEqualTo(false).jsonPath("groups.DEFAULT.triggers")
.isEqualTo(new JSONArray().appendElement("triggerOne")).jsonPath("groups.tests.paused").isEqualTo(false)
.jsonPath("groups.tests.triggers")
.isEqualTo(new JSONArray().appendElement("triggerTwo").appendElement("triggerThree"));
}
@WebEndpointTest
void quartzTriggersOrJobsAreAllowed(WebTestClient client) {
client.get().uri("/actuator/quartz/something-elese").exchange().expectStatus().isBadRequest();
}
@WebEndpointTest
void quartzJobGroupSummary(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("samples").jsonPath("jobs.jobOne.className").isEqualTo(Job.class.getName())
.jsonPath("jobs.jobTwo.className").isEqualTo(DelegatingJob.class.getName());
}
@WebEndpointTest
void quartzJobGroupSummaryWithUnknownGroup(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzTriggerGroupSummary(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/tests").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("tests").jsonPath("paused").isEqualTo("false").jsonPath("triggers.cron")
.isEmpty().jsonPath("triggers.simple.triggerTwo.interval").isEqualTo(86400000)
.jsonPath("triggers.dailyTimeInterval").isEmpty()
.jsonPath("triggers.calendarInterval.triggerThree.interval").isEqualTo(604800000)
.jsonPath("triggers.custom").isEmpty();
}
@WebEndpointTest
void quartzTriggerGroupSummaryWithUnknownGroup(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzJobDetail(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples/jobOne").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("samples").jsonPath("name").isEqualTo("jobOne").jsonPath("data.name")
.isEqualTo("test");
}
@WebEndpointTest
void quartzJobDetailWithUnknownKey(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzTriggerDetail(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/DEFAULT/triggerOne").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("DEFAULT").jsonPath("name").isEqualTo("triggerOne").jsonPath("description")
.isEqualTo("Once a day 3AM").jsonPath("state").isEqualTo("NORMAL").jsonPath("type").isEqualTo("cron")
.jsonPath("simple").doesNotExist().jsonPath("calendarInterval").doesNotExist().jsonPath("dailyInterval")
.doesNotExist().jsonPath("custom").doesNotExist().jsonPath("cron.expression").isEqualTo("0 0 3 ? * *");
}
@WebEndpointTest
void quartzTriggerDetailWithUnknownKey(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound();
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
Scheduler scheduler() throws SchedulerException {
Scheduler scheduler = mock(Scheduler.class);
mockJobs(scheduler, jobOne, jobTwo, jobThree);
mockTriggers(scheduler, triggerOne, triggerTwo, triggerThree);
return scheduler;
}
@Bean
QuartzEndpoint endpoint(Scheduler scheduler) {
return new QuartzEndpoint(scheduler);
}
@Bean
QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint);
}
private void mockJobs(Scheduler scheduler, JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
JobKey key = jobDetail.getKey();
given(scheduler.getJobDetail(key)).willReturn(jobDetail);
jobKeys.add(key.getGroup(), key);
}
given(scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet()));
for (Entry<String, List<JobKey>> entry : jobKeys.entrySet()) {
given(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
void mockTriggers(Scheduler scheduler, Trigger... triggers) throws SchedulerException {
MultiValueMap<String, TriggerKey> triggerKeys = new LinkedMultiValueMap<>();
for (Trigger trigger : triggers) {
TriggerKey key = trigger.getKey();
given(scheduler.getTrigger(key)).willReturn(trigger);
given(scheduler.getTriggerState(key)).willReturn(TriggerState.NORMAL);
triggerKeys.add(key.getGroup(), key);
}
given(scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet()));
for (Entry<String, List<TriggerKey>> entry : triggerKeys.entrySet()) {
given(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
}
}
...@@ -112,6 +112,9 @@ The following technology-agnostic endpoints are available: ...@@ -112,6 +112,9 @@ The following technology-agnostic endpoints are available:
| `mappings` | `mappings`
| Displays a collated list of all `@RequestMapping` paths. | Displays a collated list of all `@RequestMapping` paths.
|`quartz`
|Shows information about Quartz Scheduler jobs.
| `scheduledtasks` | `scheduledtasks`
| Displays the scheduled tasks in your application. | Displays the scheduled tasks in your application.
...@@ -272,6 +275,10 @@ The following table shows the default exposure for the built-in endpoints: ...@@ -272,6 +275,10 @@ The following table shows the default exposure for the built-in endpoints:
| N/A | N/A
| No | No
| `quartz`
| Yes
| No
| `scheduledtasks` | `scheduledtasks`
| Yes | Yes
| No | No
......
...@@ -6,8 +6,10 @@ plugins { ...@@ -6,8 +6,10 @@ plugins {
description = "Spring Boot Quartz smoke test" description = "Spring Boot Quartz smoke test"
dependencies { dependencies {
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jdbc")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-actuator"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-quartz")) implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-quartz"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jdbc"))
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
......
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -16,9 +16,16 @@ ...@@ -16,9 +16,16 @@
package smoketest.quartz; package smoketest.quartz;
import java.util.Calendar;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CronScheduleBuilder;
import org.quartz.DailyTimeIntervalScheduleBuilder;
import org.quartz.DateBuilder.IntervalUnit;
import org.quartz.JobBuilder; import org.quartz.JobBuilder;
import org.quartz.JobDetail; import org.quartz.JobDetail;
import org.quartz.SimpleScheduleBuilder; import org.quartz.SimpleScheduleBuilder;
import org.quartz.TimeOfDay;
import org.quartz.Trigger; import org.quartz.Trigger;
import org.quartz.TriggerBuilder; import org.quartz.TriggerBuilder;
...@@ -34,18 +41,50 @@ public class SampleQuartzApplication { ...@@ -34,18 +41,50 @@ public class SampleQuartzApplication {
} }
@Bean @Bean
public JobDetail sampleJobDetail() { public JobDetail helloJobDetail() {
return JobBuilder.newJob(SampleJob.class).withIdentity("sampleJob").usingJobData("name", "World").storeDurably() return JobBuilder.newJob(SampleJob.class).withIdentity("helloJob", "samples").usingJobData("name", "World")
.build(); .storeDurably().build();
}
@Bean
public JobDetail anotherJobDetail() {
return JobBuilder.newJob(SampleJob.class).withIdentity("anotherJob", "samples").usingJobData("name", "Everyone")
.storeDurably().build();
}
@Bean
public Trigger everyTwoSecTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("sampleTrigger")
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever()).build();
}
@Bean
public Trigger everyDayTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("every-day", "samples")
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build();
} }
@Bean @Bean
public Trigger sampleJobTrigger() { public Trigger threeAmWeekdaysTrigger() {
SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2) return TriggerBuilder.newTrigger().forJob("anotherJob", "samples").withIdentity("3am-weekdays", "samples")
.repeatForever(); .withSchedule(CronScheduleBuilder.atHourAndMinuteOnGivenDaysOfWeek(3, 0, 1, 2, 3, 4, 5)).build();
}
return TriggerBuilder.newTrigger().forJob(sampleJobDetail()).withIdentity("sampleTrigger") @Bean
.withSchedule(scheduleBuilder).build(); public Trigger onceAWeekTrigger() {
return TriggerBuilder.newTrigger().forJob("anotherJob", "samples").withIdentity("once-a-week", "samples")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1))
.build();
}
@Bean
public Trigger everyHourWorkingHourTuesdayAndThursdayTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("every-hour-tue-thu", "samples")
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY)
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0))
.endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
} }
} }
spring.quartz.job-store-type=jdbc spring.quartz.job-store-type=jdbc
management.endpoints.web.exposure.include=health,quartz
\ No newline at end of file
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2021 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -38,8 +38,9 @@ import static org.hamcrest.Matchers.containsString; ...@@ -38,8 +38,9 @@ import static org.hamcrest.Matchers.containsString;
class SampleQuartzApplicationTests { class SampleQuartzApplicationTests {
@Test @Test
void quartzJobIsTriggered(CapturedOutput output) throws InterruptedException { void quartzJobIsTriggered(CapturedOutput output) {
try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class)) { try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class,
"--server.port=0")) {
Awaitility.waitAtMost(Duration.ofSeconds(5)).until(output::toString, containsString("Hello World!")); Awaitility.waitAtMost(Duration.ofSeconds(5)).until(output::toString, containsString("Hello World!"));
} }
} }
......
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package smoketest.quartz;
import java.util.Map;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.InstanceOfAssertFactory;
import org.assertj.core.api.MapAssert;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
/**
* Web tests for {@link SampleQuartzApplication}.
*
* @author Stephane Nicoll
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class SampleQuartzApplicationWebTests {
@Autowired
private TestRestTemplate restTemplate;
@Test
void quartzGroupNames() {
Map<String, Object> content = getContent("/actuator/quartz");
assertThat(content).containsOnlyKeys("jobs", "triggers");
}
@Test
void quartzJobGroups() {
Map<String, Object> content = getContent("/actuator/quartz/jobs");
assertThat(content).containsOnlyKeys("groups");
assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("samples");
}
@Test
void quartzTriggerGroups() {
Map<String, Object> content = getContent("/actuator/quartz/triggers");
assertThat(content).containsOnlyKeys("groups");
assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("DEFAULT", "samples");
}
@Test
void quartzJobDetail() {
Map<String, Object> content = getContent("/actuator/quartz/jobs/samples/helloJob");
assertThat(content).containsEntry("name", "helloJob").containsEntry("group", "samples");
}
@Test
void quartzJobDetailWhenNameDoesNotExistReturns404() {
ResponseEntity<String> response = this.restTemplate.getForEntity("/actuator/quartz/jobs/samples/does-not-exist",
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void quartzTriggerDetail() {
Map<String, Object> content = getContent("/actuator/quartz/triggers/samples/3am-weekdays");
assertThat(content).contains(entry("group", "samples"), entry("name", "3am-weekdays"), entry("state", "NORMAL"),
entry("type", "cron"));
}
@Test
void quartzTriggerDetailWhenNameDoesNotExistReturns404() {
ResponseEntity<String> response = this.restTemplate
.getForEntity("/actuator/quartz/triggers/samples/does-not-exist", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
private Map<String, Object> getContent(String path) {
ResponseEntity<Map<String, Object>> entity = asMapEntity(this.restTemplate.getForEntity(path, Map.class));
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
return entity.getBody();
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private static <K, V> ResponseEntity<Map<K, V>> asMapEntity(ResponseEntity<Map> entity) {
return (ResponseEntity) entity;
}
@SuppressWarnings("rawtypes")
private static InstanceOfAssertFactory<Map, MapAssert<String, Object>> nestedMap() {
return InstanceOfAssertFactories.map(String.class, Object.class);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment