Skip to content

Commit

Permalink
fix: ErrorHandling and LocalDateTime json serialization (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
antonioT90 authored Feb 5, 2025
1 parent 7054748 commit 6748d91
Show file tree
Hide file tree
Showing 12 changed files with 514 additions and 102 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ val podamVersion = "8.0.2.RELEASE"
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-cache")
Expand All @@ -71,6 +72,7 @@ dependencies {

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")

//security
implementation("org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion")
Expand Down
4 changes: 4 additions & 0 deletions gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2=compileClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=compileClasspath
com.fasterxml.jackson.module:jackson-module-parameter-names:2.18.2=compileClasspath
com.fasterxml.jackson:jackson-bom:2.18.2=compileClasspath
com.fasterxml:classmate:1.7.0=compileClasspath
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath
com.nimbusds:nimbus-jose-jwt:9.37.3=compileClasspath
com.sun.xml.messaging.saaj:saaj-impl:3.0.4=compileClasspath
Expand Down Expand Up @@ -55,6 +56,8 @@ org.apache.tomcat.embed:tomcat-embed-el:10.1.34=compileClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:10.1.34=compileClasspath
org.apache.ws.xmlschema:xmlschema-core:2.3.1=compileClasspath
org.bouncycastle:bcprov-jdk18on:1.79=compileClasspath
org.hibernate.validator:hibernate-validator:8.0.2.Final=compileClasspath
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath
org.jspecify:jspecify:1.0.0=compileClasspath
org.jvnet.staxex:stax-ex:2.1.0=compileClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath
Expand All @@ -73,6 +76,7 @@ org.springframework.boot:spring-boot-starter-json:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-logging:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-tomcat:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-validation:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-web-services:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter-web:3.4.1=compileClasspath
org.springframework.boot:spring-boot-starter:3.4.1=compileClasspath
Expand Down
14 changes: 14 additions & 0 deletions openapi/p4pa-pagopa-payments.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,20 @@ components:
format: date-time
paymentsReportingFileName:
type: string
PagoPaPaymentsErrorDTO:
type: object
required:
- code
- message
properties:
code:
type: string
enum:
- PAGOPA_PAYMENTS_NOT_FOUND
- PAGOPA_PAYMENTS_BAD_REQUEST
- PAGOPA_PAYMENTS_GENERIC_ERROR
message:
type: string
securitySchemes:
BearerAuth:
type: http
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package it.gov.pagopa.pu.pagopapayments.config.json;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;
import java.util.TimeZone;

@Configuration
public class JsonConfig {

@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(configureDateTimeModule());
mapper.registerModule(new Jdk8Module());
mapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.DEFAULT));
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
mapper.setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY);
mapper.setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY);
mapper.setVisibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.PUBLIC_ONLY);
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
mapper.setVisibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.ANY);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.setTimeZone(TimeZone.getDefault());
return mapper;
}

