|
40 | 40 | import com.google.gson.JsonSerializationContext; |
41 | 41 | import com.google.gson.JsonSerializer; |
42 | 42 | import com.google.gson.annotations.SerializedName; |
| 43 | +import com.google.gson.TypeAdapter; |
| 44 | +import com.google.gson.TypeAdapterFactory; |
| 45 | +import com.google.gson.stream.JsonReader; |
| 46 | +import com.google.gson.stream.JsonWriter; |
| 47 | +import java.io.IOException; |
43 | 48 | import com.google.gson.reflect.TypeToken; |
44 | 49 | import com.google.gson.typeadapters.RuntimeTypeAdapterFactory; |
45 | 50 | import lombok.Getter; |
@@ -108,6 +113,24 @@ public final class GsonSerializer implements Serializer { |
108 | 113 | .registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>) (LocalDate date, Type typeOfSrc, JsonSerializationContext context) -> |
109 | 114 | new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE))) |
110 | 115 | .registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer()) |
| 116 | + // processing.airline_data[].passenger arrives as an array or as a single object. |
| 117 | + // These two read both shapes into a List. |
| 118 | + // |
| 119 | + // Bound by element type, so the second registration also covers PaymentSetupAirline |
| 120 | + // .passengers, which the spec declares array-only. Accepting a bare object there on |
| 121 | + // READ is wider than the spec grants but cannot lose data. Writing is handled by |
| 122 | + // singleOrArrayPassengerFactory below, which is scoped to the two airline types so |
| 123 | + // that setups .passengers keeps emitting an array (the API rejects an object there |
| 124 | + // with industry.airline[0].passengers_property_invalid). |
| 125 | + .registerTypeAdapter( |
| 126 | + new TypeToken<List<com.checkout.payments.Passenger>>() { |
| 127 | + }.getType(), |
| 128 | + singleOrArrayDeserializer(com.checkout.payments.Passenger.class)) |
| 129 | + .registerTypeAdapter( |
| 130 | + new TypeToken<List<com.checkout.payments.contexts.PaymentContextsPassenger>>() { |
| 131 | + }.getType(), |
| 132 | + singleOrArrayDeserializer(com.checkout.payments.contexts.PaymentContextsPassenger.class)) |
| 133 | + .registerTypeAdapterFactory(singleOrArrayPassengerFactory()) |
111 | 134 | // Payments - AbstractSource (polymorphic deserialization) |
112 | 135 | .registerTypeAdapterFactory( |
113 | 136 | RuntimeTypeAdapterFactory.of( |
@@ -454,6 +477,116 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() { |
454 | 477 | }; |
455 | 478 | } |
456 | 479 |
|
| 480 | + /** |
| 481 | + * Reads a property the specification declares as {@code oneOf[array, object]} into a list, |
| 482 | + * accepting either shape on the wire and normalizing a bare object into a single-element list. |
| 483 | + * <p> |
| 484 | + * The first property to need this is {@code processing.airline_data[].passenger}. |
| 485 | + * {@code AirlineData} declares it as an array, while |
| 486 | + * {@code PaymentInterfacesProcessingAirlineData} declares it as {@code oneOf[array, object]} |
| 487 | + * with the note "PayPal requires a single object". Both branches resolve to the same object, |
| 488 | + * so normalizing to a list loses nothing. |
| 489 | + * <p> |
| 490 | + * Only a deserializer is registered, never a serializer, so writing still goes through Gson's |
| 491 | + * reflective adapter and always emits an array. That is the only valid outbound shape for |
| 492 | + * {@code AirlineData}. Element deserialization is delegated to the supplied context, so the |
| 493 | + * global {@code LOWER_CASE_WITH_UNDERSCORES} naming policy and the {@code LocalDate} adapter |
| 494 | + * still apply; this deserializer never maps property names itself. |
| 495 | + * |
| 496 | + * @param elementType the list element type |
| 497 | + * @param <T> the list element type |
| 498 | + * @return a deserializer that accepts a single object or an array |
| 499 | + */ |
| 500 | + /** |
| 501 | + * Writes {@code processing.airline_data[].passenger} as a single object when there is exactly |
| 502 | + * one passenger and as an array only when there are several. |
| 503 | + * |
| 504 | + * <p>The live API does not match the specification in either direction. Verified against the |
| 505 | + * sandbox on 2026-09-25 with a complete {@code airline_data} block: |
| 506 | + * |
| 507 | + * <pre> |
| 508 | + * surface passenger: object passenger: array |
| 509 | + * POST /payments 201 201 |
| 510 | + * POST /hosted-payments accepted 422 processing_airline_data_0_passenger_invalid |
| 511 | + * POST /payment-links accepted 422 processing_airline_data_0_passenger_invalid |
| 512 | + * POST /payment-contexts 201 422 passenger_required |
| 513 | + * </pre> |
| 514 | + * |
| 515 | + * <p>A single object is accepted on every request surface; an array only on |
| 516 | + * {@code POST /payments}. {@link com.checkout.payments.ProcessingSettings} is shared by |
| 517 | + * {@code POST /payments}, hosted payments and payment links, so always emitting an array |
| 518 | + * would break the latter two. |
| 519 | + * |
| 520 | + * <p>An empty array and an explicit null are both rejected with |
| 521 | + * {@code processing_airline_data_0_passenger_invalid}, so an empty list drops the member |
| 522 | + * entirely. |
| 523 | + * |
| 524 | + * <p>Scoped to the two airline types by raw class, so {@code PaymentSetupAirline.passengers} |
| 525 | + * is untouched: the API rejects an object there with |
| 526 | + * {@code industry.airline[0].passengers_property_invalid}. |
| 527 | + * |
| 528 | + * @return a factory that fixes up the passenger cardinality on write |
| 529 | + */ |
| 530 | + private static TypeAdapterFactory singleOrArrayPassengerFactory() { |
| 531 | + return new TypeAdapterFactory() { |
| 532 | + @Override |
| 533 | + public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> type) { |
| 534 | + final Class<?> raw = type.getRawType(); |
| 535 | + if (!com.checkout.payments.AirlineData.class.equals(raw) |
| 536 | + && !com.checkout.payments.contexts.PaymentContextsAirlineData.class.equals(raw)) { |
| 537 | + return null; |
| 538 | + } |
| 539 | + |
| 540 | + // getDelegateAdapter returns the adapter Gson would otherwise use, so the |
| 541 | + // reflective serializer still writes every other field and this cannot recurse. |
| 542 | + final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type); |
| 543 | + final TypeAdapter<JsonElement> elements = gson.getAdapter(JsonElement.class); |
| 544 | + |
| 545 | + return new TypeAdapter<T>() { |
| 546 | + @Override |
| 547 | + public void write(final JsonWriter out, final T value) throws IOException { |
| 548 | + final JsonElement tree = delegate.toJsonTree(value); |
| 549 | + if (tree.isJsonObject()) { |
| 550 | + final JsonObject object = tree.getAsJsonObject(); |
| 551 | + final JsonElement passenger = object.get("passenger"); |
| 552 | + if (passenger != null && passenger.isJsonArray()) { |
| 553 | + final JsonArray array = passenger.getAsJsonArray(); |
| 554 | + if (array.size() == 0) { |
| 555 | + object.remove("passenger"); |
| 556 | + } else if (array.size() == 1) { |
| 557 | + object.add("passenger", array.get(0)); |
| 558 | + } |
| 559 | + } |
| 560 | + } |
| 561 | + elements.write(out, tree); |
| 562 | + } |
| 563 | + |
| 564 | + @Override |
| 565 | + public T read(final JsonReader in) throws IOException { |
| 566 | + return delegate.read(in); |
| 567 | + } |
| 568 | + }; |
| 569 | + } |
| 570 | + }; |
| 571 | + } |
| 572 | + |
| 573 | + private static <T> JsonDeserializer<List<T>> singleOrArrayDeserializer(final Class<T> elementType) { |
| 574 | + return (json, typeOfT, context) -> { |
| 575 | + if (json == null || json.isJsonNull()) { |
| 576 | + return null; |
| 577 | + } |
| 578 | + final List<T> values = new ArrayList<>(); |
| 579 | + if (json.isJsonArray()) { |
| 580 | + for (final JsonElement element : json.getAsJsonArray()) { |
| 581 | + values.add(context.deserialize(element, elementType)); |
| 582 | + } |
| 583 | + } else { |
| 584 | + values.add(context.deserialize(json, elementType)); |
| 585 | + } |
| 586 | + return values; |
| 587 | + }; |
| 588 | + } |
| 589 | + |
457 | 590 | private static JsonDeserializer<LocalDate> getLocalDateJsonDeserializer() { |
458 | 591 | return (json, typeOfT, context) -> { |
459 | 592 | String dateString = json.getAsString(); |
|
0 commit comments