├── LICENSE ├── README.md ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── surya │ │ └── quartz │ │ ├── Application.java │ │ ├── AutowiringSpringBeanJobFactory.java │ │ ├── job │ │ └── EmailJob.java │ │ ├── model │ │ ├── JobDescriptor.java │ │ └── TriggerDescriptor.java │ │ ├── service │ │ ├── EmailService.java │ │ └── JobService.java │ │ └── web │ │ └── rest │ │ └── EmailResource.java └── resources │ └── application.yaml └── test └── java └── com └── surya └── quartz └── ApplicationTests.java /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Suryakanta Sahoo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quartz Manager 2 | Dynanic Job Scheduling with Quartz and Spring. 3 | To understand what is happening in this project read the below: 4 | for a comprehensive overview. 5 | 6 | # Donation 7 | 8 | If you have found my softwares to be of any use to you, do consider helping me pay my internet bills. This would encourage me to create many such softwares :) 9 | 10 | | PayPal | Donate via PayPal! | 11 | |:-------------------------------------------:|:-------------------------------------------------------------:| 12 | | ₹ (INR) | Donate via Instamojo | 13 | 14 | 15 | 16 | # Spring WebFlux 17 | Checkout this [branch](https://github.com/Suryakanta97/Dynamic-Job-Scheduling-with-Quartz-and-SpringBoot) for a reactive version of the source 18 | 19 | ## Quick Start 20 | 21 | ```bash 22 | > mvnw clean spring-boot:run 23 | ``` 24 | 25 | ## Features 26 | **CREATE** 27 | Method : `POST: /api/v1.0/groups/:group/jobs` 28 | Status : `201: Created` 29 | Body : 30 | ```json 31 | { 32 | "name": "manager", 33 | "subject": "Daily Fuel Report", 34 | "messageBody": "Sample fuel report", 35 | "to": ["surya@example.com", "surya@example.net"], 36 | "triggers": 37 | [ 38 | { 39 | "name": "manager", 40 | "group": "email", 41 | "fireTime": "2017-10-02T22:00:00.000" 42 | } 43 | ] 44 | } 45 | ``` 46 | Content-Type: `application/json` 47 | 48 | **VIEW** 49 | Method : `GET: /api/v1.0/groups/:group/jobs/:name` 50 | Status : `200: Ok` 51 | Body : NULL 52 | Accept : `application/json` 53 | 54 | **UPDATE** 55 | Method : `PUT: /api/v1.0/groups/:group/jobs/:name` 56 | Status : `204: No Content` 57 | Body : 58 | ```json 59 | { 60 | "name": "manager", 61 | "subject": "Daily Fuel Report", 62 | "messageBody": "Sample fuel report", 63 | "to" : ["surya@example.com", "surya@example.net"], 64 | "cc" : ["management@example.com", "management@example.net"], 65 | "bcc": ["bcc@example.com"] 66 | } 67 | ``` 68 | Content-Type: `application/json` 69 | 70 | **UPDATE (Pause)** 71 | Method : `PATCH: /api/v1.0/groups/:group/jobs/:name/pause` 72 | Status : `204: No Content` 73 | Body : NULL 74 | Content-Type: `*/*` 75 | 76 | **UPDATE (Resume)** 77 | Method : `PATCH: /api/v1.0/groups/:group/jobs/:name/resume` 78 | Status : `204: No Content` 79 | Body : NULL 80 | Content-Type: `*/*` 81 | 82 | **DELETE** 83 | Method : `DELETE: /api/v1.0/groups/:group/jobs/:name` 84 | Status : `204: No Content` 85 | Body : NULL 86 | Content-Type: `*/*` 87 | 88 | 89 | Introduction 90 | Every developer at a certain point in his carreer is faced with the difficult task of scheduling jobs dynamically. In this post we are going to create a simple application for dynamically scheduling jobs using a REST API. 91 | We will dynamically create jobs that sends emails to a predefined group of people on a user defined schedule using Spring Boot. 92 | 93 | Project Structure 94 | At the end of this guide our folder structure will look similar to the following: 95 | 96 | . 97 | |__src/ 98 | | |__main/ 99 | | | |__java/ 100 | | | | |__com/ 101 | | | | | |__surya/ 102 | | | | | | |__quartz/ 103 | | | | | | | |__Application.java 104 | | | | | | | |__AutowiringSpringBeanJobFactory.java 105 | | | | | | | |__job/ 106 | | | | | | | | |__EmailJob.java 107 | | | | | | | |__model/ 108 | | | | | | | | |__JobDescriptor.java 109 | | | | | | | | |__TriggerDescriptor.java 110 | | | | | | | |__service/ 111 | | | | | | | | |__EmailService.java 112 | | | | | | | |__web/ 113 | | | | | | | | |__rest/ 114 | | | | | | | | | |__EmailResource.java 115 | | | |__resources/ 116 | | | | | |__application.yaml 117 | |__pom.xml 118 | Prerequisites 119 | To follow along this guide, you should have the following set up: 120 | 121 | Java Development Kit 122 | Optional 123 | Maven 124 | cURL 125 | Concepts 126 | Before we dive any further, there are a few quartz concepts we need to understand: 127 | 128 | Job - an interface to be implemented by components that you wish to have executed by the scheduler. The interface has one method execute(...). This is where your scheduled task runs. Information on the JobDetail and Trigger is retrieved using the JobExecutionContext. 129 | 130 | package org.quartz; 131 | 132 | public interface Job { 133 | public void execute(JobExecutionContext context) throws JobExecutionException; 134 | } 135 | JobDetail - used to define instances of Jobs. This defines how a job is run. Whatever data you want available to the Job when it is instantiated is provided through the JobDetail. 136 | Quartz provides a Domain Specific Language (DSL) in the form of JobBuilder for constructing JobDetail instances. 137 | 138 | // define the job and tie it to the Job implementation 139 | JobDetail job = newJob(EmailJob.class) 140 | .withIdentity("myJob", "group1") // name "myJob", group "group1" 141 | .build(); 142 | Trigger - a component that defines the schedule upon which a given Job will be executed. The trigger 143 | provides instruction on when the job is run. 144 | Quartz provides a DSL (TriggerBuilder) for constructing Trigger instances. 145 | 146 | // Trigger the job to run now, and then every 40 seconds 147 | Trigger trigger = newTrigger() 148 | .withIdentity("myTrigger", "group1") 149 | .startNow() 150 | .withSchedule(simpleSchedule() 151 | .withIntervalInSeconds(40) 152 | .repeatForever()) 153 | .build(); 154 | Scheduler - the main API for interacting with the scheduler. A Scheduler’s life-cycle is bounded by it’s creation, via a SchedulerFactory and a call to its shutdown() method. Once created the Scheduler interface can be used to add, remove, and list Jobs and Triggers, and perform other scheduling-related operations (such as pausing a trigger). However, the Scheduler will not actually act on any triggers (execute jobs) until it has been started with the start() method. 155 | 156 | SchedulerFactory schedFact = new org.quartz.impl.StdSchedulerFactory(); 157 | 158 | Scheduler sched = schedFact.getScheduler(); 159 | sched.start(); 160 | 161 | // Tell quartz to schedule the job using our trigger 162 | sched.scheduleJob(job, trigger); 163 | Create and Setup Dependencies for the Sample Application 164 | Head over to start.spring.io and build a Spring Boot template as illustrated in the image below: 165 | 166 | spring.io 167 | 168 | Spring Initializr 169 | 170 | Download the zip archive and extract the contents to a folder of your choice. Open your pom.xml located at the root of the template directory and add the following dependencies: 171 | 172 | file: pom.xml 173 | 174 | ... 175 | 176 | org.springframework 177 | spring-context-support 178 | 179 | 180 | org.quartz-scheduler 181 | quartz 182 | 2.3.0 183 | 184 | ... 185 | The above are the dependencies needed for Quartz with Spring integration. 186 | 187 | Scope 188 | By the end of this post we will be able to schedule Quartz jobs dynamically to send emails using a REST API. 189 | We will create jobs: 190 | 191 | // 192 | Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler(); 193 | scheduler.scheduleJob(jobDetail, trigger); 194 | retrieve existing jobs: 195 | 196 | // 197 | scheduler.getJobDetail(jobKey); 198 | update existing jobs: 199 | 200 | // store, and set overwrite flag to 'true' 201 | scheduler.addJob(jobDetail, true); 202 | delete existing jobs: 203 | 204 | // 205 | scheduler.deleteJob(jobKey); 206 | pause jobs: 207 | 208 | // 209 | scheduler.pauseJob(jobKey); 210 | and resume jobs: 211 | 212 | // 213 | scheduler.resumeJob(jobKey); 214 | About Jobstores 215 | JobStore’s are responsible for keeping track of all the “work data” that you give to the scheduler: jobs, triggers, calendars, etc. Selecting the appropriate JobStore for your Quartz scheduler instance is an important step. Luckily, the choice should be a very easy one once you understand the differences between them. 216 | You declare which JobStore your scheduler should use (and it’s configuration settings) in the properties file (or object) that you provide to the SchedulerFactory that you use to produce your scheduler instance. 217 | 218 | There are three types of Jobstores that are available in Quartz: 219 | 220 | RAMJobStore - is the simplest JobStore to use, it is also the most performant (in terms of CPU time). RAMJobStore gets its name in the obvious way: it keeps all of its data in RAM. This is why it’s lightning-fast, and also why it’s so simple to configure. The drawback is that when your application ends (or crashes) all of the scheduling information is lost - this means RAMJobStore cannot honor the setting of “non-volatility” on jobs and triggers. For some applications this is acceptable - or even the desired behavior, but for other applications, this may be disastrous. In this part of the series we will be using RAMJobStore. 221 | JDBCJobStore - is also aptly named - it keeps all of its data in a database via JDBC. Because of this it is a bit more complicated to configure than RAMJobStore, and it also is not as fast. However, the performance 222 | draw-back is not terribly bad, especially if you build the database tables with indexes on the primary keys. 223 | On fairly modern set of machines with a decent LAN (between the scheduler and database) the time to retrieve and update a firing trigger will typically be less than 10 milliseconds. We will talk more about JDBCJobStore in the next post. 224 | TerracottaJobStore - provides a means for scaling and robustness without the use of a database. This means your database can be kept free of load from Quartz, and can instead have all of its resources saved for the rest of your application. 225 | 226 | TerracottaJobStore can be ran clustered or non-clustered, and in either case provides a storage medium for your job data that is persistent between application restarts, because the data is stored in the Terracotta server. It’s performance is much better than using a database via JDBCJobStore (about an order of magnitude better), but fairly slower than RAMJobStore. This is out of the scope for this series. 227 | 228 | Setting up the Descriptors 229 | In order to set up the REST API for the dyanmic jobs, we will create two abstractions over JobDetail and Trigger aptly named JobDescriptor and TriggerDescriptor: 230 | 231 | file: src/main/java/com/surya/quartz/model/TriggerDescriptor.java 232 | 233 | public class TriggerDescriptor { 234 | private String name; 235 | private String group; 236 | private LocalDateTime fireTime; 237 | private String cron; 238 | 239 | /** 240 | * Convenience method for building a Trigger 241 | */ 242 | public Trigger buildTrigger() { 243 | // 244 | } 245 | 246 | /** 247 | * Convenience method for building a TriggerDescriptor 248 | */ 249 | public static TriggerDescriptor buildDescriptor(Trigger trigger) { 250 | // 251 | } 252 | // Code ommitted for brevity. Click on link to view full source 253 | } 254 | file: src/main/java/com/surya/quartz/model/JobDescriptor.java 255 | 256 | public class JobDescriptor { 257 | private String name; 258 | private String group; 259 | private String subject; 260 | private String messageBody; 261 | private List to; 262 | private List cc; 263 | private List bcc; 264 | private Map data = new LinkedHashMap<>(); 265 | @JsonProperty("triggers") 266 | private List triggerDescriptors = new ArrayList<>(); 267 | 268 | /** 269 | * Convenience method for building triggers of Job 270 | */ 271 | public Set buildTriggers() { 272 | // 273 | } 274 | 275 | /** 276 | * Convenience method for building a JobDetail 277 | */ 278 | public JobDetail buildJobDetail() { 279 | // 280 | } 281 | 282 | /** 283 | * Convenience method for building a JobDescriptor 284 | */ 285 | public static JobDescriptor buildDescriptor(JobDetail jobDetail, List triggersOfJob) { 286 | // 287 | } 288 | 289 | // Code ommitted for brevity. Click on link to view full source 290 | } 291 | Next we will define our Job class: 292 | 293 | file: src/main/java/com/surya/quartz/job/EmailJob.java 294 | 295 | public class EmailJob implements Job { 296 | 297 | @Override 298 | public void execute(JobExecutionContext context) throws JobExecutionException { 299 | // JobDataMap map = context.getJobDetail().getJobDataMap(); 300 | // JobDataMap map = context.getTrigger().getJobDataMap(); 301 | JobDataMap map = context.getMergedJobDataMap(); 302 | System.out.format("Map: [%s]\n", map.getWrappedMap()); 303 | } 304 | } 305 | The JobDataMap can be used to hold any amount of (serializable) data objects which you wish to have made available to the job instance when it executes. JobDataMap is an implementation of the Java Map interface, and has some added convenience methods for storing and retrieving data of primitive types. 306 | You can retrieve the JobDataMap from the JobExecutionContext that is stored as part of the JobDetail or Trigger. 307 | The JobDataMap that is found on the JobExecutionContext during Job execution serves as a convenience. It is a merge of the JobDataMap found on the JobDetail and the one found on the Trigger, with the values in the latter overriding any same-named values in the former. 308 | 309 | Boostrapping with Spring Boot 310 | At the beginning of this post I stated that the life-cycle of a Scheduler is bounded by it’s creation, via a SchedulerFactory and a call to its shutdown() method. For this post we will create a Singleton instance of a SchedulerFactory. We can achieve this by creating it as a Spring Bean: 311 | 312 | file: src/main/java/com/surya/quartz/Application.java 313 | 314 | ... 315 | @Bean 316 | public SchedulerFactoryBean schedulerFactory() { 317 | SchedulerFactoryBean factoryBean = new SchedulerFactoryBean(); 318 | 319 | return factoryBean; 320 | } 321 | The bean definition above is doing several things:- 322 | 323 | JobFactory - The default is Spring’s AdaptableJobFactory, which supports java.lang.Runnable objects as well as standard Quartz org.quartz.Job instances. Note that this default only applies to a local Scheduler, not to a RemoteScheduler (where setting a custom JobFactory is not supported by Quartz). 324 | ThreadPool - Default is a Quartz SimpleThreadPool with a pool size of 10. This is configured through the corresponding Quartz properties. 325 | SchedulerFactory - The default used here is the StdSchedulerFactory, reading in the standard quartz.properties from quartz.jar. 326 | JobStore - The default used is RAMJobStore which does not support persistence and is not clustered. 327 | Life-Cycle - The SchedulerFactoryBean implements org.springframework.context.SmartLifecycle and org.springframework.beans.factory.DisposableBean which means the life-cycle of the scheduler is managed by the Spring container. The sheduler.start() is called in the start() implementation of SmartLifecycle after initialization and the scheduler.shutdown() is called in the destroy() implementation of DisposableBean at application teardown. 328 | You can override the startup behaviour by setting setAutoStartup(..) to false. With this setting you have to manually start the scheduler. 329 | Creating Some Service and Controller Classes 330 | We will create a service class that will take care of Creating, Fetching, Updating, Deleting, Pausing and Resuming jobs: 331 | 332 | file: src/main/java/com/surya/quartz/service/EmailService.java 333 | 334 | @Service 335 | @Transactional 336 | public class EmailService { 337 | private final Scheduler scheduler; 338 | 339 | public EmailService(Scheduler scheduler) { 340 | this.scheduler = scheduler; 341 | } 342 | 343 | public JobDescriptor createJob(String group, JobDescriptor descriptor) { 344 | // 345 | } 346 | 347 | public JobDescriptor findJob(String group, String name) { 348 | // 349 | } 350 | 351 | public void updateJob(String group, String name, JobDescriptor descriptor) { 352 | // 353 | } 354 | 355 | public void deleteJob(String group, String name) { 356 | // 357 | } 358 | 359 | public void pauseJob(String group, String name) { 360 | // 361 | } 362 | 363 | public void resumeJob(String group, String name) { 364 | // 365 | } 366 | } 367 | Now the REST endpoints: 368 | 369 | file: src/main/java/com/surya/quartz/web/rest/EmailResource.java 370 | 371 | @RestController 372 | @RequestMapping("/api/v1.0") 373 | public class EmailResource { 374 | private final EmailService emailService; 375 | 376 | public EmailResource(EmailService emailService) { 377 | this.emailService = emailService; 378 | } 379 | 380 | @PostMapping(path = "/groups/{group}/jobs") 381 | public ResponseEntity createJob(@PathVariable String group, 382 | @RequestBody JobDescriptor descriptor) { 383 | // 384 | } 385 | 386 | @GetMapping(path = "/groups/{group}/jobs/{name}") 387 | public ResponseEntity findJob(@PathVariable String group, 388 | @PathVariable String name) { 389 | // 390 | } 391 | 392 | @PutMapping(path = "/groups/{group}/jobs/{name}") 393 | public ResponseEntity updateJob(@PathVariable String group, 394 | @PathVariable String name, @RequestBody JobDescriptor descriptor) { 395 | // 396 | } 397 | 398 | @DeleteMapping(path = "/groups/{group}/jobs/{name}") 399 | public ResponseEntity deleteJob(@PathVariable String group, @PathVariable String name) { 400 | // 401 | } 402 | 403 | @PatchMapping(path = "/groups/{group}/jobs/{name}/pause") 404 | public ResponseEntity pauseJob(@PathVariable String group, @PathVariable String name) { 405 | // 406 | } 407 | 408 | @PatchMapping(path = "/groups/{group}/jobs/{name}/resume") 409 | public ResponseEntity resumeJob(@PathVariable String group, @PathVariable String name) { 410 | // 411 | } 412 | } 413 | Start the server: 414 | 415 | > mvnw clean spring-boot:run # Windows 416 | $ ./mvnw clean spring-boot:run # Linux and Mac 417 | and test the create endpoint http://localhost:8080/api/v1.0/groups/email/jobs via post using curl or Postman with the following JSON payload and content type application/json: 418 | 419 | { 420 | "name": "manager", 421 | "subject": "Daily Fuel Report", 422 | "messageBody": "Sample fuel report", 423 | "to": ["surya@example.com", "surya@example.net"], 424 | "triggers": 425 | [ 426 | { 427 | "name": "manager", 428 | "group": "email", 429 | "cron": "0/10 * * * * ?" 430 | } 431 | ] 432 | } 433 | This will execute every 10 seconds by printing to STDOUT. 434 | 435 | Update at http://localhost:8080/api/v1.0/groups/email/jobs/manager with content type application/json and payload: 436 | 437 | { 438 | "name": "manager", 439 | "subject": "Daily Fuel Report", 440 | "messageBody": "Sample fuel report", 441 | "to": ["surya@example.com", "surya@example.net", "surya@example.org"], 442 | "cc": ["surya@example.io"] 443 | } 444 | Setting up Email 445 | Create a configuration file and add the SMTP details of your mail server which defines the behaviour of the JavaMailSender bean. We will inject this bean into the job class: 446 | 447 | file: src/main/resources/application.yaml 448 | 449 | spring: 450 | mail: 451 | host: smtp.example.com 452 | port: 587 453 | username: username@example.com 454 | password: password 455 | One thing we did not talk about, the AdaptableJobFactory does not do any dependency injection. Each (and every) time the scheduler executes the job, it creates a new instance of the class before calling its execute(..) method. When the execution is complete, references to the job class instance are dropped, and the instance is then garbage collected. One of the ramifications of this behavior is the fact that jobs must have a no-argument constructor (when using the AdaptableJobFactory implementation). Another ramification is that it does not make sense to have state data-fields defined on the job class - as their values would not be preserved between job executions. 456 | 457 | It is at this point that things get interesting. We will create our own implementation of JobFactory that supports dependency injection: 458 | 459 | file: src/main/java/com/surya/quartz/AutowiringSpringBeanJobFactory.java 460 | 461 | public class AutowiringSpringBeanJobFactory extends 462 | SpringBeanJobFactory implements ApplicationContextAware { 463 | private transient AutowireCapableBeanFactory beanFactory; 464 | 465 | @Override 466 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 467 | beanFactory = applicationContext.getAutowireCapableBeanFactory(); 468 | } 469 | 470 | @Override 471 | protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { 472 | final Object job = super.createJobInstance(bundle); 473 | beanFactory.autowireBean(job); // Dependency Injection is done here 474 | return job; 475 | } 476 | } 477 | Override the AdaptableJobFactory in the SchedulerFactoryBean and use AutowiringSpringBeanJobFactory instead: 478 | 479 | file: src/main/java/com/surya/quartz/Application.java 480 | 481 | ... 482 | @Bean 483 | public SchedulerFactoryBean schedulerFactory(ApplicationContext applicationContext) { 484 | SchedulerFactoryBean factoryBean = new SchedulerFactoryBean(); 485 | AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory(); 486 | jobFactory.setApplicationContext(applicationContext); 487 | 488 | factoryBean.setJobFactory(jobFactory); // Set jobFactory to AutowiringSpringBeanJobFactory 489 | return factoryBean; 490 | } 491 | Now inject the JavaMailSender into the job class: 492 | 493 | file: src/main/java/com/surya/quartz/job/EmailJob.java 494 | 495 | public class EmailJob implements Job { 496 | @Autowired 497 | private JavaMailSender mailSender; 498 | 499 | ... 500 | 501 | private void sendEmail(Map map) { 502 | // Get job details from map and send email 503 | try { 504 | this.mailSender.send(..); 505 | } catch (MailException ex) { 506 | // simply log it and go on... 507 | } 508 | } 509 | } 510 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.surya 7 | quartz-manager 8 | 0.0.1-SNAPSHOT 9 | jar 10 | 11 | quartz-manager 12 | Demo project for Dynamic quartz jobs 13 | 14 | 15 | org.springframework.boot 16 | spring-boot-starter-parent 17 | 1.5.7.RELEASE 18 | 19 | 20 | 21 | 22 | UTF-8 23 | UTF-8 24 | 1.8 25 | 26 | 5.0.0 27 | ${junit.version}.0 28 | 1.0.0 29 | 30 | 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-data-jpa 35 | 36 | 37 | org.liquibase 38 | liquibase-core 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-mail 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-web 47 | 48 | 49 | 50 | org.springframework 51 | spring-context-support 52 | 53 | 54 | org.quartz-scheduler 55 | quartz 56 | 2.3.0 57 | 58 | 59 | com.zaxxer 60 | HikariCP-java6 61 | 62 | 63 | com.mchange 64 | c3p0 65 | 66 | 67 | 68 | 69 | com.fasterxml.jackson.datatype 70 | jackson-datatype-jsr310 71 | 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-devtools 76 | runtime 77 | 78 | 79 | org.hsqldb 80 | hsqldb 81 | runtime 82 | 83 | 84 | org.projectlombok 85 | lombok 86 | true 87 | 88 | 89 | org.springframework.boot 90 | spring-boot-starter-test 91 | test 92 | 93 | 94 | org.junit.jupiter 95 | junit-jupiter-api 96 | ${junit.jupiter.version} 97 | test 98 | 99 | 100 | 101 | org.junit.platform 102 | junit-platform-launcher 103 | ${junit.platform.version} 104 | test 105 | 106 | 107 | 108 | org.junit.jupiter 109 | junit-jupiter-engine 110 | ${junit.jupiter.version} 111 | test 112 | 113 | 114 | 115 | org.junit.vintage 116 | junit-vintage-engine 117 | ${junit.vintage.version} 118 | test 119 | 120 | 121 | 122 | 123 | 124 | 125 | org.springframework.boot 126 | spring-boot-maven-plugin 127 | 128 | 132 | 137 | 138 | maven-surefire-plugin 139 | 140 | 141 | **/Test*.java 142 | **/*Test.java 143 | **/*Tests.java 144 | **/*TestCase.java 145 | 146 | 147 | 148 | slow 149 | 151 | 152 | 153 | 154 | 155 | org.junit.platform 156 | junit-platform-surefire-provider 157 | ${junit.platform.version} 158 | 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/Application.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.context.ApplicationContext; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.scheduling.quartz.SchedulerFactoryBean; 8 | 9 | @SpringBootApplication 10 | public class Application { 11 | 12 | public static void main(String[] args) { 13 | SpringApplication.run(Application.class, args); 14 | } 15 | 16 | @Bean 17 | public SchedulerFactoryBean schedulerFactory(ApplicationContext applicationContext) { 18 | SchedulerFactoryBean factoryBean = new SchedulerFactoryBean(); 19 | AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory(); 20 | jobFactory.setApplicationContext(applicationContext); 21 | 22 | factoryBean.setJobFactory(jobFactory); 23 | factoryBean.setApplicationContextSchedulerContextKey("applicationContext"); 24 | return factoryBean; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/AutowiringSpringBeanJobFactory.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz; 2 | 3 | import org.quartz.spi.TriggerFiredBundle; 4 | import org.springframework.beans.BeansException; 5 | import org.springframework.beans.factory.config.AutowireCapableBeanFactory; 6 | import org.springframework.context.ApplicationContext; 7 | import org.springframework.context.ApplicationContextAware; 8 | import org.springframework.scheduling.quartz.SpringBeanJobFactory; 9 | 10 | public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { 11 | private transient AutowireCapableBeanFactory beanFactory; 12 | 13 | @Override 14 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 15 | beanFactory = applicationContext.getAutowireCapableBeanFactory(); 16 | } 17 | 18 | @Override 19 | protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { 20 | final Object job = super.createJobInstance(bundle); 21 | beanFactory.autowireBean(job); 22 | return job; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/job/EmailJob.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.job; 2 | 3 | import static org.springframework.util.CollectionUtils.isEmpty; 4 | 5 | import java.io.UnsupportedEncodingException; 6 | import java.util.List; 7 | 8 | import javax.mail.MessagingException; 9 | import javax.mail.internet.MimeMessage; 10 | 11 | import org.quartz.Job; 12 | import org.quartz.JobDataMap; 13 | import org.quartz.JobExecutionContext; 14 | import org.quartz.JobExecutionException; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.mail.javamail.JavaMailSender; 17 | import org.springframework.mail.javamail.MimeMessageHelper; 18 | 19 | import lombok.extern.slf4j.Slf4j; 20 | 21 | @Slf4j 22 | public class EmailJob implements Job { 23 | @Autowired 24 | private JavaMailSender mailSender; 25 | 26 | @Override 27 | public void execute(JobExecutionContext context) throws JobExecutionException { 28 | log.info("Job triggered to send emails"); 29 | JobDataMap map = context.getMergedJobDataMap(); 30 | sendEmail(map); 31 | log.info("Job completed"); 32 | } 33 | 34 | @SuppressWarnings("unchecked") 35 | private void sendEmail(JobDataMap map) { 36 | String subject = map.getString("subject"); 37 | String messageBody = map.getString("messageBody"); 38 | List to = (List) map.get("to"); 39 | List cc = (List) map.get("cc"); 40 | List bcc = (List) map.get("bcc"); 41 | 42 | MimeMessage message = mailSender.createMimeMessage(); 43 | 44 | try { 45 | MimeMessageHelper helper = new MimeMessageHelper(message, false); 46 | for(String receipient : to) { 47 | helper.setFrom("jk@juliuskrah.com", "Julius from Dynamic Quartz"); 48 | helper.setTo(receipient); 49 | helper.setSubject(subject); 50 | helper.setText(messageBody); 51 | if(!isEmpty(cc)) 52 | helper.setCc(cc.stream().toArray(String[]::new)); 53 | if(!isEmpty(bcc)) 54 | helper.setBcc(bcc.stream().toArray(String[]::new)); 55 | mailSender.send(message); 56 | } 57 | } catch (MessagingException | UnsupportedEncodingException e) { 58 | log.error("An error occurred: {}", e.getLocalizedMessage()); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/model/JobDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.model; 2 | 3 | import java.util.ArrayList; 4 | import java.util.LinkedHashMap; 5 | import java.util.LinkedHashSet; 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.Set; 9 | 10 | import org.hibernate.validator.constraints.NotBlank; 11 | import org.hibernate.validator.constraints.NotEmpty; 12 | import static org.quartz.JobBuilder.*; 13 | import org.quartz.JobDataMap; 14 | import org.quartz.JobDetail; 15 | import org.quartz.Trigger; 16 | 17 | import com.fasterxml.jackson.annotation.JsonIgnore; 18 | import com.fasterxml.jackson.annotation.JsonProperty; 19 | import com.surya.quartz.job.EmailJob; 20 | 21 | import lombok.Data; 22 | 23 | @Data 24 | public class JobDescriptor { 25 | // TODO add boolean fields for HTML and Attachments 26 | @NotBlank 27 | private String name; 28 | private String group; 29 | @NotEmpty 30 | private String subject; 31 | @NotEmpty 32 | private String messageBody; 33 | @NotEmpty 34 | private List to; 35 | private List cc; 36 | private List bcc; 37 | private Map data = new LinkedHashMap<>(); 38 | @JsonProperty("triggers") 39 | private List triggerDescriptors = new ArrayList<>(); 40 | 41 | public JobDescriptor setName(final String name) { 42 | this.name = name; 43 | return this; 44 | } 45 | 46 | public JobDescriptor setGroup(final String group) { 47 | this.group = group; 48 | return this; 49 | } 50 | 51 | public JobDescriptor setSubject(String subject) { 52 | this.subject = subject; 53 | return this; 54 | } 55 | 56 | public JobDescriptor setMessageBody(String messageBody) { 57 | this.messageBody = messageBody; 58 | return this; 59 | } 60 | 61 | public JobDescriptor setTo(List to) { 62 | this.to = to; 63 | return this; 64 | } 65 | 66 | public JobDescriptor setCc(List cc) { 67 | this.cc = cc; 68 | return this; 69 | } 70 | 71 | public JobDescriptor setBcc(List bcc) { 72 | this.bcc = bcc; 73 | return this; 74 | } 75 | 76 | public JobDescriptor setData(final Map data) { 77 | this.data = data; 78 | return this; 79 | } 80 | 81 | public JobDescriptor setTriggerDescriptors(final List triggerDescriptors) { 82 | this.triggerDescriptors = triggerDescriptors; 83 | return this; 84 | } 85 | 86 | /** 87 | * Convenience method for building Triggers of Job 88 | * 89 | * @return Triggers for this JobDetail 90 | */ 91 | @JsonIgnore 92 | public Set buildTriggers() { 93 | Set triggers = new LinkedHashSet<>(); 94 | for (TriggerDescriptor triggerDescriptor : triggerDescriptors) { 95 | triggers.add(triggerDescriptor.buildTrigger()); 96 | } 97 | 98 | return triggers; 99 | } 100 | 101 | /** 102 | * Convenience method that builds a JobDetail 103 | * 104 | * @return the JobDetail built from this descriptor 105 | */ 106 | public JobDetail buildJobDetail() { 107 | // @formatter:off 108 | JobDataMap jobDataMap = new JobDataMap(getData()); 109 | jobDataMap.put("subject", subject); 110 | jobDataMap.put("messageBody", messageBody); 111 | jobDataMap.put("to", to); 112 | jobDataMap.put("cc", cc); 113 | jobDataMap.put("bcc", bcc); 114 | return newJob(EmailJob.class) 115 | .withIdentity(getName(), getGroup()) 116 | .usingJobData(jobDataMap) 117 | .build(); 118 | // @formatter:on 119 | } 120 | 121 | /** 122 | * Convenience method that builds a descriptor from JobDetail and Trigger(s) 123 | * 124 | * @param jobDetail 125 | * the JobDetail instance 126 | * @param triggersOfJob 127 | * the Trigger(s) to associate with the Job 128 | * @return the JobDescriptor 129 | */ 130 | @SuppressWarnings("unchecked") 131 | public static JobDescriptor buildDescriptor(JobDetail jobDetail, List triggersOfJob) { 132 | // @formatter:off 133 | List triggerDescriptors = new ArrayList<>(); 134 | 135 | for (Trigger trigger : triggersOfJob) { 136 | triggerDescriptors.add(TriggerDescriptor.buildDescriptor(trigger)); 137 | } 138 | 139 | return new JobDescriptor() 140 | .setName(jobDetail.getKey().getName()) 141 | .setGroup(jobDetail.getKey().getGroup()) 142 | .setSubject(jobDetail.getJobDataMap().getString("subject")) 143 | .setMessageBody(jobDetail.getJobDataMap().getString("messageBody")) 144 | .setTo((List)jobDetail.getJobDataMap().get("to")) 145 | .setCc((List)jobDetail.getJobDataMap().get("cc")) 146 | .setBcc((List)jobDetail.getJobDataMap().get("bcc")) 147 | // .setData(jobDetail.getJobDataMap().getWrappedMap()) 148 | .setTriggerDescriptors(triggerDescriptors); 149 | // @formatter:on 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/model/TriggerDescriptor.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.model; 2 | 3 | import static java.time.ZoneId.systemDefault; 4 | import static java.util.UUID.randomUUID; 5 | import static org.quartz.CronExpression.isValidExpression; 6 | import static org.quartz.CronScheduleBuilder.cronSchedule; 7 | import static org.quartz.SimpleScheduleBuilder.simpleSchedule; 8 | import static org.quartz.TriggerBuilder.newTrigger; 9 | import static org.springframework.util.StringUtils.isEmpty; 10 | 11 | import java.sql.Date; 12 | import java.time.LocalDateTime; 13 | import java.util.TimeZone; 14 | 15 | import org.hibernate.validator.constraints.NotBlank; 16 | import org.quartz.JobDataMap; 17 | import org.quartz.Trigger; 18 | 19 | import lombok.Data; 20 | 21 | @Data 22 | public class TriggerDescriptor { 23 | @NotBlank 24 | private String name; 25 | private String group; 26 | private LocalDateTime fireTime; 27 | private String cron; 28 | 29 | public TriggerDescriptor setName(final String name) { 30 | this.name = name; 31 | return this; 32 | } 33 | 34 | public TriggerDescriptor setGroup(final String group) { 35 | this.group = group; 36 | return this; 37 | } 38 | 39 | public TriggerDescriptor setFireTime(final LocalDateTime fireTime) { 40 | this.fireTime = fireTime; 41 | return this; 42 | } 43 | 44 | public TriggerDescriptor setCron(final String cron) { 45 | this.cron = cron; 46 | return this; 47 | } 48 | 49 | private String buildName() { 50 | return isEmpty(name) ? randomUUID().toString() : name; 51 | } 52 | 53 | /** 54 | * Convenience method for building a Trigger 55 | * 56 | * @return the Trigger associated with this descriptor 57 | */ 58 | public Trigger buildTrigger() { 59 | // @formatter:off 60 | if (!isEmpty(cron)) { 61 | if (!isValidExpression(cron)) 62 | throw new IllegalArgumentException("Provided expression " + cron + " is not a valid cron expression"); 63 | return newTrigger() 64 | .withIdentity(buildName(), group) 65 | .withSchedule(cronSchedule(cron) 66 | .withMisfireHandlingInstructionFireAndProceed() 67 | .inTimeZone(TimeZone.getTimeZone(systemDefault()))) 68 | .usingJobData("cron", cron) 69 | .build(); 70 | } else if (!isEmpty(fireTime)) { 71 | JobDataMap jobDataMap = new JobDataMap(); 72 | jobDataMap.put("fireTime", fireTime); 73 | return newTrigger() 74 | .withIdentity(buildName(), group) 75 | .withSchedule(simpleSchedule() 76 | .withMisfireHandlingInstructionNextWithExistingCount()) 77 | .startAt(Date.from(fireTime.atZone(systemDefault()).toInstant())) 78 | .usingJobData(jobDataMap) 79 | .build(); 80 | } 81 | // @formatter:on 82 | throw new IllegalStateException("unsupported trigger descriptor " + this); 83 | } 84 | 85 | /** 86 | * 87 | * @param trigger 88 | * the Trigger used to build this descriptor 89 | * @return the TriggerDescriptor 90 | */ 91 | public static TriggerDescriptor buildDescriptor(Trigger trigger) { 92 | // @formatter:off 93 | return new TriggerDescriptor() 94 | .setName(trigger.getKey().getName()) 95 | .setGroup(trigger.getKey().getGroup()) 96 | .setFireTime((LocalDateTime) trigger.getJobDataMap().get("fireTime")) 97 | .setCron(trigger.getJobDataMap().getString("cron")); 98 | // @formatter:on 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/service/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.service; 2 | 3 | import static org.quartz.JobKey.jobKey; 4 | 5 | import java.util.Objects; 6 | import java.util.Optional; 7 | import java.util.Set; 8 | 9 | import org.quartz.JobBuilder; 10 | import org.quartz.JobDataMap; 11 | import org.quartz.JobDetail; 12 | import org.quartz.Scheduler; 13 | import org.quartz.SchedulerException; 14 | import org.quartz.Trigger; 15 | import org.springframework.stereotype.Service; 16 | import org.springframework.transaction.annotation.Transactional; 17 | 18 | import com.surya.quartz.model.JobDescriptor; 19 | 20 | import lombok.RequiredArgsConstructor; 21 | import lombok.extern.slf4j.Slf4j; 22 | 23 | @Slf4j 24 | @Service 25 | @Transactional 26 | @RequiredArgsConstructor 27 | public class EmailService { 28 | private final Scheduler scheduler; 29 | 30 | public JobDescriptor createJob(String group, JobDescriptor descriptor) { 31 | descriptor.setGroup(group); 32 | JobDetail jobDetail = descriptor.buildJobDetail(); 33 | Set triggersForJob = descriptor.buildTriggers(); 34 | log.info("About to save job with key - {}", jobDetail.getKey()); 35 | try { 36 | scheduler.scheduleJob(jobDetail, triggersForJob, false); 37 | log.info("Job with key - {} saved sucessfully", jobDetail.getKey()); 38 | } catch (SchedulerException e) { 39 | log.error("Could not save job with key - {} due to error - {}", jobDetail.getKey(), e.getLocalizedMessage()); 40 | throw new IllegalArgumentException(e.getLocalizedMessage()); 41 | } 42 | return descriptor; 43 | } 44 | 45 | @Transactional(readOnly = true) 46 | public Optional findJob(String group, String name) { 47 | // @formatter:off 48 | try { 49 | JobDetail jobDetail = scheduler.getJobDetail(jobKey(name, group)); 50 | if(Objects.nonNull(jobDetail)) 51 | return Optional.of( 52 | JobDescriptor.buildDescriptor(jobDetail, 53 | scheduler.getTriggersOfJob(jobKey(name, group)))); 54 | } catch (SchedulerException e) { 55 | log.error("Could not find job with key - {}.{} due to error - {}", group, name, e.getLocalizedMessage()); 56 | } 57 | // @formatter:on 58 | log.warn("Could not find job with key - {}.{}", group, name); 59 | return Optional.empty(); 60 | } 61 | 62 | public void updateJob(String group, String name, JobDescriptor descriptor) { 63 | try { 64 | JobDetail oldJobDetail = scheduler.getJobDetail(jobKey(name, group)); 65 | if(Objects.nonNull(oldJobDetail)) { 66 | JobDataMap jobDataMap = oldJobDetail.getJobDataMap(); 67 | jobDataMap.put("subject", descriptor.getSubject()); 68 | jobDataMap.put("messageBody", descriptor.getMessageBody()); 69 | jobDataMap.put("to", descriptor.getTo()); 70 | jobDataMap.put("cc", descriptor.getCc()); 71 | jobDataMap.put("bcc", descriptor.getBcc()); 72 | JobBuilder jb = oldJobDetail.getJobBuilder(); 73 | JobDetail newJobDetail = jb.usingJobData(jobDataMap).storeDurably().build(); 74 | scheduler.addJob(newJobDetail, true); 75 | log.info("Updated job with key - {}", newJobDetail.getKey()); 76 | return; 77 | } 78 | log.warn("Could not find job with key - {}.{} to update", group, name); 79 | } catch (SchedulerException e) { 80 | log.error("Could not find job with key - {}.{} to update due to error - {}", group, name, e.getLocalizedMessage()); 81 | } 82 | } 83 | 84 | public void deleteJob(String group, String name) { 85 | try { 86 | scheduler.deleteJob(jobKey(name, group)); 87 | log.info("Deleted job with key - {}.{}", group, name); 88 | } catch (SchedulerException e) { 89 | log.error("Could not delete job with key - {}.{} due to error - {}", group, name, e.getLocalizedMessage()); 90 | } 91 | } 92 | 93 | public void pauseJob(String group, String name) { 94 | try { 95 | scheduler.pauseJob(jobKey(name, group)); 96 | log.info("Paused job with key - {}.{}", group, name); 97 | } catch (SchedulerException e) { 98 | log.error("Could not pause job with key - {}.{} due to error - {}", group, name, e.getLocalizedMessage()); 99 | } 100 | } 101 | 102 | public void resumeJob(String group, String name) { 103 | try { 104 | scheduler.resumeJob(jobKey(name, group)); 105 | log.info("Resumed job with key - {}.{}", group, name); 106 | } catch (SchedulerException e) { 107 | log.error("Could not resume job with key - {}.{} due to error - {}", group, name, e.getLocalizedMessage()); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/service/JobService.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.service; 2 | 3 | public interface JobService { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/surya/quartz/web/rest/EmailResource.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz.web.rest; 2 | 3 | import static org.springframework.http.HttpStatus.CREATED; 4 | 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.DeleteMapping; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PatchMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.PutMapping; 12 | import org.springframework.web.bind.annotation.RequestBody; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | import com.surya.quartz.model.JobDescriptor; 17 | import com.surya.quartz.service.EmailService; 18 | 19 | import lombok.RequiredArgsConstructor; 20 | 21 | @RestController 22 | @RequestMapping("/api/v1.0") 23 | @RequiredArgsConstructor 24 | public class EmailResource { 25 | private final EmailService emailService; 26 | 27 | @PostMapping(path = "/groups/{group}/jobs") 28 | public ResponseEntity createJob(@PathVariable String group, @RequestBody JobDescriptor descriptor) { 29 | return new ResponseEntity<>(emailService.createJob(group, descriptor), CREATED); 30 | } 31 | 32 | @GetMapping(path = "/groups/{group}/jobs/{name}") 33 | public ResponseEntity findJob(@PathVariable String group, @PathVariable String name) { 34 | return emailService.findJob(group, name) 35 | .map(ResponseEntity::ok) 36 | .orElse(ResponseEntity.notFound().build()); 37 | } 38 | 39 | @PutMapping(path = "/groups/{group}/jobs/{name}") 40 | public ResponseEntity updateJob(@PathVariable String group, @PathVariable String name, @RequestBody JobDescriptor descriptor) { 41 | emailService.updateJob(group, name, descriptor); 42 | return ResponseEntity.noContent().build(); 43 | } 44 | 45 | @DeleteMapping(path = "/groups/{group}/jobs/{name}") 46 | public ResponseEntity deleteJob(@PathVariable String group, @PathVariable String name) { 47 | emailService.deleteJob(group, name); 48 | return ResponseEntity.noContent().build(); 49 | } 50 | 51 | @PatchMapping(path = "/groups/{group}/jobs/{name}/pause") 52 | public ResponseEntity pauseJob(@PathVariable String group, @PathVariable String name) { 53 | emailService.pauseJob(group, name); 54 | return ResponseEntity.noContent().build(); 55 | } 56 | 57 | @PatchMapping(path = "/groups/{group}/jobs/{name}/resume") 58 | public ResponseEntity resumeJob(@PathVariable String group, @PathVariable String name) { 59 | emailService.resumeJob(group, name); 60 | return ResponseEntity.noContent().build(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | liquibase: 2 | enabled: false 3 | spring: 4 | jackson: 5 | serialization: 6 | write-dates-as-timestamps: false 7 | mail: 8 | host: smtp.gmail.com 9 | port: 587 10 | test-connection: true 11 | username: ${gmail.username} 12 | password: ${gmail.password} 13 | properties: 14 | mail.smtp.starttls.enable: true 15 | jpa: 16 | hibernate: 17 | ddl-auto: none 18 | 19 | -------------------------------------------------------------------------------- /src/test/java/com/surya/quartz/ApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.surya.quartz; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | @RunWith(SpringRunner.class) 9 | @SpringBootTest 10 | public class ApplicationTests { 11 | 12 | @Test 13 | public void contextLoads() { 14 | } 15 | 16 | } 17 | --------------------------------------------------------------------------------