├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── api ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── chrisgleissner │ │ │ └── springbatchrest │ │ │ └── api │ │ │ └── core │ │ │ ├── Constants.java │ │ │ ├── SpringBatchRestCoreAutoConfiguration.java │ │ │ ├── job │ │ │ ├── Job.java │ │ │ ├── JobController.java │ │ │ ├── JobResource.java │ │ │ └── JobService.java │ │ │ └── jobexecution │ │ │ ├── JobExecution.java │ │ │ ├── JobExecutionController.java │ │ │ ├── JobExecutionResource.java │ │ │ ├── JobExecutionService.java │ │ │ ├── ResponseExceptionHandler.java │ │ │ └── provider │ │ │ ├── AllJobExecutionProvider.java │ │ │ ├── CachedJobExecutionProvider.java │ │ │ └── JobExecutionProvider.java │ └── resources │ │ └── META-INF │ │ └── spring.factories │ └── test │ ├── java │ └── com │ │ └── github │ │ └── chrisgleissner │ │ └── springbatchrest │ │ └── api │ │ └── core │ │ ├── Fixtures.java │ │ ├── RestDisabledTest.java │ │ ├── RestTest.java │ │ ├── SpringBatchRestCoreTestApplication.java │ │ ├── job │ │ ├── JobControllerTest.java │ │ └── JobServiceTest.java │ │ └── jobexecution │ │ ├── JobExecutionControllerTest.java │ │ ├── JobExecutionServiceTest.java │ │ └── provider │ │ ├── AbstractJobExecutionProviderTest.java │ │ ├── AllJobExecutionProviderTest.java │ │ └── CachedJobExecutionProviderTest.java │ └── resources │ └── application.properties ├── example ├── api │ ├── pom.xml │ └── src │ │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── github │ │ │ │ └── chrisgleissner │ │ │ │ └── springbatchrest │ │ │ │ └── example │ │ │ │ └── core │ │ │ │ ├── PersonJobConfig.java │ │ │ │ └── SpringBatchRestCoreSampleApplication.java │ │ └── resources │ │ │ ├── application.properties │ │ │ └── person.csv │ │ └── test │ │ └── java │ │ └── com │ │ └── github │ │ └── chrisgleissner │ │ └── springbatchrest │ │ └── example │ │ └── core │ │ └── PersonJobTest.java ├── pom.xml └── quartz-api │ ├── pom.xml │ └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── chrisgleissner │ │ │ └── springbatchrest │ │ │ └── example │ │ │ └── quartz │ │ │ ├── PersonJobConfig.java │ │ │ └── SpringBatchRestQuartzSampleApplication.java │ └── resources │ │ ├── application.properties │ │ └── person.csv │ └── test │ └── java │ └── com │ └── github │ └── chrisgleissner │ └── springbatchrest │ └── example │ └── quartz │ └── PersonJobTest.java ├── pom.xml ├── quartz-api ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── chrisgleissner │ │ │ └── springbatchrest │ │ │ └── api │ │ │ └── quartz │ │ │ ├── SpringBatchRestQuartzAutoConfiguration.java │ │ │ └── jobdetail │ │ │ ├── JobDetail.java │ │ │ ├── JobDetailController.java │ │ │ ├── JobDetailResource.java │ │ │ └── JobDetailService.java │ └── resources │ │ └── META-INF │ │ └── spring.factories │ └── test │ ├── java │ └── com │ │ └── github │ │ └── chrisgleissner │ │ └── springbatchrest │ │ └── api │ │ └── quartz │ │ ├── RestTest.java │ │ ├── SpringBatchRestQuartzTestApplication.java │ │ └── jobdetail │ │ └── JobDetailControllerTest.java │ └── resources │ └── application.properties ├── system.properties └── util ├── pom.xml └── src ├── main └── java │ └── com │ └── github │ └── chrisgleissner │ └── springbatchrest │ └── util │ ├── DateUtil.java │ ├── JobParamUtil.java │ ├── TriggerUtil.java │ ├── core │ ├── AdHocStarter.java │ ├── JobBuilder.java │ ├── JobConfig.java │ ├── JobParamsDetail.java │ ├── config │ │ └── AdHocBatchConfig.java │ ├── property │ │ ├── JobExecutionAspect.java │ │ ├── JobPropertyResolver.java │ │ └── JobPropertyResolvers.java │ └── tasklet │ │ ├── PropertyResolverConsumerTasklet.java │ │ ├── RunnableTasklet.java │ │ └── StepExecutionListenerTasklet.java │ └── quartz │ ├── AdHocScheduler.java │ ├── QuartzJobLauncher.java │ └── config │ ├── AdHocSchedulerConfig.java │ └── SchedulerConfig.java └── test ├── java └── com │ └── github │ └── chrisgleissner │ └── springbatchrest │ └── util │ ├── DateUtilTest.java │ ├── JobParamUtilTest.java │ ├── core │ ├── AdHocStarterTest.java │ ├── CacheItemWriter.java │ ├── JobCompletionNotificationListener.java │ └── Person.java │ └── quartz │ ├── AdHocSchedulerParamsTest.java │ └── AdHocSchedulerTest.java └── resources ├── application.properties └── logback-test.xml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "maven" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | deps: 9 | applies-to: version-updates 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | jdk-version: [8, 11] 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: JDK Setup 18 | uses: actions/setup-java@v3 19 | with: 20 | java-version: '${{ matrix.jdk-version }}' 21 | distribution: 'adopt' 22 | cache: 'maven' 23 | 24 | - name: Build 25 | run: mvn --batch-mode --update-snapshots package -Pjacoco 26 | 27 | - name: Coveralls Report 28 | run: mvn coveralls:report --define repoToken=${{ secrets.COVERALLS_REPO_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.java.hsp 2 | *.sonarj 3 | *.sw* 4 | .DS_Store 5 | .settings 6 | .springBeans 7 | bin 8 | build.sh 9 | integration-repo 10 | ivy-cache 11 | jxl.log 12 | jmx.log 13 | derby.log 14 | spring-test/test-output/ 15 | .gradle 16 | argfile* 17 | pom.xml 18 | activemq-data/ 19 | 20 | classes/ 21 | /build 22 | buildSrc/build 23 | /spring-*/build 24 | /spring-core/kotlin-coroutines/build 25 | /framework-bom/build 26 | /integration-tests/build 27 | /src/asciidoc/build 28 | target/ 29 | .factorypath 30 | 31 | # Eclipse artifacts, including WTP generated manifests 32 | .classpath 33 | .project 34 | spring-*/src/main/java/META-INF/MANIFEST.MF 35 | 36 | # IDEA artifacts and output dirs 37 | *.iml 38 | *.ipr 39 | *.iws 40 | .idea 41 | out 42 | test-output 43 | atlassian-ide-plugin.xml 44 | .gradletasknamecache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java -Dserver.port=$PORT $JAVA_OPTS -jar example/quartz-api/target/*.jar 2 | -------------------------------------------------------------------------------- /api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-api 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-api 15 | 16 | 17 | 18 | ${project.groupId} 19 | spring-batch-rest-util 20 | ${project.version} 21 | 22 | 23 | ${project.groupId} 24 | spring-batch-rest-util 25 | tests 26 | test-jar 27 | ${project.version} 28 | test 29 | 30 | 31 | com.google.guava 32 | guava 33 | 29.0-jre 34 | 35 | 36 | com.h2database 37 | h2 38 | test 39 | 40 | 41 | org.springdoc 42 | springdoc-openapi-ui 43 | 1.2.33 44 | 45 | 46 | org.projectlombok 47 | lombok 48 | provided 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-starter-batch 53 | 54 | 55 | org.springframework.boot 56 | spring-boot-starter-test 57 | test 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-starter-hateoas 62 | 63 | 64 | org.springframework.boot 65 | spring-boot-starter-thymeleaf 66 | 67 | 68 | org.springframework.boot 69 | spring-boot-starter-web 70 | 71 | 72 | org.springframework.batch 73 | spring-batch-test 74 | test 75 | 76 | 77 | 78 | 79 | 80 | 81 | maven-enforcer-plugin 82 | 3.0.0-M3 83 | 84 | 85 | enforce-versions 86 | 87 | enforce 88 | 89 | 90 | 91 | 92 | 93 | org.quartz-scheduler 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/Constants.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | public interface Constants { 4 | String REST_API_ENABLED = "com.github.chrisgleissner.springbatchrest.enabled"; 5 | } 6 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/SpringBatchRestCoreAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.job.JobController; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionController; 5 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 6 | import io.swagger.v3.oas.models.Components; 7 | import io.swagger.v3.oas.models.OpenAPI; 8 | import io.swagger.v3.oas.models.info.Info; 9 | import io.swagger.v3.oas.models.info.License; 10 | import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 13 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 14 | import org.springframework.boot.info.BuildProperties; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.context.annotation.ComponentScan; 17 | import org.springframework.context.annotation.Configuration; 18 | 19 | import static com.github.chrisgleissner.springbatchrest.api.core.Constants.REST_API_ENABLED; 20 | 21 | @Configuration 22 | @EnableBatchProcessing 23 | @ConditionalOnProperty(name = REST_API_ENABLED, havingValue = "true", matchIfMissing = true) 24 | @ComponentScan(basePackageClasses= {AdHocStarter.class, JobController.class, JobExecutionController.class }) 25 | public class SpringBatchRestCoreAutoConfiguration { 26 | 27 | @Autowired(required = false) 28 | BuildProperties buildProperties; 29 | 30 | @Bean 31 | @ConditionalOnMissingBean 32 | public OpenAPI customOpenAPI() { 33 | return new OpenAPI() 34 | .components(new Components()) 35 | .info(new Info() 36 | .title("Spring Batch REST") 37 | .version(buildProperties == null ? null : String.format("%s - Build time %s", buildProperties.getVersion(), buildProperties.getTime())) 38 | .description("REST API for controlling and viewing " + 39 | "Spring Batch jobs and their Quartz schedules.") 40 | .license(new License().name("Apache License 2.0").url("http://github.com/chrisgleissner/spring-batch-rest/blob/master/LICENSE"))); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/job/Job.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Value; 5 | import org.springframework.hateoas.RepresentationModel; 6 | 7 | @Value 8 | @AllArgsConstructor 9 | public class Job extends RepresentationModel { 10 | private String name; 11 | } 12 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/job/JobController.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 6 | import org.springframework.hateoas.CollectionModel; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import java.util.Collection; 13 | 14 | import static com.github.chrisgleissner.springbatchrest.api.core.Constants.REST_API_ENABLED; 15 | import static java.util.stream.Collectors.toList; 16 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; 17 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 18 | 19 | @ConditionalOnProperty(name = REST_API_ENABLED, havingValue = "true", matchIfMissing = true) 20 | @RestController 21 | @RequestMapping(value = "/jobs", produces = "application/hal+json") 22 | public class JobController { 23 | 24 | @Autowired 25 | private JobService jobService; 26 | 27 | @Operation(summary = "Get a Spring Batch job by name") 28 | @GetMapping("/{jobName}") 29 | public JobResource get(@PathVariable String jobName) { 30 | return new JobResource(jobService.job(jobName)); 31 | } 32 | 33 | @Operation(summary = "Get all Spring Batch jobs") 34 | @GetMapping 35 | public CollectionModel all() { 36 | Collection jobs = jobService.jobs().stream().map(JobResource::new).collect(toList()); 37 | return new CollectionModel<>(jobs, linkTo(methodOn(JobController.class).all()).withSelfRel()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/job/JobResource.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import org.springframework.hateoas.RepresentationModel; 4 | 5 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; 6 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 7 | 8 | public class JobResource extends RepresentationModel { 9 | private Job job; 10 | 11 | // For Jackson 12 | private JobResource() {} 13 | 14 | public JobResource(final Job job) { 15 | this.job = job; 16 | add(linkTo(methodOn(JobController.class).get(job.getName())).withSelfRel()); 17 | } 18 | 19 | public Job getJob() { 20 | return job; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/job/JobService.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import org.springframework.batch.core.configuration.JobRegistry; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.util.Collection; 8 | 9 | import static java.util.stream.Collectors.toSet; 10 | 11 | @Service 12 | public class JobService { 13 | private final JobRegistry jobRegistry; 14 | 15 | @Autowired 16 | public JobService(JobRegistry jobRegistry) { 17 | this.jobRegistry = jobRegistry; 18 | } 19 | 20 | public Collection jobs() { 21 | return jobRegistry.getJobNames().stream().map(n -> new Job(n)).collect(toSet()); 22 | } 23 | 24 | public Job job(String jobName) { 25 | return new Job(jobName); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecution.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.databind.JsonSerializer; 5 | import com.fasterxml.jackson.databind.SerializerProvider; 6 | import com.fasterxml.jackson.databind.annotation.JsonSerialize; 7 | import com.github.chrisgleissner.springbatchrest.util.DateUtil; 8 | import com.google.common.base.Throwables; 9 | import lombok.Builder; 10 | import lombok.Value; 11 | import org.springframework.batch.core.BatchStatus; 12 | 13 | import java.io.IOException; 14 | import java.time.LocalDateTime; 15 | import java.util.Collection; 16 | 17 | import static java.util.stream.Collectors.toList; 18 | 19 | @Value 20 | @Builder 21 | public class JobExecution implements Comparable { 22 | 23 | private static final String EXIT_CODE = "exitCode"; 24 | private static final String EXIT_DESCRIPTION = "exitDescription"; 25 | 26 | public static JobExecution fromSpring(org.springframework.batch.core.JobExecution je) { 27 | return JobExecution.builder() 28 | .jobId(je.getJobId()) 29 | .id(je.getId()) 30 | .jobName(je.getJobInstance().getJobName()) 31 | .startTime(DateUtil.localDateTime(je.getStartTime())) 32 | .endTime(DateUtil.localDateTime(je.getEndTime())) 33 | .exitCode(je.getExitStatus() == null ? null : je.getExitStatus().getExitCode()) 34 | .exitDescription(je.getExitStatus() == null ? null : je.getExitStatus().getExitDescription()) 35 | .status(je.getStatus()) 36 | .exceptions(je.getFailureExceptions().stream().map(e -> e.getMessage() + ": " + Throwables.getStackTraceAsString(e)).collect(toList())) 37 | .build(); 38 | } 39 | 40 | private long id; 41 | private long jobId; 42 | private String jobName; 43 | private LocalDateTime startTime; 44 | private LocalDateTime endTime; 45 | private String exitCode; 46 | private String exitDescription; 47 | @JsonSerialize(using = BatchStatusSerializer.class) 48 | private BatchStatus status; 49 | 50 | private Collection exceptions; 51 | 52 | @Override 53 | public int compareTo(JobExecution o) { 54 | int result = this.getJobName() != null ? this.getJobName().compareToIgnoreCase(o.getJobName()) : 0; 55 | if (result == 0) 56 | result = Long.compare(id, o.id); 57 | if (result == 0) 58 | result = Long.compare(jobId, o.jobId); 59 | return result; 60 | } 61 | 62 | static class BatchStatusSerializer extends JsonSerializer { 63 | @Override 64 | public void serialize(BatchStatus batchStatus, JsonGenerator jsonGen, SerializerProvider serializerProvider) throws IOException { 65 | jsonGen.writeString(batchStatus.name()); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecutionController.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 4 | import io.swagger.v3.oas.annotations.Operation; 5 | import org.springframework.batch.core.ExitStatus; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 8 | import org.springframework.hateoas.CollectionModel; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RequestParam; 17 | import org.springframework.web.bind.annotation.RestController; 18 | 19 | import java.util.Collection; 20 | import java.util.Optional; 21 | 22 | import static com.github.chrisgleissner.springbatchrest.api.core.Constants.REST_API_ENABLED; 23 | import static java.util.stream.Collectors.toList; 24 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; 25 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 26 | 27 | @ConditionalOnProperty(name = REST_API_ENABLED, havingValue = "true", matchIfMissing = true) 28 | @RestController 29 | @RequestMapping(value = "/jobExecutions", produces = "application/hal+json") 30 | public class JobExecutionController { 31 | 32 | @Autowired 33 | private JobExecutionService jobExecutionService; 34 | 35 | @Operation(summary = "Get all Spring batch job execution by ID") 36 | @GetMapping("/{id}") 37 | public JobExecutionResource get(@PathVariable long id) { 38 | return new JobExecutionResource(jobExecutionService.jobExecution(id)); 39 | } 40 | 41 | @Operation(summary = "Find Spring batch job executions by job name and exit code") 42 | @GetMapping 43 | public CollectionModel all( 44 | @RequestParam(value = "jobName", required = false) String jobName, 45 | @RequestParam(value = "exitCode", required = false) String exitCode, 46 | @RequestParam(value = "limitPerJob", defaultValue = "3") Integer limitPerJob) { 47 | Collection jobExecutions = jobExecutionService.jobExecutions( 48 | optional(jobName), 49 | optional(exitCode), 50 | limitPerJob).stream().map(JobExecutionResource::new).collect(toList()); 51 | return new CollectionModel<>(jobExecutions, linkTo(methodOn(JobExecutionController.class) 52 | .all(jobName, exitCode, limitPerJob)).withSelfRel().expand()); 53 | } 54 | 55 | private Optional optional(String s) { 56 | if (s != null) { 57 | s = s.trim(); 58 | if (s.isEmpty()) 59 | s = null; 60 | } 61 | return Optional.ofNullable(s); 62 | } 63 | 64 | @Operation(summary = "Start a Spring Batch job execution") 65 | @PostMapping 66 | public ResponseEntity put(@RequestBody JobConfig jobConfig) { 67 | JobExecutionResource resource = new JobExecutionResource(jobExecutionService.launch(jobConfig)); 68 | boolean failed = resource.getJobExecution().getExitCode().equals(ExitStatus.FAILED.getExitCode()); 69 | return new ResponseEntity<>(resource, failed ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.OK); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecutionResource.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import org.springframework.hateoas.RepresentationModel; 4 | 5 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; 6 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 7 | 8 | 9 | public class JobExecutionResource extends RepresentationModel { 10 | private JobExecution jobExecution; 11 | 12 | // For Jackson 13 | private JobExecutionResource() { 14 | } 15 | 16 | public JobExecutionResource(final JobExecution jobExecution) { 17 | this.jobExecution = jobExecution; 18 | add(linkTo(methodOn(JobExecutionController.class).get(jobExecution.getId())).withSelfRel()); 19 | } 20 | 21 | public JobExecution getJobExecution() { 22 | return jobExecution; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecutionService.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.job.Job; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider.CachedJobExecutionProvider; 5 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.batch.core.explore.JobExplorer; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.stereotype.Service; 12 | 13 | import javax.batch.operations.NoSuchJobExecutionException; 14 | import java.util.Collection; 15 | import java.util.Optional; 16 | import java.util.stream.Collectors; 17 | 18 | @Service 19 | public class JobExecutionService { 20 | 21 | private final static Logger logger = LoggerFactory.getLogger(JobExecutionService.class); 22 | private final AdHocStarter adHocStarter; 23 | private final JobExplorer jobExplorer; 24 | private final CachedJobExecutionProvider jobExecutionProvider; 25 | 26 | @Autowired 27 | public JobExecutionService(JobExplorer jobExplorer, CachedJobExecutionProvider jobExecutionProvider, AdHocStarter adHocStarter) { 28 | this.jobExplorer = jobExplorer; 29 | this.adHocStarter = adHocStarter; 30 | this.jobExecutionProvider = jobExecutionProvider; 31 | } 32 | 33 | public JobExecution jobExecution(long executionId) { 34 | org.springframework.batch.core.JobExecution jobExecution = jobExplorer.getJobExecution(executionId); 35 | if (jobExecution == null) 36 | throw new NoSuchJobExecutionException("Could not find job execution with ID " + executionId); 37 | return JobExecution.fromSpring(jobExecution); 38 | 39 | } 40 | 41 | public Collection jobExecutions(Optional jobNameRegexp, 42 | Optional exitCode, 43 | int maxNumberOfExecutionsPerJobName) { 44 | logger.debug("Getting job executions(jobNameRegexp={}, exitCode={}, maxNumberOfExecutionsPerJobName={})", 45 | jobNameRegexp, exitCode, maxNumberOfExecutionsPerJobName); 46 | return jobExecutionProvider.getJobExecutions(jobNameRegexp, exitCode, maxNumberOfExecutionsPerJobName).stream() 47 | .map(JobExecution::fromSpring) 48 | .collect(Collectors.toList()); 49 | 50 | } 51 | 52 | public JobExecution launch(JobConfig jobConfig) { 53 | return JobExecution.fromSpring(adHocStarter.start(jobConfig)); 54 | } 55 | 56 | public Job job(String jobName) { 57 | return new Job(jobName); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/ResponseExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.batch.core.JobParametersInvalidException; 8 | import org.springframework.batch.core.configuration.DuplicateJobException; 9 | import org.springframework.batch.core.launch.JobParametersNotFoundException; 10 | import org.springframework.batch.core.launch.NoSuchJobException; 11 | import org.springframework.batch.core.launch.NoSuchJobExecutionException; 12 | import org.springframework.batch.core.launch.NoSuchJobInstanceException; 13 | import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; 14 | import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; 15 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 16 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 17 | import org.springframework.http.HttpHeaders; 18 | import org.springframework.http.HttpStatus; 19 | import org.springframework.http.ResponseEntity; 20 | import org.springframework.web.bind.annotation.ControllerAdvice; 21 | import org.springframework.web.bind.annotation.ExceptionHandler; 22 | import org.springframework.web.context.request.WebRequest; 23 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 24 | 25 | import javax.batch.operations.BatchRuntimeException; 26 | 27 | @ConditionalOnProperty(name = "com.github.chrisgleissner.springbatchrest.api.core.controllerAdvice", havingValue = "true", matchIfMissing = true) 28 | @ControllerAdvice 29 | @Slf4j 30 | public class ResponseExceptionHandler extends ResponseEntityExceptionHandler { 31 | 32 | @ExceptionHandler(Exception.class) 33 | protected ResponseEntity handleAnyException(Exception e, WebRequest request) { 34 | log.error("Request {} failed with {}", request, e); 35 | String message = e.getMessage(); 36 | String causeMessage = ""; 37 | if (e.getCause() != null) 38 | causeMessage = e.getCause().getMessage(); 39 | HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; 40 | ApiError apiError = new ApiError(status.toString(), message, e.getClass().getSimpleName(), causeMessage); 41 | return handleExceptionInternal(e, apiError, new HttpHeaders(), status, request); 42 | } 43 | 44 | @ExceptionHandler(BatchRuntimeException.class) 45 | protected ResponseEntity handleBatchRuntimeException(BatchRuntimeException e, WebRequest request) { 46 | log.error("Request {} failed with {}", request, e); 47 | Throwable cause = e.getCause(); 48 | HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; 49 | if (cause instanceof DuplicateJobException 50 | || cause instanceof JobExecutionAlreadyRunningException 51 | || cause instanceof JobInstanceAlreadyCompleteException) 52 | status = HttpStatus.CONFLICT; 53 | else if (cause instanceof JobParametersInvalidException 54 | || cause instanceof JobParametersNotFoundException) 55 | status = HttpStatus.BAD_REQUEST; 56 | else if (cause instanceof NoSuchJobException 57 | || cause instanceof NoSuchJobExecutionException 58 | || cause instanceof NoSuchJobInstanceException) 59 | status = HttpStatus.NOT_FOUND; 60 | 61 | ApiError apiError = new ApiError(status.toString(), cause.getMessage(), cause.getClass().getSimpleName(), e.getMessage()); 62 | return handleExceptionInternal(e, apiError, new HttpHeaders(), status, request); 63 | } 64 | 65 | @ExceptionHandler(javax.batch.operations.NoSuchJobExecutionException.class) 66 | protected ResponseEntity handleNoSuchJobExecutionException(javax.batch.operations.NoSuchJobExecutionException e, WebRequest request) { 67 | HttpStatus status = HttpStatus.NOT_FOUND; 68 | return handleExceptionInternal(e, new ApiError(status.toString(), e.getMessage(), e.getClass().getSimpleName(), ""), new HttpHeaders(), status, request); 69 | } 70 | 71 | @NoArgsConstructor 72 | @AllArgsConstructor 73 | @Data 74 | public class ApiError { 75 | String status; 76 | String message; 77 | String exception; 78 | String detail; 79 | } 80 | } -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/AllJobExecutionProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.batch.core.JobExecution; 6 | import org.springframework.batch.core.explore.JobExplorer; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.Collection; 10 | import java.util.List; 11 | import java.util.Optional; 12 | import java.util.TreeSet; 13 | import java.util.regex.Pattern; 14 | 15 | import static java.util.stream.Collectors.toList; 16 | 17 | @Slf4j 18 | @Component 19 | @RequiredArgsConstructor 20 | public class AllJobExecutionProvider implements JobExecutionProvider { 21 | 22 | private final JobExplorer jobExplorer; 23 | 24 | public Collection getJobExecutions(Optional jobNameRegexp, 25 | Optional exitCode, 26 | int limitPerJob) { 27 | log.debug("Getting job executions from JobExplorer for jobNameRegexp={}, exitCode={}, limitPerJob={}", jobNameRegexp, exitCode, limitPerJob); 28 | Optional maybeJobNamePattern = jobNameRegexp.map(Pattern::compile); 29 | List jobNames = jobExplorer.getJobNames().stream() 30 | .filter(n -> maybeJobNamePattern.map(p -> p.matcher(n).matches()).orElse(true)).collect(toList()); 31 | TreeSet result = new TreeSet<>(byDescendingTime()); 32 | for (String jobName : jobNames) 33 | jobExplorer.getJobInstances(jobName, 0, limitPerJob).stream() 34 | .flatMap(ji ->jobExplorer.getJobExecutions(ji).stream()) 35 | .filter(e -> exitCode.map(c -> e.getExitStatus().getExitCode().equals(c)).orElse(true)) 36 | .sorted(byDescendingTime()) 37 | .limit(limitPerJob).forEach(result::add); 38 | log.debug("Found {} job execution(s) for jobNameRegexp={}, exitCode={}, limitPerJob={}", 39 | jobNameRegexp, exitCode, limitPerJob, result.size()); 40 | return result; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/CachedJobExecutionProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.property.JobExecutionAspect; 4 | import com.google.common.annotations.VisibleForTesting; 5 | import com.google.common.collect.EvictingQueue; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.batch.core.JobExecution; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.*; 12 | import java.util.concurrent.ConcurrentSkipListMap; 13 | import java.util.concurrent.locks.ReadWriteLock; 14 | import java.util.concurrent.locks.ReentrantReadWriteLock; 15 | import java.util.function.Consumer; 16 | import java.util.regex.Pattern; 17 | 18 | import static com.google.common.collect.ImmutableList.copyOf; 19 | 20 | /** 21 | * Provides information for recent {@link JobExecution}s and is faster than {@link AllJobExecutionProvider} if a large 22 | * number of executions exists. 23 | */ 24 | @Slf4j 25 | @Component 26 | public class CachedJobExecutionProvider implements Consumer, JobExecutionProvider { 27 | 28 | private final int cacheSize; 29 | private final AllJobExecutionProvider allJobExecutionProvider; 30 | private final Map jobExecutionsByJobName = new ConcurrentSkipListMap<>(String::compareToIgnoreCase); 31 | 32 | public CachedJobExecutionProvider(JobExecutionAspect executionAspect, AllJobExecutionProvider allJobExecutionProvider, 33 | @Value("${com.github.chrisgleissner.springbatchrest.jobExecutionCacheSize:100}") int jobExecutionCacheSize) { 34 | executionAspect.register(this); 35 | this.allJobExecutionProvider = allJobExecutionProvider; 36 | this.cacheSize = jobExecutionCacheSize; 37 | } 38 | 39 | @VisibleForTesting 40 | Map getJobExecutionsByJobName() { 41 | return jobExecutionsByJobName; 42 | } 43 | 44 | @Override 45 | public Collection getJobExecutions(Optional jobNameRegexp, Optional exitCode, int limitPerJob) { 46 | if (limitPerJob > this.cacheSize) 47 | return allJobExecutionProvider.getJobExecutions(jobNameRegexp, exitCode, limitPerJob); 48 | else { 49 | log.debug("Getting job executions from cache for jobNameRegexp={}, exitCode={}, limitPerJob={}", jobNameRegexp, exitCode, limitPerJob); 50 | Optional maybeJobNamePattern = jobNameRegexp.map(Pattern::compile); 51 | TreeSet result = new TreeSet(byDescendingTime()); 52 | jobExecutionsByJobName.entrySet().stream() 53 | .filter(e -> maybeJobNamePattern.map(p -> p.matcher(e.getKey()).matches()).orElse(true)) 54 | .map(Map.Entry::getValue) 55 | .flatMap(je -> je.getJobExecutions(exitCode).stream().sorted(byDescendingTime()).limit(limitPerJob)) 56 | .forEach(result::add); 57 | log.debug("Found {} job execution(s) for jobNameRegexp={}, exitCode={}, limitPerJob={}", jobNameRegexp, exitCode, limitPerJob, result.size()); 58 | return result; 59 | } 60 | } 61 | 62 | class JobExecutions { 63 | private final Map> jobExecutionsByExitCode = new HashMap<>(); 64 | private final Queue jobExecutions = EvictingQueue.create(cacheSize); 65 | private final ReadWriteLock lock = new ReentrantReadWriteLock(); 66 | 67 | Collection getJobExecutions(Optional exitCode) { 68 | lock.readLock().lock(); 69 | try { 70 | return copyOf(exitCode.isPresent() ? jobExecutionsByExitCode.get(exitCode.get()) : this.jobExecutions); 71 | } finally { 72 | lock.readLock().unlock(); 73 | } 74 | } 75 | 76 | void add(JobExecution jobExecution) { 77 | lock.writeLock().lock(); 78 | try { 79 | jobExecutionsByExitCode.computeIfAbsent(jobExecution.getExitStatus().getExitCode(), 80 | (exitCode) -> EvictingQueue.create(cacheSize)).add(jobExecution); 81 | jobExecutions.add(jobExecution); 82 | } finally { 83 | lock.writeLock().unlock(); 84 | } 85 | } 86 | } 87 | 88 | @Override 89 | public void accept(JobExecution je) { 90 | if (!je.isRunning()) { 91 | String jobName = je.getJobInstance().getJobName(); 92 | jobExecutionsByJobName.computeIfAbsent(jobName, (n) -> new JobExecutions()).add(je); 93 | log.debug("Added JobExecution(id={}, name={}, jobId={}, jobInstanceIdId={}): {}. Details: {}", 94 | je.getId(), jobName, je.getJobId(), je.getJobInstance().getInstanceId(), je.getExitStatus().getExitCode(), je); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /api/src/main/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/JobExecutionProvider.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import org.springframework.batch.core.JobExecution; 4 | 5 | import java.util.Collection; 6 | import java.util.Comparator; 7 | import java.util.Optional; 8 | 9 | public interface JobExecutionProvider { 10 | 11 | Collection getJobExecutions(Optional jobNameRegexp, 12 | Optional exitCode, 13 | int limitPerJob); 14 | 15 | default Comparator byDescendingTime() { 16 | return (j1, j2) -> { 17 | int result; 18 | if (j1.getEndTime() != null && j2.getEndTime() != null) 19 | result = j1.getEndTime().compareTo(j2.getEndTime()); 20 | else 21 | result = j1.getStartTime().compareTo(j2.getStartTime()); 22 | return result * -1; 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | com.github.chrisgleissner.springbatchrest.api.core.SpringBatchRestCoreAutoConfiguration -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/Fixtures.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider.CachedJobExecutionProvider; 4 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 6 | 7 | import org.springframework.batch.core.BatchStatus; 8 | import org.springframework.batch.core.ExitStatus; 9 | import org.springframework.batch.core.JobExecution; 10 | import org.springframework.batch.core.JobInstance; 11 | import org.springframework.batch.core.configuration.JobRegistry; 12 | import org.springframework.batch.core.explore.JobExplorer; 13 | 14 | import java.util.Date; 15 | import java.util.List; 16 | 17 | import static com.google.common.collect.Lists.newArrayList; 18 | import static org.mockito.ArgumentMatchers.*; 19 | import static org.mockito.Mockito.reset; 20 | import static org.mockito.Mockito.when; 21 | import static org.springframework.batch.core.ExitStatus.COMPLETED; 22 | import static org.springframework.batch.core.ExitStatus.FAILED; 23 | 24 | public class Fixtures { 25 | 26 | private static final List JOB_NAMES = newArrayList("j1", "j2"); 27 | 28 | public static final String JOB_NAME_1 = "j1"; 29 | public static final JobInstance ji11 = new JobInstance(11L, JOB_NAME_1); 30 | public static final JobExecution je11 = jobExecution(11, ji11, COMPLETED); 31 | public static final JobInstance ji12 = new JobInstance(12L, JOB_NAME_1); 32 | public static final JobExecution je12 = jobExecution(12, ji12, FAILED); 33 | 34 | public static final String JOB_NAME_2 = "j2"; 35 | public static final JobInstance ji21 = new JobInstance(21L, JOB_NAME_2); 36 | public static final JobExecution je21 = jobExecution(21, ji21, COMPLETED); 37 | public static final JobInstance ji22 = new JobInstance(22L, JOB_NAME_2); 38 | public static final JobExecution je22 = jobExecution(22, ji22, COMPLETED); 39 | public static final JobInstance ji23 = new JobInstance(23L, JOB_NAME_2); 40 | public static final JobExecution je23 = jobExecution(23, ji23, FAILED); 41 | public static final JobExecution je24 = jobExecution(24, ji23, FAILED); // Re-run of job instance 23 42 | 43 | public static void configureMock(JobExplorer jobExplorer) { 44 | reset(jobExplorer); 45 | when(jobExplorer.getJobNames()).thenReturn(JOB_NAMES); 46 | } 47 | 48 | public static void configureMock(JobRegistry jobRegistry) { 49 | reset(jobRegistry); 50 | when(jobRegistry.getJobNames()).thenReturn(JOB_NAMES); 51 | } 52 | 53 | public static void configureMock(AdHocStarter adHocStarter) { 54 | reset(adHocStarter); 55 | when(adHocStarter.start(isA(JobConfig.class))).thenReturn(jobExecution(1, ji11, ExitStatus.EXECUTING)); 56 | } 57 | 58 | public static void configureForJobExecutionsService(JobExplorer jobExplorer) { 59 | when(jobExplorer.getJobInstances(eq(JOB_NAME_1), anyInt(), anyInt())).thenReturn(newArrayList(ji11, ji12)); 60 | when(jobExplorer.getJobInstances(eq(JOB_NAME_2), anyInt(), anyInt())).thenReturn(newArrayList(ji21, ji22, ji23)); 61 | 62 | when(jobExplorer.getJobExecutions(ji11)).thenReturn(newArrayList(je11)); 63 | when(jobExplorer.getJobExecutions(ji12)).thenReturn(newArrayList(je12)); 64 | 65 | when(jobExplorer.getJobExecutions(ji21)).thenReturn(newArrayList(je21)); 66 | when(jobExplorer.getJobExecutions(ji22)).thenReturn(newArrayList(je22)); 67 | when(jobExplorer.getJobExecutions(ji23)).thenReturn(newArrayList(je23, je24)); 68 | } 69 | 70 | public static void configureForJobExecutionsService(CachedJobExecutionProvider provider) { 71 | provider.accept(je11); 72 | provider.accept(je12); 73 | 74 | provider.accept(je21); 75 | provider.accept(je22); 76 | provider.accept(je23); 77 | provider.accept(je24); 78 | } 79 | 80 | public static JobExecution jobExecution(int id, JobInstance ji, ExitStatus exitStatus) { 81 | JobExecution jobExecution = new JobExecution(ji, (long) id, null, "config" + id); 82 | jobExecution.setCreateTime(new Date(id * 100L)); 83 | jobExecution.setStartTime(new Date(id * 200L)); 84 | 85 | if (!exitStatus.getExitCode().equals(ExitStatus.EXECUTING.getExitCode())) { 86 | jobExecution.setEndTime(new Date(id * 300L)); 87 | if (exitStatus.getExitCode().equals(ExitStatus.FAILED.getExitCode())) 88 | jobExecution.setStatus(BatchStatus.FAILED); 89 | else 90 | jobExecution.setStatus(BatchStatus.COMPLETED); 91 | } 92 | else 93 | jobExecution.setStatus(BatchStatus.STARTED); 94 | 95 | jobExecution.setExitStatus(exitStatus); 96 | return jobExecution; 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/RestDisabledTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.boot.test.web.client.TestRestTemplate; 8 | import org.springframework.boot.web.server.LocalServerPort; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.test.context.junit4.SpringRunner; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 15 | 16 | @RunWith(SpringRunner.class) 17 | @SpringBootTest(webEnvironment = RANDOM_PORT, properties = {Constants.REST_API_ENABLED + "=false"}) 18 | public class RestDisabledTest { 19 | @LocalServerPort 20 | private int port; 21 | @Autowired 22 | private TestRestTemplate restTemplate; 23 | 24 | @Test 25 | public void jobExecutionsNotExposed() { 26 | ResponseEntity entity = restTemplate.getForEntity(url("/jobExecutions?exitCode=COMPLETED"), String.class); 27 | assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); 28 | } 29 | 30 | private String url(String path) { 31 | return "http://localhost:" + port + path; 32 | } 33 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/RestTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecution; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionResource; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 7 | import com.github.chrisgleissner.springbatchrest.util.core.config.AdHocBatchConfig; 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.springframework.batch.core.ExitStatus; 12 | import org.springframework.batch.core.Job; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.boot.test.web.client.TestRestTemplate; 16 | import org.springframework.boot.web.server.LocalServerPort; 17 | import org.springframework.context.annotation.Import; 18 | import org.springframework.http.HttpStatus; 19 | import org.springframework.http.ResponseEntity; 20 | import org.springframework.test.context.TestPropertySource; 21 | import org.springframework.test.context.junit4.SpringRunner; 22 | 23 | import java.util.Set; 24 | import java.util.concurrent.ConcurrentSkipListSet; 25 | import java.util.concurrent.CountDownLatch; 26 | import java.util.concurrent.atomic.AtomicBoolean; 27 | 28 | import static org.assertj.core.api.Assertions.assertThat; 29 | import static org.springframework.batch.core.ExitStatus.COMPLETED; 30 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 31 | 32 | @RunWith(SpringRunner.class) 33 | @SpringBootTest(webEnvironment = RANDOM_PORT) 34 | @TestPropertySource(properties = "ServerTest-property=0") 35 | @Import(AdHocBatchConfig.class) 36 | public class RestTest { 37 | private static final String JOB_NAME = "ServerTest-job"; 38 | private static final String PROPERTY_NAME = "ServerTest-property"; 39 | private static final String EXCEPTION_MESSAGE_PROPERTY_NAME = "ServerTest-exceptionMessage"; 40 | 41 | private static Set propertyValues = new ConcurrentSkipListSet<>(); 42 | 43 | @LocalServerPort 44 | private int port; 45 | @Autowired 46 | private TestRestTemplate restTemplate; 47 | @Autowired 48 | private JobBuilder jobBuilder; 49 | 50 | private JobConfig jobConfig = JobConfig.builder().name(JOB_NAME).asynchronous(false).build(); 51 | private CountDownLatch jobExecutedOnce = new CountDownLatch(1); 52 | private AtomicBoolean firstExecution = new AtomicBoolean(true); 53 | 54 | @Before 55 | public void setUp() { 56 | if (firstExecution.compareAndSet(true, false)) { 57 | Job job = jobBuilder.createJob(JOB_NAME, propertyResolver -> { 58 | String propertyValue = propertyResolver.getProperty(PROPERTY_NAME); 59 | propertyValues.add(propertyValue); 60 | 61 | String exceptionMessage = propertyResolver.getProperty(EXCEPTION_MESSAGE_PROPERTY_NAME); 62 | if (exceptionMessage != null) 63 | throw new RuntimeException(exceptionMessage); 64 | 65 | jobExecutedOnce.countDown(); 66 | }); 67 | jobBuilder.registerJob(job); 68 | } 69 | } 70 | 71 | @Test 72 | public void jobsCanBeStartedWithDifferentProperties() { 73 | assertThat(propertyValues).containsExactly("0"); 74 | 75 | JobExecution je1 = startJob("1"); 76 | assertThat(propertyValues).containsExactly("0", "1"); 77 | 78 | JobExecution je2 = startJob("2"); 79 | assertThat(propertyValues).containsExactly("0", "1", "2"); 80 | 81 | assertThat(je1.getExitCode()).isEqualTo(COMPLETED.getExitCode()); 82 | assertThat(je2.getExitCode()).isEqualTo(COMPLETED.getExitCode()); 83 | 84 | assertThat(restTemplate.getForObject(url("/jobExecutions?exitCode=COMPLETED"), String.class)) 85 | .contains("\"status\":\"COMPLETED\"").contains("\"jobName\":\"ServerTest-job\""); 86 | } 87 | 88 | @Test 89 | public void jobExceptionMessageIsPropagatedToClient() { 90 | String exceptionMessage = "excepted exception"; 91 | JobExecution je = startJobThatThrowsException(exceptionMessage); 92 | assertThat(je.getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); 93 | assertThat(je.getExitDescription()).contains(exceptionMessage); 94 | 95 | assertThat(restTemplate.getForObject(url("/jobExecutions?exitCode=FAILED"), String.class)) 96 | .contains("\"exitCode\":\"FAILED\",\"exitDescription\":\"java.lang.RuntimeException"); 97 | } 98 | 99 | @Test 100 | public void swagger() { 101 | assertThat(restTemplate.getForObject(url("v3/api-docs"), String.class)) 102 | .contains("\"openapi\":\"3.0.1\""); 103 | } 104 | 105 | private JobExecution startJob(String propertyValue) { 106 | ResponseEntity responseEntity = restTemplate.postForEntity(url("/jobExecutions"), 107 | jobConfig.toBuilder().property(PROPERTY_NAME, propertyValue).build(), 108 | JobExecutionResource.class); 109 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 110 | return responseEntity.getBody().getJobExecution(); 111 | } 112 | 113 | private JobExecution startJobThatThrowsException(String exceptionMessage) { 114 | ResponseEntity responseEntity = restTemplate.postForEntity(url("/jobExecutions"), 115 | jobConfig.toBuilder().property(EXCEPTION_MESSAGE_PROPERTY_NAME, exceptionMessage).build(), 116 | JobExecutionResource.class); 117 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); 118 | return responseEntity.getBody().getJobExecution(); 119 | } 120 | 121 | private String url(String path) { 122 | return "http://localhost:" + port + path; 123 | } 124 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/SpringBatchRestCoreTestApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.job.JobController; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionController; 5 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 6 | import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.context.annotation.ComponentScan; 10 | 11 | @SpringBootApplication 12 | @EnableBatchProcessing 13 | @ComponentScan(basePackageClasses= {AdHocStarter.class, JobController.class, JobExecutionController.class }) 14 | public class SpringBatchRestCoreTestApplication { 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(SpringBatchRestCoreTestApplication.class, args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/job/JobControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 4 | import org.junit.Before; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.batch.core.configuration.JobRegistry; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.test.context.junit4.SpringRunner; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.configureMock; 15 | import static org.hamcrest.Matchers.hasSize; 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 20 | 21 | @RunWith(SpringRunner.class) 22 | @WebMvcTest 23 | public class JobControllerTest { 24 | 25 | @Autowired 26 | private MockMvc mockMvc; 27 | 28 | @MockBean 29 | private JobRegistry jobRegistry; 30 | 31 | @MockBean 32 | private AdHocStarter adHocStarter; 33 | 34 | @Before 35 | public void setUp() { 36 | configureMock(jobRegistry); 37 | } 38 | 39 | @Test 40 | public void jobs() throws Exception { 41 | mockMvc.perform(get("/jobs")) 42 | .andDo(print()) 43 | .andExpect(status().isOk()) 44 | .andExpect(jsonPath("$.*", hasSize(2))); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/job/JobServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.job; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | import org.junit.runner.RunWith; 6 | import org.mockito.Mock; 7 | import org.mockito.junit.MockitoJUnitRunner; 8 | import org.springframework.batch.core.configuration.JobRegistry; 9 | 10 | import java.util.Collection; 11 | 12 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.configureMock; 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @RunWith(MockitoJUnitRunner.class) 16 | public class JobServiceTest { 17 | 18 | @Mock 19 | private JobRegistry jobRegistry; 20 | 21 | private JobService jobService; 22 | 23 | @Before 24 | public void setUp() { 25 | configureMock(jobRegistry); 26 | jobService = new JobService(jobRegistry); 27 | } 28 | 29 | @Test 30 | public void jobs() { 31 | Collection jobs = jobService.jobs(); 32 | assertThat(jobs).hasSize(2); 33 | 34 | } 35 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecutionControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider.CachedJobExecutionProvider; 4 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 6 | import org.junit.Before; 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.springframework.batch.core.JobExecutionException; 10 | import org.springframework.batch.core.JobParametersInvalidException; 11 | import org.springframework.batch.core.configuration.DuplicateJobException; 12 | import org.springframework.batch.core.configuration.JobRegistry; 13 | import org.springframework.batch.core.explore.JobExplorer; 14 | import org.springframework.batch.core.launch.NoSuchJobException; 15 | import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; 16 | import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 19 | import org.springframework.boot.test.mock.mockito.MockBean; 20 | import org.springframework.http.HttpStatus; 21 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 22 | import org.springframework.test.context.junit4.SpringRunner; 23 | import org.springframework.test.web.servlet.MockMvc; 24 | 25 | import javax.batch.operations.BatchRuntimeException; 26 | import java.util.concurrent.atomic.AtomicBoolean; 27 | 28 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.*; 29 | import static org.hamcrest.Matchers.hasSize; 30 | import static org.mockito.ArgumentMatchers.any; 31 | import static org.mockito.Mockito.when; 32 | import static org.springframework.http.MediaType.APPLICATION_JSON; 33 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 34 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 35 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 36 | 37 | @SpringJUnitWebConfig 38 | @RunWith(SpringRunner.class) 39 | @WebMvcTest 40 | public class JobExecutionControllerTest { 41 | 42 | @Autowired 43 | private MockMvc mockMvc; 44 | 45 | @Autowired 46 | private CachedJobExecutionProvider cachedJobExecutionProvider; 47 | 48 | @MockBean 49 | private JobExplorer jobExplorer; 50 | 51 | @MockBean 52 | private JobRegistry jobRegistry; 53 | 54 | @MockBean 55 | private AdHocStarter adHocStarter; 56 | 57 | private static AtomicBoolean initialized = new AtomicBoolean(); 58 | 59 | @Before 60 | public void setUp() { 61 | if (initialized.compareAndSet(false, true)) { 62 | configureMock(jobExplorer); 63 | configureForJobExecutionsService(jobExplorer); 64 | configureMock(jobRegistry); 65 | configureForJobExecutionsService(cachedJobExecutionProvider); 66 | } 67 | } 68 | 69 | @Test 70 | public void jobExecutionById() throws Exception { 71 | when(jobExplorer.getJobExecution(je11.getId())).thenReturn(je11); 72 | mockMvc.perform(get("/jobExecutions/" + je11.getId())) 73 | .andExpect(status().isOk()) 74 | .andExpect(jsonPath("$..jobExecution", hasSize(1))); 75 | } 76 | 77 | @Test 78 | public void jobExecutionByIdNotFound() throws Exception { 79 | mockMvc.perform(get("/jobExecutions/" + 10)) 80 | .andExpect(status().isNotFound()) 81 | .andExpect(content().string("{\"status\":\"404 NOT_FOUND\",\"message\":\"Could not find job execution with ID 10\",\"exception\":\"NoSuchJobExecutionException\",\"detail\":\"\"}")); 82 | } 83 | 84 | @Test 85 | public void jobExecutions() throws Exception { 86 | mockMvc.perform(get("/jobExecutions")) 87 | .andExpect(status().isOk()) 88 | .andExpect(jsonPath("$..jobExecution", hasSize(5))); 89 | } 90 | 91 | @Test 92 | public void successfulJobExecutions() throws Exception { 93 | mockMvc.perform(get("/jobExecutions?exitCode=COMPLETED")) 94 | .andExpect(status().isOk()) 95 | .andExpect(jsonPath("$..jobExecution", hasSize(3))); 96 | } 97 | 98 | @Test 99 | public void failedJobExecutions() throws Exception { 100 | mockMvc.perform(get("/jobExecutions?exitCode=FAILED")) 101 | .andExpect(status().isOk()) 102 | .andExpect(jsonPath("$..jobExecution", hasSize(3))); 103 | } 104 | 105 | @Test 106 | public void successfulJobExecutionsPerJob() throws Exception { 107 | mockMvc.perform(get("/jobExecutions?jobName=j2&exitCode=COMPLETED")) 108 | .andExpect(status().isOk()) 109 | .andExpect(jsonPath("$..jobExecution", hasSize(2))); 110 | } 111 | 112 | @Test 113 | public void successfulJobExecutionsPerJobAndLimited() throws Exception { 114 | mockMvc.perform(get("/jobExecutions?jobName=j2&exitCode=COMPLETED&limitPerJob=1")) 115 | .andExpect(status().isOk()) 116 | .andExpect(jsonPath("$..jobExecution", hasSize(1))); 117 | } 118 | 119 | @Test 120 | public void jobFailsWithDuplicateJobException() throws Exception { 121 | assertJobExecutionExceptionToStatusMapping(new DuplicateJobException("causeMsg"), HttpStatus.CONFLICT); 122 | } 123 | 124 | @Test 125 | public void jobFailsWithJobInstanceAlreadyCompleteException() throws Exception { 126 | assertJobExecutionExceptionToStatusMapping(new JobInstanceAlreadyCompleteException("causeMsg"), HttpStatus.CONFLICT); 127 | } 128 | 129 | @Test 130 | public void jobFailsWithJobExecutionAlreadyRunningException () throws Exception { 131 | assertJobExecutionExceptionToStatusMapping(new JobExecutionAlreadyRunningException("causeMsg"), HttpStatus.CONFLICT); 132 | } 133 | 134 | @Test 135 | public void jobFailsWithNoSuchJobException() throws Exception { 136 | assertJobExecutionExceptionToStatusMapping(new NoSuchJobException("causeMsg"), HttpStatus.NOT_FOUND); 137 | } 138 | 139 | @Test 140 | public void jobFailsWithJobParametersInvalidException() throws Exception { 141 | assertJobExecutionExceptionToStatusMapping(new JobParametersInvalidException("causeMsg"), HttpStatus.BAD_REQUEST); 142 | } 143 | 144 | @Test 145 | public void jobFailsWithGenericException() throws Exception { 146 | when(adHocStarter.start(any(JobConfig.class))).thenThrow(new RuntimeException("msg", new RuntimeException("cause"))); 147 | mockMvc.perform(post("/jobExecutions").contentType(APPLICATION_JSON).content("{\"name\":\"foo\"}")) 148 | .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) 149 | .andExpect(content().string("{\"status\":\"500 INTERNAL_SERVER_ERROR\",\"message\":\"msg\",\"exception\":\"RuntimeException\",\"detail\":\"cause\"}")); 150 | } 151 | 152 | private void assertJobExecutionExceptionToStatusMapping(JobExecutionException cause, HttpStatus expectedStatus) throws Exception { 153 | when(adHocStarter.start(any(JobConfig.class))).thenThrow(new BatchRuntimeException("msg", cause)); 154 | mockMvc.perform(post("/jobExecutions").contentType(APPLICATION_JSON).content("{\"name\":\"foo\"}")) 155 | .andExpect(status().is(expectedStatus.value())) 156 | .andExpect(content().string(String.format("{\"status\":\"%s\",\"message\":\"%s\",\"exception\":\"%s\",\"detail\":\"%s\"}", 157 | expectedStatus.toString(), cause.getMessage(), cause.getClass().getSimpleName(), "msg"))); 158 | } 159 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/JobExecutionServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider.AllJobExecutionProvider; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider.CachedJobExecutionProvider; 5 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 7 | import com.github.chrisgleissner.springbatchrest.util.core.property.JobExecutionAspect; 8 | import org.assertj.core.api.Assertions; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.mockito.Mock; 13 | import org.mockito.junit.MockitoJUnitRunner; 14 | import org.mockito.junit.jupiter.MockitoSettings; 15 | import org.mockito.quality.Strictness; 16 | import org.springframework.batch.core.BatchStatus; 17 | import org.springframework.batch.core.ExitStatus; 18 | import org.springframework.batch.core.explore.JobExplorer; 19 | 20 | import javax.batch.operations.NoSuchJobExecutionException; 21 | import java.util.Collection; 22 | import java.util.Optional; 23 | 24 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.*; 25 | import static java.lang.Integer.MAX_VALUE; 26 | import static java.util.Optional.empty; 27 | import static org.assertj.core.api.Assertions.assertThat; 28 | import static org.mockito.Mockito.when; 29 | 30 | @RunWith(MockitoJUnitRunner.class) 31 | @MockitoSettings(strictness = Strictness.LENIENT) 32 | public class JobExecutionServiceTest { 33 | 34 | @Mock 35 | private JobExplorer jobExplorer; 36 | @Mock 37 | private AdHocStarter adHocStarter; 38 | @Mock 39 | private JobExecutionAspect jobExecutionAspect; 40 | private AllJobExecutionProvider allJobExecutionProvider; 41 | private CachedJobExecutionProvider cachedJobExecutionProvider; 42 | private JobExecutionService jobExecutionService; 43 | 44 | @Before 45 | public void setUp() { 46 | configureMock(jobExplorer); 47 | allJobExecutionProvider = new AllJobExecutionProvider(jobExplorer); 48 | cachedJobExecutionProvider = new CachedJobExecutionProvider(jobExecutionAspect, allJobExecutionProvider, 3); 49 | configureMock(adHocStarter); 50 | 51 | configureForJobExecutionsService(jobExplorer); 52 | when(jobExplorer.getJobExecution(je11.getId())).thenReturn(je11); 53 | 54 | configureForJobExecutionsService(cachedJobExecutionProvider); 55 | jobExecutionService = new JobExecutionService(jobExplorer, cachedJobExecutionProvider, adHocStarter); 56 | } 57 | 58 | @Test 59 | public void launchJob() { 60 | JobExecution jobExecution = jobExecutionService.launch(JobConfig.builder().name("j1").build()); 61 | assertThat(jobExecution.getJobName()).matches("j1"); 62 | assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.STARTED); 63 | } 64 | 65 | @Test 66 | public void jobExecutionsAll() { 67 | Collection jes = 68 | jobExecutionService.jobExecutions(empty(), empty(), MAX_VALUE); 69 | Assertions.assertThat(jes).hasSize(6); 70 | } 71 | 72 | @Test 73 | public void jobExecutionsId() { 74 | JobExecution je = jobExecutionService.jobExecution(je11.getId()); 75 | assertThat(je).isNotNull(); 76 | } 77 | 78 | @Test(expected = NoSuchJobExecutionException.class) 79 | public void jobExecutionsIdNotFound() { 80 | jobExecutionService.jobExecution(10); 81 | } 82 | 83 | @Test(expected = NoSuchJobExecutionException.class) 84 | public void jobExecutionsIdNotFoundNegativeId() { 85 | jobExecutionService.jobExecution(-1); 86 | } 87 | 88 | @Test 89 | public void jobExecutionsJobNameRegexp() { 90 | Collection jes = 91 | jobExecutionService.jobExecutions(Optional.of("j1"), empty(), MAX_VALUE); 92 | Assertions.assertThat(jes).hasSize(2); 93 | } 94 | 95 | @Test 96 | public void jobExecutionsStatus() { 97 | Collection jes = 98 | jobExecutionService.jobExecutions(Optional.of("j1"), Optional.of(ExitStatus.COMPLETED.getExitCode()), MAX_VALUE); 99 | Assertions.assertThat(jes).hasSize(1); 100 | } 101 | 102 | @Test 103 | public void jobExecutionsMaxNumberOfJobInstancesFailed() { 104 | Collection jes = 105 | jobExecutionService.jobExecutions(empty(), Optional.of(ExitStatus.FAILED.getExitCode()), 1); 106 | Assertions.assertThat(jes).hasSize(2); 107 | Assertions.assertThat(jes).extracting(je -> je.getExitCode()).allMatch(s -> s.equals("FAILED")); 108 | } 109 | 110 | @Test 111 | public void jobExecutionsMaxNumberOfJobInstancesCompleted() { 112 | Collection jes = 113 | jobExecutionService.jobExecutions(empty(), Optional.of(ExitStatus.COMPLETED.getExitCode()), 1); 114 | Assertions.assertThat(jes).hasSize(2); 115 | Assertions.assertThat(jes).extracting(je -> je.getJobName()).containsExactly(JOB_NAME_2, JOB_NAME_1); 116 | } 117 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/AbstractJobExecutionProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.Fixtures; 4 | import org.junit.Test; 5 | import org.junit.runner.RunWith; 6 | import org.mockito.junit.MockitoJUnitRunner; 7 | import org.springframework.batch.core.JobExecution; 8 | 9 | import java.util.Collection; 10 | import java.util.Optional; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | import static org.springframework.batch.core.ExitStatus.COMPLETED; 14 | import static org.springframework.batch.core.ExitStatus.FAILED; 15 | 16 | @RunWith(MockitoJUnitRunner.class) 17 | public abstract class AbstractJobExecutionProviderTest { 18 | 19 | public static final int MAX_NUMBER_OF_EXECUTIONS_PER_JOB_NAME = 5; 20 | 21 | protected abstract JobExecutionProvider provider(); 22 | 23 | @Test 24 | public void worksForEmptyOptionals() { 25 | Collection jes = provider().getJobExecutions(Optional.empty(), Optional.empty(), MAX_NUMBER_OF_EXECUTIONS_PER_JOB_NAME); 26 | assertThat(jes).containsExactly(Fixtures.je24, Fixtures.je23, Fixtures.je22, Fixtures.je21, Fixtures.je12, Fixtures.je11); 27 | } 28 | 29 | @Test 30 | public void worksForCompleted() { 31 | Collection jes = provider().getJobExecutions(Optional.of(Fixtures.JOB_NAME_2), Optional.of(COMPLETED.getExitCode()), MAX_NUMBER_OF_EXECUTIONS_PER_JOB_NAME); 32 | assertThat(jes).containsExactly(Fixtures.je22, Fixtures.je21); 33 | } 34 | 35 | @Test 36 | public void limitsReturnedValuesForCompleted() { 37 | Collection jes = provider().getJobExecutions(Optional.of(Fixtures.JOB_NAME_2), Optional.of(COMPLETED.getExitCode()), 1); 38 | assertThat(jes).containsExactly(Fixtures.je22); 39 | } 40 | 41 | @Test 42 | public void worksForFailed() { 43 | Collection jes = provider().getJobExecutions(Optional.of(Fixtures.JOB_NAME_1), Optional.of(FAILED.getExitCode()), MAX_NUMBER_OF_EXECUTIONS_PER_JOB_NAME); 44 | assertThat(jes).containsExactly(Fixtures.je12); 45 | } 46 | 47 | @Test 48 | public void limitsReturnedValuesForFailed() { 49 | Collection jes = provider().getJobExecutions(Optional.of(Fixtures.JOB_NAME_2), Optional.of(FAILED.getExitCode()), 1); 50 | assertThat(jes).containsExactly(Fixtures.je24); 51 | } 52 | 53 | @Test 54 | public void sortsResultsInDescendingDateOrder() { 55 | Collection jes = provider().getJobExecutions(Optional.of(Fixtures.JOB_NAME_2), Optional.of(FAILED.getExitCode()), MAX_NUMBER_OF_EXECUTIONS_PER_JOB_NAME); 56 | assertThat(jes).containsExactly(Fixtures.je24, Fixtures.je23); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/AllJobExecutionProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.Fixtures; 4 | import org.junit.Before; 5 | import org.mockito.Mock; 6 | import org.springframework.batch.core.explore.JobExplorer; 7 | 8 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.configureForJobExecutionsService; 9 | import static com.github.chrisgleissner.springbatchrest.api.core.Fixtures.configureMock; 10 | 11 | public class AllJobExecutionProviderTest extends AbstractJobExecutionProviderTest { 12 | 13 | @Mock 14 | private JobExplorer jobExplorer; 15 | 16 | private AllJobExecutionProvider provider; 17 | 18 | @Before 19 | public void setUp() { 20 | Fixtures.configureMock(jobExplorer); 21 | Fixtures.configureForJobExecutionsService(jobExplorer); 22 | provider = new AllJobExecutionProvider(jobExplorer); 23 | } 24 | 25 | @Override 26 | protected JobExecutionProvider provider() { 27 | return provider; 28 | } 29 | } -------------------------------------------------------------------------------- /api/src/test/java/com/github/chrisgleissner/springbatchrest/api/core/jobexecution/provider/CachedJobExecutionProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.core.jobexecution.provider; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.Fixtures; 4 | import com.github.chrisgleissner.springbatchrest.util.core.property.JobExecutionAspect; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.mockito.Mock; 8 | import org.springframework.batch.core.JobExecution; 9 | 10 | import java.util.Collection; 11 | import java.util.Optional; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.mockito.ArgumentMatchers.any; 15 | import static org.mockito.ArgumentMatchers.anyInt; 16 | import static org.mockito.Mockito.verify; 17 | import static org.springframework.batch.core.ExitStatus.COMPLETED; 18 | 19 | public class CachedJobExecutionProviderTest extends AbstractJobExecutionProviderTest { 20 | 21 | private static final int MAX_CACHED_RESULTS_PER_JOB_NAME = 10; 22 | public static final String FAILED = "FAILED"; 23 | 24 | @Mock 25 | private JobExecutionAspect executionAspect; 26 | @Mock 27 | private AllJobExecutionProvider allProvider; 28 | private CachedJobExecutionProvider provider; 29 | 30 | @Before 31 | public void setUp() { 32 | provider = new CachedJobExecutionProvider(executionAspect, allProvider, MAX_CACHED_RESULTS_PER_JOB_NAME); 33 | Fixtures.configureForJobExecutionsService(provider); 34 | 35 | assertExecutions(Fixtures.JOB_NAME_1, 2); 36 | assertExecutions(Fixtures.JOB_NAME_1, FAILED, 1); 37 | 38 | assertExecutions(Fixtures.JOB_NAME_2, 4); 39 | assertExecutions(Fixtures.JOB_NAME_2, FAILED, 2); 40 | } 41 | 42 | private void assertExecutions(String jobName, String exitCode, int expectedSize) { 43 | assertThat(getJobExecutions(jobName, Optional.of(exitCode))).hasSize(expectedSize); 44 | } 45 | 46 | private Collection getJobExecutions(String jobName, Optional exitCode) { 47 | return provider.getJobExecutionsByJobName().get(jobName).getJobExecutions(exitCode); 48 | } 49 | 50 | private void assertExecutions(String jobName, int expectedSize) { 51 | assertThat(getJobExecutions(jobName, Optional.empty())).hasSize(expectedSize); 52 | } 53 | 54 | @Test 55 | public void delegatesToAllProviderIfRequestingMoreThanMaxCached() { 56 | assertThat(provider.getJobExecutions(Optional.of(Fixtures.JOB_NAME_1), Optional.of(COMPLETED.getExitCode()), MAX_CACHED_RESULTS_PER_JOB_NAME + 1)).isEmpty(); 57 | verify(allProvider).getJobExecutions(any(), any(), anyInt()); 58 | } 59 | 60 | @Override 61 | protected JobExecutionProvider provider() { 62 | return provider; 63 | } 64 | } -------------------------------------------------------------------------------- /api/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.main.banner-mode=off 2 | spring.boot.admin.client.url: "http://localhost:8090" 3 | management.endpoints.web.exposure.include: "*" 4 | spring.resources.add-mappings=true 5 | server.port=9090 -------------------------------------------------------------------------------- /example/api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest-example 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-example-core 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-example-core 15 | 16 | 17 | 18 | ${project.groupId} 19 | spring-batch-rest-api 20 | ${project.version} 21 | 22 | 23 | com.h2database 24 | h2 25 | 26 | 27 | org.springframework.batch 28 | spring-batch-test 29 | test 30 | 31 | 32 | org.projectlombok 33 | lombok 34 | provided 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-test 39 | test 40 | 41 | 42 | com.github.tomakehurst 43 | wiremock 44 | 2.26.0 45 | compile 46 | 47 | 48 | 49 | 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-maven-plugin 54 | 55 | 56 | build-info 57 | 58 | build-info 59 | 60 | 61 | 62 | repackage 63 | 64 | repackage 65 | 66 | 67 | false 68 | 69 | 70 | 71 | 72 | 73 | maven-enforcer-plugin 74 | 3.0.0-M3 75 | 76 | 77 | enforce-versions 78 | 79 | enforce 80 | 81 | 82 | 83 | 84 | 85 | org.quartz-scheduler 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /example/api/src/main/java/com/github/chrisgleissner/springbatchrest/example/core/PersonJobConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import lombok.RequiredArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.batch.core.Job; 10 | import org.springframework.batch.core.Step; 11 | import org.springframework.batch.core.configuration.JobRegistry; 12 | import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; 13 | import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; 14 | import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; 15 | import org.springframework.batch.core.configuration.annotation.StepScope; 16 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 17 | import org.springframework.batch.item.ItemProcessor; 18 | import org.springframework.batch.item.ItemWriter; 19 | import org.springframework.batch.item.file.FlatFileItemReader; 20 | import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder; 21 | import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper; 22 | import org.springframework.batch.item.function.FunctionItemProcessor; 23 | import org.springframework.batch.item.support.CompositeItemProcessor; 24 | import org.springframework.beans.factory.annotation.Qualifier; 25 | import org.springframework.beans.factory.annotation.Value; 26 | import org.springframework.context.annotation.Bean; 27 | import org.springframework.context.annotation.Configuration; 28 | import org.springframework.core.env.Environment; 29 | import org.springframework.core.io.ClassPathResource; 30 | 31 | import java.util.LinkedList; 32 | import java.util.List; 33 | import java.util.Optional; 34 | 35 | import static com.google.common.collect.Lists.newArrayList; 36 | import static java.util.Collections.synchronizedList; 37 | 38 | @Configuration @EnableBatchProcessing @RequiredArgsConstructor @Slf4j 39 | public class PersonJobConfig { 40 | static final String JOB_NAME = "personJob"; 41 | static final String LAST_NAME_PREFIX = "lastNamePrefix"; 42 | 43 | private final JobBuilderFactory jobs; 44 | private final StepBuilderFactory steps; 45 | private final JobRegistry jobRegistry; 46 | private final Environment environment; 47 | 48 | @Bean 49 | Job personJob(@Qualifier("personStep") Step personStep) { 50 | return JobBuilder.registerJob(jobRegistry, jobs.get(JOB_NAME) 51 | .incrementer(new RunIdIncrementer()) 52 | .start(personStep) 53 | .build()); 54 | } 55 | 56 | @Bean 57 | Step personStep(@Qualifier("personProcessor") ItemProcessor personProcessor) { 58 | return steps.get("personStep") 59 | .allowStartIfComplete(true) 60 | .chunk(3) 61 | .reader(personReader()) 62 | .processor(personProcessor) 63 | .writer(personWriter()) 64 | .build(); 65 | } 66 | 67 | @Bean 68 | FlatFileItemReader personReader() { 69 | return new FlatFileItemReaderBuilder() 70 | .name("personItemReader") 71 | .resource(new ClassPathResource("person.csv")) 72 | .delimited() 73 | .names("firstName", "lastName") 74 | .fieldSetMapper(new BeanWrapperFieldSetMapper() {{ 75 | setTargetType(Person.class); 76 | }}) 77 | .build(); 78 | } 79 | 80 | @Bean @StepScope 81 | ItemProcessor personProcessor( 82 | @Qualifier("personNameCaseChange") ItemProcessor personNameCaseChange, 83 | @Value("#{jobParameters['" + LAST_NAME_PREFIX + "']}") String lastNamePrefix) { 84 | CompositeItemProcessor p = new CompositeItemProcessor(); 85 | p.setDelegates(newArrayList( 86 | personNameFilter(Optional.ofNullable(lastNamePrefix).orElseGet(() -> environment.getProperty(LAST_NAME_PREFIX))), 87 | personNameCaseChange)); 88 | return p; 89 | } 90 | 91 | private ItemProcessor personNameFilter(String lastNamePrefix) { 92 | return new FunctionItemProcessor(p -> { 93 | log.info("Last name prefix: {}", lastNamePrefix); 94 | return p.lastName != null && p.lastName.startsWith(lastNamePrefix) ? p : null; 95 | }); 96 | } 97 | 98 | @Bean @StepScope 99 | ItemProcessor personNameCaseChange(@Value("#{jobParameters['upperCase']}") Boolean upperCaseParam) { 100 | boolean upperCase = upperCaseParam == null ? false : upperCaseParam; 101 | log.info("personNameCaseChange(upperCase={})", upperCase); 102 | return new FunctionItemProcessor(p -> new Person( 103 | upperCase ? p.firstName.toUpperCase() : p.firstName.toLowerCase(), 104 | upperCase ? p.lastName.toUpperCase() : p.lastName.toLowerCase())); 105 | } 106 | 107 | @Bean 108 | CacheItemWriter personWriter() { 109 | return new CacheItemWriter<>(); 110 | } 111 | 112 | @Data @NoArgsConstructor @AllArgsConstructor 113 | public static class Person { 114 | private String firstName; 115 | private String lastName; 116 | } 117 | 118 | public static class CacheItemWriter implements ItemWriter { 119 | private final List items = synchronizedList(new LinkedList<>()); 120 | 121 | @Override 122 | public void write(List items) { 123 | this.items.addAll(items); 124 | } 125 | 126 | public List getItems() { 127 | return items; 128 | } 129 | 130 | public void clear() { 131 | items.clear(); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /example/api/src/main/java/com/github/chrisgleissner/springbatchrest/example/core/SpringBatchRestCoreSampleApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.core; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class SpringBatchRestCoreSampleApplication { 8 | public static void main(String[] args) { 9 | SpringApplication.run(SpringBatchRestCoreSampleApplication.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/api/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.batch.job.enabled=false 2 | lastNamePrefix= 3 | upperCase=false -------------------------------------------------------------------------------- /example/api/src/main/resources/person.csv: -------------------------------------------------------------------------------- 1 | Jill,Doe 2 | Joe,Doe 3 | Justin,Toe 4 | Jane,Toe 5 | John,Toe -------------------------------------------------------------------------------- /example/api/src/test/java/com/github/chrisgleissner/springbatchrest/example/core/PersonJobTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecution; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionResource; 5 | import com.github.chrisgleissner.springbatchrest.example.core.PersonJobConfig; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.batch.core.Job; 13 | import org.springframework.batch.core.JobParameters; 14 | import org.springframework.batch.core.JobParametersBuilder; 15 | import org.springframework.batch.core.configuration.JobRegistry; 16 | import org.springframework.batch.core.launch.JobLauncher; 17 | import org.springframework.batch.core.launch.NoSuchJobException; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 20 | import org.springframework.boot.test.context.SpringBootTest; 21 | import org.springframework.boot.test.web.client.TestRestTemplate; 22 | import org.springframework.boot.web.server.LocalServerPort; 23 | import org.springframework.http.HttpStatus; 24 | import org.springframework.http.ResponseEntity; 25 | import org.springframework.test.context.junit4.SpringRunner; 26 | 27 | import static org.assertj.core.api.Assertions.assertThat; 28 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; 29 | 30 | @RunWith(SpringRunner.class) 31 | @SpringBootTest(webEnvironment = DEFINED_PORT) 32 | @EnableAutoConfiguration 33 | public class PersonJobTest { 34 | @LocalServerPort private int port; 35 | @Autowired private TestRestTemplate restTemplate; 36 | @Autowired private PersonJobConfig.CacheItemWriter cacheItemWriter; 37 | 38 | @Test 39 | public void canStartJob() { 40 | cacheItemWriter.clear(); 41 | startJob(Optional.empty(), Optional.empty()); 42 | assertThat(cacheItemWriter.getItems()).hasSize(5); 43 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toLowerCase())); 44 | 45 | cacheItemWriter.clear(); 46 | startJob(Optional.of("D"), Optional.of(true)); 47 | assertThat(cacheItemWriter.getItems()).hasSize(2); 48 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toUpperCase())); 49 | 50 | cacheItemWriter.clear(); 51 | startJob(Optional.of("To"), Optional.of(false)); 52 | assertThat(cacheItemWriter.getItems()).hasSize(3); 53 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toLowerCase())); 54 | } 55 | 56 | private JobExecution startJob(Optional lastNamePrefix, Optional upperCase) { 57 | JobConfig.JobConfigBuilder jobConfigBuilder = JobConfig.builder() 58 | .name(PersonJobConfig.JOB_NAME).asynchronous(false); 59 | if (lastNamePrefix.isPresent()) 60 | jobConfigBuilder.property(PersonJobConfig.LAST_NAME_PREFIX, lastNamePrefix.get()); 61 | if (upperCase.isPresent()) 62 | jobConfigBuilder.property("upperCase", "" + upperCase.get()); 63 | JobConfig jobConfig = jobConfigBuilder.build(); 64 | 65 | ResponseEntity responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/jobExecutions", 66 | jobConfig, JobExecutionResource.class); 67 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 68 | return responseEntity.getBody().getJobExecution(); 69 | } 70 | } -------------------------------------------------------------------------------- /example/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-example 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-example 15 | pom 16 | 17 | 18 | api 19 | quartz-api 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/quartz-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest-example 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-example-quartz 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-example-quartz 15 | 16 | 17 | 18 | ${project.groupId} 19 | spring-batch-rest-quartz-api 20 | ${project.version} 21 | 22 | 23 | com.h2database 24 | h2 25 | 26 | 27 | org.springframework.batch 28 | spring-batch-test 29 | test 30 | 31 | 32 | org.projectlombok 33 | lombok 34 | provided 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-test 39 | test 40 | 41 | 42 | com.github.tomakehurst 43 | wiremock 44 | 2.26.0 45 | compile 46 | 47 | 48 | 49 | 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-maven-plugin 54 | 55 | 56 | build-info 57 | 58 | build-info 59 | 60 | 61 | 62 | repackage 63 | 64 | repackage 65 | 66 | 67 | false 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /example/quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/example/quartz/PersonJobConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.quartz.AdHocScheduler; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import lombok.RequiredArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.batch.core.Job; 10 | import org.springframework.batch.core.Step; 11 | import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; 12 | import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; 13 | import org.springframework.batch.core.configuration.annotation.StepScope; 14 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 15 | import org.springframework.batch.item.ItemProcessor; 16 | import org.springframework.batch.item.ItemWriter; 17 | import org.springframework.batch.item.file.FlatFileItemReader; 18 | import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder; 19 | import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper; 20 | import org.springframework.batch.item.function.FunctionItemProcessor; 21 | import org.springframework.batch.item.support.CompositeItemProcessor; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.beans.factory.annotation.Qualifier; 24 | import org.springframework.beans.factory.annotation.Value; 25 | import org.springframework.context.annotation.Bean; 26 | import org.springframework.context.annotation.Configuration; 27 | import org.springframework.core.env.Environment; 28 | import org.springframework.core.io.ClassPathResource; 29 | 30 | import java.util.LinkedList; 31 | import java.util.List; 32 | import java.util.Optional; 33 | 34 | import static com.google.common.collect.Lists.newArrayList; 35 | import static java.util.Collections.synchronizedList; 36 | 37 | @Configuration @RequiredArgsConstructor @Slf4j 38 | public class PersonJobConfig { 39 | static final String JOB_NAME = "personJob"; 40 | static final String LAST_NAME_PREFIX = "lastNamePrefix"; 41 | 42 | private final JobBuilderFactory jobs; 43 | private final StepBuilderFactory steps; 44 | private final AdHocScheduler adHocScheduler; 45 | private final Environment environment; 46 | 47 | @Bean 48 | Job personJob(@Qualifier("personStep") Step personStep) { 49 | return adHocScheduler.schedule(PersonJobConfig.JOB_NAME, jobs.get(JOB_NAME) 50 | .incrementer(new RunIdIncrementer()) 51 | .flow(personStep) 52 | .end() 53 | .build(), "0 0 12 * * ?"); 54 | } 55 | 56 | @Bean 57 | Step personStep(@Qualifier("personProcessor") ItemProcessor personProcessor) { 58 | return steps.get("personStep") 59 | .allowStartIfComplete(true) 60 | .chunk(3) 61 | .reader(personReader()) 62 | .processor(personProcessor) 63 | .writer(personWriter()) 64 | .build(); 65 | } 66 | 67 | @Bean 68 | FlatFileItemReader personReader() { 69 | return new FlatFileItemReaderBuilder() 70 | .name("personItemReader") 71 | .resource(new ClassPathResource("person.csv")) 72 | .delimited() 73 | .names(new String[]{"firstName", "lastName"}) 74 | .fieldSetMapper(new BeanWrapperFieldSetMapper() {{ 75 | setTargetType(Person.class); 76 | }}) 77 | .build(); 78 | } 79 | 80 | @Bean @StepScope 81 | ItemProcessor personProcessor( 82 | @Qualifier("personNameCaseChange") ItemProcessor personNameCaseChange, 83 | @Value("#{jobParameters['" + LAST_NAME_PREFIX + "']}") String lastNamePrefix) { 84 | CompositeItemProcessor p = new CompositeItemProcessor(); 85 | p.setDelegates(newArrayList( 86 | personNameFilter(Optional.ofNullable(lastNamePrefix).orElseGet(() -> environment.getProperty(LAST_NAME_PREFIX))), 87 | personNameCaseChange)); 88 | return p; 89 | } 90 | 91 | private ItemProcessor personNameFilter(String lastNamePrefix) { 92 | return new FunctionItemProcessor(p -> { 93 | log.info("Last name prefix: {}", lastNamePrefix); 94 | return p.lastName != null && p.lastName.startsWith(lastNamePrefix) ? p : null; 95 | }); 96 | } 97 | 98 | @Bean @StepScope 99 | ItemProcessor personNameCaseChange(@Value("#{jobParameters['upperCase']}") Boolean upperCaseParam) { 100 | boolean upperCase = upperCaseParam == null ? false : upperCaseParam; 101 | log.info("personNameCaseChange(upperCase={})", upperCase); 102 | return new FunctionItemProcessor(p -> new Person( 103 | upperCase ? p.firstName.toUpperCase() : p.firstName.toLowerCase(), 104 | upperCase ? p.lastName.toUpperCase() : p.lastName.toLowerCase())); 105 | } 106 | 107 | @Bean 108 | CacheItemWriter personWriter() { 109 | return new CacheItemWriter<>(); 110 | } 111 | 112 | public class CacheItemWriter implements ItemWriter { 113 | private List items = synchronizedList(new LinkedList<>()); 114 | 115 | @Override 116 | public void write(List items) { 117 | this.items.addAll(items); 118 | } 119 | 120 | public List getItems() { 121 | return items; 122 | } 123 | 124 | public void clear() { 125 | items.clear(); 126 | } 127 | } 128 | 129 | @Data 130 | @NoArgsConstructor 131 | @AllArgsConstructor 132 | public static class Person { 133 | private String firstName; 134 | private String lastName; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /example/quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/example/quartz/SpringBatchRestQuartzSampleApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.quartz; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class SpringBatchRestQuartzSampleApplication { 8 | public static void main(String[] args) { 9 | SpringApplication.run(SpringBatchRestQuartzSampleApplication.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/quartz-api/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.batch.job.enabled=false 2 | lastNamePrefix= 3 | upperCase=false -------------------------------------------------------------------------------- /example/quartz-api/src/main/resources/person.csv: -------------------------------------------------------------------------------- 1 | Jill,Doe 2 | Joe,Doe 3 | Justin,Toe 4 | Jane,Toe 5 | John,Toe -------------------------------------------------------------------------------- /example/quartz-api/src/test/java/com/github/chrisgleissner/springbatchrest/example/quartz/PersonJobTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.example.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecution; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionResource; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 6 | import java.util.Optional; 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.springframework.batch.core.Job; 10 | import org.springframework.batch.core.configuration.JobRegistry; 11 | import org.springframework.batch.core.launch.NoSuchJobException; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.boot.test.web.client.TestRestTemplate; 15 | import org.springframework.boot.web.server.LocalServerPort; 16 | import org.springframework.http.HttpStatus; 17 | import org.springframework.http.ResponseEntity; 18 | import org.springframework.test.context.junit4.SpringRunner; 19 | 20 | import static org.assertj.core.api.Assertions.assertThat; 21 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; 22 | 23 | @RunWith(SpringRunner.class) 24 | @SpringBootTest(webEnvironment = DEFINED_PORT) 25 | public class PersonJobTest { 26 | @LocalServerPort private int port; 27 | @Autowired private TestRestTemplate restTemplate; 28 | @Autowired private PersonJobConfig.CacheItemWriter cacheItemWriter; 29 | @Autowired private JobRegistry jobRegistry; 30 | 31 | @Test 32 | public void canStartJob() throws NoSuchJobException { 33 | Job job = jobRegistry.getJob(PersonJobConfig.JOB_NAME); 34 | assertThat(job).isNotNull(); 35 | 36 | cacheItemWriter.clear(); 37 | startJob(Optional.empty(), Optional.empty()); 38 | assertThat(cacheItemWriter.getItems()).hasSize(5); 39 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toLowerCase())); 40 | 41 | cacheItemWriter.clear(); 42 | startJob(Optional.of("D"), Optional.of(true)); 43 | assertThat(cacheItemWriter.getItems()).hasSize(2); 44 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toUpperCase())); 45 | 46 | cacheItemWriter.clear(); 47 | startJob(Optional.of("To"), Optional.of(false)); 48 | assertThat(cacheItemWriter.getItems()).hasSize(3); 49 | cacheItemWriter.getItems().forEach(p -> assertThat(p.getFirstName()).isEqualTo(p.getFirstName().toLowerCase())); 50 | } 51 | 52 | private JobExecution startJob(Optional lastNamePrefix, Optional upperCase) { 53 | JobConfig.JobConfigBuilder jobConfigBuilder = JobConfig.builder().name(PersonJobConfig.JOB_NAME).asynchronous(false); 54 | lastNamePrefix.ifPresent(s -> jobConfigBuilder.property(PersonJobConfig.LAST_NAME_PREFIX, s)); 55 | upperCase.ifPresent(aBoolean -> jobConfigBuilder.property("upperCase", "" + aBoolean)); 56 | ResponseEntity responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/jobExecutions", 57 | jobConfigBuilder.build(), JobExecutionResource.class); 58 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 59 | return responseEntity.getBody().getJobExecution(); 60 | } 61 | } -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 2.2.7.RELEASE 10 | 11 | 12 | com.github.chrisgleissner 13 | spring-batch-rest 14 | 1.5.2-SNAPSHOT 15 | pom 16 | 17 | spring-batch-rest 18 | https://github.com/chrisgleissner/spring-batch-rest 19 | 2018 20 | 21 | 22 | 23 | ASL 2.0 24 | http://www.apache.org/licenses/LICENSE-2.0 25 | repo 26 | Apache License, Version 2.0 27 | 28 | 29 | 30 | 31 | 32 | ossrh 33 | https://oss.sonatype.org/content/repositories/snapshots 34 | 35 | 36 | 37 | 38 | 1.8 39 | UTF-8 40 | 41 | 42 | 43 | util 44 | api 45 | quartz-api 46 | example 47 | 48 | 49 | 50 | 51 | 52 | kr.motd.maven 53 | os-maven-plugin 54 | 1.5.0.Final 55 | 56 | 57 | org.jvnet.wagon-svn 58 | wagon-svn 59 | 1.9 60 | 61 | 62 | 63 | 64 | 65 | maven-release-plugin 66 | 67 | v@{project.version} 68 | true 69 | release 70 | 71 | 72 | 73 | 74 | org.eluder.coveralls 75 | coveralls-maven-plugin 76 | 4.3.0 77 | 78 | 79 | javax.xml.bind 80 | jaxb-api 81 | 2.2.3 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | https://github.com/chrisgleissner/spring-batch-rest 90 | scm:git:git://github.com/chrisgleissner/spring-batch-rest.git 91 | scm:git:git@github.com:chrisgleissner/spring-batch-rest.git 92 | v1.4.0 93 | 94 | 95 | 96 | 97 | jacoco 98 | 99 | 100 | 101 | org.jacoco 102 | jacoco-maven-plugin 103 | 0.8.10 104 | 105 | 106 | 107 | prepare-agent 108 | 109 | 110 | 111 | report 112 | prepare-package 113 | 114 | report 115 | 116 | 117 | 118 | prepare-integration-test-agent 119 | 120 | prepare-agent-integration 121 | 122 | 123 | 124 | generate-integration-test-report 125 | 126 | report-integration 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | release 136 | 137 | 138 | 139 | org.sonatype.plugins 140 | nexus-staging-maven-plugin 141 | 1.6.8 142 | true 143 | 144 | ossrh 145 | https://oss.sonatype.org/ 146 | true 147 | 148 | 149 | 150 | 151 | maven-source-plugin 152 | 153 | 154 | attach-sources 155 | 156 | jar 157 | 158 | 159 | 160 | 161 | 162 | 163 | maven-javadoc-plugin 164 | 165 | 166 | attach-javadocs 167 | 168 | jar 169 | 170 | 171 | 172 | 173 | 174 | 175 | maven-gpg-plugin 176 | 1.6 177 | 178 | 179 | sign-artifacts 180 | verify 181 | 182 | sign 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /quartz-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-quartz-api 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-quartz-api 15 | 16 | 17 | 18 | ${project.groupId} 19 | spring-batch-rest-util 20 | ${project.version} 21 | 22 | 23 | ${project.groupId} 24 | spring-batch-rest-api 25 | ${project.version} 26 | 27 | 28 | ${project.groupId} 29 | spring-batch-rest-util 30 | tests 31 | test-jar 32 | ${project.version} 33 | test 34 | 35 | 36 | com.h2database 37 | h2 38 | test 39 | 40 | 41 | org.projectlombok 42 | lombok 43 | provided 44 | 45 | 46 | org.springframework.boot 47 | spring-boot-starter-test 48 | test 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-starter-quartz 53 | 54 | 55 | org.springframework.batch 56 | spring-batch-test 57 | test 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/api/quartz/SpringBatchRestQuartzAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail.JobDetailController; 4 | import com.github.chrisgleissner.springbatchrest.util.quartz.AdHocScheduler; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 7 | import org.springframework.context.annotation.ComponentScan; 8 | import org.springframework.context.annotation.Configuration; 9 | 10 | import static com.github.chrisgleissner.springbatchrest.api.core.Constants.REST_API_ENABLED; 11 | 12 | @Configuration 13 | @ConditionalOnClass(name = "org.quartz.Scheduler") 14 | @ConditionalOnProperty(name = REST_API_ENABLED, havingValue = "true", matchIfMissing = true) 15 | @ComponentScan(basePackageClasses= {JobDetailController.class, AdHocScheduler.class}) 16 | public class SpringBatchRestQuartzAutoConfiguration { 17 | } 18 | -------------------------------------------------------------------------------- /quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/api/quartz/jobdetail/JobDetail.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail; 2 | 3 | import lombok.Builder; 4 | import lombok.Value; 5 | 6 | import java.time.LocalDateTime; 7 | import java.util.Optional; 8 | 9 | @Value 10 | @Builder 11 | public class JobDetail implements Comparable { 12 | private String quartzJobName; 13 | private String quartzGroupName; 14 | private Optional springBatchJobName; 15 | private String description; 16 | private Optional cronExpression; 17 | private LocalDateTime nextFireTime; 18 | private LocalDateTime previousFireTime; 19 | 20 | @Override 21 | public int compareTo(JobDetail o) { 22 | int result = 0; 23 | if (nextFireTime != null && nextFireTime.isBefore(o.nextFireTime)) 24 | result = -1; 25 | else if (nextFireTime != null && nextFireTime.isAfter(o.nextFireTime)) 26 | result = 1; 27 | if (result == 0) 28 | result = quartzGroupName.compareToIgnoreCase(o.quartzGroupName); 29 | if (result == 0) 30 | result = quartzJobName.compareToIgnoreCase(o.quartzJobName); 31 | return result; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/api/quartz/jobdetail/JobDetailController.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 6 | import org.springframework.hateoas.CollectionModel; 7 | import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.PathVariable; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import java.util.Optional; 15 | 16 | import static com.github.chrisgleissner.springbatchrest.api.core.Constants.REST_API_ENABLED; 17 | import static java.util.stream.Collectors.toList; 18 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 19 | 20 | @ConditionalOnProperty(name = REST_API_ENABLED, havingValue = "true", matchIfMissing = true) 21 | @RestController 22 | @RequestMapping(value = "/jobDetails", produces = "application/hal+json") 23 | public class JobDetailController { 24 | 25 | @Autowired 26 | private JobDetailService jobDetailService; 27 | 28 | @Operation(summary = "Get a Quartz job detail by Quartz group and job name") 29 | @GetMapping("/{quartzGroupName}/{quartzJobName}") 30 | public JobDetailResource get(@PathVariable String quartzGroupName, @PathVariable String quartzJobName) { 31 | return new JobDetailResource(jobDetailService.jobDetail(quartzGroupName, quartzJobName)); 32 | } 33 | 34 | @Operation(summary = "Get all Quartz job details") 35 | @GetMapping 36 | public CollectionModel all(@RequestParam(value = "enabled", required = false) Boolean enabled, 37 | @RequestParam(value = "springBatchJobName", required = false) String springBatchJobName) { 38 | return new CollectionModel<>(jobDetailService.all(Optional.ofNullable(enabled), Optional.ofNullable(springBatchJobName)).stream() 39 | .map(JobDetailResource::new).collect(toList()), 40 | WebMvcLinkBuilder.linkTo(methodOn(JobDetailController.class).all(enabled, springBatchJobName)).withSelfRel().expand()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/api/quartz/jobdetail/JobDetailResource.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail; 2 | 3 | import org.springframework.hateoas.RepresentationModel; 4 | 5 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; 6 | import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; 7 | 8 | public class JobDetailResource extends RepresentationModel { 9 | private JobDetail jobDetail; 10 | 11 | // For Jackson 12 | private JobDetailResource() { 13 | } 14 | 15 | public JobDetailResource(final JobDetail jobDetail) { 16 | this.jobDetail = jobDetail; 17 | add(linkTo(methodOn(JobDetailController.class).get(jobDetail.getQuartzGroupName(), jobDetail.getQuartzJobName())).withSelfRel()); 18 | } 19 | 20 | public JobDetail getJobDetail() { 21 | return jobDetail; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /quartz-api/src/main/java/com/github/chrisgleissner/springbatchrest/api/quartz/jobdetail/JobDetailService.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.DateUtil; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.quartz.*; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.util.*; 10 | 11 | import static com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher.JOB_NAME; 12 | import static java.lang.String.format; 13 | import static java.time.LocalDateTime.now; 14 | import static java.util.stream.Collectors.toList; 15 | import static org.quartz.impl.matchers.GroupMatcher.jobGroupEquals; 16 | 17 | @Slf4j 18 | @Service 19 | public class JobDetailService { 20 | 21 | private final Scheduler scheduler; 22 | 23 | @Autowired 24 | public JobDetailService(Scheduler scheduler) { 25 | this.scheduler = scheduler; 26 | 27 | } 28 | 29 | public Collection all(Optional enabled, Optional springBatchJobName) { 30 | try { 31 | Set jobDetails = new TreeSet<>(); 32 | for (String groupName : scheduler.getJobGroupNames()) { 33 | for (JobKey jobKey : scheduler.getJobKeys(jobGroupEquals(groupName))) { 34 | jobDetails.add(jobDetail(jobKey.getGroup(), jobKey.getName())); 35 | } 36 | } 37 | return jobDetails.stream() 38 | .filter(d -> enabled.isPresent() ? enabled.get() == d.getNextFireTime().isBefore(now().plusYears(1)) : true) 39 | .filter(d -> springBatchJobName.isPresent() ? d.getSpringBatchJobName().get().equals(springBatchJobName.get()) : true) 40 | .collect(toList()); 41 | } catch (Exception e) { 42 | log.error("Couldn't get job details", e); 43 | throw new RuntimeException("Couldn't get job details", e); 44 | } 45 | } 46 | 47 | public JobDetail jobDetail(String quartzGroupName, String quartzJobName) { 48 | try { 49 | JobKey jobKey = new JobKey(quartzJobName, quartzGroupName); 50 | 51 | List triggers = (List) scheduler.getTriggersOfJob(jobKey); 52 | Trigger trigger = triggers.get(0); 53 | Date nextFireTime = trigger.getNextFireTime(); 54 | Date previousFireTime = trigger.getPreviousFireTime(); 55 | 56 | String springBatchJobName = null; 57 | JobDataMap jobDataMap = scheduler.getJobDetail(jobKey).getJobDataMap(); 58 | if (jobDataMap != null && jobDataMap.containsKey(JOB_NAME)) { 59 | springBatchJobName = (String) jobDataMap.get(JOB_NAME); 60 | } 61 | 62 | String cronExpression = null; 63 | if (trigger instanceof CronTrigger) { 64 | cronExpression = ((CronTrigger) trigger).getCronExpression(); 65 | } 66 | 67 | return JobDetail.builder().quartzJobName(quartzJobName).quartzGroupName(quartzGroupName) 68 | .springBatchJobName(Optional.ofNullable(springBatchJobName)) 69 | .nextFireTime(DateUtil.localDateTime(nextFireTime)) 70 | .previousFireTime(DateUtil.localDateTime(previousFireTime)) 71 | .cronExpression(Optional.ofNullable(cronExpression)).build(); 72 | } catch (Exception e) { 73 | log.error("Couldn't get job detail", e); 74 | throw new RuntimeException(format("Couldn't get job detail for group name '%s' and job name '%s'", quartzGroupName, quartzJobName), e); 75 | } 76 | 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /quartz-api/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | com.github.chrisgleissner.springbatchrest.api.quartz.SpringBatchRestQuartzAutoConfiguration -------------------------------------------------------------------------------- /quartz-api/src/test/java/com/github/chrisgleissner/springbatchrest/api/quartz/RestTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecution; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionResource; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 7 | import com.github.chrisgleissner.springbatchrest.util.core.config.AdHocBatchConfig; 8 | import com.github.chrisgleissner.springbatchrest.util.quartz.AdHocScheduler; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.batch.core.ExitStatus; 13 | import org.springframework.batch.core.Job; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.context.SpringBootTest; 16 | import org.springframework.boot.test.web.client.TestRestTemplate; 17 | import org.springframework.boot.web.server.LocalServerPort; 18 | import org.springframework.context.annotation.Import; 19 | import org.springframework.http.HttpStatus; 20 | import org.springframework.http.ResponseEntity; 21 | import org.springframework.test.context.TestPropertySource; 22 | import org.springframework.test.context.junit4.SpringRunner; 23 | 24 | import java.util.Set; 25 | import java.util.concurrent.ConcurrentSkipListSet; 26 | import java.util.concurrent.CountDownLatch; 27 | import java.util.concurrent.atomic.AtomicBoolean; 28 | 29 | import static java.util.concurrent.TimeUnit.SECONDS; 30 | import static org.assertj.core.api.Assertions.assertThat; 31 | import static org.springframework.batch.core.ExitStatus.COMPLETED; 32 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; 33 | 34 | @RunWith(SpringRunner.class) 35 | @SpringBootTest(webEnvironment = DEFINED_PORT) 36 | @TestPropertySource(properties = "ServerTest-property=0") 37 | @Import(AdHocBatchConfig.class) 38 | public class RestTest { 39 | private static final String JOB_NAME = "ServerTest-job"; 40 | private static final String PROPERTY_NAME = "ServerTest-property"; 41 | private static final String EXCEPTION_MESSAGE_PROPERTY_NAME = "ServerTest-exceptionMessage"; 42 | private static final String CRON_EXPRESSION = "0/1 * * * * ?"; 43 | private static final Set propertyValues = new ConcurrentSkipListSet<>(); 44 | 45 | private final JobConfig jobConfig = JobConfig.builder().name(JOB_NAME).asynchronous(false).build(); 46 | private final CountDownLatch jobExecutedOnce = new CountDownLatch(1); 47 | private final AtomicBoolean firstExecution = new AtomicBoolean(true); 48 | 49 | @LocalServerPort private int port; 50 | @Autowired private TestRestTemplate restTemplate; 51 | @Autowired private AdHocScheduler adHocScheduler; 52 | @Autowired private JobBuilder jobBuilder; 53 | 54 | @Before 55 | public void setUp() throws InterruptedException { 56 | if (firstExecution.compareAndSet(true, false)) { 57 | Job job = jobBuilder.createJob(JOB_NAME, propertyResolver -> { 58 | String propertyValue = propertyResolver.getProperty(PROPERTY_NAME); 59 | propertyValues.add(propertyValue); 60 | 61 | String exceptionMessage = propertyResolver.getProperty(EXCEPTION_MESSAGE_PROPERTY_NAME); 62 | if (exceptionMessage != null) 63 | throw new RuntimeException(exceptionMessage); 64 | 65 | jobExecutedOnce.countDown(); 66 | }); 67 | adHocScheduler.schedule(JOB_NAME, job, CRON_EXPRESSION); 68 | adHocScheduler.start(); 69 | jobExecutedOnce.await(2, SECONDS); 70 | adHocScheduler.pause(); 71 | } 72 | } 73 | 74 | @Test 75 | public void jobsCanBeStartedWithDifferentProperties() { 76 | assertThat(propertyValues).containsExactly("0"); 77 | 78 | JobExecution je1 = startJob("1"); 79 | assertThat(propertyValues).containsExactly("0", "1"); 80 | 81 | JobExecution je2 = startJob("2"); 82 | assertThat(propertyValues).containsExactly("0", "1", "2"); 83 | 84 | assertThat(je1.getExitCode()).isEqualTo(COMPLETED.getExitCode()); 85 | assertThat(je2.getExitCode()).isEqualTo(COMPLETED.getExitCode()); 86 | 87 | assertThat(restTemplate.getForObject(url("/jobExecutions?exitCode=COMPLETED"), String.class)) 88 | .contains("\"status\":\"COMPLETED\"").contains("\"jobName\":\"ServerTest-job\""); 89 | } 90 | 91 | @Test 92 | public void jobExceptionMessageIsPropagatedToClient() { 93 | String exceptionMessage = "excepted exception"; 94 | JobExecution je = startJobThatThrowsException(exceptionMessage); 95 | assertThat(je.getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); 96 | assertThat(je.getExitDescription()).contains(exceptionMessage); 97 | 98 | assertThat(restTemplate.getForObject(url("/jobExecutions?exitCode=FAILED"), String.class)) 99 | .contains("\"exitCode\":\"FAILED\",\"exitDescription\":\"java.lang.RuntimeException"); 100 | } 101 | 102 | @Test 103 | public void jobDetails() { 104 | assertThat(restTemplate.getForObject(url("/jobDetails?springBatchJobName=" + JOB_NAME), String.class)) 105 | .contains(JOB_NAME); 106 | } 107 | 108 | @Test 109 | public void swagger() { 110 | assertThat(restTemplate.getForObject(url("v3/api-docs"), String.class)) 111 | .contains("\"openapi\":\"3.0.1\""); 112 | } 113 | 114 | private JobExecution startJob(String propertyValue) { 115 | ResponseEntity responseEntity = restTemplate.postForEntity(url("/jobExecutions"), 116 | jobConfig.toBuilder().property(PROPERTY_NAME, propertyValue).build(), 117 | JobExecutionResource.class); 118 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 119 | return responseEntity.getBody().getJobExecution(); 120 | } 121 | 122 | private JobExecution startJobThatThrowsException(String exceptionMessage) { 123 | ResponseEntity responseEntity = restTemplate.postForEntity(url("/jobExecutions"), 124 | jobConfig.toBuilder().property(EXCEPTION_MESSAGE_PROPERTY_NAME, exceptionMessage).build(), 125 | JobExecutionResource.class); 126 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); 127 | return responseEntity.getBody().getJobExecution(); 128 | } 129 | 130 | private String url(String path) { 131 | return "http://localhost:" + port + path; 132 | } 133 | } -------------------------------------------------------------------------------- /quartz-api/src/test/java/com/github/chrisgleissner/springbatchrest/api/quartz/SpringBatchRestQuartzTestApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.api.core.job.JobController; 4 | import com.github.chrisgleissner.springbatchrest.api.core.jobexecution.JobExecutionController; 5 | import com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail.JobDetailController; 6 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 7 | import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; 8 | import org.springframework.boot.SpringApplication; 9 | import org.springframework.boot.autoconfigure.SpringBootApplication; 10 | import org.springframework.context.annotation.ComponentScan; 11 | 12 | @SpringBootApplication 13 | @EnableBatchProcessing 14 | @ComponentScan(basePackageClasses= {AdHocStarter.class, JobController.class, JobDetailController.class, JobExecutionController.class }) 15 | public class SpringBatchRestQuartzTestApplication { 16 | 17 | public static void main(String[] args) { 18 | SpringApplication.run(SpringBatchRestQuartzTestApplication.class, args); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /quartz-api/src/test/java/com/github/chrisgleissner/springbatchrest/api/quartz/jobdetail/JobDetailControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.api.quartz.jobdetail; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.AdHocStarter; 4 | import com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher; 5 | import com.google.common.collect.Lists; 6 | import com.google.common.collect.Sets; 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.quartz.*; 11 | import org.quartz.impl.JobDetailImpl; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 14 | import org.springframework.boot.test.mock.mockito.MockBean; 15 | import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; 16 | import org.springframework.test.context.junit4.SpringRunner; 17 | import org.springframework.test.web.servlet.MockMvc; 18 | 19 | import static com.google.common.collect.Lists.newArrayList; 20 | import static org.hamcrest.Matchers.hasSize; 21 | import static org.mockito.ArgumentMatchers.any; 22 | import static org.mockito.Mockito.doReturn; 23 | import static org.mockito.Mockito.when; 24 | import static org.quartz.CronScheduleBuilder.cronSchedule; 25 | import static org.quartz.TriggerBuilder.newTrigger; 26 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 27 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 28 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 29 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 30 | 31 | @SpringJUnitWebConfig 32 | @RunWith(SpringRunner.class) 33 | @WebMvcTest 34 | public class JobDetailControllerTest { 35 | 36 | @Autowired 37 | private MockMvc mockMvc; 38 | 39 | @MockBean 40 | private AdHocStarter adHocStarter; 41 | 42 | @MockBean 43 | private Scheduler scheduler; 44 | 45 | @Before 46 | public void setUp() throws SchedulerException { 47 | when(scheduler.getJobGroupNames()).thenReturn(newArrayList("g1")); 48 | when(scheduler.getJobKeys(any())).thenReturn(Sets.newHashSet(new JobKey("g1", "jd1"), new JobKey("g1", "jd2"))); 49 | doReturn(Lists.newArrayList((Trigger) newTrigger() 50 | .withSchedule(cronSchedule("0/1 * * * * ?")) 51 | .build())).when(scheduler).getTriggersOfJob(any()); 52 | 53 | when(scheduler.getJobDetail(any(JobKey.class))).thenAnswer(i -> { 54 | JobDetailImpl jobDetail = new JobDetailImpl(); 55 | JobDataMap jobDataMap = new JobDataMap(); 56 | jobDataMap.put(QuartzJobLauncher.JOB_NAME, ((JobKey) i.getArgument(0)).getName()); 57 | jobDetail.setJobDataMap(jobDataMap); 58 | return jobDetail; 59 | }); 60 | } 61 | 62 | @Test 63 | public void jobDetail() throws Exception { 64 | mockMvc.perform(get("/jobDetails/g1/j1")) 65 | .andDo(print()) 66 | .andExpect(status().isOk()) 67 | .andExpect(jsonPath("$..jobDetail", hasSize(1))); 68 | } 69 | 70 | @Test 71 | public void jobDetails() throws Exception { 72 | mockMvc.perform(get("/jobDetails")) 73 | .andDo(print()) 74 | .andExpect(status().isOk()) 75 | .andExpect(jsonPath("$..jobDetail", hasSize(2))); 76 | } 77 | } -------------------------------------------------------------------------------- /quartz-api/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.main.banner-mode=off 2 | spring.boot.admin.client.url: "http://localhost:8090" 3 | management.endpoints.web.exposure.include: "*" 4 | spring.resources.add-mappings=true 5 | server.port=9090 -------------------------------------------------------------------------------- /system.properties: -------------------------------------------------------------------------------- 1 | # Heroku config 2 | java.runtime.version=11 -------------------------------------------------------------------------------- /util/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4.0.0 5 | 6 | 7 | com.github.chrisgleissner 8 | spring-batch-rest 9 | 1.5.2-SNAPSHOT 10 | 11 | 12 | spring-batch-rest-util 13 | 1.5.2-SNAPSHOT 14 | spring-batch-rest-util 15 | 16 | 17 | 18 | com.h2database 19 | h2 20 | test 21 | 22 | 23 | org.projectlombok 24 | lombok 25 | provided 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-aop 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-batch 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-quartz 38 | provided 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-test 43 | test 44 | 45 | 46 | org.springframework.batch 47 | spring-batch-test 48 | test 49 | 50 | 51 | 52 | 53 | 54 | 55 | maven-jar-plugin 56 | 57 | 58 | 59 | test-jar 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/DateUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util; 2 | 3 | import java.time.LocalDateTime; 4 | import java.time.ZoneId; 5 | import java.util.Date; 6 | 7 | public class DateUtil { 8 | 9 | public static LocalDateTime localDateTime(Date date) { 10 | return date == null ? null : date.toInstant() 11 | .atZone(ZoneId.systemDefault()) 12 | .toLocalDateTime(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/JobParamUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util; 2 | 3 | import static java.util.Collections.emptyMap; 4 | import static java.util.stream.Collectors.toMap; 5 | 6 | import java.util.Date; 7 | import java.util.Map; 8 | import java.util.Optional; 9 | 10 | import org.springframework.batch.core.JobParameter; 11 | import org.springframework.batch.core.JobParameters; 12 | 13 | public class JobParamUtil { 14 | 15 | public static JobParameters convertRawToJobParams(Map properties) { 16 | return new JobParameters(convertRawToParamMap(properties)); 17 | } 18 | 19 | public static Map convertRawToParamMap(Map properties) { 20 | return Optional.ofNullable(properties).orElse(emptyMap()).entrySet().stream() 21 | .collect(toMap(Map.Entry::getKey, e -> createJobParameter(e.getValue()))); 22 | } 23 | 24 | public static JobParameter createJobParameter(Object value) { 25 | if (value instanceof Date) 26 | return new JobParameter((Date) value); 27 | else if (value instanceof Long) 28 | return new JobParameter((Long) value); 29 | else if (value instanceof Double) 30 | return new JobParameter((Double) value); 31 | else 32 | return new JobParameter("" + value); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/TriggerUtil.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util; 2 | 3 | import static org.quartz.TriggerBuilder.newTrigger; 4 | 5 | import java.util.Date; 6 | 7 | import org.quartz.CronScheduleBuilder; 8 | import org.quartz.Trigger; 9 | 10 | public class TriggerUtil { 11 | 12 | // Defined for clarity - passing in null is not obvious to what is happening 13 | // under the covers 14 | public static final String QUARTZ_DEFAULT_GROUP = null; 15 | 16 | public static Trigger triggerFor(String cronExpression, String jobName) { 17 | return triggerFor(cronExpression, jobName, QUARTZ_DEFAULT_GROUP); 18 | } 19 | 20 | public static Trigger triggerFor(String cronExpression, String jobName, String groupName) { 21 | CronScheduleBuilder builder = CronScheduleBuilder.cronSchedule(cronExpression); 22 | return newTrigger().withIdentity(jobName, groupName).withSchedule(builder).forJob(jobName, groupName).build(); 23 | } 24 | 25 | public static Trigger triggerFor(Date dateToRun, String jobName) { 26 | return triggerFor(dateToRun, jobName, QUARTZ_DEFAULT_GROUP); 27 | } 28 | 29 | public static Trigger triggerFor(Date dateToRun, String jobName, String groupName) { 30 | return newTrigger().withIdentity(jobName, groupName).startAt(dateToRun).forJob(jobName, groupName).build(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/AdHocStarter.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.JobParamUtil; 4 | import com.github.chrisgleissner.springbatchrest.util.core.property.JobPropertyResolvers; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.batch.core.Job; 7 | import org.springframework.batch.core.JobExecution; 8 | import org.springframework.batch.core.JobExecutionException; 9 | import org.springframework.batch.core.JobParameter; 10 | import org.springframework.batch.core.JobParameters; 11 | import org.springframework.batch.core.configuration.JobLocator; 12 | import org.springframework.batch.core.configuration.JobRegistry; 13 | import org.springframework.batch.core.launch.JobLauncher; 14 | import org.springframework.batch.core.launch.NoSuchJobException; 15 | import org.springframework.batch.core.launch.support.SimpleJobLauncher; 16 | import org.springframework.batch.core.repository.JobRepository; 17 | import org.springframework.beans.factory.annotation.Value; 18 | import org.springframework.core.task.SimpleAsyncTaskExecutor; 19 | import org.springframework.core.task.SyncTaskExecutor; 20 | import org.springframework.core.task.TaskExecutor; 21 | import org.springframework.stereotype.Component; 22 | 23 | import javax.batch.operations.BatchRuntimeException; 24 | import java.util.HashMap; 25 | import java.util.Map; 26 | import java.util.UUID; 27 | 28 | import static java.lang.String.format; 29 | 30 | @Slf4j 31 | @Component 32 | public class AdHocStarter { 33 | private final JobLocator jobLocator; 34 | private final SimpleJobLauncher asyncJobLauncher; 35 | private final SimpleJobLauncher syncJobLauncher; 36 | private final JobPropertyResolvers jobPropertyResolvers; 37 | private final boolean addUniqueJobParameter; 38 | private final JobRegistry jobRegistry; 39 | 40 | public AdHocStarter(JobLocator jobLocator, JobRepository jobRepository, JobPropertyResolvers jobPropertyResolvers, 41 | @Value("${com.github.chrisgleissner.springbatchrest.addUniqueJobParameter:true}") boolean addUniqueJobParameter, 42 | JobRegistry jobRegistry) { 43 | this.jobLocator = jobLocator; 44 | asyncJobLauncher = jobLauncher(new SimpleAsyncTaskExecutor(), jobRepository); 45 | syncJobLauncher = jobLauncher(new SyncTaskExecutor(), jobRepository); 46 | this.jobPropertyResolvers = jobPropertyResolvers; 47 | this.addUniqueJobParameter = addUniqueJobParameter; 48 | this.jobRegistry = jobRegistry; 49 | log.info("Adding unique job parameter: {}", addUniqueJobParameter); 50 | } 51 | 52 | private SimpleJobLauncher jobLauncher(TaskExecutor taskExecutor, JobRepository jobRepository) { 53 | SimpleJobLauncher jobLauncher = new SimpleJobLauncher(); 54 | jobLauncher.setJobRepository(jobRepository); 55 | jobLauncher.setTaskExecutor(taskExecutor); 56 | return jobLauncher; 57 | } 58 | 59 | public JobExecution start(Job job) { 60 | return this.start(job, true, null); 61 | } 62 | 63 | public JobExecution start(Job job, Boolean async, Map properties) { 64 | Job existingJob = null; 65 | try { 66 | existingJob = jobRegistry.getJob(job.getName()); 67 | } catch (NoSuchJobException e) { 68 | log.info("Registering new job: " + job.getName()); 69 | } 70 | JobConfig jobConfig = JobConfig.builder() 71 | .asynchronous(async) 72 | .properties(properties == null ? new HashMap<>() : properties) 73 | .name(job.getName()).build(); 74 | JobBuilder.registerJob(jobRegistry, existingJob == null ? job : existingJob); 75 | return this.start(jobConfig); 76 | } 77 | 78 | public JobExecution start(JobConfig jobConfig) { 79 | try { 80 | Job job = jobLocator.getJob(jobConfig.getName()); 81 | jobPropertyResolvers.started(jobConfig); 82 | 83 | Map params = JobParamUtil.convertRawToParamMap(jobConfig.getProperties()); 84 | if (addUniqueJobParameter) 85 | params.put("uuid", new JobParameter(UUID.randomUUID().toString())); 86 | JobParameters jobParameters = new JobParameters(params); 87 | 88 | log.info("Starting {} with {}", jobConfig.getName(), jobConfig); 89 | JobLauncher jobLauncher = jobConfig.isAsynchronous() ? asyncJobLauncher : syncJobLauncher; 90 | return jobLauncher.run(job, jobParameters); 91 | } catch (JobExecutionException e) { 92 | throw new BatchRuntimeException(format("Failed to start job '%s' with %s. Reason: %s", 93 | jobConfig.getName(), jobConfig, e.getMessage()), e); 94 | } catch (Exception e) { 95 | throw new RuntimeException(format("Failed to start job '%s' with %s. Reason: %s", 96 | jobConfig.getName(), jobConfig, e.getMessage()), e); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/JobBuilder.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.tasklet.PropertyResolverConsumerTasklet; 4 | import com.github.chrisgleissner.springbatchrest.util.core.tasklet.RunnableTasklet; 5 | import com.github.chrisgleissner.springbatchrest.util.core.tasklet.StepExecutionListenerTasklet; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.batch.core.Job; 8 | import org.springframework.batch.core.StepExecution; 9 | import org.springframework.batch.core.configuration.JobFactory; 10 | import org.springframework.batch.core.configuration.JobRegistry; 11 | import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; 12 | import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; 13 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 14 | import org.springframework.batch.core.step.tasklet.Tasklet; 15 | import org.springframework.core.env.Environment; 16 | import org.springframework.core.env.PropertyResolver; 17 | import org.springframework.stereotype.Component; 18 | 19 | import java.util.function.Consumer; 20 | 21 | @Component @RequiredArgsConstructor 22 | public class JobBuilder { 23 | private final JobRegistry jobRegistry; 24 | private final JobBuilderFactory jobs; 25 | private final StepBuilderFactory steps; 26 | private final Environment environment; 27 | 28 | public static Job registerJob(JobRegistry jobRegistry, Job job) { 29 | jobRegistry.unregister(job.getName()); 30 | try { 31 | jobRegistry.register(new JobFactory() { 32 | @Override 33 | public Job createJob() { 34 | return job; 35 | } 36 | 37 | @Override 38 | public String getJobName() { 39 | return job.getName(); 40 | } 41 | }); 42 | } catch (Exception e) { 43 | throw new RuntimeException("Could not create " + job.getName(), e); 44 | } 45 | return job; 46 | } 47 | 48 | public Job registerJob(Job job) { 49 | return registerJob(jobRegistry, job); 50 | } 51 | 52 | public Job createJob(String name, Runnable runnable) { 53 | return createJob(name, new RunnableTasklet(runnable)); 54 | } 55 | 56 | private Job createJob(String name, Tasklet tasklet) { 57 | return registerJob(jobs.get(name).incrementer(new RunIdIncrementer()) 58 | .start(steps.get("step").allowStartIfComplete(true).tasklet(tasklet).build()).build()); 59 | } 60 | 61 | public Job createJob(String name, Consumer propertyResolverConsumer) { 62 | return createJob(name, new PropertyResolverConsumerTasklet(environment, propertyResolverConsumer)); 63 | } 64 | 65 | public Job createJobFromStepExecutionConsumer(String name, Consumer stepExecutionConsumer) { 66 | return createJob(name, new StepExecutionListenerTasklet(stepExecutionConsumer)); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/JobConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import lombok.*; 4 | 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | 8 | @Data @NoArgsConstructor @AllArgsConstructor @Builder(toBuilder = true) 9 | public class JobConfig { 10 | private String name; 11 | @Singular("property") 12 | private Map properties = new HashMap<>(); 13 | private boolean asynchronous; 14 | } 15 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/JobParamsDetail.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import java.util.Map; 4 | 5 | import org.quartz.JobDetail; 6 | import org.quartz.JobKey; 7 | import org.quartz.impl.JobDetailImpl; 8 | import org.quartz.utils.Key; 9 | 10 | import lombok.Data; 11 | import lombok.EqualsAndHashCode; 12 | 13 | /** 14 | * Extension class to add JobParameters to a JobDetail for scheduled execution 15 | * with JobParameters. 16 | * 17 | * @author theJeff77 18 | */ 19 | 20 | @Data 21 | @EqualsAndHashCode(callSuper = false) 22 | public class JobParamsDetail extends JobDetailImpl { 23 | 24 | private static final long serialVersionUID = -4813776846767160965L; 25 | private Map rawJobParameters; 26 | 27 | // Constructor to do a deep copy all data from JobDetail into this subclass. 28 | // Based off of JobBuilder's construction code. 29 | public JobParamsDetail(JobDetail jobDetail) { 30 | 31 | this.setJobClass(jobDetail.getJobClass()); 32 | this.setDescription(jobDetail.getDescription()); 33 | if (jobDetail.getKey() == null) 34 | this.setKey(new JobKey(Key.createUniqueName(null), null)); 35 | this.setKey(jobDetail.getKey()); 36 | this.setDurability(jobDetail.isDurable()); 37 | this.setRequestsRecovery(jobDetail.requestsRecovery()); 38 | 39 | if (!jobDetail.getJobDataMap().isEmpty()) 40 | this.setJobDataMap(jobDetail.getJobDataMap()); 41 | } 42 | 43 | public JobParamsDetail(JobDetail jobDetail, Map rawJobParameters) { 44 | this(jobDetail); 45 | this.rawJobParameters = rawJobParameters; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/config/AdHocBatchConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.config; 2 | 3 | import org.springframework.batch.core.configuration.JobLocator; 4 | import org.springframework.batch.core.configuration.annotation.DefaultBatchConfigurer; 5 | import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; 6 | import org.springframework.batch.core.launch.JobLauncher; 7 | import org.springframework.batch.core.launch.support.SimpleJobLauncher; 8 | import org.springframework.batch.core.repository.JobRepository; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.context.annotation.ComponentScan; 11 | import org.springframework.context.annotation.Configuration; 12 | import org.springframework.context.annotation.EnableAspectJAutoProxy; 13 | import org.springframework.scheduling.concurrent.ConcurrentTaskExecutor; 14 | 15 | import javax.sql.DataSource; 16 | import java.util.concurrent.ExecutorService; 17 | import java.util.concurrent.Executors; 18 | 19 | @Configuration 20 | @ComponentScan(basePackages = { 21 | "com.github.chrisgleissner.springbatchrest.util.core", 22 | "com.github.chrisgleissner.springbatchrest.util.core.property" 23 | }) 24 | @EnableBatchProcessing 25 | @EnableAspectJAutoProxy 26 | public class AdHocBatchConfig extends DefaultBatchConfigurer { 27 | 28 | @Autowired 29 | private JobRepository jobRepository; 30 | 31 | // TODO: These seem to be unused. Consider removal after Chris's feedback. 32 | @Autowired 33 | private JobLocator jobLocator; 34 | 35 | @Autowired 36 | private JobLauncher jobLauncher; 37 | 38 | private ExecutorService executorService = Executors.newCachedThreadPool(); 39 | 40 | @Override 41 | public void setDataSource(DataSource dataSource) { 42 | // override to do not set datasource even if a datasource exist. 43 | // initialize will use a Map based JobRepository (instead of database) 44 | } 45 | 46 | public JobLauncher getJobLauncher() { 47 | SimpleJobLauncher jobLauncher = new SimpleJobLauncher(); 48 | jobLauncher.setJobRepository(jobRepository); 49 | jobLauncher.setTaskExecutor(new ConcurrentTaskExecutor(executorService)); 50 | return jobLauncher; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/property/JobExecutionAspect.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.property; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.aspectj.lang.annotation.Aspect; 5 | import org.aspectj.lang.annotation.Before; 6 | import org.springframework.batch.core.JobExecution; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.LinkedHashSet; 10 | import java.util.Set; 11 | import java.util.function.Consumer; 12 | 13 | @Slf4j 14 | @Aspect 15 | @Component 16 | public class JobExecutionAspect { 17 | 18 | private Set> consumers = new LinkedHashSet<>(); 19 | 20 | public void register(Consumer consumer) { 21 | consumers.add(consumer); 22 | } 23 | 24 | @Before("within(org.springframework.batch.core.repository.JobRepository+) && execution(* update(..)) && args(jobExecution)") 25 | public void jobExecutionUpdated(JobExecution jobExecution) { 26 | if (jobExecution.getStatus().isUnsuccessful()) 27 | log.error("{} {}: {}", jobExecution.getStatus().name(), jobExecution.getJobInstance().getJobName(), jobExecution); 28 | else 29 | log.info("{} {}: {}", jobExecution.getStatus().name(), jobExecution.getJobInstance().getJobName(), jobExecution); 30 | consumers.forEach(l -> l.accept(jobExecution)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/property/JobPropertyResolver.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.property; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 4 | import org.springframework.core.env.*; 5 | 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import java.util.Optional; 9 | import java.util.Properties; 10 | 11 | import static java.util.Collections.emptyMap; 12 | 13 | /** 14 | * @deprecated see notes at {@link JobPropertyResolvers#JobProperties} 15 | */ 16 | @Deprecated 17 | public class JobPropertyResolver extends PropertySourcesPropertyResolver { 18 | private final JobConfig jobConfig; 19 | 20 | public JobPropertyResolver(JobConfig jobConfig, Environment env) { 21 | super(propertySources(jobConfig, env)); 22 | this.jobConfig = jobConfig; 23 | } 24 | 25 | private static PropertySources propertySources(JobConfig jobConfig, Environment env) { 26 | MutablePropertySources propertySources = new MutablePropertySources(); 27 | Map jobProperties = new HashMap<>(Optional.ofNullable(jobConfig.getProperties()).orElse(emptyMap())); 28 | propertySources.addFirst(new MapPropertySource(jobConfig.getName(), jobProperties)); 29 | ((AbstractEnvironment) env).getPropertySources().forEach(propertySources::addLast); 30 | return propertySources; 31 | } 32 | 33 | public String toString() { 34 | return String.format("Properties for job %s: %s", jobConfig.getName(), jobConfig.getProperties()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/property/JobPropertyResolvers.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.property; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.batch.core.JobExecution; 6 | import org.springframework.batch.core.StepExecution; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.core.env.Environment; 9 | import org.springframework.core.env.PropertyResolver; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.util.Map; 13 | import java.util.concurrent.ConcurrentHashMap; 14 | import java.util.function.Consumer; 15 | 16 | /** 17 | * @deprecated see notes at {@link JobPropertyResolvers#JobProperties} 18 | */ 19 | @Deprecated 20 | @Slf4j @Component 21 | public class JobPropertyResolvers implements Consumer { 22 | 23 | /** 24 | * @deprecated Accessing {@link org.springframework.batch.core.Job} properties via this singleton is not safe for 25 | * asynchronously executing the same Job multiple times with different properties. In this case a {@link JobExecution} 26 | * may incorrectly use the properties of another, concurrently running JobExecution of the same Job. 27 | * 28 | *

29 | * Instead, it is recommended to access job properties via either {@link StepExecution#getJobParameters()} or by annotating your 30 | * Spring-wired job beans with @Value("#{jobParameters['key']}"). You can get a handle of a StepExecution 31 | * by implementing {@link org.springframework.batch.core.StepExecutionListener} or extending 32 | * {@link com.github.chrisgleissner.springbatchrest.util.core.tasklet.StepExecutionListenerTasklet}. 33 | *

34 | * 35 | *

For convenience, when using 36 | * {@link com.github.chrisgleissner.springbatchrest.util.core.JobBuilder#createJob(String, Consumer)} to build a job, 37 | * the returned {@link PropertyResolver} will first resolve against job properties, then against Spring properties. 38 | *

39 | * 40 | * @see com.github.chrisgleissner.springbatchrest.util.core.JobBuilder#createJob(String, Consumer) 41 | */ 42 | @Deprecated 43 | public static JobPropertyResolvers JobProperties; 44 | 45 | private Environment environment; 46 | private Map resolvers = new ConcurrentHashMap<>(); 47 | 48 | @Autowired 49 | public JobPropertyResolvers(Environment environment, JobExecutionAspect jobExecutionAspect) { 50 | this.environment = environment; 51 | jobExecutionAspect.register(this); 52 | JobProperties = this; 53 | } 54 | 55 | public PropertyResolver of(String jobName) { 56 | JobPropertyResolver jobPropertyResolver = resolvers.get(jobName); 57 | return jobPropertyResolver == null ? environment : jobPropertyResolver; 58 | } 59 | 60 | public void started(JobConfig jobConfig) { 61 | String jobName = jobConfig.getName(); 62 | JobPropertyResolver resolver = new JobPropertyResolver(jobConfig, environment); 63 | resolvers.put(jobName, resolver); 64 | log.info("Enabled {}", resolver); 65 | } 66 | 67 | @Override 68 | public void accept(JobExecution je) { 69 | if (!je.isRunning()) { 70 | JobPropertyResolver resolver = resolvers.remove(je.getJobInstance().getJobName()); 71 | if (resolver != null) 72 | log.info("Disabled {}", je.getJobInstance().getJobName()); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/tasklet/PropertyResolverConsumerTasklet.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.tasklet; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.batch.core.ExitStatus; 5 | import org.springframework.batch.core.Job; 6 | import org.springframework.batch.core.StepContribution; 7 | import org.springframework.batch.core.StepExecution; 8 | import org.springframework.batch.core.StepExecutionListener; 9 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 10 | import org.springframework.batch.core.scope.context.ChunkContext; 11 | import org.springframework.batch.core.step.tasklet.Tasklet; 12 | import org.springframework.batch.repeat.RepeatStatus; 13 | import org.springframework.core.env.AbstractEnvironment; 14 | import org.springframework.core.env.Environment; 15 | import org.springframework.core.env.MutablePropertySources; 16 | import org.springframework.core.env.PropertiesPropertySource; 17 | import org.springframework.core.env.PropertyResolver; 18 | import org.springframework.core.env.PropertySources; 19 | import org.springframework.core.env.PropertySourcesPropertyResolver; 20 | 21 | import java.util.Optional; 22 | import java.util.Properties; 23 | import java.util.function.Consumer; 24 | 25 | import static org.springframework.batch.repeat.RepeatStatus.FINISHED; 26 | 27 | @RequiredArgsConstructor 28 | public class PropertyResolverConsumerTasklet implements Tasklet, StepExecutionListener { 29 | private final Environment environment; 30 | private final Consumer propertyResolverConsumer; 31 | private StepExecution stepExecution; 32 | 33 | @Override 34 | public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { 35 | propertyResolverConsumer.accept(new PropertySourcesPropertyResolver(propertySources( 36 | stepExecution.getJobExecution().getJobConfigurationName(), 37 | stepExecution.getJobParameters().toProperties(), environment))); 38 | return FINISHED; 39 | } 40 | 41 | private PropertySources propertySources(String propertyName, Properties properties, Environment env) { 42 | MutablePropertySources propertySources = new MutablePropertySources(); 43 | if (properties != null) 44 | propertySources.addFirst(new PropertiesPropertySource(Optional.ofNullable(propertyName).orElse("jobConfig"), properties)); 45 | ((AbstractEnvironment) env).getPropertySources().forEach(propertySources::addLast); 46 | return propertySources; 47 | } 48 | 49 | @Override public void beforeStep(StepExecution stepExecution) { 50 | this.stepExecution = stepExecution; 51 | } 52 | 53 | @Override public ExitStatus afterStep(StepExecution stepExecution) { 54 | return null; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/tasklet/RunnableTasklet.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.tasklet; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.batch.core.StepContribution; 5 | import org.springframework.batch.core.scope.context.ChunkContext; 6 | import org.springframework.batch.core.step.tasklet.Tasklet; 7 | import org.springframework.batch.repeat.RepeatStatus; 8 | 9 | import static org.springframework.batch.repeat.RepeatStatus.FINISHED; 10 | 11 | @RequiredArgsConstructor 12 | public class RunnableTasklet implements Tasklet { 13 | private final Runnable runnable; 14 | 15 | @Override 16 | public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { 17 | runnable.run(); 18 | return FINISHED; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/core/tasklet/StepExecutionListenerTasklet.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core.tasklet; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.batch.core.ExitStatus; 5 | import org.springframework.batch.core.StepContribution; 6 | import org.springframework.batch.core.StepExecution; 7 | import org.springframework.batch.core.StepExecutionListener; 8 | import org.springframework.batch.core.scope.context.ChunkContext; 9 | import org.springframework.batch.core.step.tasklet.Tasklet; 10 | import org.springframework.batch.repeat.RepeatStatus; 11 | import org.springframework.core.env.AbstractEnvironment; 12 | import org.springframework.core.env.Environment; 13 | import org.springframework.core.env.MutablePropertySources; 14 | import org.springframework.core.env.PropertiesPropertySource; 15 | import org.springframework.core.env.PropertyResolver; 16 | import org.springframework.core.env.PropertySources; 17 | import org.springframework.core.env.PropertySourcesPropertyResolver; 18 | 19 | import java.util.Optional; 20 | import java.util.Properties; 21 | import java.util.function.Consumer; 22 | 23 | import static org.springframework.batch.repeat.RepeatStatus.FINISHED; 24 | 25 | @RequiredArgsConstructor 26 | public class StepExecutionListenerTasklet implements Tasklet, StepExecutionListener { 27 | private final Consumer stepExecutionConsumer; 28 | private StepExecution stepExecution; 29 | 30 | @Override 31 | public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { 32 | stepExecutionConsumer.accept(stepExecution); 33 | return FINISHED; 34 | } 35 | 36 | @Override public void beforeStep(StepExecution stepExecution) { 37 | this.stepExecution = stepExecution; 38 | } 39 | 40 | @Override public ExitStatus afterStep(StepExecution stepExecution) { 41 | return null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/quartz/AdHocScheduler.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.TriggerUtil; 4 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 5 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 6 | import com.github.chrisgleissner.springbatchrest.util.core.JobParamsDetail; 7 | 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.quartz.JobDetail; 10 | import org.quartz.Scheduler; 11 | import org.quartz.Trigger; 12 | import org.springframework.batch.core.Job; 13 | import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; 14 | import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.stereotype.Component; 17 | 18 | import static java.lang.String.format; 19 | import static org.quartz.JobBuilder.newJob; 20 | 21 | import java.util.Date; 22 | 23 | /** 24 | * Allows to schedule Spring Batch jobs via Quartz by using a 25 | * {@link #schedule(String, Job, String)} method rather than Spring wiring each 26 | * job. This allows for programmatic creation of multiple jobs at run-time. 27 | */ 28 | @Slf4j 29 | @Component 30 | public class AdHocScheduler { 31 | 32 | private final JobBuilder jobBuilder; 33 | private Scheduler scheduler; 34 | private JobBuilderFactory jobBuilderFactory; 35 | private StepBuilderFactory stepBuilderFactory; 36 | 37 | @Autowired 38 | public AdHocScheduler(JobBuilder jobBuilder, Scheduler scheduler, JobBuilderFactory jobBuilderFactory, 39 | StepBuilderFactory stepBuilderFactory) { 40 | this.jobBuilder = jobBuilder; 41 | this.scheduler = scheduler; 42 | this.jobBuilderFactory = jobBuilderFactory; 43 | this.stepBuilderFactory = stepBuilderFactory; 44 | } 45 | 46 | /** 47 | * Schedules a Spring Batch job via a future Date. Job referenced via 48 | * jobConfig's name must be a valid registered bean name for a Job object. 49 | */ 50 | public synchronized void schedule(JobConfig jobConfig, Date dateToRun) { 51 | log.debug("Scheduling job {} with custom Trigger", jobConfig.getName()); 52 | try { 53 | JobDetail jobDetail = this.jobDetailFor(jobConfig); 54 | Trigger trigger = TriggerUtil.triggerFor(dateToRun, jobConfig.getName()); 55 | scheduler.unscheduleJob(trigger.getKey()); 56 | scheduler.scheduleJob(jobDetail, trigger); 57 | log.info("Scheduled job {} with Date {}", jobConfig.getName(), dateToRun.toString()); 58 | } catch (Exception e) { 59 | throw new RuntimeException( 60 | format("Can't schedule job %s with date: %s", jobConfig.getName(), dateToRun.toString()), e); 61 | } 62 | } 63 | 64 | /** 65 | * Schedules a Spring Batch job via a Quartz cron expression. Uses the job name 66 | * of the provided job. 67 | */ 68 | public synchronized Job schedule(Job job, String cronExpression) { 69 | return this.schedule(job.getName(), job, cronExpression); 70 | } 71 | 72 | /** 73 | * Schedules a Spring Batch job via a Quartz cron expression. Also registers the 74 | * job with the specified jobName, rather than the job param's name 75 | */ 76 | public synchronized Job schedule(String jobName, Job job, String cronExpression) { 77 | log.debug("Scheduling job {} with CRON expression {}", jobName, cronExpression); 78 | try { 79 | jobBuilder.registerJob(job); 80 | JobDetail jobDetail = this.jobDetailFor(jobName); 81 | 82 | Trigger trigger = TriggerUtil.triggerFor(cronExpression, jobName); 83 | 84 | scheduler.unscheduleJob(trigger.getKey()); 85 | scheduler.scheduleJob(jobDetail, trigger); 86 | log.info("Scheduled job {} with CRON expression {}", jobName, cronExpression); 87 | } catch (Exception e) { 88 | throw new RuntimeException(format("Can't schedule job %s with cronExpression %s", jobName, cronExpression), 89 | e); 90 | } 91 | return job; 92 | } 93 | 94 | /** 95 | * Schedules a Spring Batch job via a Quartz cron expression. Job referenced via 96 | * jobConfig's name must be a valid registered bean name for a Job object. 97 | */ 98 | public synchronized void schedule(JobConfig jobConfig, String cronExpression) { 99 | log.debug("Scheduling job {} with CRON expression {}", jobConfig.getName(), cronExpression); 100 | try { 101 | JobDetail jobDetail = this.jobDetailFor(jobConfig); 102 | 103 | Trigger trigger = TriggerUtil.triggerFor(cronExpression, jobConfig.getName()); 104 | 105 | scheduler.unscheduleJob(trigger.getKey()); 106 | scheduler.scheduleJob(jobDetail, trigger); 107 | log.info("Scheduled job {} with CRON expression {}", jobConfig.getName(), cronExpression); 108 | } catch (Exception e) { 109 | throw new RuntimeException( 110 | format("Can't schedule job %s with cronExpression %s", jobConfig.getName(), cronExpression), e); 111 | } 112 | } 113 | 114 | /** 115 | * Starts the Quartz scheduler unless it is already started. Necessary for any 116 | * scheduled jobs to start. 117 | */ 118 | public synchronized void start() { 119 | try { 120 | if (!scheduler.isStarted()) { 121 | scheduler.start(); 122 | log.info("Started Quartz scheduler"); 123 | } else { 124 | log.warn("Quartz scheduler already started"); 125 | } 126 | } catch (Exception e) { 127 | throw new RuntimeException("Could not start Quartz scheduler", e); 128 | } 129 | } 130 | 131 | public synchronized void pause() { 132 | try { 133 | if (scheduler.isStarted() && !scheduler.isInStandbyMode()) { 134 | scheduler.pauseAll(); 135 | log.info("Paused Quartz scheduler"); 136 | } 137 | } catch (Exception e) { 138 | throw new RuntimeException("Could not pause Quartz scheduler", e); 139 | } 140 | } 141 | 142 | public synchronized void resume() { 143 | try { 144 | if (scheduler.isStarted() && scheduler.isInStandbyMode()) { 145 | scheduler.resumeAll(); 146 | log.info("Resumed Quartz scheduler"); 147 | } 148 | } catch (Exception e) { 149 | throw new RuntimeException("Could not resumse Quartz scheduler", e); 150 | } 151 | } 152 | 153 | public synchronized void stop() { 154 | try { 155 | if (scheduler.isStarted() && !scheduler.isShutdown()) { 156 | scheduler.shutdown(); 157 | log.info("Stopped Quartz scheduler"); 158 | } 159 | } catch (Exception e) { 160 | throw new RuntimeException("Could not stop Quartz scheduler", e); 161 | } 162 | } 163 | 164 | public JobBuilderFactory jobs() { 165 | return jobBuilderFactory; 166 | } 167 | 168 | public StepBuilderFactory steps() { 169 | return stepBuilderFactory; 170 | } 171 | 172 | // =============== 173 | // Private Helpers 174 | // =============== 175 | 176 | private JobDetail jobDetailFor(String jobName) { 177 | JobConfig config = new JobConfig(); 178 | config.setName(jobName); 179 | return this.jobDetailFor(config); 180 | } 181 | 182 | private JobDetail jobDetailFor(JobConfig jobConfig) { 183 | JobDetail jobDetail = newJob(QuartzJobLauncher.class) 184 | .withIdentity(jobConfig.getName(), TriggerUtil.QUARTZ_DEFAULT_GROUP) 185 | .usingJobData(QuartzJobLauncher.JOB_NAME, jobConfig.getName()).build(); 186 | 187 | if (jobConfig.getProperties() != null) { 188 | jobDetail = new JobParamsDetail(jobDetail, jobConfig.getProperties()); 189 | } 190 | return jobDetail; 191 | } 192 | } -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/quartz/QuartzJobLauncher.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.quartz.JobDataMap; 5 | import org.quartz.JobDetail; 6 | import org.quartz.JobExecutionContext; 7 | import org.springframework.batch.core.Job; 8 | import org.springframework.batch.core.JobExecution; 9 | import org.springframework.batch.core.JobParameters; 10 | import org.springframework.batch.core.configuration.JobLocator; 11 | import org.springframework.batch.core.launch.JobLauncher; 12 | import org.springframework.scheduling.quartz.QuartzJobBean; 13 | 14 | import com.github.chrisgleissner.springbatchrest.util.JobParamUtil; 15 | import com.github.chrisgleissner.springbatchrest.util.core.JobParamsDetail; 16 | 17 | @Slf4j 18 | public class QuartzJobLauncher extends QuartzJobBean { 19 | 20 | public static final String JOB_NAME = "jobName"; 21 | public static final String JOB_LOCATOR = "jobLocator"; 22 | public static final String JOB_LAUNCHER = "jobLauncher"; 23 | 24 | @Override 25 | protected void executeInternal(JobExecutionContext context) { 26 | String jobName = null; 27 | try { 28 | 29 | JobDetail jobDetail = context.getJobDetail(); 30 | JobParameters jobParams = new JobParameters(); 31 | if (jobDetail instanceof JobParamsDetail) { 32 | jobParams = JobParamUtil.convertRawToJobParams(((JobParamsDetail) jobDetail).getRawJobParameters()); 33 | } 34 | 35 | JobDataMap dataMap = context.getJobDetail().getJobDataMap(); 36 | jobName = dataMap.getString(JOB_NAME); 37 | 38 | JobLocator jobLocator = (JobLocator) context.getScheduler().getContext().get(JOB_LOCATOR); 39 | JobLauncher jobLauncher = (JobLauncher) context.getScheduler().getContext().get(JOB_LAUNCHER); 40 | 41 | Job job = jobLocator.getJob(jobName); 42 | log.info("Starting {}", job.getName()); 43 | JobExecution jobExecution = jobLauncher.run(job, jobParams); 44 | log.info("{}_{} was completed successfully", job.getName(), jobExecution.getId()); 45 | } catch (Exception e) { 46 | log.error("Job {} failed", jobName, e); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/quartz/config/AdHocSchedulerConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz.config; 2 | 3 | import com.github.chrisgleissner.springbatchrest.util.core.config.AdHocBatchConfig; 4 | import org.springframework.context.annotation.ComponentScan; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.context.annotation.Import; 7 | 8 | 9 | @Configuration 10 | @ComponentScan(basePackages = { 11 | "com.github.chrisgleissner.springbatchrest.util.quartz" 12 | }) 13 | @Import({AdHocBatchConfig.class, SchedulerConfig.class}) 14 | public class AdHocSchedulerConfig { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /util/src/main/java/com/github/chrisgleissner/springbatchrest/util/quartz/config/SchedulerConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz.config; 2 | 3 | import static com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher.JOB_LAUNCHER; 4 | import static com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher.JOB_LOCATOR; 5 | 6 | import org.quartz.Scheduler; 7 | import org.quartz.SchedulerException; 8 | import org.quartz.impl.StdSchedulerFactory; 9 | import org.springframework.batch.core.configuration.JobLocator; 10 | import org.springframework.batch.core.launch.JobLauncher; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | 14 | @Configuration 15 | public class SchedulerConfig { 16 | @Bean 17 | public Scheduler scheduler(JobLocator jobLocator, JobLauncher jobLauncher) throws SchedulerException { 18 | Scheduler scheduler = new StdSchedulerFactory().getScheduler(); 19 | scheduler.getContext().put(JOB_LOCATOR, jobLocator); 20 | scheduler.getContext().put(JOB_LAUNCHER, jobLauncher); 21 | return scheduler; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/DateUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util; 2 | 3 | import java.util.Date; 4 | import java.time.Instant; 5 | import java.time.LocalDateTime; 6 | import java.time.OffsetDateTime; 7 | 8 | import org.junit.jupiter.api.Assertions; 9 | import org.junit.jupiter.api.Test; 10 | 11 | public class DateUtilTest { 12 | 13 | @Test 14 | public void testLocalDateConversion() { 15 | Date now = new Date(); 16 | LocalDateTime localDateTime = DateUtil.localDateTime(now); 17 | Instant ldtInstant = localDateTime.toInstant(OffsetDateTime.now().getOffset()); 18 | 19 | Assertions.assertEquals(ldtInstant, now.toInstant()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/JobParamUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util; 2 | 3 | import java.util.Date; 4 | 5 | import org.junit.jupiter.api.Assertions; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.batch.core.JobParameter; 8 | 9 | public class JobParamUtilTest { 10 | 11 | @Test 12 | public void testObjectConversionHappy() { 13 | Date date = new Date(); 14 | JobParameter dateParam = JobParamUtil.createJobParameter(date); 15 | Assertions.assertEquals(dateParam.getValue(), date); 16 | 17 | Long longVar = new Long(1234); 18 | JobParameter longParam = JobParamUtil.createJobParameter(longVar); 19 | Assertions.assertEquals(longParam.getValue(), longVar); 20 | 21 | Double doubleVar = new Double(123.123); 22 | JobParameter doubleParam = JobParamUtil.createJobParameter(doubleVar); 23 | Assertions.assertEquals(doubleParam.getValue(), doubleVar); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/core/AdHocStarterTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.junit.Test; 5 | import org.junit.runner.RunWith; 6 | import org.springframework.batch.core.Job; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.core.env.Environment; 9 | import org.springframework.test.context.ContextConfiguration; 10 | import org.springframework.test.context.TestPropertySource; 11 | import org.springframework.test.context.junit4.SpringRunner; 12 | 13 | import com.github.chrisgleissner.springbatchrest.util.core.config.AdHocBatchConfig; 14 | 15 | import java.util.HashMap; 16 | import java.util.Optional; 17 | import java.util.Set; 18 | import java.util.concurrent.ConcurrentSkipListSet; 19 | import java.util.concurrent.CountDownLatch; 20 | 21 | import static com.github.chrisgleissner.springbatchrest.util.core.property.JobPropertyResolvers.JobProperties; 22 | import static java.util.concurrent.TimeUnit.SECONDS; 23 | import static org.assertj.core.api.Assertions.assertThat; 24 | 25 | @RunWith(SpringRunner.class) 26 | @ContextConfiguration(classes = AdHocBatchConfig.class) 27 | @TestPropertySource(properties = "foo=bar") 28 | @Slf4j 29 | public class AdHocStarterTest { 30 | private static final String JOB_NAME = "AdHocStarterTest"; 31 | private static final int NUMBER_OF_ITERATIONS = 3; 32 | private static final int NUMBER_OF_JOBS_PER_ITERATION = 2; 33 | public static final String PROPERTY_NAME = "foo"; 34 | 35 | @Autowired Environment env; 36 | @Autowired private AdHocStarter starter; 37 | @Autowired private JobBuilder jobBuilder; 38 | 39 | @Test 40 | public void startAsynchPropertyResolverConsumerJobWithPropertyMap() throws InterruptedException { 41 | assertPropertyResolution((propertyValue, readPropertyValues, latch) 42 | -> starter.start(createJobFromPropertyResolverConsumer(readPropertyValues, latch), true, propertyMap(propertyValue))); 43 | } 44 | 45 | @Test 46 | public void startAsynchPropertyResolverConsumerJobWithJobConfig() throws InterruptedException { 47 | assertPropertyResolution((propertyValue, readPropertyValues, latch) -> { 48 | createJobFromPropertyResolverConsumer(readPropertyValues, latch); 49 | starter.start(JobConfig.builder() 50 | .name(JOB_NAME) 51 | .property(PROPERTY_NAME, "" + propertyValue) 52 | .asynchronous(true).build()); 53 | }); 54 | } 55 | 56 | @Test 57 | public void startSynchRunnable() throws InterruptedException { 58 | assertPropertyResolution((propertyValue, readPropertyValues, latch) 59 | -> starter.start(createJobFromRunnable(readPropertyValues, latch), false, propertyMap(propertyValue))); 60 | } 61 | 62 | @Test 63 | public void startAsynchStepExecutionConsumer() throws InterruptedException { 64 | assertPropertyResolution((propertyValue, readPropertyValues, latch) 65 | -> starter.start(createJobFromStepExecutionConsumer(readPropertyValues, latch), true, propertyMap(propertyValue))); 66 | } 67 | 68 | private static HashMap propertyMap(int propertyValue) { 69 | final HashMap propMap = new HashMap<>(); 70 | propMap.put(PROPERTY_NAME, propertyValue); 71 | return propMap; 72 | } 73 | 74 | private Job createJobFromRunnable(Set readPropertyValues, CountDownLatch latch) { 75 | return jobBuilder.createJob(JOB_NAME, () -> { 76 | readPropertyValues.add(JobProperties.of(JOB_NAME).getProperty(PROPERTY_NAME)); 77 | latch.countDown(); 78 | }); 79 | } 80 | 81 | private Job createJobFromPropertyResolverConsumer(Set readPropertyValues, CountDownLatch latch) { 82 | return jobBuilder.createJob(JOB_NAME, (propertyResolver) -> { 83 | readPropertyValues.add(propertyResolver.getProperty(PROPERTY_NAME)); 84 | latch.countDown(); 85 | }); 86 | } 87 | 88 | private Job createJobFromStepExecutionConsumer(Set readPropertyValues, CountDownLatch latch) { 89 | return jobBuilder.createJobFromStepExecutionConsumer(JOB_NAME, (stepExecution) -> { 90 | String propertyValue = Optional.ofNullable(stepExecution.getJobParameters().getString(PROPERTY_NAME)) 91 | .orElseGet(() -> env.getProperty(PROPERTY_NAME)); 92 | readPropertyValues.add(propertyValue); 93 | latch.countDown(); 94 | }); 95 | } 96 | 97 | private void assertPropertyResolution(JobStarter jobStarter) throws InterruptedException { 98 | // Check that asynchronous execution with property overrides works 99 | final Set readPropertyValues = new ConcurrentSkipListSet<>(); 100 | int propertyValue = 0; 101 | for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) { 102 | final CountDownLatch latch = new CountDownLatch(NUMBER_OF_JOBS_PER_ITERATION); 103 | for (int j = 0; j < NUMBER_OF_JOBS_PER_ITERATION; j++) { 104 | jobStarter.startJob(propertyValue++, readPropertyValues, latch); 105 | } 106 | assertThat(latch.await(3, SECONDS)).isTrue(); 107 | assertThat(readPropertyValues).hasSize(propertyValue); 108 | } 109 | Thread.sleep(100); // Job completion takes place after latch is counted down 110 | assertThat(JobProperties.of(JOB_NAME).getProperty(PROPERTY_NAME)).isEqualTo("bar"); 111 | 112 | // Check that synchronous execution without overrides works 113 | starter.start(JobConfig.builder() 114 | .name(JOB_NAME) 115 | .asynchronous(false) 116 | .build()); 117 | assertThat(readPropertyValues).contains("bar"); 118 | } 119 | 120 | private interface JobStarter { 121 | void startJob(int propertyValue, Set propertyValues, CountDownLatch latch); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/core/CacheItemWriter.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import org.springframework.batch.item.ItemWriter; 4 | 5 | import java.util.LinkedList; 6 | import java.util.List; 7 | 8 | import static java.util.Collections.synchronizedList; 9 | 10 | public class CacheItemWriter implements ItemWriter { 11 | 12 | private List items = synchronizedList(new LinkedList<>()); 13 | 14 | @Override 15 | public void write(List items) { 16 | this.items.addAll(items); 17 | } 18 | 19 | public List getItems() { 20 | return items; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/core/JobCompletionNotificationListener.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import org.springframework.batch.core.BatchStatus; 4 | import org.springframework.batch.core.JobExecution; 5 | import org.springframework.batch.core.listener.JobExecutionListenerSupport; 6 | 7 | import java.util.concurrent.Semaphore; 8 | import java.util.concurrent.TimeUnit; 9 | 10 | public class JobCompletionNotificationListener extends JobExecutionListenerSupport { 11 | 12 | public Semaphore semaphore = new Semaphore(0); 13 | 14 | public void awaitCompletionOfJobs(int numberOfJobs, long maxWaitInMillis) throws InterruptedException { 15 | if (!semaphore.tryAcquire(numberOfJobs, maxWaitInMillis, TimeUnit.MILLISECONDS)) { 16 | throw new RuntimeException("Not all jobs have completed. Not completed: " + semaphore.availablePermits()); 17 | } 18 | } 19 | 20 | @Override 21 | public void afterJob(JobExecution jobExecution) { 22 | if (jobExecution.getStatus() == BatchStatus.COMPLETED) { 23 | semaphore.release(); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/core/Person.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.core; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import lombok.Setter; 7 | 8 | @Data 9 | @Setter 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class Person { 13 | private String firstName; 14 | private String lastName; 15 | } -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/quartz/AdHocSchedulerParamsTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz; 2 | 3 | import org.junit.Test; 4 | import org.junit.jupiter.api.AfterAll; 5 | import org.junit.jupiter.api.Assertions; 6 | import org.junit.runner.RunWith; 7 | import org.mockito.ArgumentCaptor; 8 | import org.quartz.Scheduler; 9 | import org.quartz.SchedulerException; 10 | import org.quartz.impl.StdSchedulerFactory; 11 | import org.springframework.batch.core.Job; 12 | import org.springframework.batch.core.JobExecution; 13 | import org.springframework.batch.core.JobParameters; 14 | import org.springframework.batch.core.JobParametersInvalidException; 15 | import org.springframework.batch.core.configuration.JobLocator; 16 | import org.springframework.batch.core.launch.JobLauncher; 17 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 18 | import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; 19 | import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; 20 | import org.springframework.batch.core.repository.JobRestartException; 21 | import org.springframework.batch.repeat.RepeatStatus; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.boot.test.mock.mockito.MockBean; 24 | import org.springframework.context.annotation.Bean; 25 | import org.springframework.context.annotation.Primary; 26 | import org.springframework.test.annotation.DirtiesContext; 27 | import org.springframework.test.annotation.DirtiesContext.MethodMode; 28 | import org.springframework.test.context.ContextConfiguration; 29 | import org.springframework.test.context.junit4.SpringRunner; 30 | 31 | import com.github.chrisgleissner.springbatchrest.util.JobParamUtil; 32 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 33 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 34 | import com.github.chrisgleissner.springbatchrest.util.quartz.config.AdHocSchedulerConfig; 35 | import com.github.chrisgleissner.springbatchrest.util.quartz.AdHocSchedulerParamsTest.CustomContextConfiguration; 36 | 37 | import static org.mockito.Mockito.timeout; 38 | import static org.mockito.Mockito.verify; 39 | import static org.mockito.Mockito.when; 40 | 41 | import java.time.Instant; 42 | import java.util.Date; 43 | import java.util.HashMap; 44 | import java.util.List; 45 | import java.util.Map; 46 | import java.util.Random; 47 | 48 | import static com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher.JOB_LAUNCHER; 49 | import static com.github.chrisgleissner.springbatchrest.util.quartz.QuartzJobLauncher.JOB_LOCATOR; 50 | 51 | /** 52 | * Tests the ad-hoc Quartz scheduling of Spring Batch jobs with parameters 53 | */ 54 | @RunWith(SpringRunner.class) 55 | @ContextConfiguration( 56 | classes = { 57 | AdHocSchedulerConfig.class, 58 | CustomContextConfiguration.class 59 | }, 60 | name = "mockJobLauncherContext") 61 | public class AdHocSchedulerParamsTest { 62 | 63 | private static final String TRIGGER_EVERY_SECOND = "0/1 * * * * ?"; 64 | 65 | @Autowired 66 | private AdHocScheduler scheduler; 67 | 68 | @Autowired 69 | private JobLauncher jobLauncher; 70 | 71 | @Autowired 72 | private JobBuilder jobBuilder; 73 | 74 | @AfterAll 75 | public void afterAll() { 76 | scheduler.stop(); 77 | } 78 | 79 | // Override the scheduler's job launcher with a mock so we can verify 80 | // that calls to it contain parameters 81 | protected static class CustomContextConfiguration { 82 | 83 | @MockBean 84 | public JobLauncher mockJobLauncher; 85 | 86 | @Bean 87 | @Primary 88 | public Scheduler scheduler(JobLocator jobLocator) throws SchedulerException { 89 | Scheduler scheduler = new StdSchedulerFactory().getScheduler(); 90 | scheduler.getContext().remove(JOB_LOCATOR); 91 | scheduler.getContext().put(JOB_LOCATOR, jobLocator); 92 | scheduler.getContext().remove(JOB_LAUNCHER); 93 | scheduler.getContext().put(JOB_LAUNCHER, mockJobLauncher); 94 | return scheduler; 95 | } 96 | } 97 | 98 | @Test 99 | @DirtiesContext(methodMode = MethodMode.BEFORE_METHOD) 100 | public void paramsAddedToScheduledJobWorks() 101 | throws InterruptedException, JobExecutionAlreadyRunningException, JobRestartException, 102 | JobInstanceAlreadyCompleteException, JobParametersInvalidException, SchedulerException { 103 | 104 | Job job1 = job("j1"); 105 | Job job2 = job("j2"); 106 | 107 | jobBuilder.registerJob(job1); 108 | jobBuilder.registerJob(job2); 109 | 110 | Map params = new HashMap(); 111 | params.put("testParamKey", "testParamValue"); 112 | 113 | JobParameters expectedParams = JobParamUtil.convertRawToJobParams(params); 114 | 115 | JobConfig job1Config = JobConfig.builder().name("j1").properties(params).build(); 116 | JobConfig job2Config = JobConfig.builder().name("j2").properties(params).build(); 117 | 118 | Date now = Date.from(Instant.now().plusMillis(2000)); 119 | 120 | scheduler.start(); 121 | 122 | when(jobLauncher.run(job1, expectedParams)) 123 | .thenReturn(new JobExecution(new Random().nextLong(), expectedParams)); 124 | 125 | when(jobLauncher.run(job2, expectedParams)) 126 | .thenReturn(new JobExecution(new Random().nextLong(), expectedParams)); 127 | 128 | scheduler.schedule(job1Config, now); 129 | scheduler.schedule(job2Config, TRIGGER_EVERY_SECOND); 130 | 131 | ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(Job.class); 132 | ArgumentCaptor jobParamCaptor = ArgumentCaptor.forClass(JobParameters.class); 133 | verify(jobLauncher, timeout(8000).times(2)).run(jobCaptor.capture(), jobParamCaptor.capture()); 134 | 135 | List paramsListAfterCall = jobParamCaptor.getAllValues(); 136 | 137 | Assertions.assertEquals(paramsListAfterCall.size(), 2); 138 | 139 | for (JobParameters jobParams : paramsListAfterCall) { 140 | Assertions.assertEquals(jobParams.getString("testParamKey"), "testParamValue"); 141 | } 142 | 143 | scheduler.pause(); 144 | } 145 | 146 | private Job job(String jobName) { 147 | return scheduler.jobs().get(jobName).incrementer(new RunIdIncrementer()) // adds unique parameter on each run so 148 | // that createJob can be rerun 149 | .start(scheduler.steps().get("step").tasklet((contribution, chunkContext) -> { 150 | return RepeatStatus.FINISHED; 151 | }).allowStartIfComplete(true).build()).build(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /util/src/test/java/com/github/chrisgleissner/springbatchrest/util/quartz/AdHocSchedulerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.chrisgleissner.springbatchrest.util.quartz; 2 | 3 | import org.junit.Test; 4 | import org.junit.jupiter.api.AfterAll; 5 | import org.junit.jupiter.api.Assertions; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.runner.RunWith; 8 | import org.springframework.batch.core.Job; 9 | import org.springframework.batch.core.launch.support.RunIdIncrementer; 10 | import org.springframework.batch.repeat.RepeatStatus; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.test.context.ContextConfiguration; 13 | import org.springframework.test.context.junit4.SpringRunner; 14 | 15 | import com.github.chrisgleissner.springbatchrest.util.core.JobBuilder; 16 | import com.github.chrisgleissner.springbatchrest.util.core.JobConfig; 17 | import com.github.chrisgleissner.springbatchrest.util.quartz.config.AdHocSchedulerConfig; 18 | 19 | import java.util.concurrent.CountDownLatch; 20 | 21 | import static java.util.concurrent.TimeUnit.SECONDS; 22 | 23 | import java.time.Instant; 24 | import java.util.Date; 25 | 26 | /** 27 | * Tests the ad-hoc Quartz scheduling of Spring Batch jobs, allowing for 28 | * programmatic scheduling after Spring wiring. 29 | */ 30 | @RunWith(SpringRunner.class) 31 | @ContextConfiguration(classes = AdHocSchedulerConfig.class) 32 | public class AdHocSchedulerTest { 33 | 34 | private static final String TRIGGER_EVERY_SECOND = "0/1 * * * * ?"; 35 | private static final int NUMBER_OF_EXECUTIONS_PER_JOB = 2; 36 | 37 | @Autowired 38 | private AdHocScheduler scheduler; 39 | 40 | @Autowired 41 | private JobBuilder jobBuilder; 42 | 43 | private CountDownLatch latch1 = new CountDownLatch(NUMBER_OF_EXECUTIONS_PER_JOB); 44 | private CountDownLatch latch2 = new CountDownLatch(NUMBER_OF_EXECUTIONS_PER_JOB); 45 | 46 | @BeforeEach 47 | public void before() { 48 | latch1 = new CountDownLatch(NUMBER_OF_EXECUTIONS_PER_JOB); 49 | latch2 = new CountDownLatch(NUMBER_OF_EXECUTIONS_PER_JOB); 50 | } 51 | 52 | @AfterAll 53 | public void afterAll() { 54 | scheduler.stop(); 55 | } 56 | 57 | @Test 58 | public void scheduleCronWithJobReferenceWorks() throws InterruptedException { 59 | scheduler.schedule("j1", job("j1", latch1), TRIGGER_EVERY_SECOND); 60 | scheduler.schedule("j2", job("j2", latch2), TRIGGER_EVERY_SECOND); 61 | scheduler.start(); 62 | 63 | latch1.await(4, SECONDS); 64 | latch2.await(4, SECONDS); 65 | scheduler.pause(); 66 | } 67 | 68 | @Test 69 | public void scheduleWithJobConfigAndDateWorks() throws InterruptedException { 70 | Job job1 = job("j1", latch1); 71 | Job job2 = job("j2", latch2); 72 | 73 | jobBuilder.registerJob(job1); 74 | jobBuilder.registerJob(job2); 75 | 76 | JobConfig job1Config = JobConfig.builder().name("j1").build(); 77 | JobConfig job2Config = JobConfig.builder().name("j2").build(); 78 | 79 | Date oneSecondFromNow = Date.from(Instant.now().plusMillis(1000)); 80 | 81 | scheduler.schedule(job1Config, oneSecondFromNow); 82 | scheduler.schedule(job2Config, oneSecondFromNow); 83 | scheduler.start(); 84 | 85 | latch1.await(4, SECONDS); 86 | latch2.await(4, SECONDS); 87 | scheduler.pause(); 88 | } 89 | 90 | @Test 91 | public void scheduleWithJobConfigAndCronWorks() throws InterruptedException { 92 | Job job1 = job("j1", latch1); 93 | Job job2 = job("j2", latch2); 94 | 95 | jobBuilder.registerJob(job1); 96 | jobBuilder.registerJob(job2); 97 | 98 | JobConfig job2Config = JobConfig.builder().name("j2").build(); 99 | 100 | scheduler.schedule("j1", job1, TRIGGER_EVERY_SECOND); 101 | scheduler.schedule(job2Config, TRIGGER_EVERY_SECOND); 102 | scheduler.start(); 103 | 104 | latch1.await(4, SECONDS); 105 | latch2.await(4, SECONDS); 106 | scheduler.pause(); 107 | } 108 | 109 | @Test 110 | public void happyCaseSchedulerStartPauseResumeNoThrow() { 111 | Assertions.assertDoesNotThrow(() -> { 112 | scheduler.start(); 113 | }); 114 | Assertions.assertDoesNotThrow(() -> { 115 | scheduler.pause(); 116 | }); 117 | Assertions.assertDoesNotThrow(() -> { 118 | scheduler.resume(); 119 | }); 120 | // Future - handle & test the case where the scheduler has been shutdown and 121 | // needs re-initialization 122 | // https://stackoverflow.com/questions/15020625/quartz-how-to-shutdown-and-restart-the-scheduler 123 | } 124 | 125 | @Test 126 | public void exceptionForBadJobConfigDate() { 127 | Assertions.assertThrows(RuntimeException.class, () -> { 128 | scheduler.schedule(JobConfig.builder().name(null).asynchronous(false).build(), new Date()); 129 | }); 130 | } 131 | 132 | @Test 133 | public void exceptionForBadJobConfigCron() { 134 | Assertions.assertThrows(RuntimeException.class, () -> { 135 | scheduler.schedule(JobConfig.builder().name(null).asynchronous(false).build(), TRIGGER_EVERY_SECOND); 136 | }); 137 | } 138 | 139 | @Test 140 | public void exceptionForBadParamsCron() { 141 | Assertions.assertThrows(RuntimeException.class, () -> { 142 | scheduler.schedule(null, null, TRIGGER_EVERY_SECOND); 143 | }); 144 | } 145 | 146 | private Job job(String jobName, CountDownLatch latch) { 147 | return scheduler.jobs().get(jobName).incrementer(new RunIdIncrementer()) // adds unique parameter on each run so 148 | // that createJob can be rerun 149 | .start(scheduler.steps().get("step").tasklet((contribution, chunkContext) -> { 150 | latch.countDown(); 151 | return RepeatStatus.FINISHED; 152 | }).allowStartIfComplete(true).build()).build(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /util/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.batch.job.enabled=false 2 | server.port=9090 -------------------------------------------------------------------------------- /util/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------