├── .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 | 
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 |
--------------------------------------------------------------------------------