diff --git a/spring-reactive-modules/spring-reactive-3/pom.xml b/spring-reactive-modules/spring-reactive-3/pom.xml index 17c9690157..45dd25794e 100644 --- a/spring-reactive-modules/spring-reactive-3/pom.xml +++ b/spring-reactive-modules/spring-reactive-3/pom.xml @@ -64,6 +64,11 @@ org.springframework.session spring-session-data-redis + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/Application.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/Application.java new file mode 100644 index 0000000000..0fcdb3a2fb --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/Application.java @@ -0,0 +1,13 @@ +package com.baeldung.custom.deserialization; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CodecCustomizerConfig.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CodecCustomizerConfig.java new file mode 100644 index 0000000000..ef3eb1e97f --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CodecCustomizerConfig.java @@ -0,0 +1,27 @@ +package com.baeldung.custom.deserialization.config; + +import org.springframework.boot.web.codec.CodecCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.util.MimeType; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Configuration +public class CodecCustomizerConfig { + + @Bean + public CodecCustomizer codecCustomizer(ObjectMapper customObjectMapper) { + return configurer -> { + MimeType mimeType = MimeType.valueOf(MediaType.APPLICATION_JSON_VALUE); + CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs(); + customCodecs.register(new Jackson2JsonDecoder(customObjectMapper, mimeType)); + customCodecs.register(new Jackson2JsonEncoder(customObjectMapper, mimeType)); + }; + } + +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomDeserializer.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomDeserializer.java new file mode 100644 index 0000000000..2eeb250e0b --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomDeserializer.java @@ -0,0 +1,21 @@ +package com.baeldung.custom.deserialization.config; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; + +public class CustomDeserializer extends LocalDateTimeDeserializer { + @Override + public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException { + try { + return OffsetDateTime.parse(jsonParser.getText()).atZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } catch (Exception e) { + return super.deserialize(jsonParser, ctxt); + } + } +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomObjectMapper.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomObjectMapper.java new file mode 100644 index 0000000000..9ebd85abf8 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/config/CustomObjectMapper.java @@ -0,0 +1,18 @@ +package com.baeldung.custom.deserialization.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +public class CustomObjectMapper { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) + .registerModule(new JavaTimeModule()); + } +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/controller/OrderController.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/controller/OrderController.java new file mode 100644 index 0000000000..20327333f1 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/controller/OrderController.java @@ -0,0 +1,37 @@ +package com.baeldung.custom.deserialization.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.baeldung.custom.deserialization.model.OrderResponse; +import com.baeldung.custom.deserialization.service.ExternalServiceV1; +import com.baeldung.custom.deserialization.service.ExternalServiceV2; + +import reactor.core.publisher.Mono; + +@RestController +public class OrderController { + + private final ExternalServiceV1 externalServiceV1; + private final ExternalServiceV2 externalServiceV2; + + public OrderController(ExternalServiceV1 externalServiceV1, ExternalServiceV2 externalServiceV2) { + this.externalServiceV1 = externalServiceV1; + this.externalServiceV2 = externalServiceV2; + } + + @GetMapping(value = "v1/order/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public final Mono searchOrderV1(@PathVariable(value = "id") int id) { + return externalServiceV1.findById(id) + .bodyToMono(OrderResponse.class); + } + + @GetMapping(value = "v2/order/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public final Mono searchOrderV2(@PathVariable(value = "id") int id) { + return externalServiceV2.findById(id) + .bodyToMono(OrderResponse.class); + } + +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/model/OrderResponse.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/model/OrderResponse.java new file mode 100644 index 0000000000..18d45e7dba --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/model/OrderResponse.java @@ -0,0 +1,19 @@ +package com.baeldung.custom.deserialization.model; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import lombok.Data; + +@Data +public class OrderResponse { + + private UUID orderId; + + private LocalDateTime orderDateTime; + + private List address; + + private List orderNotes; +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV1.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV1.java new file mode 100644 index 0000000000..34c78ae416 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV1.java @@ -0,0 +1,23 @@ +package com.baeldung.custom.deserialization.service; + +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class ExternalServiceV1 { + + private final WebClient.Builder webclientBuilder; + + public ExternalServiceV1(WebClient.Builder webclientBuilder) { + this.webclientBuilder = webclientBuilder; + } + + public WebClient.ResponseSpec findById(int id) { + return webclientBuilder.baseUrl("http://localhost:8090/") + .build() + .get() + .uri("external/order/" + id) + .retrieve(); + } + +} diff --git a/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV2.java b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV2.java new file mode 100644 index 0000000000..0f976a42f2 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/main/java/com/baeldung/custom/deserialization/service/ExternalServiceV2.java @@ -0,0 +1,40 @@ +package com.baeldung.custom.deserialization.service; + +import java.time.LocalDateTime; + +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +import com.baeldung.custom.deserialization.config.CustomDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; + +@Component +public class ExternalServiceV2 { + + public WebClient.ResponseSpec findById(int id) { + + ObjectMapper objectMapper = new ObjectMapper().registerModule(new SimpleModule().addDeserializer(LocalDateTime.class, new CustomDeserializer())); + + WebClient webClient = WebClient.builder() + .baseUrl("http://localhost:8090/") + .exchangeStrategies(ExchangeStrategies.builder() + .codecs(clientDefaultCodecsConfigurer -> { + clientDefaultCodecsConfigurer.defaultCodecs() + .jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON)); + clientDefaultCodecsConfigurer.defaultCodecs() + .jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON)); + }) + .build()) + .build(); + + return webClient.get() + .uri("external/order/" + id) + .retrieve(); + } + +} diff --git a/spring-reactive-modules/spring-reactive-3/src/test/java/com/baeldung/custom/deserialization/OrderControllerIntegrationTest.java b/spring-reactive-modules/spring-reactive-3/src/test/java/com/baeldung/custom/deserialization/OrderControllerIntegrationTest.java new file mode 100644 index 0000000000..9c8bd7c0d4 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-3/src/test/java/com/baeldung/custom/deserialization/OrderControllerIntegrationTest.java @@ -0,0 +1,135 @@ +package com.baeldung.custom.deserialization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.reactive.server.WebTestClient; + +import com.baeldung.custom.deserialization.model.OrderResponse; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@TestPropertySource(properties = "server.port=8091") +@AutoConfigureWebTestClient(timeout = "100000") +class OrderControllerIntegrationTest { + + @Autowired + private WebTestClient webTestClient; + + private static MockWebServer mockExternalService; + + @BeforeAll + static void setup() throws IOException { + mockExternalService = new MockWebServer(); + mockExternalService.start(8090); + } + + @Test + void givenMockedExternalResponse_whenSearchByIdV1_thenOrderResponseShouldFailBecauseOfUnknownProperty() { + + mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8") + .setBody("{\n" + " \"orderId\": \"a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab\",\n" + + " \"orderDateTime\": \"2024-01-20T12:34:56\",\n" + + " \"address\": [\"123 Main St\", \"Apt 456\", \"Cityville\"],\n" + + " \"orderNotes\": [\"Special request: Handle with care\", \"Gift wrapping required\"],\n" + + " \"customerName\": \"John Doe\",\n" + " \"totalAmount\": 99.99,\n" + + " \"paymentMethod\": \"Credit Card\"\n" + " }") + .setResponseCode(HttpStatus.OK.value())); + + webTestClient.get() + .uri("v1/order/1") + .exchange() + .expectStatus() + .is5xxServerError(); + } + + @Test + void givenMockedExternalResponse_whenSearchByIdV1_thenOrderResponseShouldBeReceivedSuccessfully() { + + mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8") + .setBody("{\n" + " \"orderId\": \"a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab\",\n" + + " \"orderDateTime\": \"2024-01-20T12:34:56\",\n" + + " \"address\": [\"123 Main St\", \"Apt 456\", \"Cityville\"],\n" + + " \"orderNotes\": [\"Special request: Handle with care\", \"Gift wrapping required\"]\n" + + " }") + .setResponseCode(HttpStatus.OK.value())); + + OrderResponse orderResponse = webTestClient.get() + .uri("v1/order/1") + .exchange() + .expectStatus() + .isOk() + .expectBody(OrderResponse.class) + .returnResult() + .getResponseBody(); + assertEquals(UUID.fromString("a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab"), orderResponse.getOrderId()); + assertEquals(LocalDateTime.of(2024, 1, 20, 12, 34, 56), orderResponse.getOrderDateTime()); + assertThat(orderResponse.getAddress()).hasSize(3); + assertThat(orderResponse.getOrderNotes()).hasSize(2); + } + + @Test + void givenMockedExternalResponse_whenSearchByIdV2_thenOrderResponseShouldFailBecauseOfUnknownProperty() { + + mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8") + .setBody("{\n" + " \"orderId\": \"a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab\",\n" + + " \"orderDateTime\": \"2024-01-20T12:34:56\",\n" + + " \"address\": [\"123 Main St\", \"Apt 456\", \"Cityville\"],\n" + + " \"orderNotes\": [\"Special request: Handle with care\", \"Gift wrapping required\"],\n" + + " \"customerName\": \"John Doe\",\n" + + " \"totalAmount\": 99.99,\n" + + " \"paymentMethod\": \"Credit Card\"\n" + + " }") + .setResponseCode(HttpStatus.OK.value())); + + webTestClient.get() + .uri("v2/order/1") + .exchange() + .expectStatus() + .is5xxServerError(); + } + + @Test + void givenMockedExternalResponse_whenSearchByIdV2_thenOrderResponseShouldBeReceivedSuccessfully() { + + mockExternalService.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8") + .setBody("{\n" + " \"orderId\": \"a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab\",\n" + + " \"orderDateTime\": \"2024-01-20T14:34:56+01:00\",\n" + + " \"address\": [\"123 Main St\", \"Apt 456\", \"Cityville\"],\n" + + " \"orderNotes\": [\"Special request: Handle with care\", \"Gift wrapping required\"]\n" + " }") + .setResponseCode(HttpStatus.OK.value())); + + OrderResponse orderResponse = webTestClient.get() + .uri("v2/order/1") + .exchange() + .expectStatus() + .isOk() + .expectBody(OrderResponse.class) + .returnResult() + .getResponseBody(); + assertEquals(UUID.fromString("a1b2c3d4-e5f6-4a5b-8c9d-0123456789ab"), orderResponse.getOrderId()); + assertEquals(LocalDateTime.of(2024, 1, 20, 13, 34, 56), orderResponse.getOrderDateTime()); + assertThat(orderResponse.getAddress()).hasSize(3); + assertThat(orderResponse.getOrderNotes()).hasSize(2); + } + + @AfterAll + static void tearDown() throws IOException { + mockExternalService.shutdown(); + } + +} \ No newline at end of file