Skip to content

Commit 6d5e019

Browse files
Fixed passenger specs mislignments + tests
1 parent e0997d5 commit 6d5e019

4 files changed

Lines changed: 171 additions & 19 deletions

File tree

‎src/main/java/com/checkout/GsonSerializer.java‎

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
import com.google.gson.JsonSerializationContext;
4141
import com.google.gson.JsonSerializer;
4242
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;
4348
import com.google.gson.reflect.TypeToken;
4449
import com.google.gson.typeadapters.RuntimeTypeAdapterFactory;
4550
import lombok.Getter;
@@ -108,13 +113,15 @@ public final class GsonSerializer implements Serializer {
108113
.registerTypeAdapter(LocalDate.class, (JsonSerializer<LocalDate>) (LocalDate date, Type typeOfSrc, JsonSerializationContext context) ->
109114
new JsonPrimitive(date.format(DateTimeFormatter.ISO_LOCAL_DATE)))
110115
.registerTypeAdapter(LocalDate.class, getLocalDateJsonDeserializer())
111-
// processing.airline_data[].passenger is oneOf[array, object]: PayPal returns a
112-
// single object where the array shape is declared. Read both, always write an array.
116+
// processing.airline_data[].passenger arrives as an array or as a single object.
117+
// These two read both shapes into a List.
113118
//
114119
// Bound by element type, so the second registration also covers PaymentSetupAirline
115-
// .passengers, which the spec declares array-only. Accepting a bare object there is
116-
// wider than the spec grants but cannot lose data, and TypeToken cannot distinguish
117-
// the two call sites. Deliberate; revisit only if a wrapper type is introduced.
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).
118125
.registerTypeAdapter(
119126
new TypeToken<List<com.checkout.payments.Passenger>>() {
120127
}.getType(),
@@ -123,6 +130,7 @@ public final class GsonSerializer implements Serializer {
123130
new TypeToken<List<com.checkout.payments.contexts.PaymentContextsPassenger>>() {
124131
}.getType(),
125132
singleOrArrayDeserializer(com.checkout.payments.contexts.PaymentContextsPassenger.class))
133+
.registerTypeAdapterFactory(singleOrArrayPassengerFactory())
126134
// Payments - AbstractSource (polymorphic deserialization)
127135
.registerTypeAdapterFactory(
128136
RuntimeTypeAdapterFactory.of(
@@ -489,6 +497,79 @@ private static JsonDeserializer<Instant> getInstantJsonDeserializer() {
489497
* @param <T> the list element type
490498
* @return a deserializer that accepts a single object or an array
491499
*/
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+
492573
private static <T> JsonDeserializer<List<T>> singleOrArrayDeserializer(final Class<T> elementType) {
493574
return (json, typeOfT, context) -> {
494575
if (json == null || json.isJsonNull()) {

‎src/test/java/com/checkout/payments/AirlineDataSerializationTest.java‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,21 +153,50 @@ void shouldDeserializeMultiplePassengers() {
153153
}
154154

155155
/**
156-
* Only a deserializer is registered for the single-or-array shape, never a serializer, so
157-
* writing always goes through Gson's reflective adapter and emits an array. If a serializer
158-
* were ever added, every request carrying airline data would change shape and this fails.
156+
* Pins the outbound cardinality against the live API: an object is accepted on every request
157+
* surface, an array only on POST /payments. Hosted payments, payment links and payment
158+
* contexts all reject the array form, and ProcessingSettings is shared with hosted payments
159+
* and payment links, so "always an array" would break them. See
160+
* GsonSerializer.singleOrArrayPassengerFactory for the sandbox-verified matrix.
159161
*/
160162
@Test
161-
void shouldAlwaysSerializePassengerAsAnArray() {
163+
void shouldSerializeASinglePassengerAsAnObject() {
162164
final AirlineData airline = AirlineData.builder()
163165
.passenger(Collections.singletonList(
164166
Passenger.builder().firstName("John").lastName("White").build()))
165167
.build();
166168

167169
final String json = serializer.toJson(airline);
168170

169-
assertTrue(json.contains("\"passenger\":[{"), json);
170-
assertFalse(json.contains("\"passenger\":{"), json);
171+
assertTrue(json.contains("\"passenger\":{"), json);
172+
assertFalse(json.contains("\"passenger\":["), json);
173+
}
174+
175+
@Test
176+
void shouldSerializeSeveralPassengersAsAnArray() {
177+
final AirlineData airline = AirlineData.builder()
178+
.passenger(Arrays.asList(
179+
Passenger.builder().firstName("John").build(),
180+
Passenger.builder().firstName("Jane").build()))
181+
.build();
182+
183+
assertTrue(serializer.toJson(airline).contains("\"passenger\":[{"));
184+
}
185+
186+
/**
187+
* An empty array and an explicit null are both rejected with
188+
* processing_airline_data_0_passenger_invalid, so the member has to be absent.
189+
*/
190+
@Test
191+
void shouldOmitPassengerWhenThereAreNone() {
192+
assertFalse(serializer.toJson(AirlineData.builder()
193+
.ticket(Ticket.builder().number("045").build())
194+
.passenger(Collections.<Passenger>emptyList())
195+
.build()).contains("passenger"));
196+
197+
assertFalse(serializer.toJson(AirlineData.builder()
198+
.ticket(Ticket.builder().number("045").build())
199+
.build()).contains("passenger"));
171200
}
172201

173202
@Test

‎src/test/java/com/checkout/payments/contexts/PaymentContextsAirlineSerializationTest.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ void shouldSerializeAirlineTicketAsAnObjectAndPassengerAsAnArray() {
4949

5050
assertTrue(json.contains("\"ticket\":{"), json);
5151
assertFalse(json.contains("\"ticket\":["), json);
52-
assertTrue(json.contains("\"passenger\":[{"), json);
52+
// POST /payment-contexts rejects the array form with passenger_required, so one
53+
// passenger serializes as an object.
54+
assertTrue(json.contains("\"passenger\":{"), json);
5355
assertTrue(json.contains("\"class_of_travelling\":\"J\""), json);
5456
assertTrue(json.contains("\"stop_over_code\":\"x\""), json);
5557
assertTrue(json.contains("\"flight_number\":\"101\""), json);

‎src/test/java/com/checkout/serialization/CustomDeserializerPolicyTest.java‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,59 @@ void singleOrArrayDeserializer_honorsNamingPolicyForAnArray() {
157157
}
158158

159159
/**
160-
* Only a deserializer is registered, so writing goes through the reflective adapter and
161-
* always emits an array. Registering a serializer would silently change every outbound
162-
* request that carries airline data.
160+
* singleOrArrayPassengerFactory wraps the reflective adapter on the write side and applies
161+
* the cardinality the live API requires: an object for one passenger, an array for several.
162+
* It is scoped to the two airline types, so PaymentSetupAirline.passengers is untouched.
163163
*/
164164
@Test
165-
void singleOrArrayDeserializer_isReadOnlySoWritesStayAnArray() {
166-
final String json = serializer.toJson(AirlineData.builder()
165+
void singleOrArrayPassengerFactory_writesOneAsObjectAndSeveralAsArray() {
166+
final String one = serializer.toJson(AirlineData.builder()
167167
.passenger(Collections.singletonList(
168168
Passenger.builder().firstName("John").build()))
169169
.build());
170170

171-
assertTrue(json.contains("\"passenger\":[{"), json);
172-
assertTrue(!json.contains("\"passenger\":{"), json);
171+
assertTrue(one.contains("\"passenger\":{"), one);
172+
assertTrue(!one.contains("\"passenger\":["), one);
173+
174+
final String many = serializer.toJson(AirlineData.builder()
175+
.passenger(java.util.Arrays.asList(
176+
Passenger.builder().firstName("John").build(),
177+
Passenger.builder().firstName("Jane").build()))
178+
.build());
179+
180+
assertTrue(many.contains("\"passenger\":[{"), many);
181+
}
182+
183+
/**
184+
* The factory must not reach PaymentSetupAirline.passengers: the API rejects an object there
185+
* with industry.airline[0].passengers_property_invalid.
186+
*/
187+
@Test
188+
void singleOrArrayPassengerFactory_leavesPaymentSetupPassengersAsAnArray() {
189+
final String json = serializer.toJson(
190+
com.checkout.handlepaymentsandpayouts.setups.entities.industry.AirlineData.builder()
191+
.passengers(Collections.singletonList(
192+
com.checkout.payments.contexts.PaymentContextsPassenger.builder()
193+
.firstName("John").build()))
194+
.build());
195+
196+
assertTrue(json.contains("\"passengers\":[{"), json);
197+
}
198+
199+
@Test
200+
void singleOrArrayPassengerFactory_roundTripsASinglePassenger() {
201+
final AirlineData original = AirlineData.builder()
202+
.passenger(Collections.singletonList(Passenger.builder()
203+
.firstName("John")
204+
.dateOfBirth(LocalDate.of(1990, 5, 26))
205+
.build()))
206+
.build();
207+
208+
final AirlineData result = serializer.fromJson(serializer.toJson(original), AirlineData.class);
209+
210+
assertEquals(1, result.getPassenger().size());
211+
assertEquals("John", result.getPassenger().get(0).getFirstName());
212+
assertEquals(LocalDate.of(1990, 5, 26), result.getPassenger().get(0).getDateOfBirth());
173213
}
174214

175215
@Test

0 commit comments

Comments
 (0)