Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Product.type to Support Both String and Enum Values with Enhanced Deserialization #463

Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 74 additions & 14 deletions src/main/java/com/checkout/GsonSerializer.java
Original file line number Diff line number Diff line change
@@ -18,6 +18,8 @@
import com.checkout.issuing.controls.responses.create.MccCardControlResponse;
import com.checkout.issuing.controls.responses.create.VelocityCardControlResponse;
import com.checkout.payments.PaymentDestinationType;
import com.checkout.payments.Product;
import com.checkout.payments.ProductType;
import com.checkout.payments.previous.PaymentAction;
import com.checkout.payments.sender.Sender;
import com.checkout.payments.sender.SenderType;
@@ -36,8 +38,10 @@
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
import lombok.Getter;
import org.apache.commons.lang3.EnumUtils;

import java.lang.reflect.Type;
@@ -55,6 +59,7 @@
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Getter
public class GsonSerializer implements Serializer {

private static final List<DateTimeFormatter> DEFAULT_FORMATTERS = Arrays.asList(
@@ -149,6 +154,7 @@ public class GsonSerializer implements Serializer {
.registerTypeAdapter(WEBHOOKS_TYPE, webhooksResponseDeserializer())
.registerTypeAdapter(PREVIOUS_PAYMENT_ACTIONS_TYPE, paymentActionsResponsePreviousDeserializer())
.registerTypeAdapter(PAYMENT_ACTIONS_TYPE, paymentActionsResponseDeserializer())
.registerTypeAdapter(Product.class, getProductDeserializer())
.create();

private final Gson gson;
@@ -161,10 +167,6 @@ public GsonSerializer(final Gson gson) {
this.gson = gson;
}

public Gson getGson() {
return gson;
}

@Override
public <T> String toJson(final T object) {
return gson.toJson(object);
@@ -276,18 +278,16 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
return (json, typeOfT, context) -> {
String dateString;

// Check if the JSON is a number or a string
if (json.isJsonPrimitive() && json.getAsJsonPrimitive().isNumber()) {
dateString = String.valueOf(json.getAsLong()); // Convert numeric value to string
dateString = String.valueOf(json.getAsLong());
} else {
dateString = json.getAsString(); // Use the string value directly
dateString = json.getAsString();
}

try {
// Try parsing the string as an ISO-8601 Instant
return Instant.parse(dateString);
} catch (final DateTimeParseException ex) {
if (dateString.matches("\\d{8}")) { // Handle numeric format yyyyMMdd
if (dateString.matches("\\d{8}")) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
LocalDateTime dateTime = LocalDate.parse(dateString, formatter).atStartOfDay();
@@ -296,8 +296,7 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
throw new JsonParseException("Failed to parse numeric date in format yyyyMMdd: " + dateString, e);
}
}
// Explicitly handle the yyyy-MM-dd format
if (dateString.length() == 10) { // Handle format yyyy-MM-dd
if (dateString.length() == 10) {
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate date = LocalDate.parse(dateString, formatter);
@@ -306,18 +305,79 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
throw new JsonParseException("Failed to parse date in format yyyy-MM-dd: " + dateString, e);
}
}
// Attempt parsing with the DEFAULT_FORMATTERS
for (final DateTimeFormatter formatter : DEFAULT_FORMATTERS) {
try {
final LocalDateTime dateTime = LocalDateTime.parse(dateString, formatter);
return dateTime.toInstant(ZoneOffset.UTC);
} catch (final DateTimeParseException ignored) {
// Continue with the next formatter
}
}
// Rethrow the original exception if no format matches
throw ex;
}
};
}

private static JsonDeserializer<Product> getProductDeserializer() {
return (json, typeOfT, context) -> {
Product product = new Product();
JsonObject jsonObject = json.getAsJsonObject();

JsonElement typeElement = jsonObject.get("type");
Object typeValue = null;

if (typeElement != null && typeElement.isJsonPrimitive()) {
String typeAsString = typeElement.getAsString();
if (EnumUtils.isValidEnumIgnoreCase(ProductType.class, typeAsString)) {
typeValue = ProductType.valueOf(typeAsString.toUpperCase());
} else {
typeValue = typeAsString;
}
}
product.setType(typeValue);


jsonObject.entrySet().stream()
.filter(entry -> !entry.getKey().equals("type"))
.forEach(entry -> {
try {
String jsonKey = entry.getKey();
JsonElement jsonValue = entry.getValue();

java.lang.reflect.Field field = Arrays.stream(Product.class.getDeclaredFields())
.filter(f -> {
SerializedName annotation = f.getAnnotation(SerializedName.class);
return (annotation != null && annotation.value().equals(jsonKey)) || f.getName().equals(jsonKey);
})
.findFirst()
.orElse(null);

if (field != null) {
field.setAccessible(true);
Class<?> fieldType = field.getType();

if (jsonValue.isJsonNull()) {
field.set(product, null);
} else if (fieldType.equals(String.class)) {
field.set(product, jsonValue.getAsString());
} else if (fieldType.equals(Long.class) || fieldType.equals(long.class)) {
field.set(product, jsonValue.getAsLong());
} else if (fieldType.equals(Instant.class)) {
String dateString = jsonValue.getAsString();
if (dateString.matches("\\d{4}-\\d{2}-\\d{2}")) {
dateString += "T00:00:00Z";
}
field.set(product, Instant.parse(dateString));
} else {
Object nestedObject = context.deserialize(jsonValue, fieldType);
field.set(product, nestedObject);
}
}
} catch (IllegalAccessException e) {
System.err.println("Error setting field: " + entry.getKey() + ", " + e.getMessage());
}
});

return product;
};
}
}
12 changes: 11 additions & 1 deletion src/main/java/com/checkout/payments/Product.java
Original file line number Diff line number Diff line change
@@ -4,15 +4,17 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.Instant;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public final class Product {

private ProductType type;
private Object type;

private String name;

@@ -54,4 +56,12 @@ public final class Product {
@SerializedName("service_ends_on")
private Instant serviceEndsOn;

public ProductType getTypeAsEnum() {
return type instanceof ProductType ? (ProductType) type : null;
}

public String getTypeAsString() {
return type instanceof String ? (String) type : null;
}

}
34 changes: 34 additions & 0 deletions src/test/java/com/checkout/GsonSerializerTest.java
Original file line number Diff line number Diff line change
@@ -5,6 +5,8 @@
import com.checkout.issuing.cardholders.CardholderCardsResponse;
import com.checkout.issuing.cards.responses.PhysicalCardDetailsResponse;
import com.checkout.issuing.cards.responses.VirtualCardDetailsResponse;
import com.checkout.payments.Product;
import com.checkout.payments.ProductType;
import com.checkout.payments.contexts.PaymentContextDetailsResponse;
import com.checkout.payments.previous.response.GetPaymentResponse;
import com.checkout.payments.previous.response.PaymentResponse;
@@ -171,6 +173,38 @@ void shouldSerializePaymentDetailsResponseFromJson() {
assertNotNull(paymentDetailsResponse.getPaymentPlan());
}

@Test
void shouldDeserializeProductWithEnumType() {
String json = "{ \"type\": \"DIGITAL\", \"name\": \"Product Name\" }";

Product product = serializer.fromJson(json, Product.class);

assertNotNull(product);
assertEquals(ProductType.DIGITAL, product.getTypeAsEnum());
assertNull(product.getTypeAsString());
}

@Test
void shouldDeserializeProductWithUnknownEnumValue() {
String json = "{ \"type\": \"UNKNOWN_VALUE\", \"name\": \"Product Name\", \"quantity\": 1, \"unit_price\": 1000 }";

Product product = serializer.fromJson(json, Product.class);

assertNotNull(product);
assertEquals("UNKNOWN_VALUE", product.getTypeAsString());
assertNull(product.getTypeAsEnum());
}

@Test
void shouldDeserializeProductWithNullType() {
String json = "{ \"type\": null, \"name\": \"Product Name\" }";

Product product = serializer.fromJson(json, Product.class);

assertNotNull(product);
assertNull(product.getType());
}

@Test
void shouldDeserializeMultipleDateFormats() {
Instant instant = Instant.parse("2021-06-08T00:00:00Z");
126 changes: 126 additions & 0 deletions src/test/java/com/checkout/payments/GetPaymentsTestIT.java
Original file line number Diff line number Diff line change
@@ -12,6 +12,8 @@
import com.checkout.payments.sender.PaymentIndividualSender;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -21,6 +23,7 @@
import static com.checkout.CardSourceHelper.getRequestCardSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@@ -48,6 +51,129 @@ void shouldGetCardPayment() {

}

@Test
void shouldGetPaymentWithItemsUsingEnumType() {

Product product = Product.builder()
.type(ProductType.DIGITAL)
.name("Test Product")
.quantity(1L)
.unitPrice(1000L)
.build();

PaymentRequest request = PaymentRequest.builder()
.source(getRequestCardSource())
.reference(UUID.randomUUID().toString())
.amount(1000L)
.currency(Currency.EUR)
.processingChannelId(System.getenv("CHECKOUT_PROCESSING_CHANNEL_ID"))
.items(Collections.singletonList(product))
.build();

PaymentResponse payment = blocking(() -> paymentsClient.requestPayment(request));

GetPaymentResponse paymentReturned = blocking(() -> paymentsClient.getPayment(payment.getId()));

assertNotNull(paymentReturned);
assertNotNull(paymentReturned.getItems());
assertEquals(1, paymentReturned.getItems().size());

Product returnedProduct = paymentReturned.getItems().get(0);
assertEquals(ProductType.DIGITAL, returnedProduct.getTypeAsEnum());
assertEquals(ProductType.DIGITAL, returnedProduct.getType());
assertNull(returnedProduct.getTypeAsString());
assertEquals("Test Product", returnedProduct.getName());
assertEquals(1L, returnedProduct.getQuantity());
assertEquals(1000L, returnedProduct.getUnitPrice());
}

@Test
void shouldGetPaymentWithItemsUsingStringType() {

Product product = Product.builder()
.type("CustomType")
.name("Custom Product")
.quantity(2L)
.unitPrice(2000L)
.build();

PaymentRequest request = PaymentRequest.builder()
.source(getRequestCardSource())
.reference(UUID.randomUUID().toString())
.amount(2000L)
.currency(Currency.EUR)
.processingChannelId(System.getenv("CHECKOUT_PROCESSING_CHANNEL_ID"))
.items(Collections.singletonList(product))
.build();

PaymentResponse payment = blocking(() -> paymentsClient.requestPayment(request));

GetPaymentResponse paymentReturned = blocking(() -> paymentsClient.getPayment(payment.getId()));

assertNotNull(paymentReturned);
assertNotNull(paymentReturned.getItems());
assertEquals(1, paymentReturned.getItems().size());

Product returnedProduct = paymentReturned.getItems().get(0);
assertEquals("CustomType", returnedProduct.getTypeAsString());
assertEquals("CustomType", returnedProduct.getType());
assertNull(returnedProduct.getTypeAsEnum());
assertEquals("Custom Product", returnedProduct.getName());
assertEquals(2L, returnedProduct.getQuantity());
assertEquals(2000L, returnedProduct.getUnitPrice());
}

@Test
void shouldGetPaymentWithMultipleItems() {

Product enumProduct = Product.builder()
.type(ProductType.PHYSICAL)
.name("Physical Product")
.quantity(1L)
.unitPrice(1500L)
.build();

Product stringProduct = Product.builder()
.type("CustomType")
.name("Custom Product")
.quantity(2L)
.unitPrice(3000L)
.build();

PaymentRequest request = PaymentRequest.builder()
.source(getRequestCardSource())
.reference(UUID.randomUUID().toString())
.amount(4500L)
.currency(Currency.EUR)
.processingChannelId(System.getenv("CHECKOUT_PROCESSING_CHANNEL_ID"))
.items(Arrays.asList(enumProduct, stringProduct))
.build();

PaymentResponse payment = blocking(() -> paymentsClient.requestPayment(request));

GetPaymentResponse paymentReturned = blocking(() -> paymentsClient.getPayment(payment.getId()));

assertNotNull(paymentReturned);
assertNotNull(paymentReturned.getItems());
assertEquals(2, paymentReturned.getItems().size());

Product returnedEnumProduct = paymentReturned.getItems().get(0);
assertEquals(ProductType.PHYSICAL, returnedEnumProduct.getTypeAsEnum());
assertEquals(ProductType.PHYSICAL, returnedEnumProduct.getType());
assertNull(returnedEnumProduct.getTypeAsString());
assertEquals("Physical Product", returnedEnumProduct.getName());
assertEquals(1L, returnedEnumProduct.getQuantity());
assertEquals(1500L, returnedEnumProduct.getUnitPrice());

Product returnedStringProduct = paymentReturned.getItems().get(1);
assertEquals("CustomType", returnedStringProduct.getTypeAsString());
assertEquals("CustomType", returnedStringProduct.getType());
assertNull(returnedStringProduct.getTypeAsEnum());
assertEquals("Custom Product", returnedStringProduct.getName());
assertEquals(2L, returnedStringProduct.getQuantity());
assertEquals(3000L, returnedStringProduct.getUnitPrice());
}

@Test
void shouldGetCardPaymentWithMetadata() {