Skip to main content

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 a String, or a public static method taking one with a name such as fromString, valueOf or parse. Money has neither, so parsing fails. See Swap.
  • A swap replaces the type for marshalling. With MoneySwap registered, every serializer and parser built with it treats Money as a String, 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. @Swap travels 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​