Serialize a type Juneau doesn't know
Teach Juneau to write and read a class that isn't a bean and that you can't change, by giving it a swap: a small class that converts the type to a simpler one and back.
What you'll build
An Invoice bean whose fields use a third-party-style Money type. Without help, Juneau writes Money through its toString() and can't parse it back. You'll add a MoneySwap that turns Money into a string such as "12.50 EUR" and back again, register it two ways, and round-trip an invoice through JSON.
The finished program prints:
with swap: {"number":"INV-1001","payments":["10.00 EUR","2.50 EUR"],"total":"12.50 EUR"}
round trip: Money(amount=12.50, currency=EUR), equal=true
Prerequisites and time
- Java 17 or later and Maven 3.
org.apache.juneau:juneau-marshall:10.0.0-SNAPSHOT, shown in the POM in step 1.- About 10 minutes.
10.0.0-SNAPSHOT is not on Maven Central, so the POM adds the Apache snapshots repository. See the note in the MCP Quickstart and Install Juneau.
Steps
Put each class in src/main/java/com/example/ and start each file with package com.example;. Any package works if you use it everywhere.
1. Create the project
Create an empty directory with this pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>customswaps</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<juneau.version>10.0.0-SNAPSHOT</juneau.version>
</properties>
<repositories>
<repository>
<id>apache-snapshots</id>
<url>https://repository.apache.org/snapshots/</url>
<releases><enabled>false</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-marshall</artifactId>
<version>${juneau.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.4</version>
</plugin>
</plugins>
</build>
</project>
2. Add the type Juneau doesn't know
Money stands in for a class from a library you don't control. It has a private constructor, record-style accessors instead of getters, and a toString() meant for debugging, so it isn't a bean and Juneau has no way to rebuild it.
import java.math.*;
import java.util.*;
/**
* An amount of money in one currency.
*
* <p>
* Stands in for a type you don't control: no public constructor, no getters or setters, and a
* debugging-only toString(), so Juneau has no way to turn it into JSON and back.
*/
public final class Money {
private final BigDecimal amount;
private final Currency currency;
private Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public static Money of(String amount, String currencyCode) {
return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode));
}
public BigDecimal amount() { return amount; }
public Currency currency() { return currency; }
@Override
public String toString() {
return "Money(amount=" + amount + ", currency=" + currency + ")";
}
@Override
public boolean equals(Object o) {
return o instanceof Money m && amount.equals(m.amount) && currency.equals(m.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
3. Use it in a bean
Invoice is an ordinary bean with public fields, one of them a Money and one a list of them.
import java.util.*;
/**
* A plain bean with {@link Money} properties.
*/
public class Invoice {
public String number;
public Money total;
public List<Money> payments = new ArrayList<>();
public Invoice number(String value) { number = value; return this; }
public Invoice total(Money value) { total = value; return this; }
public Invoice payment(Money value) { payments.add(value); return this; }
}
4. Write the swap
A StringSwap<Money> says "represent Money as a String". swap runs when serializing and unswap when parsing. The session and ClassMeta arguments give access to settings and the exact target type, which this swap doesn't need.
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.swap.spi.*;
/**
* Serializes {@link Money} as a string such as "12.50 EUR", and parses it back.
*/
public class MoneySwap extends StringSwap<Money> {
@Override
public String swap(MarshallingSession session, Money money) {
return money.amount().toPlainString() + " " + money.currency().getCurrencyCode();
}
@Override
public Money unswap(MarshallingSession session, String value, ClassMeta<?> hint) {
var parts = value.trim().split("\\s+");
return Money.of(parts[0], parts[1]);
}
}
5. Or attach the swap to a field
If you'd rather not register the swap on every serializer and parser, annotate the field. @Swap also works on a class you own, and on getters and setters.
import org.apache.juneau.marshall.*;
/**
* The same idea with the swap attached to the property, so no serializer setup is needed.
*/
public class InvoiceAnnotated {
public String number;
@Swap(MoneySwap.class)
public Money total;
}
6. Serialize and parse
The program first tries the invoice without the swap, then registers MoneySwap on a serializer and a parser built from the default ones, and finally writes the annotated invoice with the plain default serializer.
import org.apache.juneau.marshall.parser.*;
import org.apache.juneau.marshall.json.*;
/**
* Serializes an invoice with and without the swap, then parses it back.
*/
public class InvoiceDemo {
public static void main(String[] args) throws Exception {
System.out.println(run());
}
public static String run() throws Exception {
var invoice = new Invoice()
.number("INV-1001")
.total(Money.of("12.50", "EUR"))
.payment(Money.of("10.00", "EUR"))
.payment(Money.of("2.50", "EUR"));
// Without the swap, Juneau falls back to toString(), which can't be parsed back.
var plain = JsonSerializer.DEFAULT.write(invoice);
String plainParse;
try {
JsonParser.DEFAULT.read(plain, Invoice.class);
plainParse = "parsed";
} catch (ParseException e) {
plainParse = "fails (" + e.getClass().getSimpleName() + ")";
}
// Register the swap on the serializer and the parser.
var serializer = JsonSerializer.create().swaps(MoneySwap.class).build();
var parser = JsonParser.create().swaps(MoneySwap.class).build();
var json = serializer.write(invoice);
var parsed = parser.read(json, Invoice.class);
// The annotated bean needs no registration at all.
var annotated = new InvoiceAnnotated();
annotated.number = "INV-1002";
annotated.total = Money.of("99.95", "USD");
return String.join("\n",
"without swap: " + plain,
"parse it: " + plainParse,
"with swap: " + json,
"round trip: " + parsed.total + ", equal=" + parsed.total.equals(invoice.total),
"annotated: " + JsonSerializer.DEFAULT.write(annotated));
}
}
Run it
From the project directory:
mvn -q compile exec:java -Dexec.mainClass=com.example.InvoiceDemo
Expected output:
without swap: {"number":"INV-1001","payments":["Money(amount=10.00, currency=EUR)","Money(amount=2.50, currency=EUR)"],"total":"Money(amount=12.50, currency=EUR)"}
parse it: fails (ParseException)
with swap: {"number":"INV-1001","payments":["10.00 EUR","2.50 EUR"],"total":"12.50 EUR"}
round trip: Money(amount=12.50, currency=EUR), equal=true
annotated: {"number":"INV-1002","total":"99.95 USD"}
How it works
- Why the first attempt fails. Juneau serializes a non-bean by calling
toString(). To parse it back, it needs a public constructor taking aString, or a public static method taking one with a name such asfromString,valueOforparse.Moneyhas neither, so parsing fails. See Swap. - A swap replaces the type for marshalling. With
MoneySwapregistered, every serializer and parser built with it treatsMoneyas aString, wherever it appears: a field, a list element, or a map value.swaps(...)accepts any number of swap classes. - Registration or annotation. Registering on the builder keeps the model class untouched and lets different serializers make different choices.
@Swaptravels with the class or field, so the default serializers pick it up. See @Swap Annotation. - Other shapes. A swap can produce a map or a bean instead of a string, can be one-way for types you only write, and can render through a template. See One-way Swaps and Templated Swaps.
Next steps
- Use the swap in a REST API: put
@MarshalledConfig(swaps=MoneySwap.class)on the resource class, or keep the@Swapfield annotation, then follow Build a REST API on Spring Boot. - Read every swap variant and the built-in swaps for dates, enums and more: Swap.
- Load settings from files instead of code: Externalize config with INI/YAML and variables.