├── .github └── workflows │ └── maven.yml ├── .gitignore ├── LICENSE ├── README.md ├── customer-service ├── Dockerfile ├── pom.xml └── src │ └── main │ ├── java │ └── gr │ │ └── kmandalas │ │ └── service │ │ └── customer │ │ ├── CustomerApplication.java │ │ ├── config │ │ └── FlywayConfiguration.java │ │ ├── controller │ │ └── CustomerController.java │ │ ├── dto │ │ ├── CustomerDTO.java │ │ └── CustomerForm.java │ │ ├── entity │ │ └── Customer.java │ │ ├── exception │ │ └── CustomerNotFoundException.java │ │ ├── repository │ │ └── CustomerRepository.java │ │ └── service │ │ └── CustomerService.java │ └── resources │ ├── application.yml │ ├── db │ └── migration │ │ ├── V0_1_0__initialize.sql │ │ └── V0_1_1__insert_test_data.sql │ └── logback-spring.xml ├── diagrams ├── WebClientShowcase.png ├── consul.png ├── flux-merge.png ├── jaeger-dep-graph.png ├── jaeger-home.png └── jaeger-trace.png ├── docker-compose.yml ├── gateway-service ├── Dockerfile ├── pom.xml └── src │ └── main │ ├── java │ └── gr │ │ └── kmandalas │ │ └── services │ │ └── gateway │ │ └── GatewayApplication.java │ └── resources │ └── application.yml ├── notification-service ├── Dockerfile ├── pom.xml └── src │ └── main │ ├── java │ └── gr │ │ └── kmandalas │ │ └── service │ │ └── notification │ │ ├── NotificationApplication.java │ │ ├── controller │ │ └── NotificationController.java │ │ ├── dto │ │ ├── NotificationRequestForm.java │ │ └── NotificationResultDTO.java │ │ └── enums │ │ └── Channel.java │ └── resources │ ├── application.yml │ └── logback-spring.xml ├── number-information-service ├── Dockerfile ├── pom.xml └── src │ └── main │ ├── java │ └── gr │ │ └── kmandalas │ │ └── service │ │ └── numberinformation │ │ ├── NumberInformationService.java │ │ ├── controller │ │ └── NumberInformationController.java │ │ ├── dto │ │ ├── NotificationRequestForm.java │ │ └── NotificationResultDTO.java │ │ └── enums │ │ └── MsisdnStatus.java │ └── resources │ ├── application.yml │ └── logback-spring.xml ├── otp-service ├── Dockerfile ├── pom.xml └── src │ ├── main │ ├── java │ │ └── gr │ │ │ └── kmandalas │ │ │ └── service │ │ │ └── otp │ │ │ ├── OtpApplication.java │ │ │ ├── advice │ │ │ └── OTPControllerAdvice.java │ │ │ ├── config │ │ │ ├── FlywayConfiguration.java │ │ │ └── WebClientConfig.java │ │ │ ├── controller │ │ │ └── OTPController.java │ │ │ ├── dto │ │ │ ├── CustomerDTO.java │ │ │ ├── NotificationRequestForm.java │ │ │ ├── NotificationResultDTO.java │ │ │ └── SendForm.java │ │ │ ├── entity │ │ │ ├── Application.java │ │ │ └── OTP.java │ │ │ ├── enumeration │ │ │ ├── Channel.java │ │ │ ├── FaultReason.java │ │ │ └── OTPStatus.java │ │ │ ├── exception │ │ │ └── OTPException.java │ │ │ ├── repository │ │ │ ├── ApplicationRepository.java │ │ │ └── OTPRepository.java │ │ │ └── service │ │ │ └── OTPService.java │ └── resources │ │ ├── application.yml │ │ ├── db │ │ └── migration │ │ │ ├── V0_1_0__create_otp_table.sql │ │ │ ├── V0_1_1__create_application_table.sql │ │ │ └── V0_1_2__insert_test_data.sql │ │ └── logback-spring.xml │ └── test │ ├── java │ └── gr │ │ └── kmandalas │ │ └── service │ │ └── otp │ │ ├── OTPControllerIntegrationTests.java │ │ └── util │ │ └── PostgresContainer.java │ └── resources │ ├── application.yml │ ├── bootstrap.yml │ └── logback-test.xml └── pom.xml /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v1 21 | with: 22 | java-version: 11 23 | - name: Build with Maven 24 | run: mvn -B package --file pom.xml 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | 25 | **/*.iml 26 | **/*.patch 27 | **/target/ 28 | /.idea 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyriakos Mandalas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Java CI with Maven](https://github.com/kmandalas/webclient-showcase/workflows/Java%20CI%20with%20Maven/badge.svg) 2 | 3 | # webclient-showcase 4 | This demo project aims to be an introduction to developing Reactive Microservices based on the Spring framework. 5 | For a complete guide check our related article on Dzone here: https://dzone.com/articles/spring-reactive-microservices-a-showcase 6 | 7 | We are going to implement a simplified One Time Password (OTP) service, offering the following capabilities: 8 | 9 | * Generate OTP 10 | * Validate (use) OTP 11 | * Resend OTP 12 | * Get OTP status 13 | * Get all OTPs of a given number 14 | 15 | Our application will consist of the following microservices: 16 | 17 | * **otp-service:** which will provide the functionality above by orchestrating calls to local and remote services 18 | * **customer-service:** will keep a catalogue of registered users to our service with information like: account id, MSISDN, e-mail etc. 19 | 20 | A number of remote (external) services will be invoked. We assume that our application is authorized to use them will access them via their REST API. 21 | Of course these will be mocked for simplicity. These "3rd-party" services are: 22 | 23 | * **number-information:** takes a phone number as input and verifies that it belongs to a Telecoms operator and is currently active 24 | * **notification-service:** delivers the generated OTPs to the designated number or channel (phone, e-mail, messenger etc.) 25 | 26 | 27 | ### How to run 28 | 29 | In order to build and test the application, the prerequisites are: 30 | * Java 11 and above 31 | * Maven 32 | * Docker (because we use TestContainers during our Integration tests) 33 | 34 | Then simply execute a `mvn clean verify` 35 | 36 | The easiest way is to run the microservices using Docker and Docker Compose: 37 | 38 | > docker-compose up --build 39 | 40 | When the containers are up and running, you can visit consul's UI to see the active services: 41 | 42 | > http://localhost:8500/ui/dc1/services 43 | 44 | Below you may find `curl` commands for invoking the various endpoints via our API Gateway: 45 | 46 | **Generate OTP** 47 | ``` 48 | curl --location --request POST 'localhost:8000/otp-service/v1/otp' \ 49 | --header 'Content-Type: application/json' \ 50 | --data-raw '{ 51 | "msisdn": "00306933177321" 52 | }' 53 | ``` 54 | 55 | **Validate OTP** 56 | ``` 57 | curl --location --request POST 'http://localhost:8000/otp-service/v1/otp/36/validate?pin=356775' \ 58 | ``` 59 | 60 | **Resend OTP** 61 | ``` 62 | curl --location --request POST 'localhost:8000/otp-service/v1/otp/2?via=AUTO,EMAIL,VOICE&mail=john.doe@gmail.com' \ 63 | --header 'Content-Type: application/json' \ 64 | ``` 65 | 66 | **Get All OTPs** 67 | ``` 68 | curl --location --request GET 'localhost:8000/otp-service/v1/otp?number=00306933177321' 69 | ``` 70 | 71 | **OTP Status** 72 | ``` 73 | curl --location --request GET 'localhost:8000/otp-service/v1/otp/1' 74 | ``` 75 | 76 | ### Things covered 77 | 78 | - [x] WebClient simple usage 79 | - [x] Parallel calls to the same endpoint 80 | - [x] Parallel calls to the different endpoint 81 | - [x] .zip 82 | - [x] .zipWhen 83 | - [x] .zipDelayError 84 | - [ ] .doOnNext 85 | - [x] .doOnSuccess VS .doOnError 86 | - [x] Chaining of calls (Sequential execution) 87 | - [x] Service-to-service communication 88 | - [x] Database interaction (r2dbc/postgresql) 89 | -------------------------------------------------------------------------------- /customer-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:13-alpine 2 | VOLUME /tmp 3 | ADD target/customer-service.jar app.jar 4 | ENV JAVA_OPTS="-Xms64m -Xmx128m --enable-preview" 5 | ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ] -------------------------------------------------------------------------------- /customer-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | gr.kmandalas.services 9 | spring-webclient-showcase 10 | 1.0 11 | 12 | 13 | customer-service 14 | jar 15 | 16 | 17 | 1.14.2 18 | 6.5.4 19 | 2.23.2 20 | 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-actuator 26 | 27 | 28 | 29 | org.springframework.cloud 30 | spring-cloud-starter-zipkin 31 | 32 | 33 | 34 | org.springframework.cloud 35 | spring-cloud-starter-consul-discovery 36 | 37 | 38 | org.springframework.cloud 39 | spring-cloud-starter-loadbalancer 40 | 41 | 42 | org.projectlombok 43 | lombok 44 | true 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-starter-webflux 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-starter-data-r2dbc 53 | 54 | 55 | io.r2dbc 56 | r2dbc-postgresql 57 | runtime 58 | 59 | 60 | org.postgresql 61 | postgresql 62 | runtime 63 | 64 | 65 | org.testcontainers 66 | postgresql 67 | ${test-containers.version} 68 | test 69 | 70 | 71 | org.flywaydb 72 | flyway-core 73 | ${flyway.version} 74 | 75 | 76 | 77 | org.springframework.boot 78 | spring-boot-devtools 79 | runtime 80 | true 81 | 82 | 83 | org.springframework.boot 84 | spring-boot-starter-test 85 | test 86 | 87 | 88 | org.junit.vintage 89 | junit-vintage-engine 90 | 91 | 92 | 93 | 94 | com.github.tomakehurst 95 | wiremock-jre8 96 | ${wiremock.version} 97 | test 98 | 99 | 100 | 101 | 102 | ${project.artifactId} 103 | 104 | 105 | org.springframework.boot 106 | spring-boot-maven-plugin 107 | 108 | true 109 | 110 | 111 | 112 | 113 | repackage 114 | 115 | none 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/CustomerApplication.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; 6 | 7 | @SpringBootApplication 8 | @EnableR2dbcRepositories 9 | public class CustomerApplication { 10 | public static void main(String[] args) { 11 | SpringApplication.run(CustomerApplication.class, args); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/config/FlywayConfiguration.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.config; 2 | 3 | import org.flywaydb.core.Flyway; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.core.env.Environment; 7 | 8 | @Configuration 9 | public class FlywayConfiguration { 10 | 11 | private final Environment env; 12 | 13 | public FlywayConfiguration(final Environment env) { 14 | this.env = env; 15 | } 16 | 17 | @Bean(initMethod = "migrate") 18 | public Flyway flyway() { 19 | return new Flyway(Flyway.configure() 20 | .baselineOnMigrate(true) 21 | .dataSource( 22 | env.getRequiredProperty("spring.flyway.url"), 23 | env.getRequiredProperty("spring.flyway.user"), 24 | env.getRequiredProperty("spring.flyway.password")) 25 | ); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/controller/CustomerController.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.controller; 2 | 3 | import gr.kmandalas.service.customer.dto.CustomerDTO; 4 | import gr.kmandalas.service.customer.dto.CustomerForm; 5 | import gr.kmandalas.service.customer.exception.CustomerNotFoundException; 6 | import gr.kmandalas.service.customer.service.CustomerService; 7 | import lombok.RequiredArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.*; 11 | import reactor.core.publisher.Mono; 12 | 13 | @RestController 14 | @RequestMapping("/customers") 15 | @RequiredArgsConstructor 16 | @Slf4j 17 | public class CustomerController { 18 | 19 | private final CustomerService customerService; 20 | 21 | @GetMapping 22 | public Mono> getCustomer(@RequestParam String number) { 23 | return customerService.findByNumber(number).map(ResponseEntity::ok); 24 | } 25 | 26 | @PostMapping 27 | public Mono> createCustomer(@RequestBody CustomerForm customerForm) { 28 | return customerService.insertCustomer(customerForm).map(ResponseEntity::ok); 29 | } 30 | 31 | @ExceptionHandler(CustomerNotFoundException.class) 32 | public ResponseEntity handleCustomerNotFoundException(CustomerNotFoundException ex){ 33 | log.warn("Customer not found: ", ex); 34 | return ResponseEntity.notFound().build(); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/dto/CustomerDTO.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class CustomerDTO { 13 | 14 | private String firstName; 15 | private String lastName; 16 | private Long accountId; 17 | private String email; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/dto/CustomerForm.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class CustomerForm { 13 | 14 | private String firstName; 15 | private String lastName; 16 | private String email; 17 | private String number; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/entity/Customer.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.entity; 2 | 3 | import lombok.*; 4 | import org.springframework.data.annotation.Id; 5 | 6 | import java.time.LocalDateTime; 7 | 8 | @Data 9 | @ToString 10 | @Builder 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class Customer { 14 | 15 | @Id 16 | private Long id; 17 | private String number; 18 | private String firstName; 19 | private String lastName; 20 | private String email; 21 | private LocalDateTime createdAt; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/exception/CustomerNotFoundException.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.exception; 2 | 3 | public class CustomerNotFoundException extends RuntimeException { 4 | 5 | public CustomerNotFoundException(String message) { 6 | super(message); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/repository/CustomerRepository.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.repository; 2 | 3 | import gr.kmandalas.service.customer.entity.Customer; 4 | import org.springframework.data.repository.reactive.ReactiveCrudRepository; 5 | import org.springframework.stereotype.Repository; 6 | import reactor.core.publisher.Mono; 7 | 8 | @Repository 9 | public interface CustomerRepository extends ReactiveCrudRepository { 10 | 11 | Mono findByNumber(String number); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /customer-service/src/main/java/gr/kmandalas/service/customer/service/CustomerService.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.customer.service; 2 | 3 | import gr.kmandalas.service.customer.dto.CustomerDTO; 4 | import gr.kmandalas.service.customer.dto.CustomerForm; 5 | import gr.kmandalas.service.customer.entity.Customer; 6 | import gr.kmandalas.service.customer.exception.CustomerNotFoundException; 7 | import gr.kmandalas.service.customer.repository.CustomerRepository; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.cloud.sleuth.annotation.NewSpan; 10 | import org.springframework.stereotype.Service; 11 | import reactor.core.publisher.Mono; 12 | 13 | import java.time.LocalDateTime; 14 | 15 | @Service 16 | @RequiredArgsConstructor 17 | public class CustomerService { 18 | 19 | private final CustomerRepository customerRepository; 20 | 21 | /** 22 | * Return customer info, by number 23 | * @param number the phone number of the customer 24 | * @return customer details 25 | */ 26 | @NewSpan 27 | public Mono findByNumber(String number) { 28 | return customerRepository.findByNumber(number) 29 | .switchIfEmpty(Mono.error(new CustomerNotFoundException("Customer with number: " + number + " not found"))) 30 | .map(customer -> CustomerDTO.builder() 31 | .accountId(customer.getId()) 32 | .email(customer.getEmail()) 33 | .firstName(customer.getFirstName()) 34 | .lastName(customer.getLastName()) 35 | .build()); 36 | } 37 | 38 | 39 | /** 40 | * Create new customer in database 41 | * @param customerForm the form containing the customer's details 42 | * @return Customer entity mono 43 | */ 44 | @NewSpan 45 | public Mono insertCustomer(CustomerForm customerForm) { 46 | return customerRepository.save(Customer.builder() 47 | .email(customerForm.getEmail()) 48 | .createdAt(LocalDateTime.now()) 49 | .firstName(customerForm.getFirstName()) 50 | .lastName(customerForm.getLastName()) 51 | .number(customerForm.getNumber()) 52 | .build()) 53 | .map(customer -> CustomerDTO.builder() 54 | .lastName(customer.getLastName()) 55 | .firstName(customer.getFirstName()) 56 | .email(customer.getEmail()) 57 | .accountId(customer.getId()) 58 | .build()); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /customer-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 0 3 | 4 | spring: 5 | application: 6 | name: customer-service 7 | mvc: 8 | log-request-details: true 9 | cloud: 10 | consul: 11 | host: ${consul.host:localhost} 12 | port: ${consul.port:8500} 13 | loadbalancer: 14 | ribbon: 15 | enabled: false 16 | flyway: 17 | url: jdbc:postgresql://${postgres.host:localhost}:5432/${postgres.db:test}?currentSchema=customer 18 | user: ${postgres.user:kmandalas} 19 | password: ${postgres.password:passepartout} 20 | # schemas: demo 21 | # locations: db/migration 22 | r2dbc: 23 | url: r2dbc:postgresql://${postgres.host:localhost}:5432/${postgres.db:test}?currentSchema=customer 24 | username: ${postgres.user:kmandalas} 25 | password: ${postgres.password:passepartout} 26 | zipkin: 27 | base-url: http://${jaeger.host:localhost}:9411/ 28 | 29 | management: 30 | endpoints: 31 | web: 32 | exposure: 33 | include: "*" 34 | 35 | logging: 36 | level: 37 | gr.kmandalas: DEBUG 38 | org.springframework.web: TRACE 39 | org.springframework.data.r2dbc: TRACE -------------------------------------------------------------------------------- /customer-service/src/main/resources/db/migration/V0_1_0__initialize.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE customer ( 2 | id SERIAL PRIMARY KEY, 3 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 4 | email VARCHAR(100) NOT NULL UNIQUE, 5 | number VARCHAR(100) NOT NULL UNIQUE, 6 | first_name VARCHAR(100) NOT NULL, 7 | last_name VARCHAR(100) NOT NULL 8 | ); -------------------------------------------------------------------------------- /customer-service/src/main/resources/db/migration/V0_1_1__insert_test_data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO customer(created_at, email, "number", first_name, last_name) VALUES ( '2020-01-08 04:05:06' , 'test@example.com', '00306933177321', 'John', 'Doe') 2 | -------------------------------------------------------------------------------- /customer-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss} %5p [%X{X-B3-TraceId:-}] - [%thread] %logger{0} - %msg%n 7 | 8 | 9 | 10 | 11 | 0 12 | 1 13 | false 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /diagrams/WebClientShowcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/WebClientShowcase.png -------------------------------------------------------------------------------- /diagrams/consul.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/consul.png -------------------------------------------------------------------------------- /diagrams/flux-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/flux-merge.png -------------------------------------------------------------------------------- /diagrams/jaeger-dep-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/jaeger-dep-graph.png -------------------------------------------------------------------------------- /diagrams/jaeger-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/jaeger-home.png -------------------------------------------------------------------------------- /diagrams/jaeger-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kmandalas/webclient-showcase/5fe90400108306a26fc8a67ba1b0de368e5461f9/diagrams/jaeger-trace.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | 5 | consul: 6 | image: consul:latest 7 | command: agent -server -ui -node=server1 -bootstrap-expect=1 -client=0.0.0.0 8 | ports: 9 | - "8500:8500" 10 | - "8600:8600/udp" 11 | networks: 12 | - consul-net 13 | 14 | gateway-service: 15 | restart: on-failure 16 | build: gateway-service/. 17 | image: webclient-showcase/gateway-service:1 18 | ports: 19 | - "8000:8000" 20 | networks: 21 | - consul-net 22 | environment: 23 | - CONSUL_HOST=consul 24 | - CONSUL_PORT=8500 25 | 26 | otp-service: 27 | restart: on-failure 28 | build: otp-service/. 29 | image: webclient-showcase/otp-service:1 30 | volumes: 31 | - C://logs:/home/otp/logs # TODO: using "C://" makes it os-dependent... 32 | ports: 33 | - "8001:8001" 34 | networks: 35 | - consul-net 36 | environment: 37 | - CONSUL_HOST=consul 38 | - CONSUL_PORT=8500 39 | - POSTGRES_HOST=postgres 40 | - POSTGRES_DB=test 41 | - POSTGRES_USER=kmandalas 42 | - POSTGRES_PASSWORD=passepartout 43 | - NOTIFICATION_SERVICE_HOST=notification-service 44 | - NOTIFICATION_SERVICE_PORT=8005 45 | - NUMBER-INFORMATION_SERVICE_HOST=number-information-service 46 | - NUMBER-INFORMATION_SERVICE_PORT=8006 47 | - JAEGER_HOST=jaeger 48 | 49 | customer-service: 50 | restart: on-failure 51 | build: customer-service/. 52 | image: webclient-showcase/customer-service:1 53 | ports: 54 | - "8002:8002" 55 | networks: 56 | - consul-net 57 | environment: 58 | - CONSUL_HOST=consul 59 | - CONSUL_PORT=8500 60 | - POSTGRES_HOST=postgres 61 | - POSTGRES_DB=test 62 | - POSTGRES_USER=kmandalas 63 | - POSTGRES_PASSWORD=passepartout 64 | - JAEGER_HOST=jaeger 65 | 66 | notification-service: 67 | restart: on-failure 68 | build: notification-service/. 69 | image: webclient-showcase/notification-service:1 70 | ports: 71 | - "8005:8005" 72 | networks: 73 | - consul-net 74 | 75 | number-information-service: 76 | restart: on-failure 77 | build: number-information-service/. 78 | image: webclient-showcase/number-information-service:1 79 | ports: 80 | - "8006:8006" 81 | networks: 82 | - consul-net 83 | 84 | postgres: 85 | image: postgres:latest 86 | restart: always 87 | networks: 88 | - consul-net 89 | environment: 90 | POSTGRES_DB: test 91 | POSTGRES_USER: kmandalas 92 | POSTGRES_PASSWORD: passepartout 93 | ports: 94 | - "5432:5432" 95 | 96 | jaeger: 97 | image: jaegertracing/all-in-one:1.8 98 | ports: 99 | - "16686:16686" 100 | - "9411:9411" 101 | networks: 102 | - consul-net 103 | environment: 104 | COLLECTOR_ZIPKIN_HTTP_PORT: 9411 105 | 106 | networks: 107 | consul-net: 108 | driver: bridge -------------------------------------------------------------------------------- /gateway-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:13-alpine 2 | VOLUME /tmp 3 | ADD target/gateway-service.jar app.jar 4 | ENV JAVA_OPTS="-Xms64m -Xmx128m --enable-preview" 5 | ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ] -------------------------------------------------------------------------------- /gateway-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | gr.kmandalas.services 9 | spring-webclient-showcase 10 | 1.0 11 | 12 | 13 | gateway-service 14 | 15 | 16 | 17 | org.springframework.boot 18 | spring-boot-starter-actuator 19 | 20 | 21 | org.springframework.cloud 22 | spring-cloud-starter-consul-discovery 23 | 24 | 25 | org.springframework.cloud 26 | spring-cloud-starter-gateway 27 | 28 | 29 | org.springframework.cloud 30 | spring-cloud-starter-loadbalancer 31 | 32 | 33 | 34 | org.springframework.boot 35 | spring-boot-devtools 36 | runtime 37 | true 38 | 39 | 40 | org.springframework.boot 41 | spring-boot-starter-test 42 | test 43 | 44 | 45 | org.junit.vintage 46 | junit-vintage-engine 47 | 48 | 49 | 50 | 51 | 52 | 53 | ${project.artifactId} 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-maven-plugin 58 | 59 | true 60 | 61 | 62 | 63 | 64 | repackage 65 | 66 | none 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /gateway-service/src/main/java/gr/kmandalas/services/gateway/GatewayApplication.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.services.gateway; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class GatewayApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(GatewayApplication.class, args); 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /gateway-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8000 3 | 4 | spring: 5 | application: 6 | name: gateway-service 7 | cloud: 8 | consul: 9 | host: ${consul.host:localhost} 10 | port: ${consul.port:8500} 11 | loadbalancer: 12 | ribbon: 13 | enabled: false 14 | gateway: 15 | discovery: 16 | locator: 17 | enabled: true 18 | routes: 19 | - id: otp-service 20 | uri: lb://otp-service 21 | predicates: 22 | - Path=/otp-service/** 23 | filters: 24 | - StripPrefix=1 25 | - id: customer-service 26 | uri: lb://customer-service 27 | predicates: 28 | - Path=/customer-service/** 29 | filters: 30 | - StripPrefix=1 31 | globalcors: 32 | cors-configurations: 33 | '[/**]': 34 | allowedOrigins: ["*"] 35 | allowedMethods: ["POST","GET","DELETE","PUT"] 36 | allowedHeaders: "*" 37 | allowCredentials: true 38 | 39 | management: 40 | endpoints: 41 | web: 42 | exposure: 43 | include: "*" -------------------------------------------------------------------------------- /notification-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:13-alpine 2 | VOLUME /tmp 3 | ADD target/notification-service.jar app.jar 4 | ENV JAVA_OPTS="-Xms64m -Xmx128m --enable-preview" 5 | ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ] -------------------------------------------------------------------------------- /notification-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | gr.kmandalas.services 9 | spring-webclient-showcase 10 | 1.0 11 | 12 | 13 | notification-service 14 | jar 15 | 16 | 17 | 2.23.2 18 | 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot-starter-actuator 24 | 25 | 26 | 27 | org.projectlombok 28 | lombok 29 | true 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-webflux 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-devtools 39 | runtime 40 | true 41 | 42 | 43 | org.springframework.boot 44 | spring-boot-starter-test 45 | test 46 | 47 | 48 | org.junit.vintage 49 | junit-vintage-engine 50 | 51 | 52 | 53 | 54 | com.github.tomakehurst 55 | wiremock-jre8 56 | ${wiremock.version} 57 | test 58 | 59 | 60 | jakarta.validation 61 | jakarta.validation-api 62 | 63 | 64 | 65 | 66 | ${project.artifactId} 67 | 68 | 69 | org.springframework.boot 70 | spring-boot-maven-plugin 71 | 72 | true 73 | 74 | 75 | 76 | 77 | repackage 78 | 79 | none 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /notification-service/src/main/java/gr/kmandalas/service/notification/NotificationApplication.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.notification; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class NotificationApplication { 8 | public static void main(String[] args) { 9 | SpringApplication.run(NotificationApplication.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /notification-service/src/main/java/gr/kmandalas/service/notification/controller/NotificationController.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.notification.controller; 2 | 3 | import gr.kmandalas.service.notification.dto.NotificationRequestForm; 4 | import gr.kmandalas.service.notification.dto.NotificationResultDTO; 5 | import gr.kmandalas.service.notification.enums.Channel; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.PostMapping; 9 | import org.springframework.web.bind.annotation.RequestBody; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import javax.validation.Valid; 14 | import java.util.concurrent.TimeUnit; 15 | 16 | @RestController 17 | @RequestMapping("/notifications") 18 | @RequiredArgsConstructor 19 | public class NotificationController { 20 | 21 | @PostMapping 22 | public ResponseEntity sendNotification(@RequestBody @Valid NotificationRequestForm form) { 23 | try { 24 | Channel notificationMethod = Channel.valueOf(form.getChannel()); 25 | 26 | try { 27 | TimeUnit.SECONDS.sleep(2); 28 | } catch (InterruptedException ie) { 29 | Thread.currentThread().interrupt(); 30 | } 31 | 32 | return ResponseEntity.ok() 33 | .body(NotificationResultDTO.builder() 34 | .status("OK") 35 | .message("Notification sent to " + notificationMethod.name()) 36 | .build()); 37 | 38 | } catch (IllegalArgumentException ex) { 39 | return ResponseEntity 40 | .badRequest() 41 | .body(NotificationResultDTO.builder() 42 | .status("ERROR") 43 | .message("Unsupported communication channel") 44 | .build()); 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /notification-service/src/main/java/gr/kmandalas/service/notification/dto/NotificationRequestForm.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.notification.dto; 2 | 3 | import lombok.*; 4 | 5 | import javax.validation.constraints.NotEmpty; 6 | 7 | @Getter @Setter 8 | @AllArgsConstructor 9 | @NoArgsConstructor 10 | @ToString 11 | public class NotificationRequestForm { 12 | @NotEmpty 13 | private String channel; 14 | private String destination; 15 | private String message; 16 | } 17 | -------------------------------------------------------------------------------- /notification-service/src/main/java/gr/kmandalas/service/notification/dto/NotificationResultDTO.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.notification.dto; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | @Data 7 | @Builder 8 | public class NotificationResultDTO { 9 | private String status; 10 | private String message; 11 | } 12 | -------------------------------------------------------------------------------- /notification-service/src/main/java/gr/kmandalas/service/notification/enums/Channel.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.notification.enums; 2 | 3 | public enum Channel { 4 | AUTO, 5 | SMS, 6 | WHATSAPP, 7 | VIBER, 8 | EMAIL; 9 | } 10 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8005 3 | 4 | spring: 5 | application: 6 | name: notification-service 7 | mvc: 8 | log-request-details: true 9 | 10 | management: 11 | endpoints: 12 | web: 13 | exposure: 14 | include: "*" 15 | 16 | logging: 17 | level: 18 | gr.kmandalas: DEBUG 19 | org.springframework.web: TRACE -------------------------------------------------------------------------------- /notification-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss} [%thread] %logger{0} - %msg%n 7 | 8 | 9 | 10 | 11 | 0 12 | 1 13 | false 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /number-information-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:13-alpine 2 | VOLUME /tmp 3 | ADD target/number-information-service.jar app.jar 4 | ENV JAVA_OPTS="-Xms64m -Xmx128m --enable-preview" 5 | ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ] -------------------------------------------------------------------------------- /number-information-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | gr.kmandalas.services 9 | spring-webclient-showcase 10 | 1.0 11 | 12 | 13 | number-information-service 14 | jar 15 | 16 | 17 | 2.23.2 18 | 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot-starter-actuator 24 | 25 | 26 | 27 | org.projectlombok 28 | lombok 29 | true 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-webflux 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-devtools 39 | runtime 40 | true 41 | 42 | 43 | org.springframework.boot 44 | spring-boot-starter-test 45 | test 46 | 47 | 48 | org.junit.vintage 49 | junit-vintage-engine 50 | 51 | 52 | 53 | 54 | com.github.tomakehurst 55 | wiremock-jre8 56 | ${wiremock.version} 57 | test 58 | 59 | 60 | jakarta.validation 61 | jakarta.validation-api 62 | 63 | 64 | 65 | 66 | ${project.artifactId} 67 | 68 | 69 | org.springframework.boot 70 | spring-boot-maven-plugin 71 | 72 | true 73 | 74 | 75 | 76 | 77 | repackage 78 | 79 | none 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /number-information-service/src/main/java/gr/kmandalas/service/numberinformation/NumberInformationService.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.numberinformation; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class NumberInformationService { 8 | public static void main(String[] args) { 9 | SpringApplication.run(NumberInformationService.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /number-information-service/src/main/java/gr/kmandalas/service/numberinformation/controller/NumberInformationController.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.numberinformation.controller; 2 | 3 | import gr.kmandalas.service.numberinformation.enums.MsisdnStatus; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | @RestController 9 | @RequestMapping("/number-information") 10 | @RequiredArgsConstructor 11 | public class NumberInformationController { 12 | 13 | @GetMapping 14 | public ResponseEntity verifyMsisdn(@RequestParam String msisdn) { 15 | return ResponseEntity.ok().body(MsisdnStatus.OK.name()); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /number-information-service/src/main/java/gr/kmandalas/service/numberinformation/dto/NotificationRequestForm.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.numberinformation.dto; 2 | 3 | import lombok.*; 4 | 5 | import javax.validation.constraints.NotEmpty; 6 | 7 | @Getter @Setter 8 | @AllArgsConstructor 9 | @NoArgsConstructor 10 | @ToString 11 | public class NotificationRequestForm { 12 | @NotEmpty 13 | private String channel; 14 | private String email; 15 | private String phone; 16 | private String message; 17 | } 18 | -------------------------------------------------------------------------------- /number-information-service/src/main/java/gr/kmandalas/service/numberinformation/dto/NotificationResultDTO.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.numberinformation.dto; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | @Data 7 | @Builder 8 | public class NotificationResultDTO { 9 | private String status; 10 | private String message; 11 | } 12 | -------------------------------------------------------------------------------- /number-information-service/src/main/java/gr/kmandalas/service/numberinformation/enums/MsisdnStatus.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.numberinformation.enums; 2 | 3 | public enum MsisdnStatus { 4 | OK, 5 | NOT_EXISTANT, 6 | INACTIVE 7 | } 8 | -------------------------------------------------------------------------------- /number-information-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8006 3 | 4 | spring: 5 | application: 6 | name: number-information-service 7 | mvc: 8 | log-request-details: true 9 | 10 | management: 11 | endpoints: 12 | web: 13 | exposure: 14 | include: "*" 15 | 16 | logging: 17 | level: 18 | gr.kmandalas: DEBUG 19 | org.springframework.web: TRACE -------------------------------------------------------------------------------- /number-information-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss} [%thread] %logger{0} - %msg%n 7 | 8 | 9 | 10 | 11 | 0 12 | 1 13 | false 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /otp-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:13-alpine 2 | VOLUME /tmp 3 | ADD target/otp-service.jar app.jar 4 | ENV JAVA_OPTS="-Xms64m -Xmx128m --enable-preview -XX:+AllowRedefinitionToAddDeleteMethods" 5 | ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ] -------------------------------------------------------------------------------- /otp-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | gr.kmandalas.services 9 | spring-webclient-showcase 10 | 1.0 11 | 12 | 13 | otp-service 14 | jar 15 | 16 | 17 | 1.14.2 18 | 6.5.4 19 | 2.23.2 20 | 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-actuator 26 | 27 | 28 | org.springframework.cloud 29 | spring-cloud-starter-zipkin 30 | 31 | 32 | org.springframework.cloud 33 | spring-cloud-starter-consul-discovery 34 | 35 | 36 | org.springframework.cloud 37 | spring-cloud-starter-loadbalancer 38 | 39 | 40 | org.projectlombok 41 | lombok 42 | true 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-webflux 47 | 48 | 49 | org.springframework.boot 50 | spring-boot-starter-data-r2dbc 51 | 52 | 53 | io.r2dbc 54 | r2dbc-postgresql 55 | runtime 56 | 57 | 58 | org.postgresql 59 | postgresql 60 | runtime 61 | 62 | 63 | org.testcontainers 64 | postgresql 65 | ${test-containers.version} 66 | test 67 | 68 | 69 | org.flywaydb 70 | flyway-core 71 | ${flyway.version} 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-devtools 76 | runtime 77 | true 78 | 79 | 80 | org.springframework.boot 81 | spring-boot-starter-test 82 | test 83 | 84 | 85 | org.junit.vintage 86 | junit-vintage-engine 87 | 88 | 89 | 90 | 91 | org.testcontainers 92 | testcontainers 93 | 1.14.3 94 | test 95 | 96 | 97 | io.specto 98 | hoverfly-java 99 | 0.13.0 100 | test 101 | 102 | 103 | io.projectreactor.tools 104 | blockhound 105 | 1.0.4.RELEASE 106 | 107 | 108 | 109 | 110 | ${project.artifactId} 111 | 112 | 113 | org.springframework.boot 114 | spring-boot-maven-plugin 115 | 116 | true 117 | 118 | 119 | 120 | 121 | repackage 122 | 123 | none 124 | 125 | 126 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/OtpApplication.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; 6 | 7 | @SpringBootApplication 8 | @EnableR2dbcRepositories 9 | public class OtpApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(OtpApplication.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/advice/OTPControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.advice; 2 | 3 | import gr.kmandalas.service.otp.enumeration.FaultReason; 4 | import gr.kmandalas.service.otp.exception.OTPException; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.http.HttpStatus; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.ControllerAdvice; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | 11 | @ControllerAdvice 12 | @Slf4j 13 | public class OTPControllerAdvice { 14 | 15 | @ExceptionHandler(OTPException.class) 16 | public ResponseEntity handleOTPException(OTPException ex) { 17 | final FaultReason faultReason = ex.getFaultReason() != null ? ex.getFaultReason() : FaultReason.GENERIC_ERROR; 18 | log.error("otp-service error", ex); 19 | 20 | HttpStatus httpStatus; 21 | switch (faultReason) { 22 | case NOT_FOUND: 23 | httpStatus = HttpStatus.NOT_FOUND; 24 | break; 25 | case TOO_MANY_ATTEMPTS: { 26 | httpStatus = HttpStatus.BAD_REQUEST; 27 | break; 28 | } 29 | case EXPIRED: 30 | case INVALID_PIN: 31 | case INVALID_STATUS: 32 | case CUSTOMER_ERROR: { 33 | httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; 34 | break; 35 | } 36 | case NUMBER_INFORMATION_ERROR: 37 | default: { 38 | httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; 39 | } 40 | } 41 | 42 | return ResponseEntity.status(httpStatus).body(faultReason.getMessage()); 43 | } 44 | 45 | @ExceptionHandler(Exception.class) 46 | public ResponseEntity handleException(Exception ex) { 47 | log.error(ex.getMessage(), ex); 48 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Server was unable to fulfill the request"); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/config/FlywayConfiguration.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.config; 2 | 3 | import org.flywaydb.core.Flyway; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.core.env.Environment; 7 | 8 | @Configuration 9 | public class FlywayConfiguration { 10 | 11 | private final Environment env; 12 | 13 | public FlywayConfiguration(final Environment env) { 14 | this.env = env; 15 | } 16 | 17 | @Bean(initMethod = "migrate") 18 | public Flyway flyway() { 19 | return new Flyway(Flyway.configure() 20 | .baselineOnMigrate(true) 21 | .dataSource( 22 | env.getRequiredProperty("spring.flyway.url"), 23 | env.getRequiredProperty("spring.flyway.user"), 24 | env.getRequiredProperty("spring.flyway.password")) 25 | ); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/config/WebClientConfig.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.config; 2 | 3 | import org.springframework.cloud.client.loadbalancer.LoadBalanced; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.context.annotation.Primary; 7 | import org.springframework.http.HttpHeaders; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.web.reactive.function.client.WebClient; 10 | 11 | 12 | @Configuration 13 | public class WebClientConfig { 14 | 15 | @Bean 16 | @Primary 17 | WebClient.Builder webClient() { 18 | return WebClient.builder() 19 | .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); 20 | } 21 | 22 | @Bean 23 | @LoadBalanced 24 | WebClient.Builder loadBalanced() { 25 | return WebClient.builder() 26 | .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/controller/OTPController.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.controller; 2 | 3 | import gr.kmandalas.service.otp.dto.SendForm; 4 | import gr.kmandalas.service.otp.entity.OTP; 5 | import gr.kmandalas.service.otp.service.OTPService; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.web.bind.annotation.*; 8 | import reactor.core.publisher.Flux; 9 | import reactor.core.publisher.Mono; 10 | 11 | import java.util.List; 12 | 13 | @RestController 14 | @RequestMapping("/v1/otp") 15 | @RequiredArgsConstructor 16 | public class OTPController { 17 | 18 | private final OTPService otpService; 19 | 20 | @PostMapping 21 | public Mono send(@RequestBody SendForm form) { 22 | return otpService.send(form); 23 | } 24 | 25 | @PostMapping("/{otpId}") 26 | public Mono resend(@PathVariable Long otpId, @RequestParam(required = false) List via, 27 | @RequestParam(required = false) String mail) { 28 | return otpService.resend(otpId, via, mail); 29 | } 30 | 31 | @PostMapping("/{otpId}/validate") 32 | public Mono validate(@PathVariable Long otpId, @RequestParam Integer pin) { 33 | return otpService.validate(otpId, pin); 34 | } 35 | 36 | @GetMapping 37 | public Flux getAll(@RequestParam String number) { 38 | return otpService.getAll(number); 39 | } 40 | 41 | @GetMapping("/{otpId}") 42 | public Mono get(@PathVariable Long otpId) { 43 | return otpService.get(otpId); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/dto/CustomerDTO.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class CustomerDTO { 13 | 14 | private String firstName; 15 | private String lastName; 16 | private Long accountId; 17 | private String email; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/dto/NotificationRequestForm.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.dto; 2 | 3 | import lombok.*; 4 | 5 | import javax.validation.constraints.NotEmpty; 6 | 7 | @Getter @Setter 8 | @AllArgsConstructor 9 | @NoArgsConstructor 10 | @ToString 11 | @Builder 12 | public class NotificationRequestForm { 13 | 14 | @NotEmpty 15 | private String channel; 16 | private String destination; 17 | private String message; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/dto/NotificationResultDTO.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class NotificationResultDTO { 13 | 14 | private String status; 15 | private String message; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/dto/SendForm.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.dto; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class SendForm { 7 | 8 | private String msisdn; 9 | // add your extra inputs here... 10 | 11 | } 12 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/entity/Application.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.entity; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.relational.core.mapping.Column; 9 | import org.springframework.data.relational.core.mapping.Table; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Table("application") 16 | public class Application { 17 | 18 | @Id 19 | @Column("app_key") 20 | private String uuid; 21 | 22 | @Column("attempts_allowed") 23 | private Integer attemptsAllowed; 24 | 25 | @Column("name") 26 | private String name; 27 | 28 | @Column("ttl") 29 | private Integer ttl; 30 | 31 | @Column("is_default") 32 | private Boolean isDefault; 33 | 34 | } 35 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/entity/OTP.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.entity; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import gr.kmandalas.service.otp.enumeration.OTPStatus; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | import org.springframework.data.annotation.Id; 10 | import org.springframework.data.relational.core.mapping.Column; 11 | import org.springframework.data.relational.core.mapping.Table; 12 | 13 | import java.time.ZonedDateTime; 14 | 15 | @Data 16 | @Builder 17 | @NoArgsConstructor 18 | @AllArgsConstructor 19 | @Table("otp") 20 | public class OTP { 21 | 22 | @Id 23 | @Column("id") 24 | private Long id; 25 | 26 | @Column("customer_id") 27 | private Long customerId; 28 | 29 | @Column("msisdn") 30 | private String msisdn; 31 | 32 | @JsonIgnore 33 | @Column("pin") 34 | private Integer pin; 35 | 36 | @Column("application_id") 37 | private String applicationId; 38 | 39 | @Column("attempt_count") 40 | private Integer attemptCount; 41 | 42 | @Column("created_on") 43 | private ZonedDateTime createdOn; 44 | 45 | @Column("expires") 46 | private ZonedDateTime expires; 47 | 48 | @Column("status") 49 | private OTPStatus status; 50 | 51 | } 52 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/enumeration/Channel.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.enumeration; 2 | 3 | public enum Channel { 4 | AUTO, 5 | SMS, 6 | WHATSAPP, 7 | VIBER, 8 | EMAIL; 9 | } 10 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/enumeration/FaultReason.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.enumeration; 2 | 3 | public enum FaultReason { 4 | 5 | EXPIRED("OTP has expired"), 6 | TOO_MANY_ATTEMPTS("Too many validation attempts"), 7 | INVALID_PIN("Wrong PIN"), 8 | INVALID_STATUS("Invalid status"), 9 | NOT_FOUND("Resource not found"), 10 | CUSTOMER_ERROR("Customer retrieval failed"), 11 | NUMBER_INFORMATION_ERROR("MSIDN status check failed"), 12 | GENERIC_ERROR("The server was unable to fulfill the request"); 13 | 14 | private final String message; 15 | 16 | FaultReason(String message) { 17 | this.message = message; 18 | } 19 | 20 | public String getMessage() { 21 | return message; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/enumeration/OTPStatus.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.enumeration; 2 | 3 | public enum OTPStatus { 4 | 5 | ACTIVE, VERIFIED, EXPIRED, TOO_MANY_ATTEMPTS 6 | 7 | } 8 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/exception/OTPException.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.exception; 2 | 3 | import gr.kmandalas.service.otp.entity.OTP; 4 | import gr.kmandalas.service.otp.enumeration.FaultReason; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | import org.springframework.http.HttpStatus; 8 | 9 | @Getter 10 | @Setter 11 | public class OTPException extends RuntimeException { 12 | 13 | public HttpStatus status; 14 | 15 | public FaultReason faultReason; 16 | 17 | public OTP otp; 18 | 19 | public OTPException(HttpStatus status, String message){ 20 | super(message); 21 | this.status = status; 22 | } 23 | 24 | public OTPException(String message, FaultReason faultReason) { 25 | super(message); 26 | this.faultReason = faultReason; 27 | } 28 | 29 | public OTPException(String message, FaultReason faultReason, OTP otp) { 30 | super(message); 31 | this.faultReason = faultReason; 32 | this.otp = otp; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/repository/ApplicationRepository.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.repository; 2 | 3 | import gr.kmandalas.service.otp.entity.Application; 4 | import org.springframework.data.repository.reactive.ReactiveCrudRepository; 5 | 6 | public interface ApplicationRepository extends ReactiveCrudRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/repository/OTPRepository.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.repository; 2 | 3 | import gr.kmandalas.service.otp.entity.OTP; 4 | import gr.kmandalas.service.otp.enumeration.OTPStatus; 5 | import org.springframework.data.repository.reactive.ReactiveCrudRepository; 6 | import reactor.core.publisher.Flux; 7 | import reactor.core.publisher.Mono; 8 | 9 | public interface OTPRepository extends ReactiveCrudRepository { 10 | 11 | Flux findByMsisdn(String number); 12 | 13 | Flux findByCustomerId(Long customerId); 14 | 15 | Mono findByIdAndStatus(Long otpId, OTPStatus status); 16 | 17 | Mono findByIdAndPinAndStatus(Long otpId, Integer pin, OTPStatus status); 18 | 19 | } 20 | -------------------------------------------------------------------------------- /otp-service/src/main/java/gr/kmandalas/service/otp/service/OTPService.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.service; 2 | 3 | import gr.kmandalas.service.otp.dto.CustomerDTO; 4 | import gr.kmandalas.service.otp.dto.NotificationRequestForm; 5 | import gr.kmandalas.service.otp.dto.NotificationResultDTO; 6 | import gr.kmandalas.service.otp.dto.SendForm; 7 | import gr.kmandalas.service.otp.entity.OTP; 8 | import gr.kmandalas.service.otp.enumeration.Channel; 9 | import gr.kmandalas.service.otp.enumeration.FaultReason; 10 | import gr.kmandalas.service.otp.enumeration.OTPStatus; 11 | import gr.kmandalas.service.otp.exception.OTPException; 12 | import gr.kmandalas.service.otp.repository.ApplicationRepository; 13 | import gr.kmandalas.service.otp.repository.OTPRepository; 14 | import lombok.RequiredArgsConstructor; 15 | import lombok.extern.slf4j.Slf4j; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.beans.factory.annotation.Value; 18 | import org.springframework.cloud.client.loadbalancer.LoadBalanced; 19 | import org.springframework.cloud.sleuth.annotation.NewSpan; 20 | import org.springframework.http.HttpStatus; 21 | import org.springframework.http.MediaType; 22 | import org.springframework.stereotype.Service; 23 | import org.springframework.web.reactive.function.BodyInserters; 24 | import org.springframework.web.reactive.function.client.WebClient; 25 | import org.springframework.web.util.UriComponentsBuilder; 26 | import reactor.core.publisher.Flux; 27 | import reactor.core.publisher.Mono; 28 | import reactor.util.function.Tuple2; 29 | 30 | import java.time.Duration; 31 | import java.time.ZonedDateTime; 32 | import java.util.List; 33 | import java.util.Objects; 34 | import java.util.Random; 35 | import java.util.concurrent.atomic.AtomicReference; 36 | import java.util.stream.Collectors; 37 | 38 | @Service 39 | @RequiredArgsConstructor 40 | @Slf4j 41 | public class OTPService { 42 | 43 | private final OTPRepository otpRepository; 44 | 45 | private final ApplicationRepository applicationRepository; 46 | 47 | @Autowired 48 | @LoadBalanced 49 | private WebClient.Builder loadbalanced; 50 | 51 | @Autowired 52 | private WebClient.Builder webclient; 53 | 54 | @Value("${external.services.number-information}") 55 | private String numberInformationServiceUrl; 56 | 57 | @Value("${external.services.notifications}") 58 | private String notificationServiceUrl; 59 | 60 | /** 61 | * Generate and send an OTP 62 | * 63 | * @param form the {@link SendForm} 64 | */ 65 | @NewSpan 66 | public Mono send(SendForm form) { 67 | log.info("Entered send with argument: {}", form); 68 | 69 | String customerURI = UriComponentsBuilder.fromHttpUrl("http://customer-service/customers") 70 | .queryParam("number", form.getMsisdn()) 71 | .toUriString(); 72 | 73 | String numberInfoURI = UriComponentsBuilder.fromHttpUrl(numberInformationServiceUrl) 74 | .queryParam("msisdn", form.getMsisdn()) 75 | .toUriString(); 76 | 77 | // 1st call to customer-service using service discovery, to retrieve customer related info 78 | Mono customerInfo = loadbalanced.build() 79 | .get() 80 | .uri(customerURI) 81 | // .header("Authorization", String.format("%s %s", "Bearer", tokenUtils.getAccessToken())) 82 | .accept(MediaType.APPLICATION_JSON) 83 | .retrieve() 84 | .onStatus(HttpStatus::is4xxClientError, 85 | clientResponse -> Mono.error(new OTPException("Error retrieving Customer", FaultReason.CUSTOMER_ERROR))) 86 | .bodyToMono(CustomerDTO.class); 87 | 88 | // 2nd call to external service, to check that the MSISDN is valid 89 | Mono msisdnStatus = webclient.build() 90 | .get() 91 | .uri(numberInfoURI) 92 | .retrieve() 93 | .onStatus(HttpStatus::isError, clientResponse -> Mono.error( 94 | new OTPException("Error retrieving msisdn status", FaultReason.NUMBER_INFORMATION_ERROR))) 95 | .bodyToMono(String.class); 96 | 97 | // Combine the results in a single Mono, that completes when both calls have returned. 98 | // If an error occurs in one of the Monos, execution stops immediately. 99 | // If we want to delay errors and execute all Monos, then we can use zipDelayError instead 100 | Mono> zippedCalls = Mono.zip(customerInfo, msisdnStatus); 101 | 102 | // Perform additional actions after the combined mono has returned 103 | return zippedCalls.flatMap(resultTuple -> { 104 | 105 | // After the calls have completed, generate a random pin 106 | int pin = 100000 + new Random().nextInt(900000); 107 | 108 | // Save the OTP to local DB, in a reactive manner 109 | Mono otpMono = otpRepository.save(OTP.builder() 110 | .customerId(resultTuple.getT1().getAccountId()) 111 | .msisdn(form.getMsisdn()) 112 | .pin(pin) 113 | .createdOn(ZonedDateTime.now()) 114 | .expires(ZonedDateTime.now().plus(Duration.ofMinutes(1))) 115 | .status(OTPStatus.ACTIVE) 116 | .applicationId("PPR") 117 | .attemptCount(0) 118 | .build()); 119 | 120 | // External notification service invocation 121 | Mono notificationResultDTOMono = webclient.build() 122 | .post() 123 | .uri(notificationServiceUrl) 124 | .accept(MediaType.APPLICATION_JSON) 125 | .body(BodyInserters.fromValue(NotificationRequestForm.builder() 126 | .channel(Channel.AUTO.name()) 127 | .destination(form.getMsisdn()) 128 | .message(String.valueOf(pin)) 129 | .build())) 130 | .retrieve() 131 | .bodyToMono(NotificationResultDTO.class); 132 | 133 | // When this operation is complete, the external notification service will be invoked, to send the OTP though the default channel. 134 | // The results are combined in a single Mono: 135 | return otpMono.zipWhen(otp -> notificationResultDTOMono) 136 | // Return only the result of the first call (DB) 137 | .map(Tuple2::getT1); 138 | }); 139 | } 140 | 141 | /** 142 | * Validates an OTP and updates its status as {@link OTPStatus#ACTIVE} on success 143 | * 144 | * @param otpId the OTP id 145 | * @param pin the OTP PIN number 146 | */ 147 | @NewSpan 148 | public Mono validate(Long otpId, Integer pin) { 149 | log.info("Entered resend with arguments: {}, {}", otpId, pin); 150 | 151 | AtomicReference faultReason = new AtomicReference<>(); 152 | 153 | return otpRepository.findById(otpId) 154 | .switchIfEmpty(Mono.error(new OTPException("Error validating OTP", FaultReason.NOT_FOUND))) 155 | .zipWhen(otp -> applicationRepository.findById(otp.getApplicationId())) 156 | .flatMap(Tuple2 -> { 157 | var otp = Tuple2.getT1(); 158 | var app = Tuple2.getT2(); 159 | 160 | // FaultReason faultReason = null; 161 | if (otp.getAttemptCount() > app.getAttemptsAllowed()) { 162 | otp.setStatus(OTPStatus.TOO_MANY_ATTEMPTS); 163 | faultReason.set(FaultReason.TOO_MANY_ATTEMPTS); 164 | } else if (!otp.getPin().equals(pin)) { 165 | faultReason.set(FaultReason.INVALID_PIN); 166 | } else if (!otp.getStatus().equals(OTPStatus.ACTIVE)) { // todo: or VERIFIED 167 | faultReason.set(FaultReason.INVALID_STATUS); 168 | } else if (otp.getExpires().isBefore(ZonedDateTime.now())) { 169 | otp.setStatus(OTPStatus.EXPIRED); 170 | faultReason.set(FaultReason.EXPIRED); 171 | } else { 172 | otp.setStatus(OTPStatus.VERIFIED); 173 | } 174 | 175 | if (!otp.getStatus().equals(OTPStatus.TOO_MANY_ATTEMPTS)) 176 | otp.setAttemptCount(otp.getAttemptCount() + 1); 177 | 178 | if (otp.getStatus().equals(OTPStatus.VERIFIED)) 179 | return otpRepository.save(otp); 180 | else { 181 | return Mono.error(new OTPException("Error validating OTP", faultReason.get(), otp)); 182 | } 183 | }) 184 | .doOnError(throwable -> { 185 | if (throwable instanceof OTPException) { 186 | OTPException error = ((OTPException) throwable); 187 | if (!error.getFaultReason().equals(FaultReason.NOT_FOUND) && error.getOtp() != null) { 188 | otpRepository.save(error.getOtp()).subscribe(); 189 | } 190 | } 191 | }); 192 | } 193 | 194 | /** 195 | * Resend an already generated OTP to a number of communication channels. 196 | * 197 | * @param otpId the OTP id 198 | * @param channels the list of communication {@link Channel} 199 | * @param mail the user's alternate email address for receiving notifications 200 | */ 201 | @NewSpan 202 | public Mono resend(Long otpId, List channels, String mail) { 203 | log.info("Entered resend with arguments: {}, {}, {}", otpId, channels, mail); 204 | 205 | return otpRepository.findById(otpId) 206 | .switchIfEmpty(Mono.error(new OTPException("Error resending OTP", FaultReason.NOT_FOUND))) 207 | .zipWhen(otp -> { 208 | 209 | if (otp.getStatus() != OTPStatus.ACTIVE) 210 | return Mono.error(new OTPException("Error resending OTP", FaultReason.INVALID_STATUS)); 211 | 212 | List> monoList = channels.stream() 213 | .filter(Objects::nonNull) 214 | .map(method -> webclient.build() 215 | .post() 216 | .uri(notificationServiceUrl) 217 | .accept(MediaType.APPLICATION_JSON) 218 | .body(BodyInserters.fromValue(NotificationRequestForm.builder() 219 | .channel(method) 220 | .destination(Channel.EMAIL.name().equals(method) ? mail : otp.getMsisdn()) 221 | .message(otp.getPin().toString()) 222 | .build())) 223 | .retrieve() 224 | .bodyToMono(NotificationResultDTO.class)) 225 | .collect(Collectors.toList()); 226 | 227 | return Flux.merge(monoList).collectList(); 228 | }) 229 | .map(Tuple2::getT1); 230 | } 231 | 232 | /** 233 | * Read all OTPs of a given number 234 | * 235 | * @param number the user's msisdn 236 | */ 237 | @NewSpan 238 | public Flux getAll(String number) { 239 | log.info("Entered getAll with argument: {}", number); 240 | 241 | return otpRepository.findByMsisdn(number) 242 | .switchIfEmpty(Mono.error(new OTPException("OTPs not found", FaultReason.NOT_FOUND))); 243 | } 244 | 245 | /** 246 | * Read an already generated OTP 247 | * 248 | * @param otpId the OTP id 249 | */ 250 | @NewSpan 251 | public Mono get(Long otpId) { 252 | log.info("Entered get with argument: {}", otpId); 253 | 254 | return otpRepository.findById(otpId) 255 | .switchIfEmpty(Mono.error(new OTPException("OTP not found", FaultReason.NOT_FOUND))); 256 | } 257 | 258 | } 259 | -------------------------------------------------------------------------------- /otp-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 0 3 | 4 | spring: 5 | application: 6 | name: otp-service 7 | mvc: 8 | log-request-details: true 9 | cloud: 10 | consul: 11 | host: ${consul.host:localhost} 12 | port: ${consul.port:8500} 13 | loadbalancer: 14 | ribbon: 15 | enabled: false 16 | flyway: 17 | url: jdbc:postgresql://${postgres.host:localhost}:5432/${postgres.db:test}?currentSchema=demo 18 | user: ${postgres.user:kmandalas} 19 | password: ${postgres.password:passepartout} 20 | # schemas: demo 21 | # locations: db/migration 22 | r2dbc: 23 | url: r2dbc:postgresql://${postgres.host:localhost}:5432/${postgres.db:test}?currentSchema=demo 24 | username: ${postgres.user:kmandalas} 25 | password: ${postgres.password:passepartout} 26 | zipkin: 27 | base-url: http://${jaeger.host:localhost}:9411/ 28 | 29 | management: 30 | endpoints: 31 | web: 32 | exposure: 33 | include: "*" 34 | 35 | logging: 36 | level: 37 | gr.kmandalas: INFO 38 | org.springframework.web: INFO 39 | # file: 40 | # path: ${logging.file.path} 41 | # name: ${logging.file.name} 42 | 43 | external: 44 | services: 45 | notifications: http://${notification.service.host:localhost}:${notification.service.port:8005}/notifications 46 | number-information: http://${number-information.service.host:localhost}:${number-information.service.port:8006}/number-information 47 | -------------------------------------------------------------------------------- /otp-service/src/main/resources/db/migration/V0_1_0__create_otp_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE otp ( 2 | id SERIAL CONSTRAINT id PRIMARY KEY, 3 | pin INT NOT NULL, 4 | customer_id BIGINT NOT NULL, 5 | msisdn VARCHAR(100) NOT NULL, 6 | application_id VARCHAR(10) NOT NULL, 7 | attempt_count SMALLINT NOT NULL, 8 | created_on TIMESTAMPTZ NOT NULL, 9 | expires TIMESTAMPTZ NOT NULL, 10 | status VARCHAR(255) NOT NULL 11 | ); 12 | -------------------------------------------------------------------------------- /otp-service/src/main/resources/db/migration/V0_1_1__create_application_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE application ( 2 | app_key VARCHAR(10) PRIMARY KEY, 3 | attempts_allowed SMALLINT NOT NULL DEFAULT 2, 4 | name VARCHAR(50) NOT NULL, 5 | ttl SMALLINT NOT NULL DEFAULT 2, 6 | is_default BOOLEAN NOT NULL DEFAULT FALSE 7 | ); 8 | -------------------------------------------------------------------------------- /otp-service/src/main/resources/db/migration/V0_1_2__insert_test_data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO application(app_key, attempts_allowed, name, ttl, is_default) VALUES ( 'GGL' , 3, 'google', 5, FALSE); 2 | INSERT INTO application(app_key, attempts_allowed, name, ttl, is_default) VALUES ( 'GHB' , 3, 'github', 5, FALSE); 3 | INSERT INTO application(app_key, attempts_allowed, name, ttl, is_default) VALUES ( 'PPR' , 1, 'piedpiper', 2, TRUE); 4 | -------------------------------------------------------------------------------- /otp-service/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %d{HH:mm:ss} %5p [%X{X-B3-TraceId:-}] - [%thread] %logger{0} - %msg%n 9 | 10 | 11 | 12 | ${LOG_DIR}/otp-service.log 13 | 14 | %d{HH:mm:ss} %5p [%X{X-B3-TraceId:-}] - [%thread] %logger{0} - %msg%n 15 | 16 | 17 | ${LOG_DIR}/archived/otp-service-%d{yyyy-MM-dd}.%i.log 18 | 19 | 20 | 10MB 21 | 22 | 23 | 24 | 25 | 26 | 0 27 | 1 28 | false 29 | true 30 | 31 | 32 | 0 33 | 34 | 1 35 | false 36 | true 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /otp-service/src/test/java/gr/kmandalas/service/otp/OTPControllerIntegrationTests.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp; 2 | 3 | import gr.kmandalas.service.otp.dto.CustomerDTO; 4 | import gr.kmandalas.service.otp.dto.NotificationResultDTO; 5 | import gr.kmandalas.service.otp.dto.SendForm; 6 | import gr.kmandalas.service.otp.util.PostgresContainer; 7 | import io.specto.hoverfly.junit.core.Hoverfly; 8 | import io.specto.hoverfly.junit.core.HoverflyConfig; 9 | import lombok.Getter; 10 | import lombok.Setter; 11 | import org.jetbrains.annotations.NotNull; 12 | import org.junit.ClassRule; 13 | import org.junit.jupiter.api.AfterEach; 14 | import org.junit.jupiter.api.BeforeEach; 15 | import org.junit.jupiter.api.Test; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.boot.test.context.SpringBootTest; 18 | import org.springframework.boot.test.context.TestConfiguration; 19 | import org.springframework.cloud.client.ServiceInstance; 20 | import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; 21 | import org.springframework.context.ApplicationContextInitializer; 22 | import org.springframework.context.ConfigurableApplicationContext; 23 | import org.springframework.context.annotation.Bean; 24 | import org.springframework.test.context.ActiveProfiles; 25 | import org.springframework.test.context.ContextConfiguration; 26 | import org.springframework.test.web.reactive.server.WebTestClient; 27 | import org.testcontainers.containers.PostgreSQLContainer; 28 | import reactor.core.publisher.Flux; 29 | import reactor.core.publisher.Mono; 30 | import reactor.core.scheduler.Schedulers; 31 | 32 | import java.net.URI; 33 | import java.util.List; 34 | import java.util.Map; 35 | 36 | import static io.specto.hoverfly.junit.core.HoverflyMode.SIMULATE; 37 | import static io.specto.hoverfly.junit.core.SimulationSource.dsl; 38 | import static io.specto.hoverfly.junit.dsl.HoverflyDsl.service; 39 | import static io.specto.hoverfly.junit.dsl.HttpBodyConverter.json; 40 | import static io.specto.hoverfly.junit.dsl.ResponseCreators.success; 41 | import static io.specto.hoverfly.junit.dsl.matchers.HoverflyMatchers.matches; 42 | 43 | @ContextConfiguration(initializers = { OTPControllerIntegrationTests.PostgresContainerInitializer.class }) 44 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 45 | @ActiveProfiles("test") 46 | public class OTPControllerIntegrationTests { 47 | 48 | @Autowired 49 | private WebTestClient webTestClient; 50 | 51 | @ClassRule 52 | public static PostgreSQLContainer postgresSQLContainer = PostgresContainer.getInstance(); 53 | 54 | protected static class PostgresContainerInitializer implements ApplicationContextInitializer { 55 | public void initialize(@NotNull ConfigurableApplicationContext configurableApplicationContext) { 56 | postgresSQLContainer.start(); 57 | } 58 | } 59 | 60 | private Hoverfly hoverfly; 61 | 62 | @BeforeEach 63 | void setUp() { 64 | var simulation = dsl( 65 | // mock customer service 66 | service("http://customer-service") 67 | .get("/customers").anyQueryParams() 68 | .willReturn(success() 69 | .body(json(CustomerDTO.builder() 70 | .firstName("John") 71 | .lastName("Papadopoulos") 72 | .accountId(Long.MIN_VALUE) 73 | .email("john.papadopoulos@mail.com") 74 | .build()))), 75 | // mock number-information service 76 | service(matches("number-information")) 77 | .get("/number-information") 78 | .anyQueryParams() 79 | .willReturn(success() 80 | .body("Valid")), 81 | // mock notifications service 82 | service(matches("notifications")) 83 | .post("/notifications").anyBody() 84 | .willReturn(success() 85 | .body(json(NotificationResultDTO.builder() 86 | .status("OK") 87 | .message("A message") 88 | .build()))) 89 | ); 90 | 91 | var localConfig = HoverflyConfig.localConfigs().disableTlsVerification().asWebServer().proxyPort(8999); 92 | hoverfly = new Hoverfly(localConfig, SIMULATE); 93 | hoverfly.start(); 94 | hoverfly.simulate(simulation); 95 | } 96 | 97 | @AfterEach 98 | void tearDown() { 99 | hoverfly.close(); 100 | } 101 | 102 | //@ClassRule 103 | //public static HoverflyRule hoverflyRule = HoverflyRule.inSimulationMode( 104 | // dsl(service("customer-service").get("customers?number=1234567891") 105 | // .willReturn(success() 106 | // .body(json(CustomerDTO.builder() 107 | // .firstName("John") 108 | // .lastName("Papadopoulos") 109 | // .accountId(Long.MIN_VALUE) 110 | // .email("john.papadopoulos@mail.com") 111 | // .build()))), 112 | // service("http://localhost:8006") 113 | // .get("/number-information") 114 | // .willReturn(success() 115 | // .body("Valid")), 116 | // service("http://localhost:8005") 117 | // .get("/notifications") 118 | // .willReturn(success() 119 | // .body(json(NotificationResultDTO.builder() 120 | // .status("OK") 121 | // .message("A message") 122 | // .build())))), 123 | // HoverflyConfig.localConfigs().proxyLocalHost().proxyPort(7999)).printSimulationData(); 124 | 125 | @Test 126 | void contextLoads() { 127 | } 128 | 129 | @Test 130 | void testSend_success() throws Exception { 131 | 132 | SendForm requestForm = new SendForm(); 133 | requestForm.setMsisdn("00306933177321"); 134 | webTestClient.post() 135 | .uri("/v1/otp") 136 | .body(Mono.just(requestForm), SendForm.class) 137 | .exchange() 138 | .expectStatus() 139 | .is2xxSuccessful(); 140 | } 141 | 142 | @TestConfiguration 143 | @Getter 144 | @Setter 145 | protected static class TestConfig { 146 | 147 | @Bean 148 | public ServiceInstanceListSupplier discoveryClientServiceInstanceListSupplier() { 149 | 150 | return new ServiceInstanceListSupplier() { 151 | 152 | @Override 153 | public String getServiceId() { 154 | return "customer-service"; 155 | } 156 | 157 | @Override 158 | public Flux> get() { 159 | 160 | ServiceInstance instance1 = new ServiceInstance() { 161 | 162 | @Override 163 | public String getServiceId() { 164 | return "customer-service"; 165 | } 166 | 167 | @Override 168 | public String getHost() { 169 | return "localhost"; 170 | } 171 | 172 | @Override 173 | public int getPort() { 174 | return 8999; 175 | } 176 | 177 | @Override 178 | public boolean isSecure() { 179 | return false; 180 | } 181 | 182 | @Override 183 | public URI getUri() { 184 | return URI.create("http://localhost:8999"); 185 | } 186 | 187 | @Override 188 | public Map getMetadata() { 189 | return null; 190 | } 191 | }; 192 | 193 | Flux serviceInstances = Flux 194 | .defer(() -> Flux.fromIterable(List.of(instance1))) 195 | .subscribeOn(Schedulers.boundedElastic()); 196 | return serviceInstances.collectList().flux(); 197 | } 198 | }; 199 | } 200 | } 201 | 202 | } 203 | -------------------------------------------------------------------------------- /otp-service/src/test/java/gr/kmandalas/service/otp/util/PostgresContainer.java: -------------------------------------------------------------------------------- 1 | package gr.kmandalas.service.otp.util; 2 | 3 | import org.testcontainers.containers.PostgreSQLContainer; 4 | 5 | public class PostgresContainer extends PostgreSQLContainer { 6 | 7 | private static final String IMAGE_VERSION = "postgres:latest"; 8 | private static PostgresContainer container; 9 | 10 | private PostgresContainer() { 11 | super(IMAGE_VERSION); 12 | } 13 | 14 | public static PostgresContainer getInstance() { 15 | if (container == null) { 16 | container = new PostgresContainer(); 17 | } 18 | return container; 19 | } 20 | 21 | @Override 22 | public void start() { 23 | super.start(); 24 | System.setProperty("DB_URL", container.getJdbcUrl()); 25 | System.setProperty("DB_URL_R2DBC", container.getR2dbcUrl()); 26 | System.setProperty("DB_USERNAME", container.getUsername()); 27 | System.setProperty("DB_PASSWORD", container.getPassword()); 28 | } 29 | 30 | @Override 31 | public void stop() { 32 | super.stop(); 33 | } 34 | 35 | private String getR2dbcUrl() { 36 | return String.format("r2dbc:postgresql://%s:%d/%s?loggerLevel=OFF", this.getHost(), this.getMappedPort(POSTGRESQL_PORT), this.getDatabaseName()); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /otp-service/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | flyway: 3 | url: ${DB_URL} 4 | password: ${DB_PASSWORD} 5 | baseline-on-migrate: true 6 | user: ${DB_USERNAME} 7 | r2dbc: 8 | url: ${DB_URL_R2DBC} 9 | username: ${DB_USERNAME} 10 | password: ${DB_PASSWORD} 11 | 12 | external: 13 | services: 14 | notifications: http://localhost:8999/notifications 15 | number-information: http://localhost:8999/number-information -------------------------------------------------------------------------------- /otp-service/src/test/resources/bootstrap.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 0 3 | 4 | spring: 5 | application: 6 | name: otp-service-test 7 | cloud: 8 | # discovery: 9 | # enabled: false 10 | consul: 11 | discovery: 12 | enabled: false 13 | enabled: false 14 | loadbalancer: 15 | ribbon: 16 | enabled: false -------------------------------------------------------------------------------- /otp-service/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss} %5p [%X{X-B3-TraceId:-}] - [%thread] %logger{0} - %msg%n 7 | 8 | 9 | 10 | 11 | 0 12 | 1 13 | false 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 4 | 4.0.0 5 | 6 | gr.kmandalas.services 7 | spring-webclient-showcase 8 | 1.0 9 | pom 10 | 11 | 12 | 11 13 | UTF-8 14 | 15 | 16 | 17 | org.springframework.boot 18 | spring-boot-starter-parent 19 | 2.3.2.RELEASE 20 | 21 | 22 | 23 | 24 | 25 | org.springframework.cloud 26 | spring-cloud-dependencies 27 | Hoxton.SR7 28 | pom 29 | import 30 | 31 | 32 | 33 | 34 | 35 | otp-service 36 | customer-service 37 | gateway-service 38 | notification-service 39 | number-information-service 40 | 41 | 42 | 43 | 44 | spring-milestones 45 | Spring Milestones 46 | https://repo.spring.io/milestone 47 | 48 | 49 | 50 | --------------------------------------------------------------------------------