From b11602aeaaf28c9624e185f4806cb00557f678ca Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Mon, 29 Mar 2021 09:33:46 +0200 Subject: [PATCH] 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 --- .../src/docs/asciidoc/endpoints/quartz.adoc | 247 ++++++ .../src/main/asciidoc/endpoints/quartz.adoc | 45 -- .../QuartzEndpointAutoConfiguration.java | 17 +- .../autoconfigure/quartz/package-info.java | 4 +- .../QuartzEndpointDocumentationTests.java | 432 ++++++++-- .../QuartzEndpointAutoConfigurationTests.java | 57 +- .../boot/actuate/quartz/QuartzEndpoint.java | 759 ++++++++++++++++-- .../quartz/QuartzEndpointWebExtension.java | 87 ++ .../boot/actuate/quartz/package-info.java | 4 +- .../actuate/quartz/QuartzEndpointTests.java | 693 +++++++++++++++- .../QuartzEndpointWebIntegrationTests.java | 217 +++++ .../asciidoc/production-ready-features.adoc | 7 + .../build.gradle | 4 +- .../quartz/SampleQuartzApplication.java | 57 +- .../src/main/resources/application.properties | 2 + .../quartz/SampleQuartzApplicationTests.java | 7 +- .../SampleQuartzApplicationWebTests.java | 110 +++ 17 files changed, 2503 insertions(+), 246 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/quartz.adoc delete mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/quartz.adoc create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/quartz.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/quartz.adoc new file mode 100644 index 0000000000..384e34430c --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/quartz.adoc @@ -0,0 +1,247 @@ +[[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[] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/quartz.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/quartz.adoc deleted file mode 100644 index 8a6ffc5600..0000000000 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/quartz.adoc +++ /dev/null @@ -1,45 +0,0 @@ -[[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[] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfiguration.java index 8b28066cf3..3fedb6413b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfiguration.java @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, @@ -20,6 +20,7 @@ import org.quartz.Scheduler; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; 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.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -33,9 +34,10 @@ import org.springframework.context.annotation.Configuration; * {@link EnableAutoConfiguration Auto-configuration} for {@link QuartzEndpoint}. * * @author Vedran Pavic - * @since 2.0.0 + * @author Stephane Nicoll + * @since 2.5.0 */ -@Configuration +@Configuration(proxyBeanMethods = false) @ConditionalOnClass(Scheduler.class) @AutoConfigureAfter(QuartzAutoConfiguration.class) @ConditionalOnAvailableEndpoint(endpoint = QuartzEndpoint.class) @@ -48,4 +50,11 @@ public class QuartzEndpointAutoConfiguration { return new QuartzEndpoint(scheduler); } + @Bean + @ConditionalOnBean(QuartzEndpoint.class) + @ConditionalOnMissingBean + public QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) { + return new QuartzEndpointWebExtension(endpoint); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/package-info.java index 27b1939c5f..723fd16fd7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/package-info.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/quartz/package-info.java @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, 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 5781e232cf..acb1545543 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 @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, @@ -17,33 +17,55 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; import java.util.Collections; import java.util.Date; -import java.util.HashSet; -import java.util.regex.Pattern; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map.Entry; +import java.util.TimeZone; -import org.junit.Test; +import org.junit.jupiter.api.Test; +import org.quartz.CalendarIntervalScheduleBuilder; +import org.quartz.CalendarIntervalTrigger; +import org.quartz.CronScheduleBuilder; +import org.quartz.CronTrigger; +import org.quartz.DailyTimeIntervalScheduleBuilder; +import org.quartz.DailyTimeIntervalTrigger; +import org.quartz.DateBuilder.IntervalUnit; import org.quartz.Job; import org.quartz.JobBuilder; 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.TimeOfDay; 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.quartz.spi.OperableTrigger; import org.springframework.boot.actuate.quartz.QuartzEndpoint; +import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.scheduling.quartz.DelegatingJob; +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.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.replacePattern; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -53,76 +75,374 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * Tests for generating documentation describing the {@link QuartzEndpoint}. * * @author Vedran Pavic + * @author Stephane Nicoll */ -public class QuartzEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { +class QuartzEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { - private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne", "groupOne") - .withDescription("My first job").build(); + private static final TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); - private static final JobDetail jobTwo = JobBuilder.newJob(Job.class).withIdentity("jobTwo", "groupOne").build(); + private static final JobDetail jobOne = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobOne", "samples") + .withDescription("A sample job").usingJobData("user", "admin").usingJobData("password", "secret").build(); - private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "groupTwo").build(); + private static final JobDetail jobTwo = JobBuilder.newJob(Job.class).withIdentity("jobTwo", "samples").build(); - private static final Trigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerOne") - .withDescription("My first trigger").modifiedByCalendar("myCalendar") - .startAt(Date.from(Instant.parse("2017-12-01T12:00:00Z"))) - .endAt(Date.from(Instant.parse("2017-12-01T12:30:00Z"))) - .withSchedule(SimpleScheduleBuilder.repeatMinutelyForever()).build(); + private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "tests").build(); - private static final Trigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerTwo") - .withDescription("My second trigger").modifiedByCalendar("myCalendar") - .startAt(Date.from(Instant.parse("2017-12-01T00:00:00Z"))) - .endAt(Date.from(Instant.parse("2017-12-10T00:00:00Z"))) - .withSchedule(SimpleScheduleBuilder.repeatHourlyForever()).build(); + private static final CronTrigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withPriority(3) + .withDescription("3AM on weekdays").withIdentity("3am-weekdays", "samples") + .withSchedule( + CronScheduleBuilder.atHourAndMinuteOnGivenDaysOfWeek(3, 0, 1, 2, 3, 4, 5).inTimeZone(timeZone)) + .build(); + + private static final SimpleTrigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withPriority(7) + .withDescription("Once a day").withIdentity("every-day", "samples") + .withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build(); + + private static final CalendarIntervalTrigger triggerThree = TriggerBuilder.newTrigger().forJob(jobTwo) + .withDescription("Once a week").withIdentity("once-a-week", "samples") + .withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1) + .inTimeZone(timeZone)) + .build(); + + private static final DailyTimeIntervalTrigger triggerFour = TriggerBuilder.newTrigger().forJob(jobThree) + .withDescription("Every hour between 9AM and 6PM on Tuesday and Thursday") + .withIdentity("every-hour-tue-thu") + .withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule() + .onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY) + .startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0)) + .endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR)) + .build(); + + private static final List triggerSummary = Arrays.asList(previousFireTime(""), nextFireTime(""), + priority("")); + + private static final List cronTriggerSummary = Arrays.asList( + fieldWithPath("expression").description("Cron expression to use."), + fieldWithPath("timeZone").type(JsonFieldType.STRING).optional() + .description("Time zone for which the expression will be resolved, if any.")); + + private static final List simpleTriggerSummary = Collections + .singletonList(fieldWithPath("interval").description("Interval, in milliseconds, between two executions.")); + + private static final List dailyTimeIntervalTriggerSummary = Arrays.asList( + fieldWithPath("interval").description( + "Interval, in milliseconds, added to the fire time in order to calculate the time of the next trigger repeat."), + fieldWithPath("daysOfWeek").type(JsonFieldType.ARRAY) + .description("An array of days of the week upon which to fire."), + fieldWithPath("startTimeOfDay").type(JsonFieldType.STRING) + .description("Time of day to start firing at the given interval, if any."), + fieldWithPath("endTimeOfDay").type(JsonFieldType.STRING) + .description("Time of day to complete firing at the given interval, if any.")); + + private static final List calendarIntervalTriggerSummary = Arrays.asList( + fieldWithPath("interval").description( + "Interval, in milliseconds, added to the fire time in order to calculate the time of the next trigger repeat."), + fieldWithPath("timeZone").type(JsonFieldType.STRING) + .description("Time zone within which time calculations will be performed, if any.")); + + private static final List customTriggerSummary = Collections.singletonList( + fieldWithPath("trigger").description("A toString representation of the custom trigger instance.")); + + private static final FieldDescriptor[] commonCronDetails = new FieldDescriptor[] { + fieldWithPath("group").description("Name of the group."), + fieldWithPath("name").description("Name of the trigger."), + fieldWithPath("description").description("Description of the trigger, if any."), + fieldWithPath("state") + .description("State of the trigger, can be NONE, NORMAL, PAUSED, COMPLETE, ERROR, or BLOCKED."), + fieldWithPath("type").description( + "Type of the trigger, determine the key of the object containing implementation-specific details."), + fieldWithPath("calendarName").description("Name of the Calendar associated with this Trigger, if any."), + startTime(""), endTime(""), previousFireTime(""), nextFireTime(""), priority(""), + fieldWithPath("finalFireTime").optional().type(JsonFieldType.STRING) + .description("Last time at which the Trigger will fire, if any."), + fieldWithPath("data").optional().type(JsonFieldType.OBJECT) + .description("Job data map keyed by name, if any.") }; @MockBean private Scheduler scheduler; @Test - public void quartzReport() throws Exception { - String groupOne = jobOne.getKey().getGroup(); - String groupTwo = jobThree.getKey().getGroup(); - given(this.scheduler.getJobGroupNames()).willReturn(Arrays.asList(groupOne, groupTwo)); - given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupOne))) - .willReturn(new HashSet<>(Arrays.asList(jobOne.getKey(), jobTwo.getKey()))); - given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupTwo))) - .willReturn(Collections.singleton(jobThree.getKey())); - this.mockMvc.perform(get("/application/quartz")).andExpect(status().isOk()).andDo(document("quartz/report")); + void quartzReport() throws Exception { + mockJobs(jobOne, jobTwo, jobThree); + mockTriggers(triggerOne, triggerTwo, triggerThree, triggerFour); + this.mockMvc.perform(get("/actuator/quartz")).andExpect(status().isOk()) + .andDo(document("quartz/report", + responseFields(fieldWithPath("jobs.groups").description("An array of job group names."), + fieldWithPath("triggers.groups").description("An array of trigger group names.")))); + } + + @Test + void quartzJobs() throws Exception { + mockJobs(jobOne, jobTwo, jobThree); + this.mockMvc.perform(get("/actuator/quartz/jobs")).andExpect(status().isOk()).andDo( + document("quartz/jobs", responseFields(fieldWithPath("groups").description("Job groups keyed by name."), + fieldWithPath("groups.*.jobs").description("An array of job names.")))); + } + + @Test + void quartzTriggers() throws Exception { + mockTriggers(triggerOne, triggerTwo, triggerThree, triggerFour); + this.mockMvc.perform(get("/actuator/quartz/triggers")).andExpect(status().isOk()) + .andDo(document("quartz/triggers", + responseFields(fieldWithPath("groups").description("Trigger groups keyed by name."), + fieldWithPath("groups.*.paused").description("Whether this trigger group is paused."), + fieldWithPath("groups.*.triggers").description("An array of trigger names.")))); + } + + @Test + void quartzJobGroup() throws Exception { + mockJobs(jobOne, jobTwo, jobThree); + this.mockMvc.perform(get("/actuator/quartz/jobs/samples")).andExpect(status().isOk()) + .andDo(document("quartz/job-group", + responseFields(fieldWithPath("group").description("Name of the group."), + fieldWithPath("jobs").description("Job details keyed by name."), + fieldWithPath("jobs.*.className") + .description("Fully qualified name of the job implementation.")))); + } + + @Test + void quartzTriggerGroup() throws Exception { + CronTrigger cron = triggerOne.getTriggerBuilder().startAt(fromUtc("2020-11-30T17:00:00Z")) + .endAt(fromUtc("2020-12-30T03:00:00Z")).withIdentity("3am-week", "tests").build(); + setPreviousNextFireTime(cron, "2020-12-04T03:00:00Z", "2020-12-07T03:00:00Z"); + SimpleTrigger simple = triggerTwo.getTriggerBuilder().withIdentity("every-day", "tests").build(); + setPreviousNextFireTime(simple, null, "2020-12-04T12:00:00Z"); + CalendarIntervalTrigger calendarInterval = triggerThree.getTriggerBuilder().withIdentity("once-a-week", "tests") + .startAt(fromUtc("2019-07-10T14:00:00Z")).endAt(fromUtc("2023-01-01T12:00:00Z")).build(); + setPreviousNextFireTime(calendarInterval, "2020-12-02T14:00:00Z", "2020-12-08T14:00:00Z"); + DailyTimeIntervalTrigger tueThuTrigger = triggerFour.getTriggerBuilder().withIdentity("tue-thu", "tests") + .build(); + Trigger customTrigger = mock(Trigger.class); + given(customTrigger.getKey()).willReturn(TriggerKey.triggerKey("once-a-year-custom", "tests")); + given(customTrigger.toString()).willReturn("com.example.CustomTrigger@fdsfsd"); + given(customTrigger.getPriority()).willReturn(10); + given(customTrigger.getPreviousFireTime()).willReturn(fromUtc("2020-07-14T16:00:00Z")); + given(customTrigger.getNextFireTime()).willReturn(fromUtc("2021-07-14T16:00:00Z")); + mockTriggers(cron, simple, calendarInterval, tueThuTrigger, customTrigger); + this.mockMvc.perform(get("/actuator/quartz/triggers/tests")).andExpect(status().isOk()).andDo(document( + "quartz/trigger-group", + responseFields(fieldWithPath("group").description("Name of the group."), + fieldWithPath("paused").description("Whether the group is paused."), + fieldWithPath("triggers.cron").description("Cron triggers keyed by name, if any."), + fieldWithPath("triggers.simple").description("Simple triggers keyed by name, if any."), + fieldWithPath("triggers.dailyTimeInterval") + .description("Daily time interval triggers keyed by name, if any."), + fieldWithPath("triggers.calendarInterval") + .description("Calendar interval triggers keyed by name, if any."), + fieldWithPath("triggers.custom").description("Any other triggers keyed by name, if any.")) + .andWithPrefix("triggers.cron.*.", concat(triggerSummary, cronTriggerSummary)) + .andWithPrefix("triggers.simple.*.", concat(triggerSummary, simpleTriggerSummary)) + .andWithPrefix("triggers.dailyTimeInterval.*.", + concat(triggerSummary, dailyTimeIntervalTriggerSummary)) + .andWithPrefix("triggers.calendarInterval.*.", + concat(triggerSummary, calendarIntervalTriggerSummary)) + .andWithPrefix("triggers.custom.*.", concat(triggerSummary, customTriggerSummary)))); + } + + @Test + void quartzJob() throws Exception { + mockJobs(jobOne); + CronTrigger firstTrigger = triggerOne.getTriggerBuilder().build(); + setPreviousNextFireTime(firstTrigger, null, "2020-12-07T03:00:00Z"); + SimpleTrigger secondTrigger = triggerTwo.getTriggerBuilder().build(); + setPreviousNextFireTime(secondTrigger, "2020-12-04T03:00:00Z", "2020-12-04T12:00:00Z"); + mockTriggers(firstTrigger, secondTrigger); + given(this.scheduler.getTriggersOfJob(jobOne.getKey())) + .willAnswer((invocation) -> Arrays.asList(firstTrigger, secondTrigger)); + this.mockMvc.perform(get("/actuator/quartz/jobs/samples/jobOne")).andExpect(status().isOk()).andDo(document( + "quartz/job-details", + responseFields(fieldWithPath("group").description("Name of the group."), + fieldWithPath("name").description("Name of the job."), + fieldWithPath("description").description("Description of the job, if any."), + fieldWithPath("className").description("Fully qualified name of the job implementation."), + fieldWithPath("durable") + .description("Whether the job should remain stored after it is orphaned."), + fieldWithPath("requestRecovery").description( + "Whether the job should be re-executed if a 'recovery' or 'fail-over' situation is encountered."), + fieldWithPath("data.*").description("Job data map as key/value pairs, if any."), + fieldWithPath("triggers").description("An array of triggers associated to the job, if any."), + fieldWithPath("triggers.[].group").description("Name of the the trigger group."), + fieldWithPath("triggers.[].name").description("Name of the the trigger."), + previousFireTime("triggers.[]."), nextFireTime("triggers.[]."), priority("triggers.[].")))); + } + + @Test + void quartzTriggerCron() throws Exception { + setupTriggerDetails(triggerOne.getTriggerBuilder(), TriggerState.NORMAL); + this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk()) + .andDo(document("quartz/trigger-details-cron", + responseFields(commonCronDetails) + .and(fieldWithPath("cron").description("Cron trigger specific details.")) + .andWithPrefix("cron.", cronTriggerSummary))); + } + + @Test + void quartzTriggerSimple() throws Exception { + setupTriggerDetails(triggerTwo.getTriggerBuilder(), TriggerState.NORMAL); + this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk()) + .andDo(document("quartz/trigger-details-simple", + responseFields(commonCronDetails) + .and(fieldWithPath("simple").description("Simple trigger specific details.")) + .andWithPrefix("simple.", simpleTriggerSummary) + .and(repeatCount("simple."), timesTriggered("simple.")))); + } + + @Test + void quartzTriggerCalendarInterval() throws Exception { + setupTriggerDetails(triggerThree.getTriggerBuilder(), TriggerState.NORMAL); + this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk()) + .andDo(document("quartz/trigger-details-calendar-interval", responseFields(commonCronDetails) + .and(fieldWithPath("calendarInterval") + .description("Calendar interval trigger specific details.")) + .andWithPrefix("calendarInterval.", calendarIntervalTriggerSummary) + .and(timesTriggered("calendarInterval."), + fieldWithPath("calendarInterval.preserveHourOfDayAcrossDaylightSavings").description( + "Whether to fire the trigger at the same time of day, regardless of daylight " + + "saving time transitions."), + fieldWithPath("calendarInterval.skipDayIfHourDoesNotExist").description( + "Whether to skip if the hour of the day does not exist on a given day.")))); + } + + @Test + void quartzTriggerDailyTimeInterval() throws Exception { + setupTriggerDetails(triggerFour.getTriggerBuilder(), TriggerState.PAUSED); + this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk()) + .andDo(document("quartz/trigger-details-daily-time-interval", + responseFields(commonCronDetails) + .and(fieldWithPath("dailyTimeInterval") + .description("Daily time interval trigger specific details.")) + .andWithPrefix("dailyTimeInterval.", dailyTimeIntervalTriggerSummary) + .and(repeatCount("dailyTimeInterval."), timesTriggered("dailyTimeInterval.")))); } @Test - public void quartzJob() throws Exception { - JobKey jobKey = jobOne.getKey(); - given(this.scheduler.getJobDetail(jobKey)).willReturn(jobOne); - given(this.scheduler.getTriggersOfJob(jobKey)).willAnswer(invocation -> Arrays.asList(triggerOne, triggerTwo)); - this.mockMvc.perform(get("/application/quartz/groupOne/jobOne")).andExpect(status().isOk()).andDo(document( - "quartz/job", - preprocessResponse(replacePattern(Pattern.compile("org.quartz.Job"), "com.example.MyJob")), - responseFields(fieldWithPath("jobGroup").description("Job group."), - fieldWithPath("jobName").description("Job name."), - fieldWithPath("description").description("Job description, if any."), - fieldWithPath("className").description("Job class."), - fieldWithPath("triggers.[].triggerGroup").description("Trigger group."), - fieldWithPath("triggers.[].triggerName").description("Trigger name."), - fieldWithPath("triggers.[].description").description("Trigger description, if any."), - fieldWithPath("triggers.[].calendarName").description("Trigger's calendar name, if any."), - fieldWithPath("triggers.[].startTime").description("Trigger's start time."), - fieldWithPath("triggers.[].endTime").description("Trigger's end time."), - fieldWithPath("triggers.[].nextFireTime").description("Trigger's next fire time."), - fieldWithPath("triggers.[].previousFireTime") - .description("Trigger's previous fire time, if any."), - fieldWithPath("triggers.[].finalFireTime").description("Trigger's final fire time, if any.")))); - } - - @Configuration + void quartzTriggerCustom() throws Exception { + Trigger trigger = mock(Trigger.class); + given(trigger.getKey()).willReturn(TriggerKey.triggerKey("example", "samples")); + given(trigger.getDescription()).willReturn("Example trigger."); + given(trigger.toString()).willReturn("com.example.CustomTrigger@fdsfsd"); + given(trigger.getPriority()).willReturn(10); + given(trigger.getStartTime()).willReturn(fromUtc("2020-11-30T17:00:00Z")); + given(trigger.getEndTime()).willReturn(fromUtc("2020-12-30T03:00:00Z")); + given(trigger.getCalendarName()).willReturn("bankHolidays"); + given(trigger.getPreviousFireTime()).willReturn(fromUtc("2020-12-04T03:00:00Z")); + given(trigger.getNextFireTime()).willReturn(fromUtc("2020-12-07T03:00:00Z")); + given(this.scheduler.getTriggerState(trigger.getKey())).willReturn(TriggerState.NORMAL); + mockTriggers(trigger); + this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk()) + .andDo(document("quartz/trigger-details-custom", + responseFields(commonCronDetails) + .and(fieldWithPath("custom").description("Custom trigger specific details.")) + .andWithPrefix("custom.", customTriggerSummary))); + } + + private T setupTriggerDetails(TriggerBuilder builder, TriggerState state) + throws SchedulerException { + T trigger = builder.withIdentity("example", "samples").withDescription("Example trigger") + .startAt(fromUtc("2020-11-30T17:00:00Z")).modifiedByCalendar("bankHolidays") + .endAt(fromUtc("2020-12-30T03:00:00Z")).build(); + setPreviousNextFireTime(trigger, "2020-12-04T03:00:00Z", "2020-12-07T03:00:00Z"); + given(this.scheduler.getTriggerState(trigger.getKey())).willReturn(state); + mockTriggers(trigger); + return trigger; + } + + private static FieldDescriptor startTime(String prefix) { + return fieldWithPath(prefix + "startTime").description("Time at which the Trigger should take effect, if any."); + } + + private static FieldDescriptor endTime(String prefix) { + return fieldWithPath(prefix + "endTime").description( + "Time at which the Trigger should quit repeating, regardless of any remaining repeats, if any."); + } + + private static FieldDescriptor previousFireTime(String prefix) { + return fieldWithPath(prefix + "previousFireTime").optional().type(JsonFieldType.STRING) + .description("Last time the trigger fired, if any."); + } + + private static FieldDescriptor nextFireTime(String prefix) { + return fieldWithPath(prefix + "nextFireTime").optional().type(JsonFieldType.STRING) + .description("Next time at which the Trigger is scheduled to fire, if any."); + } + + private static FieldDescriptor priority(String prefix) { + return fieldWithPath(prefix + "priority") + .description("Priority to use if two triggers have the same scheduled fire time."); + } + + private static FieldDescriptor repeatCount(String prefix) { + return fieldWithPath(prefix + "repeatCount") + .description("Number of times the trigger should repeat, or -1 to repeat indefinitely."); + } + + private static FieldDescriptor timesTriggered(String prefix) { + return fieldWithPath(prefix + "timesTriggered").description("Number of times the trigger has already fired."); + } + + private static List concat(List initial, List additionalFields) { + List result = new ArrayList<>(initial); + result.addAll(additionalFields); + return result; + } + + private void mockJobs(JobDetail... jobs) throws SchedulerException { + MultiValueMap jobKeys = new LinkedMultiValueMap<>(); + for (JobDetail jobDetail : jobs) { + JobKey key = jobDetail.getKey(); + given(this.scheduler.getJobDetail(key)).willReturn(jobDetail); + jobKeys.add(key.getGroup(), key); + } + given(this.scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet())); + for (Entry> entry : jobKeys.entrySet()) { + given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + private void mockTriggers(Trigger... triggers) throws SchedulerException { + MultiValueMap triggerKeys = new LinkedMultiValueMap<>(); + for (Trigger trigger : triggers) { + TriggerKey key = trigger.getKey(); + given(this.scheduler.getTrigger(key)).willReturn(trigger); + triggerKeys.add(key.getGroup(), key); + } + given(this.scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet())); + for (Entry> entry : triggerKeys.entrySet()) { + given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + private T setPreviousNextFireTime(T trigger, String previousFireTime, String nextFireTime) { + OperableTrigger operableTrigger = (OperableTrigger) trigger; + if (previousFireTime != null) { + operableTrigger.setPreviousFireTime(fromUtc(previousFireTime)); + } + if (nextFireTime != null) { + operableTrigger.setNextFireTime(fromUtc(nextFireTime)); + } + return trigger; + } + + private static Date fromUtc(String utcTime) { + return Date.from(Instant.parse(utcTime)); + } + + @Configuration(proxyBeanMethods = false) @Import(BaseDocumentationConfiguration.class) static class TestConfiguration { @Bean - public QuartzEndpoint endpoint(Scheduler scheduler) { + QuartzEndpoint endpoint(Scheduler scheduler) { return new QuartzEndpoint(scheduler); } + @Bean + QuartzEndpointWebExtension endpointWebExtension(QuartzEndpoint endpoint) { + return new QuartzEndpointWebExtension(endpoint); + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfigurationTests.java index 090079aba8..5343b9b0de 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/quartz/QuartzEndpointAutoConfigurationTests.java @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,7 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.quartz; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.quartz.Scheduler; import org.springframework.boot.actuate.quartz.QuartzEndpoint; @@ -32,30 +32,59 @@ import static org.mockito.Mockito.mock; * Tests for {@link QuartzEndpointAutoConfiguration}. * * @author Vedran Pavic + * @author Stephane Nicoll */ -public class QuartzEndpointAutoConfigurationTests { +class QuartzEndpointAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class)) - .withUserConfiguration(QuartzConfiguration.class); + .withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class)); @Test - public void runShouldHaveEndpointBean() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class)); + void endpointIsAutoConfigured() { + this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class)) + .withPropertyValues("management.endpoints.web.exposure.include=quartz") + .run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class)); } @Test - public void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() throws Exception { - this.contextRunner.withPropertyValues("management.endpoint.quartz.enabled:false") + void endpointIsNotAutoConfiguredIfSchedulerIsNotAvailable() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=quartz") .run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class)); } - @Configuration - static class QuartzConfiguration { + @Test + 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 - public Scheduler scheduler() { - return mock(Scheduler.class); + CustomEndpoint customEndpoint() { + return new CustomEndpoint(); + } + + } + + private static final class CustomEndpoint extends QuartzEndpoint { + + private CustomEndpoint() { + super(mock(Scheduler.class)); } } 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 92116377ff..8e22ba4c51 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 @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,99 +16,421 @@ package org.springframework.boot.actuate.quartz; +import java.time.Duration; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.ArrayList; -import java.util.Date; +import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import org.quartz.CalendarIntervalTrigger; +import org.quartz.CronTrigger; +import org.quartz.DailyTimeIntervalTrigger; +import org.quartz.DateBuilder.IntervalUnit; import org.quartz.Job; +import org.quartz.JobDataMap; import org.quartz.JobDetail; import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; +import org.quartz.SimpleTrigger; +import org.quartz.TimeOfDay; import org.quartz.Trigger; +import org.quartz.Trigger.TriggerState; +import org.quartz.TriggerKey; import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.boot.actuate.endpoint.Sanitizer; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; -import org.springframework.boot.actuate.endpoint.annotation.Selector; import org.springframework.util.Assert; /** - * {@link Endpoint} to expose Quartz Scheduler info. + * {@link Endpoint} to expose Quartz Scheduler jobs and triggers. * * @author Vedran Pavic - * @since 2.0.0 + * @author Stephane Nicoll + * @since 2.5.0 */ @Endpoint(id = "quartz") public class QuartzEndpoint { + private static final Comparator TRIGGER_COMPARATOR = Comparator + .comparing(Trigger::getNextFireTime, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(Comparator.comparingInt(Trigger::getPriority).reversed()); + private final Scheduler scheduler; - public QuartzEndpoint(Scheduler scheduler) { + private final Sanitizer sanitizer; + + /** + * Create an instance for the specified {@link Scheduler} and {@link Sanitizer}. + * @param scheduler the scheduler to use to retrieve jobs and triggers details + * @param sanitizer the sanitizer to use to sanitize data maps + */ + public QuartzEndpoint(Scheduler scheduler, Sanitizer sanitizer) { Assert.notNull(scheduler, "Scheduler must not be null"); + Assert.notNull(sanitizer, "Sanitizer must not be null"); this.scheduler = scheduler; + this.sanitizer = sanitizer; } + /** + * Create an instance for the specified {@link Scheduler} using a default + * {@link Sanitizer}. + * @param scheduler the scheduler to use to retrieve jobs and triggers details + */ + public QuartzEndpoint(Scheduler scheduler) { + this(scheduler, new Sanitizer()); + } + + /** + * Return the available job and trigger group names. + * @return a report of the available group names + * @throws SchedulerException if retrieving the information from the scheduler failed + */ @ReadOperation - public Map quartzReport() { + public QuartzReport quartzReport() throws SchedulerException { + return new QuartzReport(new GroupNames(this.scheduler.getJobGroupNames()), + new GroupNames(this.scheduler.getTriggerGroupNames())); + } + + /** + * Return the available job names, identified by group name. + * @return the available job names + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public QuartzGroups quartzJobGroups() throws SchedulerException { Map result = new LinkedHashMap<>(); - try { - for (String groupName : this.scheduler.getJobGroupNames()) { - List jobs = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupName)).stream() - .map(JobKey::getName).collect(Collectors.toList()); - result.put(groupName, jobs); - } + for (String groupName : this.scheduler.getJobGroupNames()) { + List jobs = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupName)).stream() + .map((key) -> key.getName()).collect(Collectors.toList()); + result.put(groupName, Collections.singletonMap("jobs", jobs)); } - catch (SchedulerException ignored) { + return new QuartzGroups(result); + } + + /** + * Return the available trigger names, identified by group name. + * @return the available trigger names + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public QuartzGroups quartzTriggerGroups() throws SchedulerException { + Map result = new LinkedHashMap<>(); + Set pausedTriggerGroups = this.scheduler.getPausedTriggerGroups(); + for (String groupName : this.scheduler.getTriggerGroupNames()) { + Map groupDetails = new LinkedHashMap<>(); + groupDetails.put("paused", pausedTriggerGroups.contains(groupName)); + groupDetails.put("triggers", this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(groupName)) + .stream().map((key) -> key.getName()).collect(Collectors.toList())); + result.put(groupName, groupDetails); } - return result; + return new QuartzGroups(result); } - @ReadOperation - public QuartzJob quartzJob(@Selector String groupName, @Selector String jobName) { - try { - JobKey jobKey = JobKey.jobKey(jobName, groupName); - JobDetail jobDetail = this.scheduler.getJobDetail(jobKey); - List triggers = this.scheduler.getTriggersOfJob(jobKey); - return new QuartzJob(jobDetail, triggers); + /** + * Return a summary of the jobs group with the specified name or {@code null} if no + * such group exists. + * @param group the name of a jobs group + * @return a summary of the jobs in the given {@code group} + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public QuartzJobGroupSummary quartzJobGroupSummary(String group) throws SchedulerException { + List jobs = findJobsByGroup(group); + if (jobs.isEmpty() && !this.scheduler.getJobGroupNames().contains(group)) { + return null; + } + Map result = new LinkedHashMap<>(); + for (JobDetail job : jobs) { + result.put(job.getKey().getName(), QuartzJobSummary.of(job)); + } + return new QuartzJobGroupSummary(group, result); + } + + private List findJobsByGroup(String group) throws SchedulerException { + List jobs = new ArrayList<>(); + Set jobKeys = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group)); + for (JobKey jobKey : jobKeys) { + jobs.add(this.scheduler.getJobDetail(jobKey)); } - catch (SchedulerException e) { + return jobs; + } + + /** + * Return a summary of the triggers group with the specified name or {@code null} if + * no such group exists. + * @param group the name of a triggers group + * @return a summary of the triggers in the given {@code group} + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public QuartzTriggerGroupSummary quartzTriggerGroupSummary(String group) throws SchedulerException { + List triggers = findTriggersByGroup(group); + if (triggers.isEmpty() && !this.scheduler.getTriggerGroupNames().contains(group)) { return null; } + Map> result = new LinkedHashMap<>(); + triggers.forEach((trigger) -> { + TriggerDescription triggerDescription = TriggerDescription.of(trigger); + Map triggerTypes = result.computeIfAbsent(triggerDescription.getType(), + (key) -> new LinkedHashMap<>()); + triggerTypes.put(trigger.getKey().getName(), triggerDescription.buildSummary(true)); + }); + boolean paused = this.scheduler.getPausedTriggerGroups().contains(group); + return new QuartzTriggerGroupSummary(group, paused, result); + } + + private List findTriggersByGroup(String group) throws SchedulerException { + List triggers = new ArrayList<>(); + Set triggerKeys = this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group)); + for (TriggerKey triggerKey : triggerKeys) { + triggers.add(this.scheduler.getTrigger(triggerKey)); + } + return triggers; + } + + /** + * Return the {@link QuartzJobDetails details of 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 + * @return the details of the job or {@code null} if such job does not exist + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public QuartzJobDetails quartzJob(String groupName, String jobName) throws SchedulerException { + JobKey jobKey = JobKey.jobKey(jobName, groupName); + JobDetail jobDetail = this.scheduler.getJobDetail(jobKey); + if (jobDetail != null) { + List triggers = this.scheduler.getTriggersOfJob(jobKey); + return new QuartzJobDetails(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(), + jobDetail.getDescription(), jobDetail.getJobClass().getName(), jobDetail.isDurable(), + jobDetail.requestsRecovery(), sanitizeJobDataMap(jobDetail.getJobDataMap()), + extractTriggersSummary(triggers)); + } + return null; + } + + private static List> extractTriggersSummary(List triggers) { + List triggersToSort = new ArrayList<>(triggers); + triggersToSort.sort(TRIGGER_COMPARATOR); + List> result = new ArrayList<>(); + triggersToSort.forEach((trigger) -> { + Map triggerSummary = new LinkedHashMap<>(); + triggerSummary.put("group", trigger.getKey().getGroup()); + triggerSummary.put("name", trigger.getKey().getName()); + triggerSummary.putAll(TriggerDescription.of(trigger).buildSummary(false)); + result.add(triggerSummary); + }); + return result; + } + + /** + * Return the details of the trigger identified by the given group name and trigger + * name. + * @param groupName the name of the group + * @param triggerName the name of the trigger + * @return the details of the trigger or {@code null} if such trigger does not exist + * @throws SchedulerException if retrieving the information from the scheduler failed + */ + public Map quartzTrigger(String groupName, String triggerName) throws SchedulerException { + TriggerKey triggerKey = TriggerKey.triggerKey(triggerName, groupName); + Trigger trigger = this.scheduler.getTrigger(triggerKey); + return (trigger != null) ? TriggerDescription.of(trigger).buildDetails( + this.scheduler.getTriggerState(triggerKey), sanitizeJobDataMap(trigger.getJobDataMap())) : null; + } + + private static Duration getIntervalDuration(long amount, IntervalUnit unit) { + return temporalUnit(unit).getDuration().multipliedBy(amount); + } + + private static LocalTime getLocalTime(TimeOfDay timeOfDay) { + return (timeOfDay != null) ? LocalTime.of(timeOfDay.getHour(), timeOfDay.getMinute(), timeOfDay.getSecond()) + : null; + } + + private Map sanitizeJobDataMap(JobDataMap dataMap) { + if (dataMap != null) { + Map map = new LinkedHashMap<>(dataMap.getWrappedMap()); + map.replaceAll(this.sanitizer::sanitize); + return map; + } + return null; + } + + private static TemporalUnit temporalUnit(IntervalUnit unit) { + switch (unit) { + case DAY: + return ChronoUnit.DAYS; + case HOUR: + return ChronoUnit.HOURS; + case MINUTE: + return ChronoUnit.MINUTES; + case MONTH: + return ChronoUnit.MONTHS; + case SECOND: + return ChronoUnit.SECONDS; + case MILLISECOND: + return ChronoUnit.MILLIS; + case WEEK: + return ChronoUnit.WEEKS; + case YEAR: + return ChronoUnit.YEARS; + default: + throw new IllegalArgumentException("Unknown IntervalUnit"); + } + } + + /** + * A report of available job and trigger group names, primarily intended for + * serialization to JSON. + */ + public static final class QuartzReport { + + private final GroupNames jobs; + + private final GroupNames triggers; + + QuartzReport(GroupNames jobs, GroupNames triggers) { + this.jobs = jobs; + this.triggers = triggers; + } + + public GroupNames getJobs() { + return this.jobs; + } + + public GroupNames getTriggers() { + return this.triggers; + } + + } + + /** + * A set of group names, primarily intended for serialization to JSON. + */ + public static class GroupNames { + + private final Set groups; + + public GroupNames(List groups) { + this.groups = new LinkedHashSet<>(groups); + } + + public Set getGroups() { + return this.groups; + } + + } + + /** + * A summary for each group identified by name, primarily intended for serialization + * to JSON. + */ + public static class QuartzGroups { + + private final Map groups; + + public QuartzGroups(Map groups) { + this.groups = groups; + } + + public Map getGroups() { + return this.groups; + } + + } + + /** + * A summary report of the {@link JobDetail jobs} in a given group. + */ + public static final class QuartzJobGroupSummary { + + private final String group; + + private final Map jobs; + + private QuartzJobGroupSummary(String group, Map jobs) { + this.group = group; + this.jobs = jobs; + } + + public String getGroup() { + return this.group; + } + + public Map getJobs() { + return this.jobs; + } + } /** - * Details of a {@link Job Quartz Job}. + * Details of a {@link Job Quartz Job}, primarily intended for serialization to JSON. */ - public static final class QuartzJob { + public static final class QuartzJobSummary { - private final String jobGroup; + private final String className; + + private QuartzJobSummary(JobDetail job) { + this.className = job.getJobClass().getName(); + } - private final String jobName; + private static QuartzJobSummary of(JobDetail job) { + return new QuartzJobSummary(job); + } + + public String getClassName() { + return this.className; + } + + } + + /** + * Details of a {@link Job Quartz Job}, primarily intended for serialization to JSON. + */ + public static final class QuartzJobDetails { + + private final String group; + + private final String name; private final String description; private final String className; - private final List triggers = new ArrayList<>(); + private final boolean durable; - QuartzJob(JobDetail jobDetail, List triggers) { - this.jobGroup = jobDetail.getKey().getGroup(); - this.jobName = jobDetail.getKey().getName(); - this.description = jobDetail.getDescription(); - this.className = jobDetail.getJobClass().getName(); - triggers.forEach(trigger -> this.triggers.add(new QuartzTrigger(trigger))); + private final boolean requestRecovery; + + private final Map data; + + private final List> triggers; + + QuartzJobDetails(String group, String name, String description, String className, boolean durable, + boolean requestRecovery, Map data, List> triggers) { + this.group = group; + this.name = name; + this.description = description; + this.className = className; + this.durable = durable; + this.requestRecovery = requestRecovery; + this.data = data; + this.triggers = triggers; } - public String getJobGroup() { - return this.jobGroup; + public String getGroup() { + return this.group; } - public String getJobName() { - return this.jobName; + public String getName() { + return this.name; } public String getDescription() { @@ -119,81 +441,360 @@ public class QuartzEndpoint { return this.className; } - public List getTriggers() { + public boolean isDurable() { + return this.durable; + } + + public boolean isRequestRecovery() { + return this.requestRecovery; + } + + public Map getData() { + return this.data; + } + + public List> getTriggers() { return this.triggers; } } /** - * Details of a {@link Trigger Quartz Trigger}. + * A summary report of the {@link Trigger triggers} in a given group. */ - public static final class QuartzTrigger { + public static final class QuartzTriggerGroupSummary { - private final String triggerGroup; + private final String group; - private final String triggerName; + private final boolean paused; - private final String description; + private final Triggers triggers; + + private QuartzTriggerGroupSummary(String group, boolean paused, + Map> descriptionsByType) { + this.group = group; + this.paused = paused; + this.triggers = new Triggers(descriptionsByType); + + } + + public String getGroup() { + return this.group; + } + + public boolean isPaused() { + return this.paused; + } + + public Triggers getTriggers() { + return this.triggers; + } + + public static final class Triggers { + + private final Map cron; + + private final Map simple; + + private final Map dailyTimeInterval; + + private final Map calendarInterval; + + private final Map custom; - private final String calendarName; + private Triggers(Map> descriptionsByType) { + this.cron = descriptionsByType.getOrDefault(TriggerType.CRON, Collections.emptyMap()); + this.dailyTimeInterval = descriptionsByType.getOrDefault(TriggerType.DAILY_INTERVAL, + Collections.emptyMap()); + this.calendarInterval = descriptionsByType.getOrDefault(TriggerType.CALENDAR_INTERVAL, + Collections.emptyMap()); + this.simple = descriptionsByType.getOrDefault(TriggerType.SIMPLE, Collections.emptyMap()); + this.custom = descriptionsByType.getOrDefault(TriggerType.CUSTOM_TRIGGER, Collections.emptyMap()); + } + + public Map getCron() { + return this.cron; + } + + public Map getSimple() { + return this.simple; + } + + public Map getDailyTimeInterval() { + return this.dailyTimeInterval; + } + + public Map getCalendarInterval() { + return this.calendarInterval; + } + + public Map getCustom() { + return this.custom; + } + + } + + } - private final Date startTime; + private enum TriggerType { - private final Date endTime; + CRON("cron"), - private final Date previousFireTime; + CUSTOM_TRIGGER("custom"), - private final Date nextFireTime; + CALENDAR_INTERVAL("calendarInterval"), - private final Date finalFireTime; + DAILY_INTERVAL("dailyTimeInterval"), - QuartzTrigger(Trigger trigger) { - this.triggerGroup = trigger.getKey().getGroup(); - this.triggerName = trigger.getKey().getName(); - this.description = trigger.getDescription(); - this.calendarName = trigger.getCalendarName(); - this.startTime = trigger.getStartTime(); - this.endTime = trigger.getEndTime(); - this.previousFireTime = trigger.getPreviousFireTime(); - this.nextFireTime = trigger.getNextFireTime(); - this.finalFireTime = trigger.getFinalFireTime(); + SIMPLE("simple"); + + private final String id; + + TriggerType(String id) { + this.id = id; } - public String getTriggerGroup() { - return this.triggerGroup; + public String getId() { + return this.id; } - public String getTriggerName() { - return this.triggerName; + } + + /** + * Base class for descriptions of a {@link Trigger}. + */ + public abstract static class TriggerDescription { + + private static final Map, Function> DESCRIBERS = new LinkedHashMap<>(); + + static { + DESCRIBERS.put(CronTrigger.class, (trigger) -> new CronTriggerDescription((CronTrigger) trigger)); + DESCRIBERS.put(SimpleTrigger.class, (trigger) -> new SimpleTriggerDescription((SimpleTrigger) trigger)); + DESCRIBERS.put(DailyTimeIntervalTrigger.class, + (trigger) -> new DailyTimeIntervalTriggerDescription((DailyTimeIntervalTrigger) trigger)); + DESCRIBERS.put(CalendarIntervalTrigger.class, + (trigger) -> new CalendarIntervalTriggerDescription((CalendarIntervalTrigger) trigger)); } - public String getDescription() { - return this.description; + private final Trigger trigger; + + private final TriggerType type; + + private static TriggerDescription of(Trigger trigger) { + return DESCRIBERS.entrySet().stream().filter((entry) -> entry.getKey().isInstance(trigger)) + .map((entry) -> entry.getValue().apply(trigger)).findFirst() + .orElse(new CustomTriggerDescription(trigger)); } - public String getCalendarName() { - return this.calendarName; + protected TriggerDescription(Trigger trigger, TriggerType type) { + this.trigger = trigger; + this.type = type; } - public Date getStartTime() { - return this.startTime; + /** + * Build the summary of the trigger. + * @param addTriggerSpecificSummary whether to add trigger-implementation specific + * summary. + * @return basic properties of the trigger + */ + public Map buildSummary(boolean addTriggerSpecificSummary) { + Map summary = new LinkedHashMap<>(); + putIfNoNull(summary, "previousFireTime", this.trigger.getPreviousFireTime()); + putIfNoNull(summary, "nextFireTime", this.trigger.getNextFireTime()); + summary.put("priority", this.trigger.getPriority()); + if (addTriggerSpecificSummary) { + appendSummary(summary); + } + return summary; } - public Date getEndTime() { - return this.endTime; + /** + * Append trigger-implementation specific summary items to the specified + * {@code content}. + * @param content the summary of the trigger + */ + protected abstract void appendSummary(Map content); + + /** + * Build the full details of the trigger. + * @param triggerState the current state of the trigger + * @param sanitizedDataMap a sanitized data map or {@code null} + * @return all properties of the trigger + */ + public Map buildDetails(TriggerState triggerState, Map sanitizedDataMap) { + Map details = new LinkedHashMap<>(); + details.put("group", this.trigger.getKey().getGroup()); + details.put("name", this.trigger.getKey().getName()); + putIfNoNull(details, "description", this.trigger.getDescription()); + details.put("state", triggerState); + details.put("type", getType().getId()); + putIfNoNull(details, "calendarName", this.trigger.getCalendarName()); + putIfNoNull(details, "startTime", this.trigger.getStartTime()); + putIfNoNull(details, "endTime", this.trigger.getEndTime()); + putIfNoNull(details, "previousFireTime", this.trigger.getPreviousFireTime()); + putIfNoNull(details, "nextFireTime", this.trigger.getNextFireTime()); + putIfNoNull(details, "priority", this.trigger.getPriority()); + putIfNoNull(details, "finalFireTime", this.trigger.getFinalFireTime()); + putIfNoNull(details, "data", sanitizedDataMap); + Map typeDetails = new LinkedHashMap<>(); + appendDetails(typeDetails); + details.put(getType().getId(), typeDetails); + return details; } - public Date getPreviousFireTime() { - return this.previousFireTime; + /** + * Append trigger-implementation specific details to the specified + * {@code content}. + * @param content the details of the trigger + */ + protected abstract void appendDetails(Map content); + + protected void putIfNoNull(Map content, String key, Object value) { + if (value != null) { + content.put(key, value); + } + } + + protected Trigger getTrigger() { + return this.trigger; + } + + protected TriggerType getType() { + return this.type; + } + + } + + /** + * A description of a {@link CronTrigger}. + */ + public static final class CronTriggerDescription extends TriggerDescription { + + private final CronTrigger trigger; + + public CronTriggerDescription(CronTrigger trigger) { + super(trigger, TriggerType.CRON); + this.trigger = trigger; + } + + @Override + protected void appendSummary(Map content) { + content.put("expression", this.trigger.getCronExpression()); + putIfNoNull(content, "timeZone", this.trigger.getTimeZone()); + } + + @Override + protected void appendDetails(Map content) { + appendSummary(content); + } + + } + + /** + * A description of a {@link SimpleTrigger}. + */ + public static final class SimpleTriggerDescription extends TriggerDescription { + + private final SimpleTrigger trigger; + + public SimpleTriggerDescription(SimpleTrigger trigger) { + super(trigger, TriggerType.SIMPLE); + this.trigger = trigger; + } + + @Override + protected void appendSummary(Map content) { + content.put("interval", this.trigger.getRepeatInterval()); + } + + @Override + protected void appendDetails(Map content) { + appendSummary(content); + content.put("repeatCount", this.trigger.getRepeatCount()); + content.put("timesTriggered", this.trigger.getTimesTriggered()); + } + + } + + /** + * A description of a {@link DailyTimeIntervalTrigger}. + */ + public static final class DailyTimeIntervalTriggerDescription extends TriggerDescription { + + private final DailyTimeIntervalTrigger trigger; + + public DailyTimeIntervalTriggerDescription(DailyTimeIntervalTrigger trigger) { + super(trigger, TriggerType.DAILY_INTERVAL); + this.trigger = trigger; + } + + @Override + protected void appendSummary(Map content) { + content.put("interval", + getIntervalDuration(this.trigger.getRepeatInterval(), this.trigger.getRepeatIntervalUnit()) + .toMillis()); + putIfNoNull(content, "daysOfWeek", this.trigger.getDaysOfWeek()); + putIfNoNull(content, "startTimeOfDay", getLocalTime(this.trigger.getStartTimeOfDay())); + putIfNoNull(content, "endTimeOfDay", getLocalTime(this.trigger.getEndTimeOfDay())); + } + + @Override + protected void appendDetails(Map content) { + appendSummary(content); + content.put("repeatCount", this.trigger.getRepeatCount()); + content.put("timesTriggered", this.trigger.getTimesTriggered()); + } + + } + + /** + * A description of a {@link CalendarIntervalTrigger}. + */ + public static final class CalendarIntervalTriggerDescription extends TriggerDescription { + + private final CalendarIntervalTrigger trigger; + + public CalendarIntervalTriggerDescription(CalendarIntervalTrigger trigger) { + super(trigger, TriggerType.CALENDAR_INTERVAL); + this.trigger = trigger; + } + + @Override + protected void appendSummary(Map content) { + content.put("interval", + getIntervalDuration(this.trigger.getRepeatInterval(), this.trigger.getRepeatIntervalUnit()) + .toMillis()); + putIfNoNull(content, "timeZone", this.trigger.getTimeZone()); + } + + @Override + protected void appendDetails(Map content) { + appendSummary(content); + content.put("timesTriggered", this.trigger.getTimesTriggered()); + content.put("preserveHourOfDayAcrossDaylightSavings", + this.trigger.isPreserveHourOfDayAcrossDaylightSavings()); + content.put("skipDayIfHourDoesNotExist", this.trigger.isSkipDayIfHourDoesNotExist()); + } + + } + + /** + * A description of a custom {@link Trigger}. + */ + public static final class CustomTriggerDescription extends TriggerDescription { + + public CustomTriggerDescription(Trigger trigger) { + super(trigger, TriggerType.CUSTOM_TRIGGER); } - public Date getNextFireTime() { - return this.nextFireTime; + @Override + protected void appendSummary(Map content) { + content.put("trigger", getTrigger().toString()); } - public Date getFinalFireTime() { - return this.finalFireTime; + @Override + protected void appendDetails(Map content) { + appendSummary(content); } } 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 new file mode 100644 index 0000000000..ab52b8707d --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java @@ -0,0 +1,87 @@ +/* + * 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 quartzJobOrTriggerGroups(@Selector String jobsOrTriggers) + throws SchedulerException { + return handle(jobsOrTriggers, this.delegate::quartzJobGroups, this.delegate::quartzTriggerGroups); + } + + @ReadOperation + public WebEndpointResponse quartzJobOrTriggerGroup(@Selector String jobsOrTriggers, @Selector String group) + throws SchedulerException { + return handle(jobsOrTriggers, () -> this.delegate.quartzJobGroupSummary(group), + () -> this.delegate.quartzTriggerGroupSummary(group)); + } + + @ReadOperation + public WebEndpointResponse 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 WebEndpointResponse handle(String jobsOrTriggers, ResponseSupplier jobAction, + ResponseSupplier 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 WebEndpointResponse handleNull(T value) { + if (value != null) { + return new WebEndpointResponse<>(value); + } + return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND); + } + + @FunctionalInterface + private interface ResponseSupplier { + + T get() throws SchedulerException; + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/package-info.java index 0f27e990dc..c6d3863952 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/package-info.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/package-info.java @@ -1,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, 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 71e8a0768c..8f9787af00 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,11 +1,11 @@ /* - * 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"); * you may not use this file except in compliance with the License. * 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 * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,64 +16,695 @@ 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.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; import java.util.Collections; +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; +import java.util.stream.Stream; -import org.junit.Test; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.quartz.CalendarIntervalScheduleBuilder; +import org.quartz.CalendarIntervalTrigger; +import org.quartz.CronScheduleBuilder; +import org.quartz.CronTrigger; +import org.quartz.DailyTimeIntervalScheduleBuilder; +import org.quartz.DailyTimeIntervalTrigger; +import org.quartz.DateBuilder.IntervalUnit; import org.quartz.Job; import org.quartz.JobBuilder; 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.TimeOfDay; 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.quartz.spi.OperableTrigger; -import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJob; +import org.springframework.boot.actuate.endpoint.Sanitizer; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobDetails; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobGroupSummary; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobSummary; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzReport; +import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummary; +import org.springframework.scheduling.quartz.DelegatingJob; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; /** * Tests for {@link QuartzEndpoint}. * * @author Vedran Pavic + * @author Stephane Nicoll */ -public class QuartzEndpointTests { +class QuartzEndpointTests { - private final Scheduler scheduler = mock(Scheduler.class); + private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne").build(); - private final QuartzEndpoint endpoint = new QuartzEndpoint(this.scheduler); + private static final JobDetail jobTwo = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobTwo").build(); - private final JobDetail jobDetail = JobBuilder.newJob(Job.class).withIdentity("testJob").build(); + private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "samples").build(); - private final Trigger trigger = TriggerBuilder.newTrigger().forJob(this.jobDetail).withIdentity("testTrigger") + private static final Trigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerOne") .build(); + private static final Trigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerTwo") + .build(); + + private static final Trigger triggerThree = TriggerBuilder.newTrigger().forJob(jobThree) + .withIdentity("triggerThree", "samples").build(); + + private final Scheduler scheduler; + + private final QuartzEndpoint endpoint; + + QuartzEndpointTests() { + this.scheduler = mock(Scheduler.class); + this.endpoint = new QuartzEndpoint(this.scheduler); + } + + @Test + void quartzReport() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Arrays.asList("jobSamples", "DEFAULT")); + given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("triggerSamples")); + QuartzReport quartzReport = this.endpoint.quartzReport(); + assertThat(quartzReport.getJobs().getGroups()).containsOnly("jobSamples", "DEFAULT"); + assertThat(quartzReport.getTriggers().getGroups()).containsOnly("triggerSamples"); + verify(this.scheduler).getJobGroupNames(); + verify(this.scheduler).getTriggerGroupNames(); + verifyNoMoreInteractions(this.scheduler); + } + + @Test + void quartzReportWithNoJob() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Collections.emptyList()); + given(this.scheduler.getTriggerGroupNames()).willReturn(Arrays.asList("triggerSamples", "DEFAULT")); + QuartzReport quartzReport = this.endpoint.quartzReport(); + assertThat(quartzReport.getJobs().getGroups()).isEmpty(); + assertThat(quartzReport.getTriggers().getGroups()).containsOnly("triggerSamples", "DEFAULT"); + } + + @Test + void quartzReportWithNoTrigger() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("jobSamples")); + given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.emptyList()); + QuartzReport quartzReport = this.endpoint.quartzReport(); + assertThat(quartzReport.getJobs().getGroups()).containsOnly("jobSamples"); + assertThat(quartzReport.getTriggers().getGroups()).isEmpty(); + } + + @Test + void quartzJobGroupsWithExistingGroups() throws SchedulerException { + mockJobs(jobOne, jobTwo, jobThree); + Map jobGroups = this.endpoint.quartzJobGroups().getGroups(); + assertThat(jobGroups).containsOnlyKeys("DEFAULT", "samples"); + assertThat(jobGroups).extractingByKey("DEFAULT", nestedMap()) + .containsOnly(entry("jobs", Arrays.asList("jobOne", "jobTwo"))); + assertThat(jobGroups).extractingByKey("samples", nestedMap()) + .containsOnly(entry("jobs", Collections.singletonList("jobThree"))); + } + + @Test + void quartzJobGroupsWithNoGroup() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Collections.emptyList()); + Map jobGroups = this.endpoint.quartzJobGroups().getGroups(); + assertThat(jobGroups).isEmpty(); + } + + @Test + void quartzTriggerGroupsWithExistingGroups() throws SchedulerException { + mockTriggers(triggerOne, triggerTwo, triggerThree); + given(this.scheduler.getPausedTriggerGroups()).willReturn(Collections.singleton("samples")); + Map triggerGroups = this.endpoint.quartzTriggerGroups().getGroups(); + assertThat(triggerGroups).containsOnlyKeys("DEFAULT", "samples"); + assertThat(triggerGroups).extractingByKey("DEFAULT", nestedMap()).containsOnly(entry("paused", false), + entry("triggers", Arrays.asList("triggerOne", "triggerTwo"))); + assertThat(triggerGroups).extractingByKey("samples", nestedMap()).containsOnly(entry("paused", true), + entry("triggers", Collections.singletonList("triggerThree"))); + } + + @Test + void quartzTriggerGroupsWithNoGroup() throws SchedulerException { + given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.emptyList()); + Map triggerGroups = this.endpoint.quartzTriggerGroups().getGroups(); + assertThat(triggerGroups).isEmpty(); + } + + @Test + void quartzJobGroupSummaryWithInvalidGroup() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("DEFAULT")); + QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("unknown"); + assertThat(summary).isNull(); + } + + @Test + void quartzJobGroupSummaryWithEmptyGroup() throws SchedulerException { + given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("samples")); + given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals("samples"))).willReturn(Collections.emptySet()); + QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("samples"); + assertThat(summary).isNotNull(); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.getJobs()).isEmpty(); + } + + @Test + void quartzJobGroupSummaryWithJobs() throws SchedulerException { + mockJobs(jobOne, jobTwo); + QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("DEFAULT"); + assertThat(summary).isNotNull(); + assertThat(summary.getGroup()).isEqualTo("DEFAULT"); + Map jobSummaries = summary.getJobs(); + assertThat(jobSummaries).containsOnlyKeys("jobOne", "jobTwo"); + assertThat(jobSummaries.get("jobOne").getClassName()).isEqualTo(Job.class.getName()); + assertThat(jobSummaries.get("jobTwo").getClassName()).isEqualTo(DelegatingJob.class.getName()); + } + + @Test + void quartzTriggerGroupSummaryWithInvalidGroup() throws SchedulerException { + given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("DEFAULT")); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("unknown"); + assertThat(summary).isNull(); + } + + @Test + void quartzTriggerGroupSummaryWithEmptyGroup() throws SchedulerException { + given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("samples")); + given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals("samples"))) + .willReturn(Collections.emptySet()); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary).isNotNull(); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).isEmpty(); + assertThat(summary.getTriggers().getSimple()).isEmpty(); + assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty(); + assertThat(summary.getTriggers().getCalendarInterval()).isEmpty(); + assertThat(summary.getTriggers().getCustom()).isEmpty(); + } + + @Test + void quartzTriggerGroupSummaryWithCronTrigger() throws SchedulerException { + CronTrigger cronTrigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples") + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build(); + mockTriggers(cronTrigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).containsOnlyKeys("3am-every-day"); + assertThat(summary.getTriggers().getSimple()).isEmpty(); + assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty(); + assertThat(summary.getTriggers().getCalendarInterval()).isEmpty(); + assertThat(summary.getTriggers().getCustom()).isEmpty(); + } + + @Test + void quartzTriggerGroupSummaryWithCronTriggerDetails() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); + CronTrigger cronTrigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(3) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build(); + ((OperableTrigger) cronTrigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) cronTrigger).setNextFireTime(nextFireTime); + mockTriggers(cronTrigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + Map triggers = summary.getTriggers().getCron(); + assertThat(triggers).containsOnlyKeys("3am-every-day"); + assertThat(triggers).extractingByKey("3am-every-day", nestedMap()).containsOnly( + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 3), + entry("expression", "0 0 3 ? * *"), entry("timeZone", timeZone)); + } + + @Test + void quartzTriggerGroupSummaryWithSimpleTrigger() throws SchedulerException { + SimpleTrigger simpleTrigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples") + .withSchedule(SimpleScheduleBuilder.repeatHourlyForever(1)).build(); + mockTriggers(simpleTrigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).isEmpty(); + assertThat(summary.getTriggers().getSimple()).containsOnlyKeys("every-hour"); + assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty(); + assertThat(summary.getTriggers().getCalendarInterval()).isEmpty(); + assertThat(summary.getTriggers().getCustom()).isEmpty(); + } + + @Test + void quartzTriggerGroupSummaryWithSimpleTriggerDetails() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + SimpleTrigger simpleTrigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples").withPriority(7) + .withSchedule(SimpleScheduleBuilder.repeatHourlyForever(1)).build(); + ((OperableTrigger) simpleTrigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) simpleTrigger).setNextFireTime(nextFireTime); + mockTriggers(simpleTrigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + Map triggers = summary.getTriggers().getSimple(); + assertThat(triggers).containsOnlyKeys("every-hour"); + assertThat(triggers).extractingByKey("every-hour", nestedMap()).containsOnly( + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 7), + entry("interval", 3600000L)); + } + + @Test + void quartzTriggerGroupSummaryWithDailyIntervalTrigger() throws SchedulerException { + DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-9am", "samples") + .withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule() + .startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0)).withInterval(1, IntervalUnit.HOUR)) + .build(); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).isEmpty(); + assertThat(summary.getTriggers().getSimple()).isEmpty(); + assertThat(summary.getTriggers().getDailyTimeInterval()).containsOnlyKeys("every-hour-9am"); + assertThat(summary.getTriggers().getCalendarInterval()).isEmpty(); + assertThat(summary.getTriggers().getCustom()).isEmpty(); + } + @Test - public void quartzReport() throws Exception { - String jobGroup = this.jobDetail.getKey().getGroup(); - given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList(jobGroup)); - given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(jobGroup))) - .willReturn(Collections.singleton(this.jobDetail.getKey())); - Map quartzReport = this.endpoint.quartzReport(); - assertThat(quartzReport).hasSize(1); - } - - @Test - public void quartzJob() throws Exception { - JobKey jobKey = this.jobDetail.getKey(); - given(this.scheduler.getJobDetail(jobKey)).willReturn(this.jobDetail); - given(this.scheduler.getTriggersOfJob(jobKey)) - .willAnswer(invocation -> Collections.singletonList(this.trigger)); - QuartzJob quartzJob = this.endpoint.quartzJob(jobKey.getGroup(), jobKey.getName()); - assertThat(quartzJob.getJobGroup()).isEqualTo(jobKey.getGroup()); - assertThat(quartzJob.getJobName()).isEqualTo(jobKey.getName()); - assertThat(quartzJob.getClassName()).isEqualTo(this.jobDetail.getJobClass().getName()); - assertThat(quartzJob.getTriggers()).hasSize(1); - assertThat(quartzJob.getTriggers().get(0).getTriggerGroup()).isEqualTo(this.trigger.getKey().getGroup()); - assertThat(quartzJob.getTriggers().get(0).getTriggerName()).isEqualTo(this.trigger.getKey().getName()); + void quartzTriggerGroupSummaryWithDailyIntervalTriggerDetails() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-tue-thu", "samples") + .withPriority(4) + .withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule() + .onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY) + .startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0)) + .endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR)) + .build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + Map triggers = summary.getTriggers().getDailyTimeInterval(); + assertThat(triggers).containsOnlyKeys("every-hour-tue-thu"); + assertThat(triggers).extractingByKey("every-hour-tue-thu", nestedMap()).containsOnly( + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 4), + entry("interval", 3600000L), entry("startTimeOfDay", LocalTime.of(9, 0)), + entry("endTimeOfDay", LocalTime.of(18, 0)), + entry("daysOfWeek", new LinkedHashSet<>(Arrays.asList(3, 5)))); + } + + @Test + void quartzTriggerGroupSummaryWithCalendarIntervalTrigger() throws SchedulerException { + CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples") + .withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1)) + .build(); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).isEmpty(); + assertThat(summary.getTriggers().getSimple()).isEmpty(); + assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty(); + assertThat(summary.getTriggers().getCalendarInterval()).containsOnlyKeys("once-a-week"); + assertThat(summary.getTriggers().getCustom()).isEmpty(); + } + + @Test + void quartzTriggerGroupSummaryWithCalendarIntervalTriggerDetails() throws SchedulerException { + TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples") + .withPriority(8).withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule() + .withIntervalInWeeks(1).inTimeZone(timeZone)) + .build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + Map triggers = summary.getTriggers().getCalendarInterval(); + assertThat(triggers).containsOnlyKeys("once-a-week"); + assertThat(triggers).extractingByKey("once-a-week", nestedMap()).containsOnly( + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 8), + entry("interval", 604800000L), entry("timeZone", timeZone)); + } + + @Test + void quartzTriggerGroupSummaryWithCustomTrigger() throws SchedulerException { + Trigger trigger = mock(Trigger.class); + given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples")); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + assertThat(summary.getGroup()).isEqualTo("samples"); + assertThat(summary.isPaused()).isFalse(); + assertThat(summary.getTriggers().getCron()).isEmpty(); + assertThat(summary.getTriggers().getSimple()).isEmpty(); + assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty(); + assertThat(summary.getTriggers().getCalendarInterval()).isEmpty(); + assertThat(summary.getTriggers().getCustom()).containsOnlyKeys("custom"); + } + + @Test + void quartzTriggerGroupSummaryWithCustomTriggerDetails() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + Trigger trigger = mock(Trigger.class); + given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples")); + given(trigger.getPreviousFireTime()).willReturn(previousFireTime); + given(trigger.getNextFireTime()).willReturn(nextFireTime); + given(trigger.getPriority()).willReturn(9); + mockTriggers(trigger); + QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples"); + Map triggers = summary.getTriggers().getCustom(); + assertThat(triggers).containsOnlyKeys("custom"); + assertThat(triggers).extractingByKey("custom", nestedMap()).containsOnly( + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 9), + entry("trigger", trigger.toString())); + } + + @Test + void quartzTriggerWithCronTrigger() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(3) + .withDescription("Sample description") + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("3am-every-day", "samples"))) + .willReturn(TriggerState.NORMAL); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "3am-every-day"); + assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "3am-every-day"), + entry("description", "Sample description"), entry("type", "cron"), entry("state", TriggerState.NORMAL), + entry("priority", 3)); + assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime), + entry("nextFireTime", nextFireTime)); + assertThat(triggerDetails).doesNotContainKeys("simple", "dailyTimeInterval", "calendarInterval", "custom"); + assertThat(triggerDetails).extractingByKey("cron", nestedMap()).containsOnly(entry("expression", "0 0 3 ? * *"), + entry("timeZone", timeZone)); + } + + @Test + void quartzTriggerWithSimpleTrigger() throws SchedulerException { + Date startTime = Date.from(Instant.parse("2020-01-01T09:00:00Z")); + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + Date endTime = Date.from(Instant.parse("2020-01-31T09:00:00Z")); + SimpleTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples").withPriority(20) + .withDescription("Every hour").startAt(startTime).endAt(endTime) + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInHours(1).withRepeatCount(2000)) + .build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("every-hour", "samples"))) + .willReturn(TriggerState.COMPLETE); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "every-hour"); + assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "every-hour"), + entry("description", "Every hour"), entry("type", "simple"), entry("state", TriggerState.COMPLETE), + entry("priority", 20)); + assertThat(triggerDetails).contains(entry("startTime", startTime), entry("previousFireTime", previousFireTime), + entry("nextFireTime", nextFireTime), entry("endTime", endTime)); + assertThat(triggerDetails).doesNotContainKeys("cron", "dailyTimeInterval", "calendarInterval", "custom"); + assertThat(triggerDetails).extractingByKey("simple", nestedMap()).containsOnly(entry("interval", 3600000L), + entry("repeatCount", 2000), entry("timesTriggered", 0)); + } + + @Test + void quartzTriggerWithDailyTimeIntervalTrigger() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-mon-wed", "samples") + .withDescription("Every working hour Mon Wed").withPriority(4) + .withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule() + .onDaysOfTheWeek(Calendar.MONDAY, Calendar.WEDNESDAY) + .startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0)) + .endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR)) + .build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("every-hour-mon-wed", "samples"))) + .willReturn(TriggerState.NORMAL); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "every-hour-mon-wed"); + assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "every-hour-mon-wed"), + entry("description", "Every working hour Mon Wed"), entry("type", "dailyTimeInterval"), + entry("state", TriggerState.NORMAL), entry("priority", 4)); + assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime), + entry("nextFireTime", nextFireTime)); + assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "calendarInterval", "custom"); + assertThat(triggerDetails).extractingByKey("dailyTimeInterval", nestedMap()).containsOnly( + entry("interval", 3600000L), entry("startTimeOfDay", LocalTime.of(9, 0)), + entry("endTimeOfDay", LocalTime.of(18, 0)), + entry("daysOfWeek", new LinkedHashSet<>(Arrays.asList(2, 4))), entry("repeatCount", -1), + entry("timesTriggered", 0)); + } + + @Test + void quartzTriggerWithCalendarTimeIntervalTrigger() throws SchedulerException { + TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples") + .withDescription("Once a week").withPriority(8) + .withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1) + .inTimeZone(timeZone).preserveHourOfDayAcrossDaylightSavings(true)) + .build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("once-a-week", "samples"))) + .willReturn(TriggerState.BLOCKED); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "once-a-week"); + assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "once-a-week"), + entry("description", "Once a week"), entry("type", "calendarInterval"), + entry("state", TriggerState.BLOCKED), entry("priority", 8)); + assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime), + entry("nextFireTime", nextFireTime)); + assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "dailyTimeInterval", "custom"); + assertThat(triggerDetails).extractingByKey("calendarInterval", nestedMap()).containsOnly( + entry("interval", 604800000L), entry("timeZone", timeZone), + entry("preserveHourOfDayAcrossDaylightSavings", true), entry("skipDayIfHourDoesNotExist", false), + entry("timesTriggered", 0)); + } + + @Test + void quartzTriggerWithCustomTrigger() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + Trigger trigger = mock(Trigger.class); + given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples")); + given(trigger.getPreviousFireTime()).willReturn(previousFireTime); + given(trigger.getNextFireTime()).willReturn(nextFireTime); + given(trigger.getPriority()).willReturn(9); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("custom", "samples"))) + .willReturn(TriggerState.ERROR); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "custom"); + assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "custom"), entry("type", "custom"), + entry("state", TriggerState.ERROR), entry("priority", 9)); + assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime), + entry("nextFireTime", nextFireTime)); + assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "calendarInterval", "dailyTimeInterval"); + assertThat(triggerDetails).extractingByKey("custom", nestedMap()) + .containsOnly(entry("trigger", trigger.toString())); + } + + @Test + void quartzTriggerWithDataMap() throws SchedulerException { + CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples") + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).usingJobData("user", "user") + .usingJobData("password", "secret").usingJobData("url", "https://user:secret@example.com").build(); + mockTriggers(trigger); + given(this.scheduler.getTriggerState(TriggerKey.triggerKey("3am-every-day", "samples"))) + .willReturn(TriggerState.NORMAL); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "3am-every-day"); + assertThat(triggerDetails).extractingByKey("data", nestedMap()).containsOnly(entry("user", "user"), + entry("password", "******"), entry("url", "https://user:******@example.com")); + } + + @ParameterizedTest(name = "unit {1}") + @MethodSource("intervalUnitParameters") + void canConvertIntervalUnit(int amount, IntervalUnit unit, Duration expectedDuration) throws SchedulerException { + CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger", "samples") + .withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withInterval(amount, unit)) + .build(); + mockTriggers(trigger); + Map triggerDetails = this.endpoint.quartzTrigger("samples", "trigger"); + assertThat(triggerDetails).extractingByKey("calendarInterval", nestedMap()) + .contains(entry("interval", expectedDuration.toMillis())); + } + + static Stream intervalUnitParameters() { + return Stream.of(Arguments.of(3, IntervalUnit.DAY, Duration.ofDays(3)), + Arguments.of(2, IntervalUnit.HOUR, Duration.ofHours(2)), + Arguments.of(5, IntervalUnit.MINUTE, Duration.ofMinutes(5)), + Arguments.of(1, IntervalUnit.MONTH, ChronoUnit.MONTHS.getDuration()), + Arguments.of(30, IntervalUnit.SECOND, Duration.ofSeconds(30)), + Arguments.of(100, IntervalUnit.MILLISECOND, Duration.ofMillis(100)), + Arguments.of(1, IntervalUnit.WEEK, ChronoUnit.WEEKS.getDuration()), + Arguments.of(1, IntervalUnit.YEAR, ChronoUnit.YEARS.getDuration())); + } + + @Test + void quartzJobWithoutTrigger() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").withDescription("A sample job") + .storeDurably().requestRecovery(false).build(); + mockJobs(job); + QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello"); + assertThat(jobDetails.getGroup()).isEqualTo("samples"); + assertThat(jobDetails.getName()).isEqualTo("hello"); + assertThat(jobDetails.getDescription()).isEqualTo("A sample job"); + assertThat(jobDetails.getClassName()).isEqualTo(Job.class.getName()); + assertThat(jobDetails.isDurable()).isTrue(); + assertThat(jobDetails.isRequestRecovery()).isFalse(); + assertThat(jobDetails.getData()).isEmpty(); + assertThat(jobDetails.getTriggers()).isEmpty(); + } + + @Test + void quartzJobWithTrigger() throws SchedulerException { + Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z")); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build(); + TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris"); + Trigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(4) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build(); + ((OperableTrigger) trigger).setPreviousFireTime(previousFireTime); + ((OperableTrigger) trigger).setNextFireTime(nextFireTime); + mockJobs(job); + mockTriggers(trigger); + given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples"))) + .willAnswer((invocation) -> Collections.singletonList(trigger)); + QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello"); + assertThat(jobDetails.getTriggers()).hasSize(1); + Map triggerDetails = jobDetails.getTriggers().get(0); + assertThat(triggerDetails).containsOnly(entry("group", "samples"), entry("name", "3am-every-day"), + entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 4)); + } + + @Test + void quartzJobOrdersTriggersAccordingToNextFireTime() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build(); + mockJobs(job); + Date triggerOneNextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + CronTrigger triggerOne = TriggerBuilder.newTrigger().withIdentity("one", "samples").withPriority(5) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build(); + ((OperableTrigger) triggerOne).setNextFireTime(triggerOneNextFireTime); + Date triggerTwoNextFireTime = Date.from(Instant.parse("2020-12-01T02:00:00Z")); + CronTrigger triggerTwo = TriggerBuilder.newTrigger().withIdentity("two", "samples").withPriority(10) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(2, 0)).build(); + ((OperableTrigger) triggerTwo).setNextFireTime(triggerTwoNextFireTime); + mockTriggers(triggerOne, triggerTwo); + given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples"))) + .willAnswer((invocation) -> Arrays.asList(triggerOne, triggerTwo)); + QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello"); + assertThat(jobDetails.getTriggers()).hasSize(2); + assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two"); + assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one"); + } + + @Test + void quartzJobOrdersTriggersAccordingNextFireTimeAndPriority() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build(); + mockJobs(job); + Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z")); + CronTrigger triggerOne = TriggerBuilder.newTrigger().withIdentity("one", "samples").withPriority(3) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build(); + ((OperableTrigger) triggerOne).setNextFireTime(nextFireTime); + CronTrigger triggerTwo = TriggerBuilder.newTrigger().withIdentity("two", "samples").withPriority(7) + .withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build(); + ((OperableTrigger) triggerTwo).setNextFireTime(nextFireTime); + mockTriggers(triggerOne, triggerTwo); + given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples"))) + .willAnswer((invocation) -> Arrays.asList(triggerOne, triggerTwo)); + QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello"); + assertThat(jobDetails.getTriggers()).hasSize(2); + assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two"); + assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one"); + } + + @Test + void quartzJobWithSensitiveDataMap() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").usingJobData("user", "user") + .usingJobData("password", "secret").usingJobData("url", "https://user:secret@example.com").build(); + mockJobs(job); + QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello"); + assertThat(jobDetails.getData()).containsOnly(entry("user", "user"), entry("password", "******"), + entry("url", "https://user:******@example.com")); + } + + @Test + void quartzJobWithSensitiveDataMapAndCustomSanitizier() throws SchedulerException { + JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").usingJobData("test", "value") + .usingJobData("secret", "value").build(); + mockJobs(job); + Sanitizer sanitizer = mock(Sanitizer.class); + given(sanitizer.sanitize("test", "value")).willReturn("value"); + given(sanitizer.sanitize("secret", "value")).willReturn("----"); + QuartzJobDetails jobDetails = new QuartzEndpoint(this.scheduler, sanitizer).quartzJob("samples", "hello"); + assertThat(jobDetails.getData()).containsOnly(entry("test", "value"), entry("secret", "----")); + verify(sanitizer).sanitize("test", "value"); + verify(sanitizer).sanitize("secret", "value"); + verifyNoMoreInteractions(sanitizer); + } + + private void mockJobs(JobDetail... jobs) throws SchedulerException { + MultiValueMap jobKeys = new LinkedMultiValueMap<>(); + for (JobDetail jobDetail : jobs) { + JobKey key = jobDetail.getKey(); + given(this.scheduler.getJobDetail(key)).willReturn(jobDetail); + jobKeys.add(key.getGroup(), key); + } + given(this.scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet())); + for (Entry> entry : jobKeys.entrySet()) { + given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + private void mockTriggers(Trigger... triggers) throws SchedulerException { + MultiValueMap triggerKeys = new LinkedMultiValueMap<>(); + for (Trigger trigger : triggers) { + TriggerKey key = trigger.getKey(); + given(this.scheduler.getTrigger(key)).willReturn(trigger); + triggerKeys.add(key.getGroup(), key); + } + given(this.scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet())); + for (Entry> entry : triggerKeys.entrySet()) { + given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + @SuppressWarnings("rawtypes") + private static InstanceOfAssertFactory> nestedMap() { + return InstanceOfAssertFactories.map(String.class, Object.class); } } 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 new file mode 100644 index 0000000000..f13b6631f8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java @@ -0,0 +1,217 @@ +/* + * 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 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> entry : jobKeys.entrySet()) { + given(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + void mockTriggers(Scheduler scheduler, Trigger... triggers) throws SchedulerException { + MultiValueMap 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> entry : triggerKeys.entrySet()) { + given(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey()))) + .willReturn(new LinkedHashSet<>(entry.getValue())); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc index 2f1004fc96..e7e76af3b1 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc @@ -112,6 +112,9 @@ The following technology-agnostic endpoints are available: | `mappings` | Displays a collated list of all `@RequestMapping` paths. +|`quartz` +|Shows information about Quartz Scheduler jobs. + | `scheduledtasks` | Displays the scheduled tasks in your application. @@ -272,6 +275,10 @@ The following table shows the default exposure for the built-in endpoints: | N/A | No +| `quartz` +| Yes +| No + | `scheduledtasks` | Yes | No diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/build.gradle index f1d8cb6bd0..1872bd7c51 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/build.gradle +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/build.gradle @@ -6,8 +6,10 @@ plugins { description = "Spring Boot Quartz smoke test" 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-jdbc")) runtimeOnly("com.h2database:h2") diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java index 680c382280..221cde31f8 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java @@ -1,5 +1,5 @@ /* - * 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"); * you may not use this file except in compliance with the License. @@ -16,9 +16,16 @@ 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.JobDetail; import org.quartz.SimpleScheduleBuilder; +import org.quartz.TimeOfDay; import org.quartz.Trigger; import org.quartz.TriggerBuilder; @@ -34,18 +41,50 @@ public class SampleQuartzApplication { } @Bean - public JobDetail sampleJobDetail() { - return JobBuilder.newJob(SampleJob.class).withIdentity("sampleJob").usingJobData("name", "World").storeDurably() - .build(); + public JobDetail helloJobDetail() { + return JobBuilder.newJob(SampleJob.class).withIdentity("helloJob", "samples").usingJobData("name", "World") + .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 - public Trigger sampleJobTrigger() { - SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2) - .repeatForever(); + public Trigger threeAmWeekdaysTrigger() { + return TriggerBuilder.newTrigger().forJob("anotherJob", "samples").withIdentity("3am-weekdays", "samples") + .withSchedule(CronScheduleBuilder.atHourAndMinuteOnGivenDaysOfWeek(3, 0, 1, 2, 3, 4, 5)).build(); + } - return TriggerBuilder.newTrigger().forJob(sampleJobDetail()).withIdentity("sampleTrigger") - .withSchedule(scheduleBuilder).build(); + @Bean + 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(); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/resources/application.properties index 70c20575f5..b30784c173 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/resources/application.properties +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/resources/application.properties @@ -1 +1,3 @@ spring.quartz.job-store-type=jdbc + +management.endpoints.web.exposure.include=health,quartz \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationTests.java index dd0a9740d4..cfdd5f1fbc 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationTests.java @@ -1,5 +1,5 @@ /* - * 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"); * you may not use this file except in compliance with the License. @@ -38,8 +38,9 @@ import static org.hamcrest.Matchers.containsString; class SampleQuartzApplicationTests { @Test - void quartzJobIsTriggered(CapturedOutput output) throws InterruptedException { - try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class)) { + void quartzJobIsTriggered(CapturedOutput output) { + try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class, + "--server.port=0")) { Awaitility.waitAtMost(Duration.ofSeconds(5)).until(output::toString, containsString("Hello World!")); } } diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java new file mode 100644 index 0000000000..7da70d2f35 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java @@ -0,0 +1,110 @@ +/* + * 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 content = getContent("/actuator/quartz"); + assertThat(content).containsOnlyKeys("jobs", "triggers"); + } + + @Test + void quartzJobGroups() { + Map content = getContent("/actuator/quartz/jobs"); + assertThat(content).containsOnlyKeys("groups"); + assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("samples"); + } + + @Test + void quartzTriggerGroups() { + Map content = getContent("/actuator/quartz/triggers"); + assertThat(content).containsOnlyKeys("groups"); + assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("DEFAULT", "samples"); + } + + @Test + void quartzJobDetail() { + Map content = getContent("/actuator/quartz/jobs/samples/helloJob"); + assertThat(content).containsEntry("name", "helloJob").containsEntry("group", "samples"); + } + + @Test + void quartzJobDetailWhenNameDoesNotExistReturns404() { + ResponseEntity response = this.restTemplate.getForEntity("/actuator/quartz/jobs/samples/does-not-exist", + String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void quartzTriggerDetail() { + Map 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 response = this.restTemplate + .getForEntity("/actuator/quartz/triggers/samples/does-not-exist", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private Map getContent(String path) { + ResponseEntity> entity = asMapEntity(this.restTemplate.getForEntity(path, Map.class)); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + return entity.getBody(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static ResponseEntity> asMapEntity(ResponseEntity entity) { + return (ResponseEntity) entity; + } + + @SuppressWarnings("rawtypes") + private static InstanceOfAssertFactory> nestedMap() { + return InstanceOfAssertFactories.map(String.class, Object.class); + } + +}