Tipado polimórfico para inputs JSON en Java
Si sos programador web, quizás te hayas topado con el caso de tener que diseñar un endpoint REST que admita distintos tipos de body, cada uno con sus correspondientes campos.
El requisito es el siguiente:
- Soportar distintos tipos de objetos como input con el máximo tipado posible sin apelar a
String,Object,anyu otras artimañas propias de cada lenguaje. - Soportar datos legacy o desconocidos sin que deban ser tipados (tipado incremental).
- Agregar casos nuevos forzando al cliente a tratarlos en tiempo de compilación para que no rompa.
Este post muestra una forma de resolver el requisito con Java 22, Jackson y Lombok. ¿Por qué esa elección? Al día de escribir esto, esos son requisitos de mi trabajo actual. Creo que esta solución es adaptable a otros lenguajes.
Nota: si el lenguaje que usás soporta union types, el camino correcto probablemente sea revisar esa parte de su documentación.
Ejemplo "eventos"
Tenemos un endpoint POST /events que recibe un payload con un evento. Los eventos pueden ser:
- create
- update
- delete
pero al estar trabajando con código legacy, sabemos que existen otros tipos de eventos; y en el futuro podemos agregar más.
Además, en este caso, los eventos desconocidos pueden tener campos con el mismo nombre que algunos de los tres conocidos.
Veamos JSONs de ejemplo que nos pueden llegar:
Create
{
"type": "creation",
"created_at": "2024-09-09T17:00:00",
"table_name": "users"
}
Update
{
"type": "update",
"created_at": "2024-09-09T17:00:00",
"new_name": "orders"
}
Delete
{
"type": "deletion",
"created_at": "2024-09-09T17:00:00",
"deletion_mode": "logical",
"table_to_delete": "items"
}
Un evento desconocido para nuestra API (podría o no llevar type)
{
"created_at": "2024-09-09T17:30:00",
"table_name": "users",
"items_quantity": 1,
"deletion_mode": "physical",
"this_is_not_in_any_class_so_what_can_we_do_about_it": "get it anyways :)",
"are_you_reading_this": true
}
Teniendo en cuenta estos casos, armemos una jerarquía de clases con AbstractEvent como padre y CreationEvent, UpdateEvent, y DeletionEvent como hijos:
AbstractEvent
@Data
@NoArgsConstructor
@SuperBuilder
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true, defaultImpl = UnknownEvent.class)
@JsonSubTypes({
@JsonSubTypes.Type(value = CreationEvent.class, name = "creation"),
@JsonSubTypes.Type(value = UpdateEvent.class, name = "update"),
@JsonSubTypes.Type(value = DeletionEvent.class, name = "deletion")
})
public sealed abstract class AbstractEvent permits CreationEvent, UpdateEvent, DeletionEvent, UnknownEvent {
LocalDateTime createdAt;
String type;
abstract boolean isKnownEvent(); // this method is not needed and must be considered a convenience for API consumers
}
- •
sealedhace queAbstractEventconozca a todos sus hijos en tiempo de compilación. Esto posibilita mantener compatibilidad con los clientes. - •
@JsonTypeInfopermite deserializar JSON polimórficamente. Acá le estamos diciendo que use la propertytype, y que en caso de no matchear con un valor de@JsonSubTypes, vaya a deserializar contraUnknownEvent. - •
@JsonSubTypespermite indicar los valores para matchear cada caso. - •
isKnownEvent()no es parte de la solución.
CreationEvent
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@SuperBuilder
@ToString(callSuper=true)
@Jacksonized
public final class CreationEvent extends AbstractEvent {
private String tableName;
@Override
boolean isKnownEvent() {
return true;
}
}
- •
finalhace que los hijos no puedan ser extendidos. - • Los eventos conocidos son un POJO con annotations de lombok para generar getters y builder, nada más.
- •
UpdateEventyDeletionEventson clases iguales aCreationEvent, excepto por sus atributos.
¿Cómo manejamos los eventos (inputs) desconocidos?
Acá viene lo interesante. En Java no existe la herencia múltiple, así que nos tenemos que ayudar con delegados para modelar una bolsa de gatos más o menos tipada.
Esta bolsa de gatos UnknownEvent debe tener como atributos la combinación de todos sus hermanos más los campos que no estén en ningún lado:
UnknownEvent
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper=true)
// null fields should not be output, as this class will contain all the possible fields of its siblings
@JsonInclude(JsonInclude.Include.NON_NULL)
public final class UnknownEvent extends AbstractEvent {
// 'delegate' as in standard OOP delegation, this makes Java pass all the fields of delegate to this class fields while maintaining original getter/setter logic
@Delegate
// we should not serialize delegates
@JsonIgnore
// initialize delegates as to avoid NPE
private CreationEvent creationEvent = new CreationEvent();
@Delegate
@JsonIgnore
private UpdateEvent updateEvent = new UpdateEvent();
@Delegate
@JsonIgnore
private DeletionEvent deletionEvent = new DeletionEvent();
// use a Map<String, Object> to store unknown fields. these should be serialized in a plain fashion (unknownFields must not be a JSON output field)
private Map<String, Object> unknownFields = new HashMap<>();
@JsonAnyGetter
public Map<String, Object> getUnknownFields() {
return unknownFields;
}
@JsonAnySetter
public void setUnknownFields(String name, Object value) {
unknownFields.put(name, value);
}
@Override
boolean isKnownEvent() {
return false;
}
}
- •
@Delegatehace queUnknownEventpareciera tener todos los atributos de sus hermanos. Esto es transparente para quien consuma el objeto. - • Con
@JsonAnyGettery@JsonAnySetterllevamos a memoria los campos no mapeados. - • Lamentablemente, si modelamos un nuevo tipo de evento, además de tocar la clase padre como corresponde, tenemos que incluirlo como un delegado acá. Lo bueno es que si nos olvidamos este último paso, no se rompe nada.
Show me the code!
// instantiate helper objects
FileUtils fileUtils = new FileUtils();
Mapper mapperGenerator = new Mapper();
ObjectMapper mapper = mapperGenerator.getMapper();
// read from JSON files to Java objects (in a real-world scenario, this could be the input of an HTTP request in a controller)
AbstractEvent creationEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/creation_event_1.json"), AbstractEvent.class);
AbstractEvent updateEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/update_event_1.json"), AbstractEvent.class);
AbstractEvent deletionEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/deletion_event_1.json"), AbstractEvent.class);
AbstractEvent notInCodeTypeEvent = mapper.readValue(fileUtils.getFileFromResourceAsStream("examples/random_event_1.json"), AbstractEvent.class);
// create a list of the read events, with the list type being the closest parent to all events
List<AbstractEvent> events = List.of(creationEvent, updateEvent, deletionEvent, notInCodeTypeEvent);
// process each event according to its type
events.forEach(event -> {
switch (event) {
// for known events, further usage and serialization is straightforward
case CreationEvent c -> println("Received a creation event:" + c);
case UpdateEvent u -> println("Received an update event: " + u);
case DeletionEvent d -> println("Received a deletion event: " + d);
// for unhandled or unknown events, we set the following goals:
// 1. the unknown event must contain all the typed values of all its 'sibling' classes
// 2. the unknown event must handle the case in which there are additional unmapped (untyped) fields
case UnknownEvent un -> {
println("Received an unknown event: " + un);
// accomplishing (1)
println("I can access properties of the unknown event in a type safe way, knowing those might be null. Deletion mode (should not be null): " + un.getDeletionMode() + ", table name (should not be null): " + un.getTableName() + ", new name (should be null): " + un.getNewName());
try {
// accomplishing (2)
String unkownEventAsJson = mapper.writeValueAsString(un);
println("An unknown event is safe to serialize as JSON, and it will behave as the union of all other types plus untyped fields: " + unkownEventAsJson);
} catch (JsonProcessingException ex) {
throw new RuntimeException(ex);
}
}
}
});
Output:
Received a creation event:CreationEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=creation), tableName=users)
Received an update event: UpdateEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=update), newName=orders)
Received a deletion event: DeletionEvent(super=AbstractEvent(createdAt=2024-09-09T17:00, type=deletion), deletionMode=logical, tableToDelete=items)
Received an unknown event: UnknownEvent(super=AbstractEvent(createdAt=2024-09-09T17:30, type=null), creationEvent=CreationEvent(super=AbstractEvent(createdAt=2024-09-09T17:30, type=null), tableName=users), updateEvent=UpdateEvent(super=AbstractEvent(createdAt=null, type=null), newName=null), deletionEvent=DeletionEvent(super=AbstractEvent(createdAt=null, type=null), deletionMode=physical, tableToDelete=null), unknownFields={are_you_reading_this=true, items_quantity=1, this_is_not_in_any_class_so_what_can_we_do_about_it=get it anyways :)})
I can access properties of the unknown event in a type safe way, knowing those might be null. Deletion mode (should not be null): physical, table name (should not be null): users, new name (should be null): null
An unknown event is safe to serialize as JSON, and it will behave as the union of all other types plus untyped fields: {"created_at":"2024-09-09T17:30:00","table_name":"users","deletion_mode":"physical","are_you_reading_this":true,"items_quantity":1,"this_is_not_in_any_class_so_what_can_we_do_about_it":"get it anyways :)"}
- • Jackson está instanciando las subclases correctamente gracias a las annotations.
- • Con un
switchsobre el tipo de dato (A.K.A. uninstanceof) podemos tratar cada evento como queramos. - • El evento desconocido está tipado para las propiedades conocidas y conserva las desconocidas a la hora de volver a ser serializado.
- • Agregar o quitar eventos como input no rompe la API (serán
UnknownEvent). - • Tipar eventos nuevos en el código fuerza al cliente a manejar el caso en tiempo de compilación. El
switches exhaustivo porque el padre essealed(notar que no hay ramadefault).