/** openApi is documenting LocalDateTime as date-time, which is interpreted as an OffsetDateTime by openApiGenerator */
private static SimpleModule configureDateTimeModule() {
return new JavaTimeModule()
.addSerializer(LocalDateTime.class, new LocalDateTimeToOffsetDateTimeSerializer())
.addDeserializer(LocalDateTime.class, new OffsetDateTimeToLocalDateTimeDeserializer());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package it.gov.pagopa.pu.pagopapayments.config.json;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;

@Configuration
public class LocalDateTimeToOffsetDateTimeSerializer extends JsonSerializer<LocalDateTime> {

@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value != null) {
OffsetDateTime offsetDateTime = value.atZone(ZoneId.systemDefault()).toOffsetDateTime();
gen.writeString(offsetDateTime.toString());
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package it.gov.pagopa.pu.pagopapayments.config.json;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;


@Configuration
public class OffsetDateTimeToLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {

String dateString = p.getValueAsString();
if(dateString.contains("+")){
return OffsetDateTime.parse(dateString).toLocalDateTime();
} else {
return LocalDateTime.parse(dateString);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package it.gov.pagopa.pu.pagopapayments.exception;

import com.fasterxml.jackson.databind.JsonMappingException;
import it.gov.pagopa.pu.pagopapayments.dto.generated.PagoPaPaymentsErrorDTO;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ValidationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
public class PagopaPaymentsExceptionHandler {

@ExceptionHandler(NotFoundException.class)
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public ResponseEntity<PagoPaPaymentsErrorDTO> handleResourceNotFoundException(NotFoundException ex, HttpServletRequest request) {
return handleException(ex, request, HttpStatus.NOT_FOUND, PagoPaPaymentsErrorDTO.CodeEnum.NOT_FOUND);
}

@ExceptionHandler({ValidationException.class, HttpMessageNotReadableException.class, MethodArgumentNotValidException.class})
public ResponseEntity<PagoPaPaymentsErrorDTO> handleViolationException(Exception ex, HttpServletRequest request) {
return handleException(ex, request, HttpStatus.BAD_REQUEST, PagoPaPaymentsErrorDTO.CodeEnum.BAD_REQUEST);
}

@ExceptionHandler({ServletException.class})
public ResponseEntity<PagoPaPaymentsErrorDTO> handleServletException(ServletException ex, HttpServletRequest request) {
HttpStatusCode httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
PagoPaPaymentsErrorDTO.CodeEnum errorCode = PagoPaPaymentsErrorDTO.CodeEnum.GENERIC_ERROR;
if (ex instanceof ErrorResponse errorResponse) {
httpStatus = errorResponse.getStatusCode();
if (httpStatus.is4xxClientError()) {
errorCode = PagoPaPaymentsErrorDTO.CodeEnum.BAD_REQUEST;
}
}
return handleException(ex, request, httpStatus, errorCode);
}

@ExceptionHandler({RuntimeException.class})
public ResponseEntity<PagoPaPaymentsErrorDTO> handleRuntimeException(RuntimeException ex, HttpServletRequest request) {
return handleException(ex, request, HttpStatus.INTERNAL_SERVER_ERROR, PagoPaPaymentsErrorDTO.CodeEnum.GENERIC_ERROR);
}

static ResponseEntity<PagoPaPaymentsErrorDTO> handleException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus, PagoPaPaymentsErrorDTO.CodeEnum errorEnum) {
logException(ex, request, httpStatus);

String message = buildReturnedMessage(ex);

return ResponseEntity
.status(httpStatus)
.body(new PagoPaPaymentsErrorDTO(errorEnum, message));
}

private static void logException(Exception ex, HttpServletRequest request, HttpStatusCode httpStatus) {
log.info("A {} occurred handling request {}: HttpStatus {} - {}",
ex.getClass(),
getRequestDetails(request),
httpStatus.value(),
ex.getMessage());
}

private static String buildReturnedMessage(Exception ex) {
if (ex instanceof HttpMessageNotReadableException) {
if(ex.getCause() instanceof JsonMappingException jsonMappingException){
return "Cannot parse body: " +
jsonMappingException.getPath().stream()
.map(JsonMappingException.Reference::getFieldName)
.collect(Collectors.joining(".")) +
": " + jsonMappingException.getOriginalMessage();
}
return "Required request body is missing";
} else if (ex instanceof MethodArgumentNotValidException methodArgumentNotValidException) {
return "Invalid request content:" +
methodArgumentNotValidException.getBindingResult()
.getAllErrors().stream()
.map(e -> " " +
(e instanceof FieldError fieldError? fieldError.getField(): e.getObjectName()) +
": " + e.getDefaultMessage())
.sorted()
.collect(Collectors.joining(";"));
} else {
return ex.getMessage();
}
}

static String getRequestDetails(HttpServletRequest request) {
return "%s %s".formatted(request.getMethod(), request.getRequestURI());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package it.gov.pagopa.pu.pagopapayments.config.json;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.io.IOException;
import java.time.LocalDateTime;
import java.util.TimeZone;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;

@ExtendWith(MockitoExtension.class)
class LocalDateTimeToOffsetDateTimeSerializerTest {

@Mock
private JsonGenerator jsonGenerator;

@Mock
private SerializerProvider serializerProvider;

private LocalDateTimeToOffsetDateTimeSerializer dateTimeSerializer;

@BeforeEach
public void setUp() {
dateTimeSerializer = new LocalDateTimeToOffsetDateTimeSerializer();
}

@Test
void testDateSerializer() throws IOException {
LocalDateTime localDateTime = LocalDateTime.of(2025, 1, 16, 9, 15, 20);

TimeZone.setDefault(TimeZone.getTimeZone("Europe/Rome"));

dateTimeSerializer.serialize(localDateTime, jsonGenerator, serializerProvider);

verify(jsonGenerator).writeString("2025-01-16T09:15:20+01:00");
}

@Test
void testNullDateSerializer() throws IOException {
dateTimeSerializer.serialize(null, jsonGenerator, serializerProvider);

verifyNoInteractions(jsonGenerator);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package it.gov.pagopa.pu.pagopapayments.config.json;

import com.fasterxml.jackson.core.JsonParser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;

class OffsetDateTimeToLocalDateTimeDeserializerTest {

private final OffsetDateTimeToLocalDateTimeDeserializer deserializer = new OffsetDateTimeToLocalDateTimeDeserializer();

@Test
void givenOffsetDateTimeWhenThenOk() throws IOException {
// Given
OffsetDateTime offsetDateTime = OffsetDateTime.now();
JsonParser parser = Mockito.mock(JsonParser.class);
Mockito.when(parser.getValueAsString())
.thenReturn(offsetDateTime.toString());

// When
LocalDateTime result = deserializer.deserialize(parser, null);

// Then
Assertions.assertEquals(offsetDateTime.toLocalDateTime(), result);
}

@Test
void givenLocalDateTimeWhenThenOk() throws IOException {
// Given
LocalDateTime localDateTime = LocalDateTime.now();
JsonParser parser = Mockito.mock(JsonParser.class);
Mockito.when(parser.getValueAsString())
.thenReturn(localDateTime.toString());

// When
LocalDateTime result = deserializer.deserialize(parser, null);

// Then
Assertions.assertEquals(localDateTime, result);
}
}
Loading

0 comments on commit 6748d91

Please sign in to comment.