diff --git a/README.md b/README.md index 5bdcea9..fe6cb46 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ running server: `curl "http://localhost:8080/greeting?salutation=good%20morning&name=robin"` - ### Docker The application can be built into a Docker image and run on a docker environment: @@ -29,4 +28,12 @@ The application can be built into a Docker image and run on a docker environment docker build -t robjwilkins/showcase . docker run -p 8080:8080 robjwilkins/showcase -``` \ No newline at end of file +``` + +### Add a demo of quartz + +Quartz is a java based framework which provides scheduling functionality. This allows jobs to be +created which will be run at a later point-in-time. + +The job details are persisted in a database. This demo uses an in-memory h2 database. A flyway migration +is also added to create the schema for this database diff --git a/build.gradle b/build.gradle index d93539e..fd6afc8 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,13 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation('org.springframework.boot:spring-boot-starter-test') + implementation("org.springframework.boot:spring-boot-starter-quartz") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation('org.flywaydb:flyway-core') + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + runtimeOnly('com.h2database:h2') } test { diff --git a/src/main/java/com/wilkins/showcase/controllers/GreetingController.java b/src/main/java/com/wilkins/showcase/controllers/GreetingController.java index 38c24c5..97a267e 100644 --- a/src/main/java/com/wilkins/showcase/controllers/GreetingController.java +++ b/src/main/java/com/wilkins/showcase/controllers/GreetingController.java @@ -5,15 +5,37 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.quartz.*; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.UUID; + +import static org.quartz.TriggerBuilder.newTrigger; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.ResponseEntity.noContent; +import static org.springframework.http.ResponseEntity.notFound; @RestController +@RequestMapping(path = "/greeting", produces = APPLICATION_JSON_VALUE) public class GreetingController { - private final Logger log = LoggerFactory.getLogger(GreetingController.class); + private static final Logger log = LoggerFactory.getLogger(GreetingController.class); + private static final String JOB_GROUP = "greeting-jobs"; + + private final Scheduler scheduler; + + public GreetingController(Scheduler scheduler) { + this.scheduler = scheduler; + } - @GetMapping("/greeting") + @GetMapping public Greeting getGreeting(@RequestParam(name = "salutation", required = false, defaultValue = "hello") String salutationParam, - @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam) { + @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam) { log.info("A greeting was requested"); @@ -23,4 +45,43 @@ public Greeting getGreeting(@RequestParam(name = "salutation", required = false, return greeting; } + + @PostMapping + public String postGreeting(@RequestParam(name = "salutation", required = false, defaultValue = "hello") String salutationParam, + @RequestParam(name = "name", required = false, defaultValue = "world") String nameParam, + @RequestParam(name = "durationMins", required = false, defaultValue = "2") String durationMins) throws SchedulerException { + var jobDataMap = new JobDataMap(); + jobDataMap.put("salutation", salutationParam); + jobDataMap.put("name", nameParam); + jobDataMap.put("greeting", Greeting.of(salutationParam, nameParam)); + var id = UUID.randomUUID().toString(); + var jobDetail = JobBuilder.newJob() + .ofType(GreetingJob.class) + .withIdentity(id, JOB_GROUP) + .withDescription("Send Greeting Job") + .usingJobData(jobDataMap) + .requestRecovery() + .build(); + var startAt = Timestamp.valueOf(LocalDateTime.from( + new Date().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime()) + .plusMinutes(1)); + var trigger = newTrigger() + .forJob(jobDetail) + .withIdentity(jobDetail.getKey().getName(), "greeting-triggers") + .withDescription("Send Greeting Trigger") + .startAt(startAt) + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withRepeatCount(0)) + .build(); + log.info("scheduling job to run in 1 minutes time"); + scheduler.scheduleJob(jobDetail, trigger); + return id; + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteGreeting(@PathVariable String id) throws SchedulerException { + log.info("deleting job: {}", id); + return scheduler.deleteJob(new JobKey(id, JOB_GROUP)) ? noContent().build() : notFound().build(); + } } diff --git a/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java b/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java new file mode 100644 index 0000000..a229c54 --- /dev/null +++ b/src/main/java/com/wilkins/showcase/controllers/GreetingJob.java @@ -0,0 +1,19 @@ +package com.wilkins.showcase.controllers; + +import com.wilkins.showcase.services.Greeter; +import org.quartz.Job; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public record GreetingJob(Greeter greeter) implements Job { + + private static final Logger log = LoggerFactory.getLogger(GreetingJob.class); + @Override + public void execute(JobExecutionContext context) { + JobDataMap jobDataMap = context.getMergedJobDataMap(); + log.info("******** {}, {} *************", context.getJobDetail().getKey().getName(), + greeter.greet((Greeting) jobDataMap.get("greeting"))); + } +} diff --git a/src/main/java/com/wilkins/showcase/services/Greeter.java b/src/main/java/com/wilkins/showcase/services/Greeter.java new file mode 100644 index 0000000..f96cb3f --- /dev/null +++ b/src/main/java/com/wilkins/showcase/services/Greeter.java @@ -0,0 +1,12 @@ +package com.wilkins.showcase.services; + +import com.wilkins.showcase.controllers.Greeting; +import org.springframework.stereotype.Component; + +@Component +public class Greeter { + + public String greet(Greeting greeting) { + return greeting.salutation() + " " + greeting.name(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..1dd79f5 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,16 @@ +spring: + quartz: + job-store-type: jdbc + properties: + org: + quartz: + jobStore: + driverDelegateClass: "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate" + wait-for-jobs-to-complete-on-shutdown: true + jdbc: + initialize-schema: never + + datasource: + url: jdbc:h2:mem:testdb + username: sa + password: password \ No newline at end of file diff --git a/src/main/resources/db/migration/V0__add_quartz.sql b/src/main/resources/db/migration/V0__add_quartz.sql new file mode 100644 index 0000000..60c6904 --- /dev/null +++ b/src/main/resources/db/migration/V0__add_quartz.sql @@ -0,0 +1,214 @@ +create table qrtz_job_details +( + sched_name varchar(120) not null, + job_name varchar(200) not null, + job_group varchar(200) not null, + description varchar(250), + job_class_name varchar(250) not null, + is_durable boolean not null, + is_nonconcurrent boolean not null, + is_update_data boolean not null, + requests_recovery boolean not null, + job_data bytea, + constraint qrtz_job_details_pkey + primary key (sched_name, job_name, job_group) +); + +create index idx_qrtz_j_req_recovery + on qrtz_job_details (sched_name, requests_recovery); + +create index idx_qrtz_j_grp + on qrtz_job_details (sched_name, job_group); + +create table qrtz_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + job_name varchar(200) not null, + job_group varchar(200) not null, + description varchar(250), + next_fire_time bigint, + prev_fire_time bigint, + priority integer, + trigger_state varchar(16) not null, + trigger_type varchar(8) not null, + start_time bigint not null, + end_time bigint, + calendar_name varchar(200), + misfire_instr smallint, + job_data bytea, + constraint qrtz_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_triggers_sched_name_job_name_job_group_fkey + foreign key (sched_name, job_name, job_group) references qrtz_job_details +); + +create index idx_qrtz_t_j + on qrtz_triggers (sched_name, job_name, job_group); + +create index idx_qrtz_t_jg + on qrtz_triggers (sched_name, job_group); + +create index idx_qrtz_t_c + on qrtz_triggers (sched_name, calendar_name); + +create index idx_qrtz_t_g + on qrtz_triggers (sched_name, trigger_group); + +create index idx_qrtz_t_state + on qrtz_triggers (sched_name, trigger_state); + +create index idx_qrtz_t_n_state + on qrtz_triggers (sched_name, trigger_name, trigger_group, trigger_state); + +create index idx_qrtz_t_n_g_state + on qrtz_triggers (sched_name, trigger_group, trigger_state); + +create index idx_qrtz_t_next_fire_time + on qrtz_triggers (sched_name, next_fire_time); + +create index idx_qrtz_t_nft_st + on qrtz_triggers (sched_name, trigger_state, next_fire_time); + +create index idx_qrtz_t_nft_misfire + on qrtz_triggers (sched_name, misfire_instr, next_fire_time); + +create index idx_qrtz_t_nft_st_misfire + on qrtz_triggers (sched_name, misfire_instr, next_fire_time, trigger_state); + +create index idx_qrtz_t_nft_st_misfire_grp + on qrtz_triggers (sched_name, misfire_instr, next_fire_time, trigger_group, trigger_state); + +create table qrtz_blob_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + blob_data bytea, + constraint qrtz_blob_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_blob_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_calendars +( + sched_name varchar(120) not null, + calendar_name varchar(200) not null, + calendar bytea not null, + constraint qrtz_calendars_pkey + primary key (sched_name, calendar_name) +); + +create table qrtz_cron_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + cron_expression varchar(120) not null, + time_zone_id varchar(80), + constraint qrtz_cron_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_cron_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_fired_triggers +( + sched_name varchar(120) not null, + entry_id varchar(95) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + instance_name varchar(200) not null, + fired_time bigint not null, + sched_time bigint not null, + priority integer not null, + state varchar(16) not null, + job_name varchar(200), + job_group varchar(200), + is_nonconcurrent boolean, + requests_recovery boolean, + constraint qrtz_fired_triggers_pkey + primary key (sched_name, entry_id) +); + +create index idx_qrtz_ft_trig_inst_name + on qrtz_fired_triggers (sched_name, instance_name); + +create index idx_qrtz_ft_inst_job_req_rcvry + on qrtz_fired_triggers (sched_name, instance_name, requests_recovery); + +create index idx_qrtz_ft_j_g + on qrtz_fired_triggers (sched_name, job_name, job_group); + +create index idx_qrtz_ft_jg + on qrtz_fired_triggers (sched_name, job_group); + +create index idx_qrtz_ft_t_g + on qrtz_fired_triggers (sched_name, trigger_name, trigger_group); + +create index idx_qrtz_ft_tg + on qrtz_fired_triggers (sched_name, trigger_group); + +create table qrtz_locks +( + sched_name varchar(120) not null, + lock_name varchar(40) not null, + constraint qrtz_locks_pkey + primary key (sched_name, lock_name) +); + +create table qrtz_paused_trigger_grps +( + sched_name varchar(120) not null, + trigger_group varchar(200) not null, + constraint qrtz_paused_trigger_grps_pkey + primary key (sched_name, trigger_group) +); + +create table qrtz_scheduler_state +( + sched_name varchar(120) not null, + instance_name varchar(200) not null, + last_checkin_time bigint not null, + checkin_interval bigint not null, + constraint qrtz_scheduler_state_pkey + primary key (sched_name, instance_name) +); + +create table qrtz_simple_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + repeat_count bigint not null, + repeat_interval bigint not null, + times_triggered bigint not null, + constraint qrtz_simple_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_simple_triggers_sched_name_trigger_name_trigger_group_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); + +create table qrtz_simprop_triggers +( + sched_name varchar(120) not null, + trigger_name varchar(200) not null, + trigger_group varchar(200) not null, + str_prop_1 varchar(512), + str_prop_2 varchar(512), + str_prop_3 varchar(512), + int_prop_1 integer, + int_prop_2 integer, + long_prop_1 bigint, + long_prop_2 bigint, + dec_prop_1 numeric(13,4), + dec_prop_2 numeric(13,4), + bool_prop_1 boolean, + bool_prop_2 boolean, + constraint qrtz_simprop_triggers_pkey + primary key (sched_name, trigger_name, trigger_group), + constraint qrtz_simprop_triggers_sched_name_trigger_name_trigger_grou_fkey + foreign key (sched_name, trigger_name, trigger_group) references qrtz_triggers +); diff --git a/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java b/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java index 76cb2c2..c949e83 100644 --- a/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java +++ b/src/test/java/com/wilkins/showcase/controllers/GreetingControllerTest.java @@ -1,14 +1,21 @@ package com.wilkins.showcase.controllers; import org.junit.jupiter.api.Test; +import org.quartz.Scheduler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest public class GreetingControllerTest { @@ -16,6 +23,9 @@ public class GreetingControllerTest { @Autowired MockMvc mockMvc; + @MockBean + Scheduler scheduler; + @Test void returnsGreeting() throws Exception { mockMvc.perform(get("/greeting")) @@ -24,4 +34,11 @@ void returnsGreeting() throws Exception { .andExpect(jsonPath("$.name", is("world"))); } + @Test + void scheduleGreeting() throws Exception { + mockMvc.perform(post("/greeting")) + .andExpect(status().isOk()) + .andExpect(content().string(notNullValue())); + verify(scheduler).scheduleJob(any(), any()); + } }