From abaf36d8c56f8bbd70429351268fe8b4d15b5100 Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Thu, 17 Oct 2024 14:51:34 +0200 Subject: [PATCH 1/3] Migrating Commit 7fd4c1e from zalando/jackson-datatype-money MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on-behalf-of: Zalando OSS Community Co-authored-by: Willi Schönborn Co-authored-by: Philipp Hirch Co-authored-by: Jörn Horstmann Co-authored-by: Lauri at Zalando Co-authored-by: msparer Co-authored-by: Alexander Yastrebov Co-authored-by: Alexey Venderov Co-authored-by: Alexander Yastrebov Co-authored-by: Arnaud BOIVIN Co-authored-by: Bartosz Ocytko Co-authored-by: Carlos Freund Co-authored-by: Dario Seidl Co-authored-by: Georgios Andrianakis Co-authored-by: Lauri at Zalando Co-authored-by: Martin Prebio Co-authored-by: Sean Sullivan Co-authored-by: Touko Vainio-Kaila Co-authored-by: lukasniemeier-zalando --- README.md | 3 + javax-money/README.md | 212 ++++++++++ javax-money/pom.xml | 124 ++++++ .../jackson/datatype/money/AmountWriter.java | 16 + .../money/BigDecimalAmountWriter.java | 17 + .../money/CurrencyUnitDeserializer.java | 32 ++ .../money/CurrencyUnitSerializer.java | 35 ++ .../datatype/money/DecimalAmountWriter.java | 19 + .../jackson/datatype/money/FieldNames.java | 26 ++ .../money/MonetaryAmountDeserializer.java | 77 ++++ .../datatype/money/MonetaryAmountFactory.java | 17 + .../money/MonetaryAmountFormatFactory.java | 20 + .../money/MonetaryAmountSerializer.java | 122 ++++++ .../jackson/datatype/money/MoneyModule.java | 171 ++++++++ .../datatype/money/PackageVersion.java.in | 20 + .../money/QuotedDecimalAmountWriter.java | 20 + .../src/main/resources/META-INF/LICENSE | 23 ++ .../src/main/resources/META-INF/NOTICE | 17 + .../com.fasterxml.jackson.databind.Module | 1 + javax-money/src/moditect/module-info.java | 13 + .../money/CurrencyUnitDeserializerTest.java | 43 ++ .../CurrencyUnitSchemaSerializerTest.java | 25 ++ .../money/CurrencyUnitSerializerTest.java | 27 ++ .../datatype/money/FieldNamesTest.java | 20 + .../money/MonetaryAmountDeserializerTest.java | 273 +++++++++++++ .../MonetaryAmountSchemaSerializerTest.java | 87 ++++ .../money/MonetaryAmountSerializerTest.java | 377 ++++++++++++++++++ .../datatype/money/SchemaTestClass.java | 15 + pom.xml | 2 + 29 files changed, 1854 insertions(+) create mode 100644 javax-money/README.md create mode 100644 javax-money/pom.xml create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/AmountWriter.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/BigDecimalAmountWriter.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/DecimalAmountWriter.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/FieldNames.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFactory.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFormatFactory.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializer.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/PackageVersion.java.in create mode 100644 javax-money/src/main/java/com/fasterxml/jackson/datatype/money/QuotedDecimalAmountWriter.java create mode 100644 javax-money/src/main/resources/META-INF/LICENSE create mode 100644 javax-money/src/main/resources/META-INF/NOTICE create mode 100644 javax-money/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 javax-money/src/moditect/module-info.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/FieldNamesTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializerTest.java create mode 100644 javax-money/src/test/java/com/fasterxml/jackson/datatype/money/SchemaTestClass.java diff --git a/README.md b/README.md index 361cf31..e450c45 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ datatype modules to support 3rd party libraries. Currently included are: * [jackson-datatype-joda-money](joda-money/) for [Joda-Money](https://www.joda.org/joda-money/) datatypes +* [jackson-datatype-money](javax-money/) for [JavaMoney](https://javamoney.github.io/) datatypes (starting with Jackson 2.19) * JSR-353/JSON-P: 2 variants (starting with Jackson 2.12.2) * [jackson-datatype-jsr353](jsr-353/) for older "javax.json" [JSR-353](https://www.jcp.org/en/jsr/detail?id=353) (aka JSON-P) datatypes (package `javax.json`) * [jackson-datatype-jakarta-jsonp](jakarta-jsonp/) for newer "Jakarta" JSON-P datatypes (package `jakarta.json`) @@ -16,6 +17,7 @@ Currently included are: Note that this repo was created for Jackson 2.11: prior to this, individual datatype modules had their own repositories. + ## License All modules are licensed under [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt). @@ -62,6 +64,7 @@ mapper.registerModule(new JSONPModule()); // new (jakarta) json-P API ObjectMapper mapper = JsonMapper.builder() .addModule(new JsonOrgModule()) .addModule(new JodaMoneyModule()) + .addModule(new MoneyModule()) // ONE of these (not both): .addModule(new JSR353Module()) // old (javax) json-p API .addModule(new JSONPModule()) // new (jakarta) json-P API diff --git a/javax-money/README.md b/javax-money/README.md new file mode 100644 index 0000000..2e02b54 --- /dev/null +++ b/javax-money/README.md @@ -0,0 +1,212 @@ +# Jackson Datatype Money + +*Jackson Datatype Money* is a [Jackson](https://github.com/codehaus/jackson) module to support JSON serialization and +deserialization of [JavaMoney](https://github.com/JavaMoney/jsr354-api) data types. It fills a niche, in that it +integrates JavaMoney and Jackson so that they work seamlessly together, without requiring additional +developer effort. In doing so, it aims to perform a small but repetitive task — once and for all. + +With this library, it is possible to represent monetary amounts in JSON as follows: + +```json +{ + "amount": 29.95, + "currency": "EUR" +} +``` + +## Features + +- enables you to express monetary amounts in JSON +- can be used in a REST APIs +- customized field names +- localization of formatted monetary amounts +- allows you to implement RESTful API endpoints that format monetary amounts based on the Accept-Language header +- is unique and flexible + +## Dependencies + +- Java 8 or higher +- Any build tool using Maven Central, or direct download +- Jackson +- JavaMoney + +## Installation + +Add the following dependency to your project: + +```xml + + + com.fasterxml.jackson.datatype + jackson-datatype-money + ${jackson-datatype-money.version} + +``` + +For ultimate flexibility, this module is compatible with the official version as well as the backport of JavaMoney. The +actual version will be selected by a profile based on the current JDK version. + +## Configuration + +Register the module with your `ObjectMapper`: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule()); +``` + +Alternatively, you can use the SPI capabilities: + +```java +ObjectMapper mapper = new ObjectMapper() + .findAndRegisterModules(); +``` + +### Serialization + +For serialization this module currently supports +[ +`javax.money.MonetaryAmount`](https://github.com/JavaMoney/jsr354-api/blob/master/src/main/java/javax/money/MonetaryAmount.java) +and will, by default, serialize it as: + +```json +{ + "amount": 99.95, + "currency": "EUR" +} +``` + +To serialize number as a JSON string, you have to configure the quoted decimal number value serializer: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule().withQuotedDecimalNumbers()); +``` + +```json +{ + "amount": "99.95", + "currency": "EUR" +} +``` + +### Formatting + +A special feature for serializing monetary amounts is *formatting*, which is **disabled by default**. To enable it, you +have to either enable default formatting: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule().withDefaultFormatting()); +``` + +... or pass in a `MonetaryAmountFormatFactory` implementation to the `MoneyModule`: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule() + .withFormatting(new CustomMonetaryAmountFormatFactory())); +``` + +The default formatting delegates directly to `MonetaryFormats.getAmountFormat(Locale, String...)`. + +Formatting only affects the serialization and can be customized based on the *current* locale, as defined by the +[ +`SerializationConfig`](https://fasterxml.github.io/jackson-databind/javadoc/2.0.0/com/fasterxml/jackson/databind/SerializationConfig.html#with\(java.util.Locale\)). +This allows to implement RESTful API endpoints +that format monetary amounts based on the `Accept-Language` header. + +The first example serializes a monetary amount using the `de_DE` locale: + +```java +ObjectWriter writer = mapper.writer().with(Locale.GERMANY); +writer. + +writeValueAsString(Money.of(29.95, "EUR")); +``` + +```json +{ + "amount": 29.95, + "currency": "EUR", + "formatted": "29,95 EUR" +} +``` + +The following example uses `en_US`: + +```java +ObjectWriter writer = mapper.writer().with(Locale.US); +writer. + +writeValueAsString(Money.of(29.95, "USD")); +``` + +```json +{ + "amount": 29.95, + "currency": "USD", + "formatted": "USD29.95" +} +``` + +More sophisticated formatting rules can be supported by implementing `MonetaryAmountFormatFactory` directly. + +### Deserialization + +This module will use `org.javamoney.moneta.Money` as an implementation for `javax.money.MonetaryAmount` by default when +deserializing money values. If you need a different implementation, you can pass a different `MonetaryAmountFactory` +to the `MoneyModule`: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule() + .withMonetaryAmount(new CustomMonetaryAmountFactory())); +``` + +You can also pass in a method reference: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule() + .withMonetaryAmount(FastMoney::of)); +``` + +*Jackson Datatype Money* comes with support for all `MonetaryAmount` implementations from Moneta, the reference +implementation of JavaMoney: + +| `MonetaryAmount` Implementation | Factory | +|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| `org.javamoney.moneta.FastMoney` | [`new MoneyModule().withFastMoney()`](src/main/java/com/fasterxml/jackson/datatype/money/FastMoneyFactory.java) | +| `org.javamoney.moneta.Money` | [`new MoneyModule().withMoney()`](src/main/java/com/fasterxml/jackson/datatype/money/MoneyFactory.java) | +| `org.javamoney.moneta.RoundedMoney` | [`new MoneyModule().withRoundedMoney()`](src/main/java/com/fasterxml/jackson/datatype/money/RoundedMoneyFactory.java) | | + +Module supports deserialization of amount number from JSON number as well as from JSON string without any special +configuration required. + +### Custom Field Names + +As you have seen in the previous examples the `MoneyModule` uses the field names `amount`, `currency` and `formatted` +by default. Those names can be overridden if desired: + +```java +ObjectMapper mapper = new ObjectMapper() + .registerModule(new MoneyModule() + .withAmountFieldName("value") + .withCurrencyFieldName("unit") + .withFormattedFieldName("pretty")); +``` + +## Usage + +After registering and configuring the module you're now free to directly use `MonetaryAmount` in your data types: + +```java +import javax.money.MonetaryAmount; + +public class Product { + private String sku; + private MonetaryAmount price; + ... +} +``` \ No newline at end of file diff --git a/javax-money/pom.xml b/javax-money/pom.xml new file mode 100644 index 0000000..575601d --- /dev/null +++ b/javax-money/pom.xml @@ -0,0 +1,124 @@ + + + + + + + 4.0.0 + + com.fasterxml.jackson.datatype + jackson-datatypes-misc-parent + 2.19.0-SNAPSHOT + + jackson-datatype-money + Jackson datatype: javax-money + jar + 2.19.0-SNAPSHOT + Support for datatypes of Javax Money library (https://javamoney.github.io/) + + https://github.com/FasterXML/jackson-datatypes-misc + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + com/fasterxml/jackson/datatype/money + ${project.groupId}.money + 2.0.6 + 5.9.2 + + + + + javax.money + money-api + 1.1 + + + + org.javamoney.moneta + moneta-core + 1.4.2 + + + + org.apiguardian + apiguardian-api + 1.1.2 + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + org.projectlombok + lombok + 1.18.34 + provided + + + + + org.slf4j + slf4j-nop + ${slf4j.version} + test + + + org.assertj + assertj-core + 3.24.2 + test + + + org.mockito + mockito-core + 4.5.1 + test + + + com.fasterxml.jackson.module + jackson-module-jsonSchema + test + + + com.kjetland + mbknor-jackson-jsonschema_2.12 + 1.0.39 + test + + + javax.validation + validation-api + 2.0.1.Final + test + + + + pl.pragmatists + JUnitParams + 1.1.1 + test + + + + + + + com.google.code.maven-replacer-plugin + replacer + + + org.moditect + moditect-maven-plugin + + + + + diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/AmountWriter.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/AmountWriter.java new file mode 100644 index 0000000..0327409 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/AmountWriter.java @@ -0,0 +1,16 @@ +package com.fasterxml.jackson.datatype.money; + +import org.apiguardian.api.API; + +import javax.money.MonetaryAmount; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +@API(status = EXPERIMENTAL) +public interface AmountWriter { + + Class getType(); + + T write(MonetaryAmount amount); + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/BigDecimalAmountWriter.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/BigDecimalAmountWriter.java new file mode 100644 index 0000000..849a360 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/BigDecimalAmountWriter.java @@ -0,0 +1,17 @@ +package com.fasterxml.jackson.datatype.money; + +import org.apiguardian.api.API; + +import java.math.BigDecimal; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +@API(status = EXPERIMENTAL) +public interface BigDecimalAmountWriter extends AmountWriter { + + @Override + default Class getType() { + return BigDecimal.class; + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java new file mode 100644 index 0000000..7ed5c34 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java @@ -0,0 +1,32 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; +import org.apiguardian.api.API; + +import javax.money.CurrencyUnit; +import javax.money.Monetary; +import java.io.IOException; + +import static org.apiguardian.api.API.Status.MAINTAINED; + +@API(status = MAINTAINED) +public final class CurrencyUnitDeserializer extends JsonDeserializer { + + @Override + public Object deserializeWithType(final JsonParser parser, final DeserializationContext context, + final TypeDeserializer deserializer) throws IOException { + + // effectively assuming no type information at all + return deserialize(parser, context); + } + + @Override + public CurrencyUnit deserialize(final JsonParser parser, final DeserializationContext context) throws IOException { + final String currencyCode = parser.getValueAsString(); + return Monetary.getCurrency(currencyCode); + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java new file mode 100644 index 0000000..fb511c0 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java @@ -0,0 +1,35 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.apiguardian.api.API; + +import javax.money.CurrencyUnit; +import java.io.IOException; + +import static org.apiguardian.api.API.Status.MAINTAINED; + +@API(status = MAINTAINED) +public final class CurrencyUnitSerializer extends StdSerializer { + + CurrencyUnitSerializer() { + super(CurrencyUnit.class); + } + + @Override + public void serialize(final CurrencyUnit value, final JsonGenerator generator, final SerializerProvider serializers) + throws IOException { + generator.writeString(value.getCurrencyCode()); + } + + @Override + public void acceptJsonFormatVisitor(final JsonFormatVisitorWrapper visitor, final JavaType hint) + throws JsonMappingException { + visitor.expectStringFormat(hint); + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/DecimalAmountWriter.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/DecimalAmountWriter.java new file mode 100644 index 0000000..3f5b3a7 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/DecimalAmountWriter.java @@ -0,0 +1,19 @@ +package com.fasterxml.jackson.datatype.money; + +import javax.annotation.Nonnull; +import javax.money.MonetaryAmount; +import java.math.BigDecimal; +import java.math.RoundingMode; + +final class DecimalAmountWriter implements BigDecimalAmountWriter { + + @Override + public BigDecimal write(@Nonnull final MonetaryAmount amount) { + final BigDecimal decimal = amount.getNumber().numberValueExact(BigDecimal.class); + final int defaultFractionDigits = amount.getCurrency().getDefaultFractionDigits(); + final int scale = Math.max(decimal.scale(), defaultFractionDigits); + + return decimal.setScale(scale, RoundingMode.UNNECESSARY); + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/FieldNames.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/FieldNames.java new file mode 100644 index 0000000..ed06953 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/FieldNames.java @@ -0,0 +1,26 @@ +package com.fasterxml.jackson.datatype.money; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.With; + +@AllArgsConstructor(staticName = "valueOf") +@Getter +final class FieldNames { + + static final FieldNames DEFAULT = FieldNames.valueOf("amount", "currency", "formatted"); + + @With + private final String amount; + + @With + private final String currency; + + @With + private final String formatted; + + static FieldNames defaults() { + return DEFAULT; + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java new file mode 100644 index 0000000..e053b50 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java @@ -0,0 +1,77 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; + +import javax.annotation.Nullable; +import javax.money.CurrencyUnit; +import javax.money.MonetaryAmount; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Arrays; + +import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; +import static java.lang.String.format; + +final class MonetaryAmountDeserializer extends JsonDeserializer { + + private final MonetaryAmountFactory factory; + private final FieldNames names; + + MonetaryAmountDeserializer(final MonetaryAmountFactory factory, final FieldNames names) { + this.factory = factory; + this.names = names; + } + + @Override + public Object deserializeWithType(final JsonParser parser, final DeserializationContext context, + final TypeDeserializer deserializer) throws IOException { + + // effectively assuming no type information at all + return deserialize(parser, context); + } + + @Override + public M deserialize(final JsonParser parser, final DeserializationContext context) throws IOException { + BigDecimal amount = null; + CurrencyUnit currency = null; + + while (parser.nextToken() != JsonToken.END_OBJECT) { + final String field = parser.getCurrentName(); + + parser.nextToken(); + + if (field.equals(names.getAmount())) { + amount = context.readValue(parser, BigDecimal.class); + } else if (field.equals(names.getCurrency())) { + currency = context.readValue(parser, CurrencyUnit.class); + } else if (field.equals(names.getFormatted())) { + //noinspection UnnecessaryContinue + continue; + } else if (context.isEnabled(FAIL_ON_UNKNOWN_PROPERTIES)) { + throw UnrecognizedPropertyException.from(parser, MonetaryAmount.class, field, + Arrays.asList(names.getAmount(), names.getCurrency(), names.getFormatted())); + } else { + parser.skipChildren(); + } + } + + checkPresent(parser, amount, names.getAmount()); + checkPresent(parser, currency, names.getCurrency()); + + return factory.create(amount, currency); + } + + private void checkPresent(final JsonParser parser, @Nullable final Object value, final String name) + throws JsonParseException { + if (value == null) { + throw new JsonParseException(parser, format("Missing property: '%s'", name)); + } + } + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFactory.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFactory.java new file mode 100644 index 0000000..e422914 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFactory.java @@ -0,0 +1,17 @@ +package com.fasterxml.jackson.datatype.money; + +import org.apiguardian.api.API; + +import javax.money.CurrencyUnit; +import javax.money.MonetaryAmount; +import java.math.BigDecimal; + +import static org.apiguardian.api.API.Status.STABLE; + +@API(status = STABLE) +@FunctionalInterface +public interface MonetaryAmountFactory { + + M create(BigDecimal amount, CurrencyUnit currency); + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFormatFactory.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFormatFactory.java new file mode 100644 index 0000000..39d8d63 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountFormatFactory.java @@ -0,0 +1,20 @@ +package com.fasterxml.jackson.datatype.money; + +import org.apiguardian.api.API; + +import javax.annotation.Nullable; +import javax.money.format.MonetaryAmountFormat; +import java.util.Locale; + +import static org.apiguardian.api.API.Status.STABLE; + +@API(status = STABLE) +@FunctionalInterface +public interface MonetaryAmountFormatFactory { + + MonetaryAmountFormatFactory NONE = locale -> null; + + @Nullable + MonetaryAmountFormat create(final Locale defaultLocale); + +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializer.java new file mode 100644 index 0000000..2aadde7 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializer.java @@ -0,0 +1,122 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.util.NameTransformer; + +import javax.annotation.Nullable; +import javax.money.CurrencyUnit; +import javax.money.MonetaryAmount; +import javax.money.format.MonetaryAmountFormat; +import java.io.IOException; +import java.util.Locale; + +final class MonetaryAmountSerializer extends StdSerializer { + + private final FieldNames names; + private final AmountWriter writer; + private final MonetaryAmountFormatFactory factory; + private final boolean isUnwrapping; + private final NameTransformer nameTransformer; + + MonetaryAmountSerializer(final FieldNames names, final AmountWriter writer, + final MonetaryAmountFormatFactory factory, boolean isUnwrapping, @Nullable final NameTransformer nameTransformer) { + super(MonetaryAmount.class); + this.writer = writer; + this.factory = factory; + this.names = names; + this.isUnwrapping = isUnwrapping; + this.nameTransformer = nameTransformer; + } + + MonetaryAmountSerializer(final FieldNames names, final AmountWriter writer, + final MonetaryAmountFormatFactory factory) { + this(names, writer, factory, false, null); + } + + @Override + public void acceptJsonFormatVisitor(final JsonFormatVisitorWrapper wrapper, final JavaType hint) + throws JsonMappingException { + + @Nullable final JsonObjectFormatVisitor visitor = wrapper.expectObjectFormat(hint); + + if (visitor == null) { + return; + } + + final SerializerProvider provider = wrapper.getProvider(); + + visitor.property(names.getAmount(), + provider.findValueSerializer(writer.getType()), + provider.constructType(writer.getType())); + + visitor.property(names.getCurrency(), + provider.findValueSerializer(CurrencyUnit.class), + provider.constructType(CurrencyUnit.class)); + + visitor.optionalProperty(names.getFormatted(), + provider.findValueSerializer(String.class), + provider.constructType(String.class)); + } + + @Override + public void serializeWithType(final MonetaryAmount value, final JsonGenerator generator, + final SerializerProvider provider, final TypeSerializer serializer) throws IOException { + + // effectively assuming no type information at all + serialize(value, generator, provider); + } + + @Override + public void serialize(final MonetaryAmount value, final JsonGenerator json, final SerializerProvider provider) + throws IOException { + + final CurrencyUnit currency = value.getCurrency(); + @Nullable final String formatted = format(value, provider); + + if (!isUnwrapping) { + json.writeStartObject(); + } + + { + provider.defaultSerializeField(transformName(names.getAmount()), writer.write(value), json); + provider.defaultSerializeField(transformName(names.getCurrency()), currency, json); + + if (formatted != null) { + provider.defaultSerializeField(transformName(names.getFormatted()), formatted, json); + } + } + + if (!isUnwrapping) { + json.writeEndObject(); + } + } + + private String transformName(String name) { + return (nameTransformer != null) ? nameTransformer.transform(name) : name; + } + + @Nullable + private String format(final MonetaryAmount value, final SerializerProvider provider) { + final Locale locale = provider.getConfig().getLocale(); + final MonetaryAmountFormat format = factory.create(locale); + return format == null ? null : format.format(value); + } + + @Override + public boolean isUnwrappingSerializer() { + return isUnwrapping; + } + + @Override + public JsonSerializer unwrappingSerializer(@Nullable final NameTransformer nameTransformer) { + return new MonetaryAmountSerializer(names, writer, factory, true, nameTransformer); + } +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java new file mode 100644 index 0000000..f350ded --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java @@ -0,0 +1,171 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.util.VersionUtil; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.module.SimpleDeserializers; +import com.fasterxml.jackson.databind.module.SimpleSerializers; +import org.apiguardian.api.API; +import org.javamoney.moneta.FastMoney; +import org.javamoney.moneta.Money; +import org.javamoney.moneta.RoundedMoney; + +import javax.money.CurrencyUnit; +import javax.money.MonetaryAmount; +import javax.money.MonetaryOperator; +import javax.money.MonetaryRounding; +import javax.money.format.MonetaryFormats; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.apiguardian.api.API.Status.STABLE; + + +@API(status = STABLE) +public final class MoneyModule extends Module { + + private final AmountWriter writer; + private final FieldNames names; + private final MonetaryAmountFormatFactory formatFactory; + private final MonetaryAmountFactory amountFactory; + private final MonetaryAmountFactory fastMoneyFactory; + private final MonetaryAmountFactory moneyFactory; + private final MonetaryAmountFactory roundedMoneyFactory; + + public MoneyModule() { + this(new DecimalAmountWriter(), FieldNames.defaults(), MonetaryAmountFormatFactory.NONE, + Money::of, FastMoney::of, Money::of, RoundedMoney::of); + } + + private MoneyModule(final AmountWriter writer, + final FieldNames names, + final MonetaryAmountFormatFactory formatFactory, + final MonetaryAmountFactory amountFactory, + final MonetaryAmountFactory fastMoneyFactory, + final MonetaryAmountFactory moneyFactory, + final MonetaryAmountFactory roundedMoneyFactory) { + + this.writer = writer; + this.names = names; + this.formatFactory = formatFactory; + this.amountFactory = amountFactory; + this.fastMoneyFactory = fastMoneyFactory; + this.moneyFactory = moneyFactory; + this.roundedMoneyFactory = roundedMoneyFactory; + } + + @Override + public String getModuleName() { + return MoneyModule.class.getSimpleName(); + } + + @Override + @SuppressWarnings("deprecation") + public Version version() { + final ClassLoader loader = MoneyModule.class.getClassLoader(); + return VersionUtil.mavenVersionFor(loader, "com.fasterxml.jackson.datatype.money", "jackson-datatype-money"); + } + + @Override + public void setupModule(final SetupContext context) { + final SimpleSerializers serializers = new SimpleSerializers(); + serializers.addSerializer(CurrencyUnit.class, new CurrencyUnitSerializer()); + serializers.addSerializer(MonetaryAmount.class, new MonetaryAmountSerializer(names, writer, formatFactory)); + context.addSerializers(serializers); + + final SimpleDeserializers deserializers = new SimpleDeserializers(); + deserializers.addDeserializer(CurrencyUnit.class, new CurrencyUnitDeserializer()); + deserializers.addDeserializer(MonetaryAmount.class, new MonetaryAmountDeserializer<>(amountFactory, names)); + // for reading into concrete implementation types + deserializers.addDeserializer(Money.class, new MonetaryAmountDeserializer<>(moneyFactory, names)); + deserializers.addDeserializer(FastMoney.class, new MonetaryAmountDeserializer<>(fastMoneyFactory, names)); + deserializers.addDeserializer(RoundedMoney.class, new MonetaryAmountDeserializer<>(roundedMoneyFactory, names)); + context.addDeserializers(deserializers); + } + + public MoneyModule withDecimalNumbers() { + return withNumbers(new DecimalAmountWriter()); + } + + public MoneyModule withQuotedDecimalNumbers() { + return withNumbers(new QuotedDecimalAmountWriter()); + } + + @API(status = EXPERIMENTAL) + public MoneyModule withNumbers(final AmountWriter writer) { + return new MoneyModule(writer, names, formatFactory, amountFactory, + fastMoneyFactory, moneyFactory, roundedMoneyFactory); + } + + /** + * @see FastMoney + * @return new {@link MoneyModule} using {@link FastMoney} + */ + public MoneyModule withFastMoney() { + return withMonetaryAmount(fastMoneyFactory); + } + + /** + * @see Money + * @return new {@link MoneyModule} using {@link Money} + */ + public MoneyModule withMoney() { + return withMonetaryAmount(moneyFactory); + } + + /** + * @see RoundedMoney + * @return new {@link MoneyModule} using {@link RoundedMoney} + */ + public MoneyModule withRoundedMoney() { + return withMonetaryAmount(roundedMoneyFactory); + } + + /** + * @see RoundedMoney + * @param rounding the rounding operator + * @return new {@link MoneyModule} using {@link RoundedMoney} with the given {@link MonetaryRounding} + */ + public MoneyModule withRoundedMoney(final MonetaryOperator rounding) { + final MonetaryAmountFactory factory = (amount, currency) -> + RoundedMoney.of(amount, currency, rounding); + + return new MoneyModule(writer, names, formatFactory, factory, + fastMoneyFactory, moneyFactory, factory); + } + + public MoneyModule withMonetaryAmount(final MonetaryAmountFactory amountFactory) { + return new MoneyModule(writer, names, formatFactory, amountFactory, + fastMoneyFactory, moneyFactory, roundedMoneyFactory); + } + + public MoneyModule withoutFormatting() { + return withFormatting(MonetaryAmountFormatFactory.NONE); + } + + public MoneyModule withDefaultFormatting() { + return withFormatting(MonetaryFormats::getAmountFormat); + } + + public MoneyModule withFormatting(final MonetaryAmountFormatFactory formatFactory) { + return new MoneyModule(writer, names, formatFactory, amountFactory, + fastMoneyFactory, moneyFactory, roundedMoneyFactory); + } + + public MoneyModule withAmountFieldName(final String name) { + return withFieldNames(names.withAmount(name)); + } + + public MoneyModule withCurrencyFieldName(final String name) { + return withFieldNames(names.withCurrency(name)); + } + + public MoneyModule withFormattedFieldName(final String name) { + return withFieldNames(names.withFormatted(name)); + } + + private MoneyModule withFieldNames(final FieldNames names) { + return new MoneyModule(writer, names, formatFactory, amountFactory, + fastMoneyFactory, moneyFactory, roundedMoneyFactory); + } + +} \ No newline at end of file diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/PackageVersion.java.in b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/PackageVersion.java.in new file mode 100644 index 0000000..7860aa1 --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/PackageVersion.java.in @@ -0,0 +1,20 @@ +package @package@; + +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.core.Versioned; +import com.fasterxml.jackson.core.util.VersionUtil; + +/** + * Automatically generated from PackageVersion.java.in during + * packageVersion-generate execution of maven-replacer-plugin in + * pom.xml. + */ +public final class PackageVersion implements Versioned { + public final static Version VERSION = VersionUtil.parseVersion( + "@projectversion@", "@projectgroupid@", "@projectartifactid@"); + + @Override + public Version version() { + return VERSION; + } +} diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/QuotedDecimalAmountWriter.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/QuotedDecimalAmountWriter.java new file mode 100644 index 0000000..c5c8fac --- /dev/null +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/QuotedDecimalAmountWriter.java @@ -0,0 +1,20 @@ +package com.fasterxml.jackson.datatype.money; + +import javax.money.MonetaryAmount; +import java.math.BigDecimal; + +final class QuotedDecimalAmountWriter implements AmountWriter { + + private final AmountWriter delegate = new DecimalAmountWriter(); + + @Override + public Class getType() { + return String.class; + } + + @Override + public String write(final MonetaryAmount amount) { + return delegate.write(amount).toPlainString(); + } + +} diff --git a/javax-money/src/main/resources/META-INF/LICENSE b/javax-money/src/main/resources/META-INF/LICENSE new file mode 100644 index 0000000..003d9c7 --- /dev/null +++ b/javax-money/src/main/resources/META-INF/LICENSE @@ -0,0 +1,23 @@ +TODO What goes here? (This original one or another?) + +The MIT License (MIT) + +Copyright (c) 2015-2016 Zalando SE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/javax-money/src/main/resources/META-INF/NOTICE b/javax-money/src/main/resources/META-INF/NOTICE new file mode 100644 index 0000000..d55c59a --- /dev/null +++ b/javax-money/src/main/resources/META-INF/NOTICE @@ -0,0 +1,17 @@ +# Jackson JSON processor + +Jackson is a high-performance, Free/Open Source JSON processing library. +It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +been in development since 2007. +It is currently developed by a community of developers. + +## Licensing + +Jackson components are licensed under Apache (Software) License, version 2.0, +as per accompanying LICENSE file. + +## Credits + +A list of contributors may be found from CREDITS file, which is included +in some artifacts (usually source distributions); but is always available +from the source code management (SCM) system project uses. diff --git a/javax-money/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/javax-money/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 0000000..935b160 --- /dev/null +++ b/javax-money/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +com.fasterxml.jackson.datatype.money.MoneyModule diff --git a/javax-money/src/moditect/module-info.java b/javax-money/src/moditect/module-info.java new file mode 100644 index 0000000..b8dab6f --- /dev/null +++ b/javax-money/src/moditect/module-info.java @@ -0,0 +1,13 @@ +//TODO how is this generated +// Generated 27-Mar-2019 using Moditect maven plugin +module com.fasterxml.jackson.datatype.money { + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.databind; + requires javax.money; + + exports com.fasterxml.jackson.datatype.money; + + provides com.fasterxml.jackson.databind.Module with + com.fasterxml.jackson.datatype.money.MoneyModule; +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java new file mode 100644 index 0000000..670848c --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java @@ -0,0 +1,43 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import org.javamoney.moneta.CurrencyUnitBuilder; +import org.junit.Test; + +import javax.money.CurrencyUnit; +import javax.money.UnknownCurrencyException; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; + +public final class CurrencyUnitDeserializerTest { + + private final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + + @Test + public void shouldDeserialize() throws IOException { + final CurrencyUnit actual = unit.readValue("\"EUR\"", CurrencyUnit.class); + final CurrencyUnit expected = CurrencyUnitBuilder.of("EUR", "default").build(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void shouldNotDeserializeInvalidCurrency() { + assertThrows(UnknownCurrencyException.class, () -> + unit.readValue("\"FOO\"", CurrencyUnit.class)); + } + + @Test + public void shouldDeserializeWithTyping() throws IOException { + unit.activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build()); + + final CurrencyUnit actual = unit.readValue("\"EUR\"", CurrencyUnit.class); + final CurrencyUnit expected = CurrencyUnitBuilder.of("EUR", "default").build(); + + assertThat(actual).isEqualTo(expected); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java new file mode 100644 index 0000000..ceaa6f6 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java @@ -0,0 +1,25 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import org.junit.Test; + +import javax.money.CurrencyUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class CurrencyUnitSchemaSerializerTest { + + private final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + + @Test + public void shouldSerializeJsonSchema() throws Exception { + JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); + JsonSchema jsonSchema = generator.generateSchema(CurrencyUnit.class); + final String actual = unit.writeValueAsString(jsonSchema); + final String expected = "{\"type\":\"string\"}"; + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializerTest.java new file mode 100644 index 0000000..0ee47f5 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializerTest.java @@ -0,0 +1,27 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.javamoney.moneta.CurrencyUnitBuilder; +import org.junit.Test; + +import javax.money.CurrencyUnit; + +import static org.assertj.core.api.Assertions.assertThat; + + +public final class CurrencyUnitSerializerTest { + + private final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + + @Test + public void shouldSerialize() throws JsonProcessingException { + final String expected = "EUR"; + final CurrencyUnit currency = CurrencyUnitBuilder.of(expected, "default").build(); + + final String actual = unit.writeValueAsString(currency); + + assertThat(actual).isEqualTo('"' + expected + '"'); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/FieldNamesTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/FieldNamesTest.java new file mode 100644 index 0000000..9d1702d --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/FieldNamesTest.java @@ -0,0 +1,20 @@ +package com.fasterxml.jackson.datatype.money; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class FieldNamesTest { + + @Test + public void shouldOptimizeWithMethods() { + final FieldNames expected = FieldNames.defaults(); + final FieldNames actual = expected + .withAmount(expected.getAmount()) + .withCurrency(expected.getCurrency()) + .withFormatted(expected.getFormatted()); + + assertThat(actual).isSameAs(expected); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java new file mode 100644 index 0000000..55551b1 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java @@ -0,0 +1,273 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; +import org.javamoney.moneta.FastMoney; +import org.javamoney.moneta.Money; +import org.javamoney.moneta.RoundedMoney; +import org.junit.Test; +import org.junit.runner.RunWith; + +import javax.money.Monetary; +import javax.money.MonetaryAmount; +import java.io.IOException; +import java.math.BigDecimal; + +import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; +import static junitparams.JUnitParamsRunner.$; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; + +@RunWith(JUnitParamsRunner.class) +public final class MonetaryAmountDeserializerTest { + + @SuppressWarnings("unused") + private Object[] data() { + return $($(Money.class, (Configurer) module -> module), + $(FastMoney.class, (Configurer) module -> new MoneyModule().withFastMoney()), + $(Money.class, (Configurer) module -> new MoneyModule().withMoney()), + $(RoundedMoney.class, (Configurer) module -> new MoneyModule().withRoundedMoney()), + $(RoundedMoney.class, (Configurer) module -> module.withRoundedMoney(Monetary.getDefaultRounding()))); + } + + private interface Configurer { + MoneyModule configure(MoneyModule module); + } + + private ObjectMapper unit(final Configurer configurer) { + return unit(module(configurer)); + } + + private ObjectMapper unit(final Module module) { + return new ObjectMapper().registerModule(module); + } + + private MoneyModule module(final Configurer configurer) { + return configurer.configure(new MoneyModule()); + } + + @Test + public void shouldDeserializeMoneyByDefault() throws IOException { + final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, MonetaryAmount.class); + + assertThat(amount).isInstanceOf(Money.class); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeToCorrectType(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount).isInstanceOf(type); + } + + @Test + @Parameters(method = "data") + public void shouldDeserialize(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo(new BigDecimal("29.95")); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeWithHighNumberOfFractionDigits(final Class type, + final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.9501,\"currency\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo(new BigDecimal("29.9501")); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo("EUR"); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeCorrectlyWhenAmountIsAStringValue(final Class type, + final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"currency\":\"EUR\",\"amount\":\"29.95\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo(new BigDecimal("29.95")); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo(("EUR")); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeCorrectlyWhenPropertiesAreInDifferentOrder(final Class type, + final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"currency\":\"EUR\",\"amount\":29.95}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo((new BigDecimal("29.95"))); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo(("EUR")); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeWithCustomNames(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(module(configurer) + .withAmountFieldName("value") + .withCurrencyFieldName("unit")); + + final String content = "{\"value\":29.95,\"unit\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo((new BigDecimal("29.95"))); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo(("EUR")); + } + + @Test + @Parameters(method = "data") + public void shouldIgnoreFormattedValue(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\",\"formatted\":\"30.00 EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount.getNumber().numberValueExact(BigDecimal.class)).isEqualTo((new BigDecimal("29.95"))); + assertThat(amount.getCurrency().getCurrencyCode()).isEqualTo(("EUR")); + } + + @Test + @Parameters(method = "data") + public void shouldUpdateExistingValueUsingTreeTraversingParser(final Class type, + final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final MonetaryAmount amount = unit.readValue(content, type); + + assertThat(amount).isNotNull(); + + // we need a json node to get a TreeTraversingParser with codec of type ObjectReader + final JsonNode ownerNode = + unit.readTree("{\"value\":{\"amount\":29.95,\"currency\":\"EUR\",\"formatted\":\"30.00EUR\"}}"); + + final Owner owner = new Owner(); + owner.setValue(amount); + + // try to update + final Owner result = unit.readerForUpdating(owner).readValue(ownerNode); + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo((amount)); + } + + private static class Owner { + + private MonetaryAmount value; + + MonetaryAmount getValue() { + return value; + } + + void setValue(final MonetaryAmount value) { + this.value = value; + } + + } + + @Test + @Parameters(method = "data") + public void shouldFailToDeserializeWithoutAmount(final Class type, final Configurer configurer) { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"currency\":\"EUR\"}"; + + final JsonProcessingException exception = assertThrows( + JsonProcessingException.class, () -> unit.readValue(content, type)); + + assertThat(exception.getMessage()).contains("Missing property: 'amount'"); + } + + @Test + @Parameters(method = "data") + public void shouldFailToDeserializeWithoutCurrency(final Class type, final Configurer configurer) { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95}"; + + final JsonProcessingException exception = assertThrows( + JsonProcessingException.class, () -> unit.readValue(content, type)); + + assertThat(exception.getMessage()).contains("Missing property: 'currency'"); + } + + @Test + @Parameters(method = "data") + public void shouldFailToDeserializeWithAdditionalProperties(final Class type, + final Configurer configurer) { + final ObjectMapper unit = unit(configurer); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\",\"version\":\"1\"}"; + + final JsonProcessingException exception = assertThrows( + UnrecognizedPropertyException.class, () -> unit.readValue(content, type)); + + assertThat(exception.getMessage()).startsWith( + "Unrecognized field \"version\" (class javax.money.MonetaryAmount), " + + "not marked as ignorable (3 known properties: \"amount\", \"currency\", \"formatted\"])"); + } + + @Test + @Parameters(method = "data") + public void shouldNotFailToDeserializeWithAdditionalProperties(final Class type, + final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer).disable(FAIL_ON_UNKNOWN_PROPERTIES); + + final String content = "{\"source\":{\"provider\":\"ECB\",\"date\":\"2016-09-29\"},\"amount\":29.95,\"currency\":\"EUR\",\"version\":\"1\"}"; + unit.readValue(content, type); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeWithTypeInformation(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer) + .activateDefaultTyping( + BasicPolymorphicTypeValidator.builder().build(), + ObjectMapper.DefaultTyping.OBJECT_AND_NON_CONCRETE, + JsonTypeInfo.As.EXISTING_PROPERTY) + .disable(FAIL_ON_UNKNOWN_PROPERTIES); + + final String content = "{\"type\":\"org.javamoney.moneta.Money\",\"amount\":29.95,\"currency\":\"EUR\"}"; + final M amount = unit.readValue(content, type); + + // type information is ignored?! + assertThat(amount).isInstanceOf(type); + } + + @Test + @Parameters(method = "data") + public void shouldDeserializeWithoutTypeInformation(final Class type, final Configurer configurer) throws IOException { + final ObjectMapper unit = unit(configurer).activateDefaultTyping( + BasicPolymorphicTypeValidator.builder().build()); + + final String content = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final M amount = unit.readValue(content, type); + + assertThat(amount).isInstanceOf(type); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java new file mode 100644 index 0000000..c827bd6 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java @@ -0,0 +1,87 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import org.junit.Test; + +import javax.money.MonetaryAmount; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class MonetaryAmountSchemaSerializerTest { + + @Test + public void shouldSerializeJsonSchema() throws Exception { + final ObjectMapper unit = unit(module()); + final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); + final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final String actual = unit.writeValueAsString(jsonSchema); + final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + + "{\"amount\":{\"type\":\"number\",\"required\":true}," + + "\"currency\":{\"type\":\"string\",\"required\":true}," + + "\"formatted\":{\"type\":\"string\"}}}"; + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void shouldSerializeJsonSchemaWithCustomFieldNames() throws Exception { + final ObjectMapper unit = unit(module().withAmountFieldName("value") + .withCurrencyFieldName("unit") + .withFormattedFieldName("pretty")); + final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); + final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final String actual = unit.writeValueAsString(jsonSchema); + final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + + "{\"value\":{\"type\":\"number\",\"required\":true}," + + "\"unit\":{\"type\":\"string\",\"required\":true}," + + "\"pretty\":{\"type\":\"string\"}}}"; + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void shouldSerializeJsonSchemaWithQuotedDecimalNumbers() throws Exception { + final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); + final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); + final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final String actual = unit.writeValueAsString(jsonSchema); + final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + + "{\"amount\":{\"type\":\"string\",\"required\":true}," + + "\"currency\":{\"type\":\"string\",\"required\":true}," + + "\"formatted\":{\"type\":\"string\"}}}"; + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void shouldSerializeJsonSchemaWithMultipleMonetayAmountsAndAlternativeGenerator() throws Exception { + final ObjectMapper unit = unit(module()); + final com.kjetland.jackson.jsonSchema.JsonSchemaGenerator generator = + new com.kjetland.jackson.jsonSchema.JsonSchemaGenerator(unit); + + final JsonNode jsonSchema = generator.generateJsonSchema(SchemaTestClass.class); + + final String actual = unit.writeValueAsString(jsonSchema); + final String expected = "{\"$schema\":\"http://json-schema.org/draft-04/schema#\",\"title\":\"Schema Test Class\"," + + "\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"moneyOne\":{\"$ref\":" + + "\"#/definitions/MonetaryAmount\"},\"moneyTwo\":{\"$ref\":\"#/definitions/MonetaryAmount\"}}," + + "\"definitions\":{\"MonetaryAmount\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\"" + + ":{\"amount\":{\"type\":\"number\"},\"currency\":{\"type\":\"string\"},\"formatted\":" + + "{\"type\":\"string\"}},\"required\":[\"amount\",\"currency\"]}}}"; + + assertThat(actual).isEqualTo(expected); + } + + private ObjectMapper unit(final Module module) { + return new ObjectMapper().registerModule(module); + } + + private MoneyModule module() { + return new MoneyModule(); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializerTest.java new file mode 100644 index 0000000..f95bca1 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSerializerTest.java @@ -0,0 +1,377 @@ +package com.fasterxml.jackson.datatype.money; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.json.JsonWriteFeature; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.type.SimpleType; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; +import lombok.Value; +import org.javamoney.moneta.FastMoney; +import org.javamoney.moneta.Money; +import org.javamoney.moneta.RoundedMoney; +import org.junit.Test; +import org.junit.runner.RunWith; + +import javax.money.MonetaryAmount; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Locale; + +import static javax.money.Monetary.getDefaultRounding; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@RunWith(JUnitParamsRunner.class) +public final class MonetaryAmountSerializerTest { + + static Iterable amounts() { + return Arrays.asList( + FastMoney.of(29.95, "EUR"), + Money.of(29.95, "EUR"), + RoundedMoney.of(29.95, "EUR", getDefaultRounding())); + } + + static Iterable hundreds() { + return Arrays.asList( + FastMoney.of(100, "EUR"), + Money.of(100, "EUR"), + RoundedMoney.of(100, "EUR", getDefaultRounding())); + } + + static Iterable fractions() { + return Arrays.asList( + FastMoney.of(0.0001, "EUR"), + Money.of(0.0001, "EUR"), + RoundedMoney.of(0.0001, "EUR", getDefaultRounding())); + } + + private ObjectMapper unit() { + return unit(module()); + } + + private ObjectMapper unit(final Module module) { + return build(module).build(); + } + + private JsonMapper.Builder build() { + return build(module()); + } + + private JsonMapper.Builder build(final Module module) { + return JsonMapper.builder() + .addModule(module); + } + + private MoneyModule module() { + return new MoneyModule(); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerialize(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(); + + final String expected = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithoutFormattedValueIfFactoryProducesNull( + final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withoutFormatting()); + + final String expected = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithFormattedGermanValue(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(new MoneyModule().withDefaultFormatting()); + + final String expected = "{\"amount\":29.95,\"currency\":\"EUR\",\"formatted\":\"29,95 EUR\"}"; + + final ObjectWriter writer = unit.writer().with(Locale.GERMANY); + final String actual = writer.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithFormattedAmericanValue(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withDefaultFormatting()); + + final String expected = "{\"amount\":29.95,\"currency\":\"USD\",\"formatted\":\"USD29.95\"}"; + + final ObjectWriter writer = unit.writer().with(Locale.US); + final String actual = writer.writeValueAsString(amount.getFactory().setCurrency("USD").create()); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithCustomName(final MonetaryAmount amount) throws IOException { + final ObjectMapper unit = unit(module().withDefaultFormatting() + .withAmountFieldName("value") + .withCurrencyFieldName("unit") + .withFormattedFieldName("pretty")); + + final String expected = "{\"value\":29.95,\"unit\":\"EUR\",\"pretty\":\"29,95 EUR\"}"; + + final ObjectWriter writer = unit.writer().with(Locale.GERMANY); + final String actual = writer.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeAmountAsDecimal(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withDecimalNumbers()); + + final String expected = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldSerializeAmountAsDecimalWithDefaultFractionDigits( + final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withDecimalNumbers()); + + final String expected = "{\"amount\":100.00,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "fractions") + public void shouldSerializeAmountAsDecimalWithHigherNumberOfFractionDigits( + final MonetaryAmount fraction) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withDecimalNumbers()); + + final String expected = "{\"amount\":0.0001,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(fraction); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldSerializeAmountAsDecimalWithLowerNumberOfFractionDigits( + final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withNumbers(new AmountWriter() { + @Override + public Class getType() { + return BigDecimal.class; + } + + @Override + public BigDecimal write(final MonetaryAmount amount) { + return amount.getNumber().numberValueExact(BigDecimal.class).stripTrailingZeros(); + } + })); + + final String expected = "{\"amount\":1E+2,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeAmountAsQuotedDecimal(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); + + final String expected = "{\"amount\":\"29.95\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldSerializeAmountAsQuotedDecimalWithDefaultFractionDigits( + final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); + + final String expected = "{\"amount\":\"100.00\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "fractions") + public void shouldSerializeAmountAsQuotedDecimalWithHigherNumberOfFractionDigits( + final MonetaryAmount fraction) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); + + final String expected = "{\"amount\":\"0.0001\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(fraction); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldSerializeAmountAsQuotedDecimalWithLowerNumberOfFractionDigits( + final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withNumbers(new AmountWriter() { + @Override + public Class getType() { + return String.class; + } + + @Override + public String write(final MonetaryAmount amount) { + return amount.getNumber().numberValueExact(BigDecimal.class).stripTrailingZeros().toPlainString(); + } + })); + + final String expected = "{\"amount\":\"100\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldSerializeAmountAsQuotedDecimalPlainString(final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); + unit.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN); + + final String expected = "{\"amount\":\"100.00\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "amounts") + public void shouldWriteNumbersAsStrings(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = build() + .enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS) + .build(); + + final String expected = "{\"amount\":\"29.95\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(amount); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @Parameters(method = "hundreds") + public void shouldWriteNumbersAsPlainStrings(final MonetaryAmount hundred) throws JsonProcessingException { + final ObjectMapper unit = build() + .enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS) + .enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN) + .build(); + + final String expected = "{\"amount\":\"100.00\",\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(hundred); + + assertThat(actual).isEqualTo(expected); + } + + @Value + private static class Price { + MonetaryAmount amount; + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithType(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module()).activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build()); + + final String expected = "{\"amount\":{\"amount\":29.95,\"currency\":\"EUR\"}}"; + final String actual = unit.writeValueAsString(new Price(amount)); + + assertThat(actual).isEqualTo(expected); + } + + @Value + private static class PriceUnwrapped { + @JsonUnwrapped + MonetaryAmount amount; + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithTypeUnwrapped(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module()).activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build()); + + final String expected = "{\"amount\":29.95,\"currency\":\"EUR\"}"; + final String actual = unit.writeValueAsString(new PriceUnwrapped(amount)); + + assertThat(actual).isEqualTo(expected); + } + + @Value + private static class PriceUnwrappedTransformedNames { + @JsonUnwrapped(prefix = "Price-", suffix = "-Field") + MonetaryAmount amount; + } + + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithTypeUnwrappedAndNamesTransformed(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module()).activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build()); + + final String expected = "{\"Price-amount-Field\":29.95,\"Price-currency-Field\":\"EUR\"}"; + final String actual = unit.writeValueAsString(new PriceUnwrappedTransformedNames(amount)); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void shouldHandleNullValueFromExpectObjectFormatInSchemaVisitor() throws Exception { + final MonetaryAmountSerializer unit = new MonetaryAmountSerializer(FieldNames.defaults(), + new DecimalAmountWriter(), MonetaryAmountFormatFactory.NONE); + + final JsonFormatVisitorWrapper wrapper = mock(JsonFormatVisitorWrapper.class); + unit.acceptJsonFormatVisitor(wrapper, SimpleType.constructUnsafe(MonetaryAmount.class)); + } + + /** + * Fixes a bug that caused the amount field to be written as + * + * "amount": {"BigDecimal":12.34} + * + * + * @param amount + * @throws JsonProcessingException + */ + @Test + @Parameters(method = "amounts") + public void shouldSerializeWithWrapRootValue(final MonetaryAmount amount) throws JsonProcessingException { + final ObjectMapper unit = unit(module()) + .configure(SerializationFeature.WRAP_ROOT_VALUE, true); + + final String expected = "{\"Price\":{\"amount\":{\"amount\":29.95,\"currency\":\"EUR\"}}}"; + final String actual = unit.writeValueAsString(new Price(amount)); + + assertThat(actual).isEqualTo(expected); + } + +} diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/SchemaTestClass.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/SchemaTestClass.java new file mode 100644 index 0000000..21448e4 --- /dev/null +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/SchemaTestClass.java @@ -0,0 +1,15 @@ +package com.fasterxml.jackson.datatype.money; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import javax.money.MonetaryAmount; + +@AllArgsConstructor +@Getter +public class SchemaTestClass { + + private final MonetaryAmount moneyOne; + private final MonetaryAmount moneyTwo; + +} diff --git a/pom.xml b/pom.xml index 3df30cf..a26acb1 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,8 @@ jakarta-jsonp jakarta-mail + + javax-money https://github.com/FasterXML/jackson-datatypes-misc From 8d99b6dba96b12024760ac181c0905d581b2c831 Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Mon, 28 Oct 2024 13:20:20 +0100 Subject: [PATCH 2/3] Respond to review comments Changed License reference (WIP) Removed cross-module dependency to jsonSchema Testing specific Modules instead of findAndRegisterModules Assert subtypes of JSONProcessingException CurrencyUnitDeserializer extends StdScalarDeserializer CurrencyUnitSerializer extends StdScalarSerializer MonetaryAmountDeserializer throws semantic exceptions using DeserializationContext MoneyModule version uses PackageVersion --- javax-money/pom.xml | 11 ++----- .../money/CurrencyUnitDeserializer.java | 7 +++- .../money/CurrencyUnitSerializer.java | 3 +- .../money/MonetaryAmountDeserializer.java | 21 +++++++----- .../jackson/datatype/money/MoneyModule.java | 5 +-- .../src/main/resources/META-INF/LICENSE | 2 -- .../money/CurrencyUnitDeserializerTest.java | 2 +- .../CurrencyUnitSchemaSerializerTest.java | 17 ++++------ .../money/MonetaryAmountDeserializerTest.java | 5 +-- .../MonetaryAmountSchemaSerializerTest.java | 33 +++++++++---------- 10 files changed, 50 insertions(+), 56 deletions(-) diff --git a/javax-money/pom.xml b/javax-money/pom.xml index 575601d..f3dd5a8 100644 --- a/javax-money/pom.xml +++ b/javax-money/pom.xml @@ -14,13 +14,13 @@ Jackson datatype: javax-money jar 2.19.0-SNAPSHOT - Support for datatypes of Javax Money library (https://javamoney.github.io/) + Support for datatypes of Money API spec from JSR 354 (https://javamoney.github.io/api.html) https://github.com/FasterXML/jackson-datatypes-misc - The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt + MIT License + https://opensource.org/licenses/MIT repo @@ -82,11 +82,6 @@ 4.5.1 test - - com.fasterxml.jackson.module - jackson-module-jsonSchema - test - com.kjetland mbknor-jackson-jsonschema_2.12 diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java index 7ed5c34..917beca 100644 --- a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializer.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; import org.apiguardian.api.API; @@ -13,7 +14,11 @@ import static org.apiguardian.api.API.Status.MAINTAINED; @API(status = MAINTAINED) -public final class CurrencyUnitDeserializer extends JsonDeserializer { +public final class CurrencyUnitDeserializer extends StdScalarDeserializer { + + public CurrencyUnitDeserializer() { + super(CurrencyUnit.class); + } @Override public Object deserializeWithType(final JsonParser parser, final DeserializationContext context, diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java index fb511c0..745dbc0 100644 --- a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSerializer.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import org.apiguardian.api.API; @@ -14,7 +15,7 @@ import static org.apiguardian.api.API.Status.MAINTAINED; @API(status = MAINTAINED) -public final class CurrencyUnitSerializer extends StdSerializer { +public final class CurrencyUnitSerializer extends StdScalarSerializer { CurrencyUnitSerializer() { super(CurrencyUnit.class); diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java index e053b50..d4a869e 100644 --- a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializer.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; @@ -14,6 +15,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.util.Arrays; +import java.util.Objects; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; import static java.lang.String.format; @@ -61,17 +63,18 @@ public M deserialize(final JsonParser parser, final DeserializationContext conte } } - checkPresent(parser, amount, names.getAmount()); - checkPresent(parser, currency, names.getCurrency()); + String missingName; - return factory.create(amount, currency); - } - - private void checkPresent(final JsonParser parser, @Nullable final Object value, final String name) - throws JsonParseException { - if (value == null) { - throw new JsonParseException(parser, format("Missing property: '%s'", name)); + if (Objects.isNull(currency)) { + missingName = names.getCurrency(); + } else if (Objects.isNull(amount)) { + missingName = names.getAmount(); + } else { + return factory.create(amount, currency); } + + return context.reportPropertyInputMismatch(MonetaryAmount.class, missingName, format("Missing property: '%s'", missingName)); + } } diff --git a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java index f350ded..75157f6 100644 --- a/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java +++ b/javax-money/src/main/java/com/fasterxml/jackson/datatype/money/MoneyModule.java @@ -1,7 +1,6 @@ package com.fasterxml.jackson.datatype.money; import com.fasterxml.jackson.core.Version; -import com.fasterxml.jackson.core.util.VersionUtil; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.module.SimpleDeserializers; import com.fasterxml.jackson.databind.module.SimpleSerializers; @@ -59,10 +58,8 @@ public String getModuleName() { } @Override - @SuppressWarnings("deprecation") public Version version() { - final ClassLoader loader = MoneyModule.class.getClassLoader(); - return VersionUtil.mavenVersionFor(loader, "com.fasterxml.jackson.datatype.money", "jackson-datatype-money"); + return PackageVersion.VERSION; } @Override diff --git a/javax-money/src/main/resources/META-INF/LICENSE b/javax-money/src/main/resources/META-INF/LICENSE index 003d9c7..6448684 100644 --- a/javax-money/src/main/resources/META-INF/LICENSE +++ b/javax-money/src/main/resources/META-INF/LICENSE @@ -1,5 +1,3 @@ -TODO What goes here? (This original one or another?) - The MIT License (MIT) Copyright (c) 2015-2016 Zalando SE diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java index 670848c..3e9e339 100644 --- a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitDeserializerTest.java @@ -14,7 +14,7 @@ public final class CurrencyUnitDeserializerTest { - private final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + private final ObjectMapper unit = new ObjectMapper().registerModule(new MoneyModule()); @Test public void shouldDeserialize() throws IOException { diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java index ceaa6f6..54e3352 100644 --- a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/CurrencyUnitSchemaSerializerTest.java @@ -1,25 +1,22 @@ package com.fasterxml.jackson.datatype.money; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.kjetland.jackson.jsonSchema.JsonSchemaGenerator; import org.junit.Test; import javax.money.CurrencyUnit; - import static org.assertj.core.api.Assertions.assertThat; public final class CurrencyUnitSchemaSerializerTest { - private final ObjectMapper unit = new ObjectMapper().findAndRegisterModules(); + private final ObjectMapper unit = new ObjectMapper().registerModule(new MoneyModule()); @Test - public void shouldSerializeJsonSchema() throws Exception { + public void shouldSerializeJsonSchema() { JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); - JsonSchema jsonSchema = generator.generateSchema(CurrencyUnit.class); - final String actual = unit.writeValueAsString(jsonSchema); - final String expected = "{\"type\":\"string\"}"; - - assertThat(actual).isEqualTo(expected); + JsonNode schemaNode = generator.generateJsonSchema(CurrencyUnit.class); + assertThat(schemaNode.get("type")).isNotNull(); + assertThat(schemaNode.get("type").asText()).isEqualTo("string"); } } diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java index 55551b1..3e8e4f6 100644 --- a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountDeserializerTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; import junitparams.JUnitParamsRunner; @@ -209,8 +210,8 @@ public void shouldFailToDeserializeWithoutCurrency(final Class type, final Co final String content = "{\"amount\":29.95}"; - final JsonProcessingException exception = assertThrows( - JsonProcessingException.class, () -> unit.readValue(content, type)); + final MismatchedInputException exception = assertThrows( + MismatchedInputException.class, () -> unit.readValue(content, type)); assertThat(exception.getMessage()).contains("Missing property: 'currency'"); } diff --git a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java index c827bd6..db7a965 100644 --- a/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java +++ b/javax-money/src/test/java/com/fasterxml/jackson/datatype/money/MonetaryAmountSchemaSerializerTest.java @@ -3,8 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.kjetland.jackson.jsonSchema.JsonSchemaGenerator; import org.junit.Test; import javax.money.MonetaryAmount; @@ -17,12 +16,12 @@ public final class MonetaryAmountSchemaSerializerTest { public void shouldSerializeJsonSchema() throws Exception { final ObjectMapper unit = unit(module()); final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); - final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final JsonNode jsonSchema = generator.generateJsonSchema(MonetaryAmount.class); final String actual = unit.writeValueAsString(jsonSchema); - final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + - "{\"amount\":{\"type\":\"number\",\"required\":true}," + - "\"currency\":{\"type\":\"string\",\"required\":true}," + - "\"formatted\":{\"type\":\"string\"}}}"; + final String expected = "{\"$schema\":\"http://json-schema.org/draft-04/schema#\",\"title\":\"Monetary Amount\"" + + ",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"amount\":{\"type\":\"number\"}" + + ",\"currency\":{\"type\":\"string\"},\"formatted\":{\"type\":\"string\"}}" + + ",\"required\":[\"amount\",\"currency\"]}"; assertThat(actual).isEqualTo(expected); } @@ -33,12 +32,11 @@ public void shouldSerializeJsonSchemaWithCustomFieldNames() throws Exception { .withCurrencyFieldName("unit") .withFormattedFieldName("pretty")); final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); - final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final JsonNode jsonSchema = generator.generateJsonSchema(MonetaryAmount.class); final String actual = unit.writeValueAsString(jsonSchema); - final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + - "{\"value\":{\"type\":\"number\",\"required\":true}," + - "\"unit\":{\"type\":\"string\",\"required\":true}," + - "\"pretty\":{\"type\":\"string\"}}}"; + final String expected = "{\"$schema\":\"http://json-schema.org/draft-04/schema#\",\"title\":\"Monetary Amount\"" + + ",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"value\":{\"type\":\"number\"}" + + ",\"unit\":{\"type\":\"string\"},\"pretty\":{\"type\":\"string\"}},\"required\":[\"value\",\"unit\"]}"; assertThat(actual).isEqualTo(expected); } @@ -47,18 +45,17 @@ public void shouldSerializeJsonSchemaWithCustomFieldNames() throws Exception { public void shouldSerializeJsonSchemaWithQuotedDecimalNumbers() throws Exception { final ObjectMapper unit = unit(module().withQuotedDecimalNumbers()); final JsonSchemaGenerator generator = new JsonSchemaGenerator(unit); - final JsonSchema jsonSchema = generator.generateSchema(MonetaryAmount.class); + final JsonNode jsonSchema = generator.generateJsonSchema(MonetaryAmount.class); final String actual = unit.writeValueAsString(jsonSchema); - final String expected = "{\"type\":\"object\",\"id\":\"urn:jsonschema:javax:money:MonetaryAmount\",\"properties\":" + - "{\"amount\":{\"type\":\"string\",\"required\":true}," + - "\"currency\":{\"type\":\"string\",\"required\":true}," + - "\"formatted\":{\"type\":\"string\"}}}"; + final String expected = "{\"$schema\":\"http://json-schema.org/draft-04/schema#\",\"title\":\"Monetary Amount\"" + + ",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"amount\":{\"type\":\"string\"}" + + ",\"currency\":{\"type\":\"string\"},\"formatted\":{\"type\":\"string\"}},\"required\":[\"amount\",\"currency\"]}"; assertThat(actual).isEqualTo(expected); } @Test - public void shouldSerializeJsonSchemaWithMultipleMonetayAmountsAndAlternativeGenerator() throws Exception { + public void shouldSerializeJsonSchemaWithMultipleMonetayAmounts() throws Exception { final ObjectMapper unit = unit(module()); final com.kjetland.jackson.jsonSchema.JsonSchemaGenerator generator = new com.kjetland.jackson.jsonSchema.JsonSchemaGenerator(unit); From 487cbf95ed751a5d12d229024b81c14d1f8fb6da Mon Sep 17 00:00:00 2001 From: Sri Adarsh Kumar Date: Tue, 29 Oct 2024 13:00:02 +0100 Subject: [PATCH 3/3] Move Money.md in from source repo --- javax-money/MONEY.md | 118 ++++++++++++++++++++++++++++++++++++++++++ javax-money/README.md | 2 + 2 files changed, 120 insertions(+) create mode 100644 javax-money/MONEY.md diff --git a/javax-money/MONEY.md b/javax-money/MONEY.md new file mode 100644 index 0000000..7070876 --- /dev/null +++ b/javax-money/MONEY.md @@ -0,0 +1,118 @@ +# Representing Money in JSON + +> A large proportion of the computers in this world manipulate money, so it's always puzzled me that money isn't actually a first class data type in any mainstream programming language. +> +> [Martin Fowler](https://martinfowler.com/eaaCatalog/money.html) + +Unfortunately JSON is no different. This document tries to change that by proposing and comparing different styles to represent money, some inspired by external sources and some based on our own experience. + +## ⚠️ Monetary amounts ≠ floats + +Before we dive into details, always keep the following in mind. However you desire to format money in JSON, nothing changes the fact that you should... + +> **Never hold monetary values [..] in a float variable.** Floating point is not suitable for this work, and you must use either [fixed-point](#fixed-point) or [decimal](#decimal) values. +> +> [Coinkite: Common Terms and Data Objects](https://web.archive.org/web/20150924073850/https://docs.coinkite.com/api/common.html) + +## Styles + +We identified the following styles that all of different advantages and disadvantages that are discussed in their respective section. + +| Style | Expressive | Arithmetic | Pitfalls / Misuses | +|------------------------------------|------------|------------|--------------------| +| [Decimal](#decimal) | ✔ | ✔ | Precision | +| [Quoted Decimal](#quoted-decimal) | ✔ | ✘ | Parsing | +| [Fixed Point](#fixed-point) | ✘ | ✔ | Mixed scales | +| [Mixed](#mixed) | ✘ | ✔ | Consistency | + +### Decimal + +The most straightforward way to represent a monetary amount would be a base-10 decimal number: + +```json +{ + "amount": 49.95, + "currency": "EUR" +} +``` + +It's expressive, readable and allows arithmetic operations. The downside is that most [JSON decoders will treat it as a floating point](https://tools.ietf.org/html/rfc7159#section-6) number which is very much undesirable. + +Most programming languages have support for arbitrary-precision [decimals](#decimal-implementations) and JSON decoders that can be configured to use them. In general it can be considered to be a problem of the implementation, not the format itself. + +### Quoted Decimal + +Same as [Decimal](#decimal) but quoted so your JSON decoder treats it as a string: + +```json +{ + "amount": "49.95", + "currency": "EUR" +} +``` + +It solves the precision problem of decimals on the expense of performing arithmetic operations on it. It also requires a two-phase parsing, i.e. parsing the JSON text into a data structure and then parsing quoted amounts into decimals. + +### Fixed Point + +> A value of a fixed-point data type is essentially an integer that is scaled by an implicit specific factor determined by the type. +> +> [Wikipedia: Fixed-point arithmetic](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) + +```json +{ + "amount": 4995, + "currency": "EUR" +} +``` + +The implicit scaling factor is defined as (0.1 raised to the power of) the currency's [default number of fraction digits](http://www.localeplanet.com/icu/currency.html). + +In rare cases one might need a higher precision, e.g. to have sub-cent. In this case the scale can be defined explicitly: + +```json +{ + "amount": 499599, + "currency": "EUR", + "scale": 4 +} +``` + +The downside with fixed-point amounts is that reading them is a bit harder and arithmetic with mixed scale amounts can be tricky and error-prone. + +### Mixed + +As a way to counter all negative aspects of the styles above one idea would be to have a single object that contains all of the formats: + +```json +{ + "decimal": 49.95, + "quoted_decimal": "49.95", + "fixed_point": 4995, + "scale": 2, + "currency": "EUR" +} +``` + +Decoders can choose the representation that fits them the best. Encoders on the other hand have the harder task by providing all of them and making sure that all values are in fact consistent. + +## Decimal Implementations + +| Language | Implementation | +|------------|---------------------------------------------------------------------------------------------| +| C# | [decimal](https://msdn.microsoft.com/en-us/library/364x0z75.aspx) | +| Java | [java.math.BigDecimal](https://docs.oracle.com/javase/8/docs/api/java/math/BigDecimal.html) | +| JavaScript | [decimal.js](https://github.com/MikeMcl/decimal.js/) | +| Python | [decimal.Decimal](https://docs.python.org/2/library/decimal.html) | + +## Credits and References + +- [Coinkite: Currency Amounts](https://web.archive.org/web/20150924073850/https://docs.coinkite.com/api/common.html#currency-amounts) +- [Culttt: How to handle money and currency in web applications](http://culttt.com/2014/05/28/handle-money-currency-web-applications/) +- [Currency codes - ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) +- [LocalePlanet: ICU Currencies](http://www.localeplanet.com/icu/currency.html) +- [RFC 7159: The JavaScript Object Notation (JSON) Data Interchange Format](https://tools.ietf.org/html/rfc7159#section-6) +- [Stackoverflow: What is the standard for formatting currency values in JSON?](http://stackoverflow.com/questions/30249406/what-is-the-standard-for-formatting-currency-values-in-json) +- [Stackoverflow: Why not use Double or Float to represent currency?](http://stackoverflow.com/questions/3730019/why-not-use-double-or-float-to-represent-currency/3730040#3730040) +- [TechEmpower: Mangling JSON numbers](https://www.techempower.com/blog/2016/07/05/mangling-json-numbers/) +- [Wikipedia: Fixed-point arithmetic](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) diff --git a/javax-money/README.md b/javax-money/README.md index 2e02b54..c83ce77 100644 --- a/javax-money/README.md +++ b/javax-money/README.md @@ -5,6 +5,8 @@ deserialization of [JavaMoney](https://github.com/JavaMoney/jsr354-api) data typ integrates JavaMoney and Jackson so that they work seamlessly together, without requiring additional developer effort. In doing so, it aims to perform a small but repetitive task — once and for all. +This library reflects an opinionated API [representation of monetary amounts in JSON](MONEY.md) + With this library, it is possible to represent monetary amounts in JSON as follows: ```json