From 9e89fea1c521f37638560c0b8604d0c63afebb77 Mon Sep 17 00:00:00 2001 From: chaokunyang Date: Mon, 3 Aug 2026 02:14:34 +0800 Subject: [PATCH] feat(java): add JSON date/time format annotation --- docs/guide/java/android-support.md | 11 +- docs/guide/java/graalvm-support.md | 10 +- docs/guide/java/json-support.md | 67 ++- .../processing/JsonMixinAnnotations.java | 2 + .../processing/JsonTypeProcessorTest.java | 61 ++ java/fory-json/README.md | 59 +- .../fory/json/annotation/JsonFormat.java | 53 ++ .../fory/json/annotation/JsonMixin.java | 12 +- .../fory/json/codec/DateTimeFormatCodec.java | 251 +++++++++ .../fory/json/codec/ObjectCodecBuilder.java | 121 +++- .../apache/fory/json/codec/ScalarCodecs.java | 10 + .../fory/json/meta/JsonCreatorFieldInfo.java | 18 +- .../apache/fory/json/meta/JsonFieldInfo.java | 9 +- .../apache/fory/json/reader/JsonReader.java | 15 + .../json/resolver/JsonMixinAnnotations.java | 2 + .../fory/json/resolver/JsonTypeResolver.java | 122 +++- .../json/resolver/JsonValueDeclaration.java | 2 + .../fory/json/JsonAndroidRuntimeTest.java | 20 + .../fory/json/JsonFormatAnnotationTest.java | 531 ++++++++++++++++++ 19 files changed, 1330 insertions(+), 46 deletions(-) create mode 100644 java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java create mode 100644 java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java create mode 100644 java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java diff --git a/docs/guide/java/android-support.md b/docs/guide/java/android-support.md index 0a45194ae5..0a90942f79 100644 --- a/docs/guide/java/android-support.md +++ b/docs/guide/java/android-support.md @@ -157,11 +157,12 @@ The same exact-rule approach supports every `JsonCodec` member; it is not limite codecs. `JsonType` is not required for codec selection on an ordinary class. For `@JsonType` models, the generated R8 rules also retain `JsonValue` fields and effective methods, -fixed `JsonRawValue` and `JsonBase64` fields and getters, their runtime annotations, and the Base64 -codec constructor. Without `@JsonType`, these annotations still work through reflection, but a -release-minified application must keep the exact annotated members, annotation attributes, and -codec constructor itself. A `JsonValue` method may use a non-JavaBean name, so its manual rule must -name that method explicitly. +fixed `JsonRawValue` and `JsonBase64` fields and getters, `JsonFormat` date/time fields, their runtime +annotations, and the Base64 codec constructor. Without `@JsonType`, these annotations still work +through reflection, but a release-minified application must keep the exact annotated members, +annotation attributes, and codec constructor itself. A `JsonValue` method may use a non-JavaBean +name, so its manual rule must name that method explicitly. `JsonFormat` keeps the same direct-field +and one-wrapper-level behavior as on the JVM. Android Fory JSON requires a retained no-argument constructor for an ordinary mutable class; it may be non-public when Android reflection can make it accessible. `JsonCreator` constructor-backed diff --git a/docs/guide/java/graalvm-support.md b/docs/guide/java/graalvm-support.md index 7da3e7a2a4..a41068a985 100644 --- a/docs/guide/java/graalvm-support.md +++ b/docs/guide/java/graalvm-support.md @@ -143,10 +143,12 @@ on the JVM and Android. `JsonValue` fields and effective public zero-argument methods are supported, including matching one-String `JsonCreator` constructors and public static factories. Fixed `JsonRawValue` fields and getters support trusted raw String values, and fixed `JsonBase64` fields and getters support Base64 -`byte[]` values as on the JVM. For direct target annotations, annotate each reachable owning model -with `JsonType` so Native Image retains these members and the Base64 codec constructor. A directly -annotated `JsonValue` Record uses its generated component accessor and canonical constructor -operations. An effective declaration supplied by a Mixin uses the Mixin workflow above instead. +`byte[]` values as on the JVM. `JsonFormat` date/time fields use the same direct-field and +one-wrapper-level behavior as on the JVM. For direct target annotations, annotate each reachable +owning model with `JsonType` so Native Image retains these members and the Base64 codec constructor. +A directly annotated `JsonValue` Record uses its generated component accessor and canonical +constructor operations. An effective declaration supplied by a Mixin uses the Mixin workflow above +instead. `JsonAnyProperty` and `JsonAnyGetter` flatten their Map into the enclosing object. Use `@JsonCodec(valueCodec = ...)` on that field or getter to customize each dynamic value. A second diff --git a/docs/guide/java/json-support.md b/docs/guide/java/json-support.md index 1dbeab5b94..73b9c96574 100644 --- a/docs/guide/java/json-support.md +++ b/docs/guide/java/json-support.md @@ -307,7 +307,7 @@ disabled. Every other builder option keeps the behavior described above. Fory JSON provides `JsonProperty`, `JsonPropertyOrder`, `JsonIgnore`, `JsonAnyProperty`, `JsonAnyGetter`, `JsonAnySetter`, `JsonCreator`, `JsonCodec`, `JsonValue`, `JsonRawValue`, -`JsonBase64`, `JsonUnwrapped`, and `JsonSubTypes` as mapping annotations under +`JsonBase64`, `JsonFormat`, `JsonUnwrapped`, and `JsonSubTypes` as mapping annotations under `org.apache.fory.json.annotation`. `JsonType` is a separate build-time generation marker. They are not Jackson, Gson, or Fory binary-protocol annotations. @@ -321,6 +321,7 @@ import org.apache.fory.json.annotation.JsonAnySetter; import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonMixin; import org.apache.fory.json.annotation.JsonMixinRemove; @@ -379,7 +380,7 @@ that the subclass inherits, but the resulting annotation applies only while that mapped. All Fory JSON mapping annotations are supported: `JsonAnyGetter`, `JsonAnyProperty`, -`JsonAnySetter`, `JsonBase64`, `JsonCodec`, `JsonCreator`, `JsonIgnore`, `JsonProperty`, +`JsonAnySetter`, `JsonBase64`, `JsonCodec`, `JsonCreator`, `JsonFormat`, `JsonIgnore`, `JsonProperty`, `JsonPropertyOrder`, `JsonRawValue`, `JsonSubTypes`, `JsonUnwrapped`, and `JsonValue`. `JsonType` cannot be added or removed because it controls build-time generation rather than the JSON schema. @@ -404,7 +405,8 @@ abstract class QuotedMessageMixin { Removal affects only the matched declaration in the exact-target configuration. Removing `JsonRawValue` restores ordinary quoted String output; removing `JsonBase64` restores the ordinary -`byte[]` representation; removing `JsonUnwrapped` restores a nested object property. Type-level +`byte[]` representation; removing `JsonFormat` restores the ordinary date/time representation; +removing `JsonUnwrapped` restores a nested object property. Type-level removal can mask inherited `JsonCodec` or `JsonPropertyOrder` declarations for the exact target. Removing an absent annotation is harmless, but the selector must still match exactly one target declaration. A source cannot both declare and remove the same annotation type on one declaration. @@ -612,7 +614,7 @@ Java null follows the property's existing inclusion rule and is written as JSON Reading is unchanged and still expects a JSON string. A raw object or array written through the property cannot be read back into that `String`. The annotation does not apply to setters, creator parameters, Any declarations, container elements, or Map values, and it cannot share an occurrence -with `JsonCodec`. +with `JsonCodec` or `JsonFormat`. As an occurrence-local representation, it keeps the raw String shape even when the value type has an exact builder-registered codec. @@ -641,9 +643,58 @@ normal inclusion rule. The annotation is not a type-use annotation and does not affect ordinary `byte[]` properties, container elements, or Map values. It cannot share a logical property with `JsonRawValue`, an -occurrence `JsonCodec`, or an Any declaration. The equivalent explicit codec is +occurrence `JsonCodec`, `JsonFormat`, or an Any declaration. The equivalent explicit codec is `@JsonCodec(Base64ByteArrayCodec.class)`. +### `JsonFormat` + +Use `JsonFormat` on a date/time field to select its JSON text pattern in both directions. Patterns +use `DateTimeFormatter` syntax and the root locale: + +```java +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.fory.json.annotation.JsonFormat; + +public final class Schedule { + @JsonFormat(pattern = "dd/MM/uuuu") + public LocalDate day; + + @JsonFormat(pattern = "dd/MM/uuuu") + public Optional optionalDay; + + @JsonFormat(pattern = "dd/MM/uuuu") + public List days; + + @JsonFormat(pattern = "dd/MM/uuuu") + public Map daysByName; +} +``` + +For `day = LocalDate.of(2024, 1, 2)`, the property is written as `"day":"02/01/2024"` and +the same text reads back to that date. The annotation applies to the field value when it is a +supported date/time type. For one direct wrapper, it applies to an array or collection element, an +`AtomicReferenceArray` element, an `Optional` or `AtomicReference` content value, or a Map value. +This includes `List`, `Set`, and their concrete `Collection` implementations. Null handling still +follows the property's ordinary inclusion rule. + +Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `ZonedDateTime`, +`Year`, `YearMonth`, `MonthDay`, `OffsetTime`, `OffsetDateTime`, `HijrahDate`, `JapaneseDate`, +`MinguoDate`, and `ThaiBuddhistDate` types. `Instant` uses UTC; zoned and offset types use the zone or +offset carried by the value. The pattern must contain enough information to reconstruct the +declared type. + +`JsonFormat` is a field annotation, not a type-use annotation. A record component works through its +generated field. Nested wrappers, Map keys, raw or wildcard direct children, JSON Any values, and +unwrapped values are intentionally rejected. Types with ambiguous formatting semantics, including +legacy and SQL date types, `Duration`, `Period`, `TimeZone`, `ZoneId`, and `ZoneOffset`, are not +supported. A wrapper with a complete registered, annotation-selected, polymorphic, or `JsonValue` +representation is also rejected because that representation owns the whole wrapper. +`JsonFormat` cannot share a field with `JsonCodec`, `JsonBase64`, `JsonRawValue`, `JsonAnyProperty`, +`JsonUnwrapped`, or `JsonValue`. + ### `JsonUnwrapped` Use `JsonUnwrapped` when an object-valued property should keep its Java object boundary but place @@ -689,7 +740,7 @@ Fory rejects duplicate final names, recursive chains made only of unwrapped prop parameterized children, JSON Any children, polymorphic or custom-codec child roots, and scalar, array, collection, or Map children. Flatten Maps with `JsonAnyProperty`, `JsonAnyGetter`, or `JsonAnySetter`. An unwrapped property cannot use `JsonProperty.value`, a non-default -`JsonProperty.include`, or `JsonCodec`; ordinary leaf properties inside the child keep their normal +`JsonProperty.include`, `JsonCodec`, or `JsonFormat`; ordinary leaf properties inside the child keep their normal annotations. ### Dynamic object members @@ -1166,8 +1217,8 @@ default. URL and arbitrary unsupported Number/CharSequence subclasses require ex - No InputStream parser, incremental `OutputStream` writer on the `ForyJson` root API, or pretty-print configuration. - No Jackson/Gson annotation compatibility. -- No aliases, views, filters, injection, managed/back references, object identity annotations, root - wrapping, or format annotations. +- No aliases, views, filters, injection, managed/back references, object identity annotations, or + root wrapping. - Fory core's `Expose` is ignored. Circular graphs eventually fail `maxDepth`; they are not reconstructed. diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonMixinAnnotations.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonMixinAnnotations.java index 0c0d414e9e..25ea6b01f4 100644 --- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonMixinAnnotations.java +++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonMixinAnnotations.java @@ -56,6 +56,7 @@ final class JsonMixinAnnotations { private static final String JSON_BASE64 = JSON_PACKAGE + ".annotation.JsonBase64"; private static final String JSON_CODEC = JSON_PACKAGE + ".annotation.JsonCodec"; private static final String JSON_CREATOR = JSON_PACKAGE + ".annotation.JsonCreator"; + private static final String JSON_FORMAT = JSON_PACKAGE + ".annotation.JsonFormat"; private static final String JSON_IGNORE = JSON_PACKAGE + ".annotation.JsonIgnore"; private static final String JSON_PROPERTY = JSON_PACKAGE + ".annotation.JsonProperty"; private static final String JSON_PROPERTY_ORDER = JSON_PACKAGE + ".annotation.JsonPropertyOrder"; @@ -74,6 +75,7 @@ final class JsonMixinAnnotations { JSON_BASE64, JSON_CODEC, JSON_CREATOR, + JSON_FORMAT, JSON_IGNORE, JSON_PROPERTY, JSON_PROPERTY_ORDER, diff --git a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java index e1940089b4..0c19450e7f 100644 --- a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java +++ b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java @@ -33,6 +33,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -799,6 +800,66 @@ public void encodedCreatorPipeline() throws Exception { new Object[] {new byte[] {1, 2, 3}}); } + @Test + public void formatRecordPipeline() throws Exception { + assumeJava16Source(); + CompilationResult result = + compile( + "test.FormatRecord", + "package test;\n" + + "import java.time.LocalDate;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "@JsonType public record FormatRecord(\n" + + " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value) {}\n"); + assertTrue(result.success, result.diagnostics()); + String rules = result.generatedResource(RULE_PREFIX + "test.FormatRecord.pro"); + assertTrue(rules.contains("@interface org.apache.fory.json.annotation.JsonFormat"), rules); + ClassLoader loader = result.classLoader(); + Class type = loader.loadClass("test.FormatRecord"); + Object value = type.getConstructor(LocalDate.class).newInstance(LocalDate.of(2024, 1, 2)); + for (ForyJson json : jsonRuntimes(loader)) { + assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); + Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", type); + assertEquals(type.getMethod("value").invoke(decoded), LocalDate.of(2024, 1, 3)); + } + } + + @Test + public void formatMixinPipeline() throws Exception { + CompilationResult result = + compile( + "test.FormatTarget", + "package test;\n" + + "import java.time.LocalDate;\n" + + "import org.apache.fory.json.annotation.*;\n" + + "public final class FormatTarget { public LocalDate value; }\n" + + "@JsonMixin(target = FormatTarget.class) abstract class FormatMixin {\n" + + " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value;\n" + + "}\n"); + assertTrue(result.success, result.diagnostics()); + String base = "FormatMixin_ForyJsonMixin_test_x2e_FormatTarget"; + assertTrue(result.hasGeneratedSource("test/" + base + "_ForyJsonCodec.java")); + String rules = result.generatedResource(MIXIN_RULE_PREFIX + "test.FormatMixin.pro"); + assertTrue(rules.contains("@interface org.apache.fory.json.annotation.JsonFormat"), rules); + ClassLoader loader = result.classLoader(); + Class target = loader.loadClass("test.FormatTarget"); + Class mixin = loader.loadClass("test.FormatMixin"); + Object value = target.getConstructor().newInstance(); + target.getField("value").set(value, LocalDate.of(2024, 1, 2)); + for (boolean codegen : new boolean[] {false, true}) { + ForyJson json = + ForyJson.builder() + .withCodegen(codegen) + .withAsyncCompilation(false) + .withClassLoader(loader) + .registerMixin(mixin) + .build(); + assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); + Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", target); + assertEquals(target.getField("value").get(decoded), LocalDate.of(2024, 1, 3)); + } + } + @Test public void missingCompanionFails() throws Exception { CompilationResult result = diff --git a/java/fory-json/README.md b/java/fory-json/README.md index 944a99c6e3..eb7aa1d086 100644 --- a/java/fory-json/README.md +++ b/java/fory-json/README.md @@ -382,7 +382,7 @@ automatically disabled. Every other builder option keeps the behavior described ## JSON annotations Fory JSON provides these mapping annotations in `org.apache.fory.json.annotation`: -`JsonAnyGetter`, `JsonAnyProperty`, `JsonAnySetter`, `JsonBase64`, `JsonCodec`, `JsonCreator`, +`JsonAnyGetter`, `JsonAnyProperty`, `JsonAnySetter`, `JsonBase64`, `JsonCodec`, `JsonCreator`, `JsonFormat`, `JsonIgnore`, `JsonProperty`, `JsonPropertyOrder`, `JsonRawValue`, `JsonSubTypes`, `JsonUnwrapped`, and `JsonValue`. `JsonType` is a separate build-time generation marker. They are Fory JSON APIs, not Jackson, Gson, or Fory binary-protocol compatibility annotations. @@ -711,9 +711,58 @@ follows the property's normal inclusion rule and reads from JSON null as null. The annotation is not a type-use annotation and does not change ordinary unannotated `byte[]` properties, container elements, or Map values. It cannot share a logical property with -`JsonRawValue`, an occurrence `JsonCodec`, or an Any declaration. The equivalent explicit codec is +`JsonRawValue`, an occurrence `JsonCodec`, `JsonFormat`, or an Any declaration. The equivalent explicit codec is `@JsonCodec(Base64ByteArrayCodec.class)`. +### `JsonFormat` + +Use `JsonFormat` on a date/time field to select its JSON text pattern in both directions. Patterns +use `DateTimeFormatter` syntax and the root locale: + +```java +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.fory.json.annotation.JsonFormat; + +public final class Schedule { + @JsonFormat(pattern = "dd/MM/uuuu") + public LocalDate day; + + @JsonFormat(pattern = "dd/MM/uuuu") + public Optional optionalDay; + + @JsonFormat(pattern = "dd/MM/uuuu") + public List days; + + @JsonFormat(pattern = "dd/MM/uuuu") + public Map daysByName; +} +``` + +For `day = LocalDate.of(2024, 1, 2)`, the property is written as `"day":"02/01/2024"` and +the same text reads back to that date. The annotation applies to the field value when it is a +supported date/time type. For one direct wrapper, it applies to an array or collection element, an +`AtomicReferenceArray` element, an `Optional` or `AtomicReference` content value, or a Map value. +This includes `List`, `Set`, and their concrete `Collection` implementations. Null handling still +follows the property's ordinary inclusion rule. + +Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `ZonedDateTime`, +`Year`, `YearMonth`, `MonthDay`, `OffsetTime`, `OffsetDateTime`, `HijrahDate`, `JapaneseDate`, +`MinguoDate`, and `ThaiBuddhistDate` types. `Instant` uses UTC; zoned and offset types use the zone or +offset carried by the value. The pattern must contain enough information to reconstruct the +declared type. + +`JsonFormat` is a field annotation, not a type-use annotation. A record component works through its +generated field. Nested wrappers, Map keys, raw or wildcard direct children, JSON Any values, and +unwrapped values are intentionally rejected. Types with ambiguous formatting semantics, including +legacy and SQL date types, `Duration`, `Period`, `TimeZone`, `ZoneId`, and `ZoneOffset`, are not +supported. A wrapper with a complete registered, annotation-selected, polymorphic, or `JsonValue` +representation is also rejected because that representation owns the whole wrapper. +`JsonFormat` cannot share a field with `JsonCodec`, `JsonBase64`, `JsonRawValue`, `JsonAnyProperty`, +`JsonUnwrapped`, or `JsonValue`. + ### `JsonUnwrapped` Use `JsonUnwrapped` to place an object-valued property's members directly in the containing JSON @@ -759,7 +808,7 @@ before dynamic Any handling. Fory rejects duplicate final names, recursive chains made only of unwrapped properties, parameterized children, JSON Any children, polymorphic or custom-codec child roots, and scalar, array, collection, or Map children. Use `JsonAnyProperty`, `JsonAnyGetter`, or `JsonAnySetter` to -flatten a Map. `JsonProperty.value`, non-default `JsonProperty.include`, and `JsonCodec` are not +flatten a Map. `JsonProperty.value`, non-default `JsonProperty.include`, `JsonCodec`, and `JsonFormat` are not valid on an unwrapped property; ordinary child leaf properties may still use them. ### Dynamic object members @@ -1298,8 +1347,8 @@ Jackson object mapping: - no `InputStream` parser or incremental `OutputStream` writer on the `ForyJson` root API; - no pretty-print configuration; - no Jackson/Gson annotation compatibility layer; -- no aliases, views, filters, injection, managed/back references, object identity annotations, - root wrapping, or format annotations; +- no aliases, views, filters, injection, managed/back references, object identity annotations, or + root wrapping; - no Fory core `Expose` processing. Circular graphs eventually fail `maxDepth`; they are not reconstructed. Use Fory core's binary diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java new file mode 100644 index 0000000000..329d554ef6 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonFormat.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.json.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Selects a custom textual format for one directly declared date/time field or direct wrapper + * child. + * + *

The pattern uses {@link java.time.format.DateTimeFormatter} syntax with {@link + * java.util.Locale#ROOT}. Supported values are {@link java.time.LocalDate}, {@link + * java.time.LocalTime}, {@link java.time.LocalDateTime}, {@link java.time.Instant}, {@link + * java.time.ZonedDateTime}, {@link java.time.Year}, {@link java.time.YearMonth}, {@link + * java.time.MonthDay}, {@link java.time.OffsetTime}, {@link java.time.OffsetDateTime}, {@link + * java.time.chrono.HijrahDate}, {@link java.time.chrono.JapaneseDate}, {@link + * java.time.chrono.MinguoDate}, and {@link java.time.chrono.ThaiBuddhistDate}. Instant values use + * UTC; zoned and offset values use the zone or offset carried by the value. The pattern must retain + * enough information to reconstruct the declared type. + * + *

Formatting is applied in both JSON directions. Arrays and collections apply it to their direct + * element, maps to their direct value, and optional and atomic-reference wrappers to their direct + * content. Nested wrappers, map keys, JSON Any values, and unwrapped properties are not supported. + * A wrapper with a complete custom or {@link JsonValue} representation is also rejected. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface JsonFormat { + /** Returns the required date/time pattern. */ + String pattern(); +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonMixin.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonMixin.java index 89e93a62cb..7992c39479 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonMixin.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonMixin.java @@ -37,12 +37,12 @@ * access, invocation, and value. * *

A Mixin may contribute {@link JsonAnyGetter}, {@link JsonAnyProperty}, {@link JsonAnySetter}, - * {@link JsonBase64}, {@link JsonCodec}, {@link JsonCreator}, {@link JsonIgnore}, {@link - * JsonProperty}, {@link JsonPropertyOrder}, {@link JsonRawValue}, {@link JsonSubTypes}, {@link - * JsonUnwrapped}, and {@link JsonValue}. {@link JsonType} remains a marker declared directly on a - * model and cannot be contributed or removed by a Mixin. A contributed annotation completely - * replaces the target annotation of the same type at the matched declaration; annotation members - * are not merged individually. Use {@link JsonMixinRemove} for explicit removal. + * {@link JsonBase64}, {@link JsonCodec}, {@link JsonCreator}, {@link JsonFormat}, {@link + * JsonIgnore}, {@link JsonProperty}, {@link JsonPropertyOrder}, {@link JsonRawValue}, {@link + * JsonSubTypes}, {@link JsonUnwrapped}, and {@link JsonValue}. {@link JsonType} remains a marker + * declared directly on a model and cannot be contributed or removed by a Mixin. A contributed + * annotation completely replaces the target annotation of the same type at the matched declaration; + * annotation members are not merged individually. Use {@link JsonMixinRemove} for explicit removal. * *

A contributed {@link JsonCodec} follows the same codec resolution as a codec declared on the * target. An exact codec registered with {@link diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java new file mode 100644 index 0000000000..49faf931c8 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.json.codec; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZonedDateTime; +import java.time.chrono.HijrahChronology; +import java.time.chrono.HijrahDate; +import java.time.chrono.JapaneseChronology; +import java.time.chrono.JapaneseDate; +import java.time.chrono.MinguoChronology; +import java.time.chrono.MinguoDate; +import java.time.chrono.ThaiBuddhistChronology; +import java.time.chrono.ThaiBuddhistDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.Locale; +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; + +/** Property-local codec for one validated {@code JsonFormat} date/time field. */ +final class DateTimeFormatCodec implements JsonValueCodec { + private static final int LOCAL_DATE = 1; + private static final int LOCAL_TIME = 2; + private static final int LOCAL_DATE_TIME = 3; + private static final int INSTANT = 4; + private static final int ZONED_DATE_TIME = 5; + private static final int YEAR = 6; + private static final int YEAR_MONTH = 7; + private static final int MONTH_DAY = 8; + private static final int OFFSET_TIME = 9; + private static final int OFFSET_DATE_TIME = 10; + private static final int HIJRAH_DATE = 11; + private static final int JAPANESE_DATE = 12; + private static final int MINGUO_DATE = 13; + private static final int THAI_BUDDHIST_DATE = 14; + + private final Class type; + private final int kind; + private final DateTimeFormatter formatter; + + static JsonValueCodec create(Class type, String pattern) { + if (pattern.isEmpty()) { + throw invalidPattern(type, pattern, null); + } + int kind = kind(type); + DateTimeFormatter formatter; + try { + formatter = DateTimeFormatter.ofPattern(pattern, Locale.ROOT); + } catch (IllegalArgumentException e) { + throw invalidPattern(type, pattern, e); + } + if (kind == INSTANT) { + formatter = formatter.withZone(java.time.ZoneOffset.UTC); + } else if (kind == HIJRAH_DATE) { + formatter = formatter.withChronology(HijrahChronology.INSTANCE); + } else if (kind == JAPANESE_DATE) { + formatter = formatter.withChronology(JapaneseChronology.INSTANCE); + } else if (kind == MINGUO_DATE) { + formatter = formatter.withChronology(MinguoChronology.INSTANCE); + } else if (kind == THAI_BUDDHIST_DATE) { + formatter = formatter.withChronology(ThaiBuddhistChronology.INSTANCE); + } + return new DateTimeFormatCodec(type, kind, formatter); + } + + private DateTimeFormatCodec(Class type, int kind, DateTimeFormatter formatter) { + this.type = type; + this.kind = kind; + this.formatter = formatter; + } + + @Override + public void writeString(StringJsonWriter writer, Object value) { + if (value == null) { + writer.writeNull(); + } else { + writer.writeTemporal((TemporalAccessor) value, formatter); + } + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, Object value) { + if (value == null) { + writer.writeNull(); + } else { + writer.writeTemporal((TemporalAccessor) value, formatter); + } + } + + @Override + public Object readLatin1(Latin1JsonReader reader) { + CharSequence value = reader.readDateTimeText(); + return value == null ? null : parse(value); + } + + @Override + public Object readUtf16(Utf16JsonReader reader) { + CharSequence value = reader.readDateTimeText(); + return value == null ? null : parse(value); + } + + @Override + public Object readUtf8(Utf8JsonReader reader) { + CharSequence value = reader.readDateTimeText(); + return value == null ? null : parse(value); + } + + private Object parse(CharSequence value) { + try { + TemporalAccessor parsed = formatter.parse(value); + switch (kind) { + case LOCAL_DATE: + return LocalDate.from(parsed); + case LOCAL_TIME: + return LocalTime.from(parsed); + case LOCAL_DATE_TIME: + return LocalDateTime.from(parsed); + case INSTANT: + return Instant.from(parsed); + case ZONED_DATE_TIME: + return ZonedDateTime.from(parsed); + case YEAR: + return Year.from(parsed); + case YEAR_MONTH: + return YearMonth.from(parsed); + case MONTH_DAY: + return MonthDay.from(parsed); + case OFFSET_TIME: + return OffsetTime.from(parsed); + case OFFSET_DATE_TIME: + return OffsetDateTime.from(parsed); + case HIJRAH_DATE: + return HijrahDate.from(parsed); + case JAPANESE_DATE: + return JapaneseDate.from(parsed); + case MINGUO_DATE: + return MinguoDate.from(parsed); + case THAI_BUDDHIST_DATE: + return ThaiBuddhistDate.from(parsed); + default: + throw new AssertionError(kind); + } + } catch (RuntimeException e) { + throw invalidValue(type, value, e); + } + } + + static boolean supports(Class type) { + return type == LocalDate.class + || type == LocalTime.class + || type == LocalDateTime.class + || type == Instant.class + || type == ZonedDateTime.class + || type == Year.class + || type == YearMonth.class + || type == MonthDay.class + || type == OffsetTime.class + || type == OffsetDateTime.class + || type == HijrahDate.class + || type == JapaneseDate.class + || type == MinguoDate.class + || type == ThaiBuddhistDate.class; + } + + private static int kind(Class type) { + if (type == LocalDate.class) { + return LOCAL_DATE; + } + if (type == LocalTime.class) { + return LOCAL_TIME; + } + if (type == LocalDateTime.class) { + return LOCAL_DATE_TIME; + } + if (type == Instant.class) { + return INSTANT; + } + if (type == ZonedDateTime.class) { + return ZONED_DATE_TIME; + } + if (type == Year.class) { + return YEAR; + } + if (type == YearMonth.class) { + return YEAR_MONTH; + } + if (type == MonthDay.class) { + return MONTH_DAY; + } + if (type == OffsetTime.class) { + return OFFSET_TIME; + } + if (type == OffsetDateTime.class) { + return OFFSET_DATE_TIME; + } + if (type == HijrahDate.class) { + return HIJRAH_DATE; + } + if (type == JapaneseDate.class) { + return JAPANESE_DATE; + } + if (type == MinguoDate.class) { + return MINGUO_DATE; + } + if (type == ThaiBuddhistDate.class) { + return THAI_BUDDHIST_DATE; + } + throw new ForyJsonException("@JsonFormat is not supported on field type " + type.getTypeName()); + } + + private static ForyJsonException invalidPattern(Class type, String pattern, Throwable cause) { + return new ForyJsonException( + "Invalid @JsonFormat pattern for " + type.getTypeName() + ": " + pattern, cause); + } + + private static ForyJsonException invalidValue( + Class type, CharSequence value, Throwable cause) { + return new ForyJsonException( + "Invalid @JsonFormat value for " + type.getTypeName() + ": " + value.toString(), cause); + } +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java index ce6998826c..a872d05f91 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java @@ -45,6 +45,7 @@ import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonProperty; import org.apache.fory.json.annotation.JsonPropertyOrder; @@ -117,6 +118,13 @@ boolean record = + " on " + type.getName()); } + if (anyBuilder != null && anyBuilder.formatAnnotation != null) { + throw new ForyJsonException( + "@JsonFormat is not supported on JSON Any logical property " + + anyBuilder.name + + " on " + + type.getName()); + } List creatorOnlyUnwrapped = new ArrayList<>(); JsonCreatorInfo creatorInfo = record @@ -1051,7 +1059,8 @@ private static JsonCreatorInfo buildRecordCreatorInfo( resolved, rawTypes[i], builder.codecAnnotation(), - builder.valueCodecClass())); + builder.valueCodecClass(), + builder.formatAnnotation())); } JsonCreatorFieldInfo[] fieldArray = fields.toArray(new JsonCreatorFieldInfo[0]); rejectCreatorHashCollisions(fieldArray); @@ -1144,7 +1153,8 @@ private static JsonCreatorInfo buildCreatorInfo( resolved, rawTypes[i], codecAnnotation, - valueCodecClass)); + valueCodecClass, + builder.formatAnnotation())); } } } else { @@ -1201,12 +1211,13 @@ private static JsonCreatorInfo buildCreatorInfo( : builder.codecAnnotation(); Class> valueCodecClass = builder == null ? null : builder.valueCodecClass(); + JsonFormat formatAnnotation = builder == null ? null : builder.formatAnnotation(); JsonUnwrapped unwrapped = builder == null ? annotations.get(parameters[i], JsonUnwrapped.class) : builder.unwrappedAnnotation; if (unwrapped != null) { - if (codecAnnotation != null || valueCodecClass != null) { + if (codecAnnotation != null || valueCodecClass != null || formatAnnotation != null) { throw new ForyJsonException( "Value codecs are not supported on @JsonUnwrapped creator property " + jsonName); } @@ -1227,7 +1238,13 @@ private static JsonCreatorInfo buildCreatorInfo( } else { fields.add( new JsonCreatorFieldInfo( - jsonName, i, resolved, rawTypes[i], codecAnnotation, valueCodecClass)); + jsonName, + i, + resolved, + rawTypes[i], + codecAnnotation, + valueCodecClass, + formatAnnotation)); } } } @@ -1356,6 +1373,9 @@ private static boolean validateMemberAnnotations( boolean hasAnyField = false; for (Class current = type; current != null; current = current.getSuperclass()) { for (Field field : current.getDeclaredFields()) { + if (annotations.has(field, JsonFormat.class)) { + validateFormatField(field, annotations); + } if (annotations.has(field, JsonBase64.class)) { validateBase64Field(field, annotations); } @@ -1671,6 +1691,24 @@ private static void validateBase64Field(Field field, Annotations annotations) { } } + private static void validateFormatField(Field field, Annotations annotations) { + if (!isEligibleField(field)) { + throw new ForyJsonException("Invalid @JsonFormat field " + field); + } + if (annotations.has(field, JsonCodec.class) + || annotations.has(field, JsonBase64.class) + || annotations.has(field, JsonRawValue.class) + || annotations.has(field, JsonAnyProperty.class) + || annotations.has(field, JsonUnwrapped.class) + || annotations.has(field, JsonValue.class)) { + throw new ForyJsonException("Conflicting JSON annotations on @JsonFormat field " + field); + } + JsonIgnore ignore = annotations.get(field, JsonIgnore.class); + if (ignore != null && ignore.ignoreRead() && ignore.ignoreWrite()) { + throw new ForyJsonException("@JsonFormat has no JSON read or write direction: " + field); + } + } + private static void validateBase64Method( Class type, Method method, @@ -2177,6 +2215,8 @@ private static final class FieldBuilder { private int creatorArgumentIndex = -1; private JsonCodec codecAnnotation; private Class> valueCodecClass; + private JsonFormat formatAnnotation; + private AnnotatedElement formatSource; private AnnotatedElement codecSource; private JsonUnwrapped unwrappedAnnotation; private AnnotatedElement unwrappedSource; @@ -2205,6 +2245,7 @@ private void setField( if (readSink) { readField = field; } + mergeFormat(field); mergeAnnotation(type, field); if (annotations.has(field, JsonAnyProperty.class)) { if (!writeSource && !readSink) { @@ -2285,6 +2326,7 @@ private boolean hasConfiguration() { || codecAnnotation != null || rawValueSource != null || valueCodecClass != null + || formatAnnotation != null || unwrappedAnnotation != null; } @@ -2364,7 +2406,7 @@ private JsonFieldInfo build( throw new ForyJsonException( "@JsonRawValue requires an exact String write source for property " + name); } - if (codecAnnotation != null || valueCodecClass != null) { + if (codecAnnotation != null || valueCodecClass != null || formatAnnotation != null) { throw new ForyJsonException( "@JsonRawValue cannot coexist with a value codec for property " + name); } @@ -2381,6 +2423,7 @@ private JsonFieldInfo build( ownerType, codecAnnotation, valueCodecClass, + formatAnnotation, rawValue); } @@ -2403,7 +2446,10 @@ private void validateUnwrapped(Class type, JsonCreatorInfo creatorInfo) { + "." + name); } - if (codecAnnotation != null || valueCodecClass != null || rawValueSource != null) { + if (codecAnnotation != null + || valueCodecClass != null + || formatAnnotation != null + || rawValueSource != null) { throw new ForyJsonException( "Value representation annotations are not supported on @JsonUnwrapped property " + type.getName() @@ -2463,10 +2509,19 @@ private Class> valueCodecClass() { return valueCodecClass; } + private JsonFormat formatAnnotation() { + return formatAnnotation; + } + private void mergeAnnotation(Class type, AnnotatedElement source) { mergeCodec(source); - if (annotations.has(source, JsonRawValue.class) && rawValueSource == null) { - rawValueSource = source; + if (annotations.has(source, JsonRawValue.class)) { + if (formatAnnotation != null) { + throw formatConflict(source, "@JsonRawValue"); + } + if (rawValueSource == null) { + rawValueSource = source; + } } mergeUnwrapped(source); JsonProperty property = annotations.get(source, JsonProperty.class); @@ -2582,6 +2637,9 @@ private void mergeUnwrapped(AnnotatedElement source) { if (declared == null) { return; } + if (formatAnnotation != null) { + throw formatConflict(source, "@JsonUnwrapped"); + } if (unwrappedAnnotation != null && (!unwrappedAnnotation.prefix().equals(declared.prefix()) || !unwrappedAnnotation.suffix().equals(declared.suffix()))) { @@ -2602,6 +2660,9 @@ private void mergeUnwrapped(AnnotatedElement source) { private void mergeCodec(AnnotatedElement source) { JsonCodec declared = annotations.get(source, JsonCodec.class); if (annotations.has(source, JsonBase64.class)) { + if (formatAnnotation != null) { + throw formatConflict(source, "@JsonBase64"); + } if (declared != null || codecAnnotation != null) { throw new ForyJsonException( "@JsonBase64 cannot coexist with @JsonCodec for property " + name); @@ -2612,6 +2673,9 @@ private void mergeCodec(AnnotatedElement source) { } return; } + if (declared != null && formatAnnotation != null) { + throw formatConflict(source, "@JsonCodec"); + } if (declared != null && valueCodecClass != null) { throw new ForyJsonException( "@JsonBase64 cannot coexist with @JsonCodec for property " + name); @@ -2634,6 +2698,47 @@ private void mergeCodec(AnnotatedElement source) { } } + private void mergeFormat(AnnotatedElement source) { + JsonFormat declared = annotations.get(source, JsonFormat.class); + if (declared == null) { + return; + } + if (codecAnnotation != null || valueCodecClass != null) { + throw formatConflict(source, "a value codec"); + } + if (rawValueSource != null) { + throw formatConflict(source, "@JsonRawValue"); + } + if (unwrappedAnnotation != null) { + throw formatConflict(source, "@JsonUnwrapped"); + } + if (formatAnnotation != null && !formatAnnotation.equals(declared)) { + throw new ForyJsonException( + "Conflicting @JsonFormat declarations for property " + + name + + " from " + + formatSource + + " and " + + source); + } + if (formatAnnotation == null) { + formatAnnotation = declared; + formatSource = source; + } + } + + private ForyJsonException formatConflict(AnnotatedElement source, String annotation) { + return new ForyJsonException( + "@JsonFormat cannot coexist with " + + annotation + + " for property " + + name + + " from " + + formatSource + + " and " + + source); + } + private void validateTypes(TypeRef ownerType) { Type writeType = writeGetter == null ? fieldType(writeField) : writeGetter.getGenericReturnType(); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java index dd1ad5e80e..c71e4113c4 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java @@ -115,6 +115,16 @@ public final class ScalarCodecs { private ScalarCodecs() {} + @Internal + public static boolean supportsDateTimeFormat(Class type) { + return DateTimeFormatCodec.supports(type); + } + + @Internal + public static JsonValueCodec dateTimeFormatCodec(Class type, String pattern) { + return DateTimeFormatCodec.create(type, pattern); + } + public static final class NaturalCodec implements JsonValueCodec { public static final NaturalCodec INSTANCE = new NaturalCodec(); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java index e0f223438d..bb6b2a98c8 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java @@ -23,6 +23,7 @@ import org.apache.fory.annotation.Internal; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.codec.JsonValueCodec; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; @@ -40,6 +41,7 @@ public final class JsonCreatorFieldInfo { private final Class rawType; private final JsonCodec codecAnnotation; private final Class> valueCodecClass; + private final JsonFormat formatAnnotation; private JsonTypeInfo typeInfo; public JsonCreatorFieldInfo( @@ -48,7 +50,8 @@ public JsonCreatorFieldInfo( Type type, Class rawType, JsonCodec codecAnnotation, - Class> valueCodecClass) { + Class> valueCodecClass, + JsonFormat formatAnnotation) { this.name = name; nameHash = JsonFieldNameHash.hash(name); this.argumentIndex = argumentIndex; @@ -56,6 +59,7 @@ public JsonCreatorFieldInfo( this.rawType = rawType; this.codecAnnotation = codecAnnotation; this.valueCodecClass = valueCodecClass; + this.formatAnnotation = formatAnnotation; } public String name() { @@ -65,7 +69,13 @@ public String name() { /** Returns parent-local metadata with a transformed JSON name and the same creator argument. */ public JsonCreatorFieldInfo withName(String transformedName) { return new JsonCreatorFieldInfo( - transformedName, argumentIndex, type, rawType, codecAnnotation, valueCodecClass); + transformedName, + argumentIndex, + type, + rawType, + codecAnnotation, + valueCodecClass, + formatAnnotation); } public long nameHash() { @@ -94,7 +104,9 @@ public void resolveType(JsonTypeResolver resolver) { ? resolver.getTypeInfo(type, rawType, codecAnnotation) : valueCodecClass != null ? resolver.getTypeInfo(type, rawType, valueCodecClass) - : resolver.getTypeInfo(type, rawType); + : formatAnnotation != null + ? resolver.getTypeInfo(type, rawType, formatAnnotation) + : resolver.getTypeInfo(type, rawType); } public Object readLatin1(Latin1JsonReader reader) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java index 176db70e7f..82d146de44 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java @@ -27,6 +27,7 @@ import java.util.Map; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.codec.CodecUtils; import org.apache.fory.json.codec.JsonValueCodec; import org.apache.fory.json.reader.JsonReader; @@ -89,6 +90,7 @@ public final class JsonFieldInfo { private final Class readRawType; private final JsonCodec codecAnnotation; private final Class> valueCodecClass; + private final JsonFormat formatAnnotation; private JsonFieldKind writeKind; private JsonFieldKind readKind; private int writeKindId; @@ -147,6 +149,7 @@ public JsonFieldInfo( TypeRef ownerType, JsonCodec codecAnnotation, Class> valueCodecClass, + JsonFormat formatAnnotation, boolean rawValue) { this.name = name; // The write-null decision and read index are both immutable after ObjectCodec construction. @@ -167,6 +170,7 @@ public JsonFieldInfo( this.readRawType = semanticRawType(readType, readFallback); this.codecAnnotation = codecAnnotation; this.valueCodecClass = valueCodecClass; + this.formatAnnotation = formatAnnotation; this.writeAccessor = writeAccessor; this.readAccessor = readAccessor; writeKind = writeRawType == null ? null : kind(writeRawType); @@ -267,6 +271,7 @@ public JsonFieldInfo withName(String transformedName, TypeRef ownerType) { ownerType, codecAnnotation, valueCodecClass, + formatAnnotation, writesRawString()); copy.setReadIndex(readIndex()); return copy; @@ -386,7 +391,9 @@ public void resolveTypes(JsonTypeResolver typeResolver) { ? typeResolver.getTypeInfo(codecType, codecRawType, codecAnnotation) : valueCodecClass != null ? typeResolver.getTypeInfo(codecType, codecRawType, valueCodecClass) - : null; + : formatAnnotation != null + ? typeResolver.getTypeInfo(codecType, codecRawType, formatAnnotation) + : null; boolean rawString = writeKindId == KIND_RAW_STRING; if (writeRawType != null) { writeTypeInfo = diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java index 590a72d5e4..53ac2cd304 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java @@ -350,6 +350,21 @@ public String readNullableString() { return tryReadNull() ? null : readString(); } + /** + * Reads nullable date/time text without materializing an ASCII string. + * + *

The returned view is reused by this reader and must be parsed before another reader method + * is called. Escaped or non-ASCII input falls back to an ordinary String. + */ + @Internal + public final CharSequence readDateTimeText() { + if (tryReadNull()) { + return null; + } + CharSequence value = tryReadAsciiStringView(); + return value == null ? readString() : value; + } + /** Reads a nullable Base64 JSON string directly into its decoded bytes. */ public final byte[] readBase64() { if (tryReadNull()) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java index 74b19be92e..159c06a45e 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java @@ -48,6 +48,7 @@ import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonMixin; import org.apache.fory.json.annotation.JsonMixinRemove; @@ -70,6 +71,7 @@ final class JsonMixinAnnotations { JsonBase64.class, JsonCodec.class, JsonCreator.class, + JsonFormat.class, JsonIgnore.class, JsonProperty.class, JsonPropertyOrder.class, diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java index 8b59697c6c..c908b6f72c 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java @@ -41,6 +41,7 @@ import org.apache.fory.collection.Tuple2; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.codec.ArrayCodec; import org.apache.fory.json.codec.ClosedSubtypeCodec; import org.apache.fory.json.codec.CodecUtils; @@ -331,6 +332,22 @@ public JsonTypeInfo getTypeInfo( return annotationTypeInfo(declaredType, rawType, codecClass); } + @Internal + public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback, JsonFormat annotation) { + Class rawType = CodecUtils.rawType(declaredType, fallback); + ResolutionSnapshot snapshot = beginResolution(); + try { + JsonTypeInfo result = resolveTypeInfo(declaredType, rawType, annotation); + completeResolution(snapshot); + return result; + } catch (RuntimeException | Error e) { + rollbackResolution(snapshot); + throw e; + } finally { + endResolution(); + } + } + private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, Object key) { JsonTypeInfo typeInfo = customTypeInfo(declaredType, rawType); if (typeInfo != null) { @@ -451,6 +468,74 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonCo throw invalidCodecConfig(rawType, "does not support child codecs"); } + private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonFormat annotation) { + if (ScalarCodecs.supportsDateTimeFormat(rawType)) { + return formatTypeInfo(declaredType, rawType, annotation); + } + if (sharedRegistry.customCodec(rawType) != null + || sharedRegistry.codecDeclaration(rawType) != null + || sharedRegistry.valueDeclaration(rawType) != null + || sharedRegistry.subTypesInfo(rawType) != null) { + throw invalidFormatConfig(rawType, "a complete representation hides its direct child"); + } + sharedRegistry.checkSecure(rawType); + TypeRef typeRef = typeRef(declaredType, rawType); + if (rawType.isArray()) { + Type elementType = + declaredType instanceof GenericArrayType + ? ((GenericArrayType) declaredType).getGenericComponentType() + : rawType.getComponentType(); + requireConcreteChild(elementType, rawType, "element", "@JsonFormat"); + Class elementRawType = CodecUtils.rawType(elementType, rawType.getComponentType()); + JsonTypeInfo elementInfo = formatTypeInfo(elementType, elementRawType, annotation); + return newTypeInfo(declaredType, rawType, ArrayCodec.create(rawType, elementInfo)); + } + if (rawType == AtomicReferenceArray.class) { + TypeRef elementType = directElementType(typeRef, rawType, "element", "@JsonFormat"); + JsonTypeInfo elementInfo = + formatTypeInfo(elementType.getType(), elementType.getRawType(), annotation); + return newTypeInfo( + declaredType, rawType, new ScalarCodecs.AtomicReferenceArrayCodec(elementInfo)); + } + if (Collection.class.isAssignableFrom(rawType)) { + TypeRef elementType = directElementType(typeRef, rawType, "element", "@JsonFormat"); + JsonTypeInfo elementInfo = + formatTypeInfo(elementType.getType(), elementType.getRawType(), annotation); + return newTypeInfo( + declaredType, + rawType, + CollectionCodec.create(rawType, elementType.getRawType(), elementInfo, this)); + } + if (Map.class.isAssignableFrom(rawType)) { + requireTypeArguments(typeRef, rawType, "@JsonFormat"); + Tuple2, TypeRef> children = CodecUtils.mapKeyValueTypeRefs(typeRef); + TypeRef valueType = children.f1; + requireConcreteChild(valueType.getType(), rawType, "value", "@JsonFormat"); + JsonTypeInfo valueInfo = + formatTypeInfo(valueType.getType(), valueType.getRawType(), annotation); + Class keyRawType = children.f0.getRawType(); + checkMapKeySecure(keyRawType); + return newTypeInfo(declaredType, rawType, MapCodec.create(rawType, keyRawType, valueInfo)); + } + if (rawType == Optional.class || rawType == AtomicReference.class) { + TypeRef contentType = directElementType(typeRef, rawType, "content", "@JsonFormat"); + JsonTypeInfo contentInfo = + formatTypeInfo(contentType.getType(), contentType.getRawType(), annotation); + JsonValueCodec codec = + rawType == Optional.class + ? new ScalarCodecs.OptionalCodec(contentInfo) + : new ScalarCodecs.AtomicReferenceCodec(contentInfo); + return newTypeInfo(declaredType, rawType, codec); + } + throw invalidFormatConfig(rawType, "requires a date/time value or supported direct wrapper"); + } + + private JsonTypeInfo formatTypeInfo(Type type, Class rawType, JsonFormat annotation) { + sharedRegistry.checkSecure(rawType); + JsonValueCodec codec = ScalarCodecs.dateTimeFormatCodec(rawType, annotation.pattern()); + return newTypeInfo(type, rawType, JsonFieldKind.OBJECT, codec, true); + } + private JsonTypeInfo customTypeInfo(Type declaredType, Class rawType) { JsonValueCodec codec = sharedRegistry.customCodec(rawType); if (codec != null) { @@ -481,25 +566,40 @@ private JsonTypeInfo annotationTypeInfo( } private static TypeRef directElementType(TypeRef typeRef, Class rawType, String slot) { - requireTypeArguments(typeRef, rawType); + return directElementType(typeRef, rawType, slot, "@JsonCodec"); + } + + private static TypeRef directElementType( + TypeRef typeRef, Class rawType, String slot, String annotation) { + requireTypeArguments(typeRef, rawType, annotation); TypeRef elementType = CodecUtils.elementTypeRef(typeRef); - requireConcreteChild(elementType.getType(), rawType, slot); + requireConcreteChild(elementType.getType(), rawType, slot, annotation); return elementType; } private static void requireTypeArguments(TypeRef typeRef, Class rawType) { + requireTypeArguments(typeRef, rawType, "@JsonCodec"); + } + + private static void requireTypeArguments( + TypeRef typeRef, Class rawType, String annotation) { if (!typeRef.hasExplicitTypeArguments() && rawType.getTypeParameters().length != 0) { - throw invalidCodecConfig(rawType, "child codecs require concrete type arguments"); + throw invalidConfig(rawType, annotation, "direct child requires concrete type arguments"); } } private static void requireConcreteChild(Type type, Class rawType, String slot) { + requireConcreteChild(type, rawType, slot, "@JsonCodec"); + } + + private static void requireConcreteChild( + Type type, Class rawType, String slot, String annotation) { if (type instanceof TypeVariable || type instanceof WildcardType) { - throw invalidCodecConfig(rawType, slot + " requires a concrete direct child type"); + throw invalidConfig(rawType, annotation, slot + " requires a concrete direct child type"); } if (type instanceof ParameterizedType && !(((ParameterizedType) type).getRawType() instanceof Class)) { - throw invalidCodecConfig(rawType, slot + " requires a concrete direct child type"); + throw invalidConfig(rawType, annotation, slot + " requires a concrete direct child type"); } } @@ -511,7 +611,17 @@ private static void requireSlots( } private static ForyJsonException invalidCodecConfig(Class rawType, String reason) { - return new ForyJsonException("Invalid @JsonCodec for " + rawType.getTypeName() + ": " + reason); + return invalidConfig(rawType, "@JsonCodec", reason); + } + + private static ForyJsonException invalidFormatConfig(Class rawType, String reason) { + return invalidConfig(rawType, "@JsonFormat", reason); + } + + private static ForyJsonException invalidConfig( + Class rawType, String annotation, String reason) { + return new ForyJsonException( + "Invalid " + annotation + " for " + rawType.getTypeName() + ": " + reason); } private void rejectConflictingValue(Class rawType) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonValueDeclaration.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonValueDeclaration.java index 85b67d2e6b..b4e3fb6983 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonValueDeclaration.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonValueDeclaration.java @@ -33,6 +33,7 @@ import org.apache.fory.json.annotation.JsonAnyProperty; import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonProperty; import org.apache.fory.json.annotation.JsonRawValue; @@ -190,6 +191,7 @@ private static void validateField(Class type, Field field, JsonSharedRegistry } if (registry.annotation(type, field, JsonCodec.class) != null || registry.annotation(type, field, JsonBase64.class) != null + || registry.annotation(type, field, JsonFormat.class) != null || registry.annotation(type, field, JsonAnyProperty.class) != null || registry.annotation(type, field, JsonUnwrapped.class) != null || registry.annotation(type, field, JsonIgnore.class) != null) { diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java index d188a660b2..22d8649be2 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAndroidRuntimeTest.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -35,6 +36,7 @@ import org.apache.fory.json.annotation.JsonBase64; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.annotation.JsonRawValue; import org.apache.fory.json.annotation.JsonValue; import org.apache.fory.platform.AndroidSupport; @@ -109,6 +111,16 @@ private static void assertRoundTrips(ForyJson json) { json.fromJson("{\"body\":\"text\",\"bytes\":\"AQID\"}", AndroidRaw.class); assertEquals(decodedRaw.body, "text"); assertEquals(decodedRaw.bytes, new byte[] {1, 2, 3}); + + AndroidFormat format = new AndroidFormat(); + format.value = LocalDate.of(2024, 1, 2); + format.values = Arrays.asList(LocalDate.of(2024, 1, 3), LocalDate.of(2024, 1, 4)); + String formatJson = json.toJson(format); + assertTrue(formatJson.contains("\"value\":\"02/01/2024\""), formatJson); + assertTrue(formatJson.contains("\"values\":[\"03/01/2024\",\"04/01/2024\"]"), formatJson); + AndroidFormat decodedFormat = json.fromJson(formatJson, AndroidFormat.class); + assertEquals(decodedFormat.value, format.value); + assertEquals(decodedFormat.values, format.values); } private static List javaCommand(String classPath, Class mainClass) { @@ -211,4 +223,12 @@ public static final class AndroidRaw { @JsonRawValue public String body; @JsonBase64 public byte[] bytes; } + + public static final class AndroidFormat { + @JsonFormat(pattern = "dd/MM/uuuu") + public LocalDate value; + + @JsonFormat(pattern = "dd/MM/uuuu") + public List values; + } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java new file mode 100644 index 0000000000..1eb837d6a1 --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonFormatAnnotationTest.java @@ -0,0 +1,531 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fory.json; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.expectThrows; + +import java.nio.charset.StandardCharsets; +import java.sql.Date; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.chrono.HijrahDate; +import java.time.chrono.JapaneseDate; +import java.time.chrono.MinguoDate; +import java.time.chrono.ThaiBuddhistDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicReferenceArray; +import org.apache.fory.json.annotation.JsonAnyProperty; +import org.apache.fory.json.annotation.JsonCodec; +import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.annotation.JsonFormat; +import org.apache.fory.json.annotation.JsonMixin; +import org.apache.fory.json.annotation.JsonMixinRemove; +import org.apache.fory.json.annotation.JsonProperty; +import org.apache.fory.json.annotation.JsonRawValue; +import org.apache.fory.json.annotation.JsonUnwrapped; +import org.apache.fory.json.annotation.JsonValue; +import org.apache.fory.json.codec.Base64ByteArrayCodec; +import org.apache.fory.platform.JdkVersion; +import org.testng.SkipException; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; + +public class JsonFormatAnnotationTest extends ForyJsonTestModels { + private static final String DATE_PATTERN = "dd/MM/uuuu"; + + @Factory(dataProvider = "enableCodegen") + public JsonFormatAnnotationTest(boolean codegen) { + super(codegen); + } + + @Test + public void fieldRoundTrip() { + ForyJson json = newJson(); + FormattedFields value = new FormattedFields(); + value.dayFirst = LocalDate.of(2024, 1, 2); + value.monthFirst = LocalDate.of(2025, 3, 4); + value.unicode = LocalDateTime.of(2026, 5, 6, 7, 8); + String expected = + "{\"dayFirst\":\"02/01/2024\",\"monthFirst\":\"03-04-2025\"," + + "\"unicode\":\"2026年05月06日 07:08\"}"; + assertEquals(json.toJson(value), expected); + assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), expected); + assertFormattedFields(json.fromJson(expected, FormattedFields.class)); + assertFormattedFields( + json.fromJson(expected.getBytes(StandardCharsets.UTF_8), FormattedFields.class)); + assertEquals(json.toJson(LocalDate.of(2024, 1, 2)), "\"2024-01-02\""); + assertGeneratedWhenSupported(json, FormattedFields.class); + } + + @Test + public void wrapperRoundTrip() { + ForyJson json = newJson(); + WrapperFields value = new WrapperFields(); + value.optional = Optional.of(LocalDate.of(2024, 1, 2)); + value.list = new ArrayList<>(Arrays.asList(LocalDate.of(2024, 1, 3), null)); + value.set = new LinkedHashSet<>(Arrays.asList(LocalDate.of(2024, 1, 4), null)); + value.map = new LinkedHashMap<>(); + value.map.put("a", LocalDate.of(2024, 1, 5)); + value.map.put("b", null); + value.array = new LocalDate[] {LocalDate.of(2024, 1, 6), null}; + value.atomic = new AtomicReference<>(LocalDate.of(2024, 1, 7)); + value.atomicArray = + new AtomicReferenceArray<>(new LocalDate[] {LocalDate.of(2024, 1, 8), null}); + String text = json.toJson(value); + assertTrue(text.contains("\"optional\":\"02/01/2024\""), text); + assertTrue(text.contains("\"list\":[\"03/01/2024\",null]"), text); + assertTrue(text.contains("\"set\":[\"04/01/2024\",null]"), text); + assertTrue(text.contains("\"map\":{\"a\":\"05/01/2024\",\"b\":null}"), text); + assertTrue(text.contains("\"array\":[\"06/01/2024\",null]"), text); + assertTrue(text.contains("\"atomic\":\"07/01/2024\""), text); + assertTrue(text.contains("\"atomicArray\":[\"08/01/2024\",null]"), text); + assertWrapperFields(json.fromJson(text, WrapperFields.class)); + byte[] bytes = json.toJsonBytes(value); + assertEquals(new String(bytes, StandardCharsets.UTF_8), text); + assertWrapperFields(json.fromJson(bytes, WrapperFields.class)); + assertGeneratedWhenSupported(json, WrapperFields.class); + } + + @Test + public void temporalTypes() { + ForyJson json = newJson(); + TemporalFields value = temporalFields(); + String text = json.toJson(value); + assertTrue(text.contains("\"instant\":\"2024/01/02 03:04:05.006\""), text); + assertTemporalFields(json.fromJson(text, TemporalFields.class), value); + byte[] bytes = json.toJsonBytes(value); + assertEquals(new String(bytes, StandardCharsets.UTF_8), text); + assertTemporalFields(json.fromJson(bytes, TemporalFields.class), value); + assertGeneratedWhenSupported(json, TemporalFields.class); + } + + @Test + public void creatorRoundTrip() { + ForyJson json = newJson(); + CreatorField value = new CreatorField(LocalDate.of(2024, 1, 2)); + assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); + assertEquals( + json.fromJson("{\"value\":\"03/01/2024\"}", CreatorField.class).value, + LocalDate.of(2024, 1, 3)); + assertGeneratedWhenSupported(json, CreatorField.class); + } + + @Test + public void recordRoundTrip() throws Exception { + if (JdkVersion.MAJOR_VERSION < 17) { + throw new SkipException("Java record test requires JDK 17+"); + } + Class type = + compileRecordClass( + "JsonFormatRecord", + "package org.apache.fory.json.records;\n" + + "import java.time.LocalDate;\n" + + "import org.apache.fory.json.annotation.JsonFormat;\n" + + "public record JsonFormatRecord(" + + "@JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value) {}\n"); + Object value = type.getConstructor(LocalDate.class).newInstance(LocalDate.of(2024, 1, 2)); + for (ForyJson json : new ForyJson[] {newJson(), newJsonBuilder().withFieldMode(true).build()}) { + assertEquals(json.toJson(value), "{\"value\":\"02/01/2024\"}"); + Object decoded = json.fromJson("{\"value\":\"03/01/2024\"}", type); + assertEquals(type.getMethod("value").invoke(decoded), LocalDate.of(2024, 1, 3)); + } + } + + @Test + public void mixinRoundTrip() { + ForyJson mixinJson = newJsonBuilder().registerMixin(FormatMixin.class).build(); + MixinTarget value = new MixinTarget(); + value.value = LocalDate.of(2024, 1, 2); + assertEquals(mixinJson.toJson(value), "{\"value\":\"02/01/2024\"}"); + assertEquals( + mixinJson.fromJson("{\"value\":\"03/01/2024\"}", MixinTarget.class).value, + LocalDate.of(2024, 1, 3)); + + ForyJson removalJson = newJsonBuilder().registerMixin(RemoveFormatMixin.class).build(); + IntrinsicTarget intrinsic = new IntrinsicTarget(); + intrinsic.value = LocalDate.of(2024, 1, 2); + assertEquals(removalJson.toJson(intrinsic), "{\"value\":\"2024-01-02\"}"); + } + + @Test + public void nullAndInvalidInput() { + ForyJson json = newJson(); + assertEquals(json.toJson(new AlwaysFormat()), "{\"value\":null}"); + assertEquals( + new String(json.toJsonBytes(new AlwaysFormat()), StandardCharsets.UTF_8), + "{\"value\":null}"); + assertThrows( + ForyJsonException.class, + () -> json.fromJson("{\"value\":\"2024-01-02\"}", AlwaysFormat.class)); + } + + @Test + public void rejectAmbiguousDeclarations() { + ForyJson json = newJson(); + assertThrows(ForyJsonException.class, () -> json.toJson(new NestedWrapper())); + assertThrows(ForyJsonException.class, () -> json.toJson(new MapKeyFormat())); + ForyJsonException rawError = + expectThrows(ForyJsonException.class, () -> json.toJson(new RawWrapper())); + assertTrue(rawError.getMessage().contains("@JsonFormat"), rawError.getMessage()); + ForyJsonException wildcardError = + expectThrows(ForyJsonException.class, () -> json.toJson(new WildcardWrapper())); + assertTrue(wildcardError.getMessage().contains("@JsonFormat"), wildcardError.getMessage()); + assertThrows(ForyJsonException.class, () -> json.toJson(new ValueWrapperField())); + ForyJson mixinJson = newJsonBuilder().registerMixin(ValueWrapperMixin.class).build(); + assertThrows(ForyJsonException.class, () -> mixinJson.toJson(new ValueWrapperTarget())); + } + + @Test + public void rejectInvalidDeclarations() { + ForyJson json = newJson(); + assertThrows(ForyJsonException.class, () -> json.toJson(new EmptyPattern())); + assertThrows(ForyJsonException.class, () -> json.toJson(new InvalidPattern())); + assertThrows(ForyJsonException.class, () -> json.toJson(new StringFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new DurationFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new LegacyDateFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new CalendarFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new SqlDateFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new StaticFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new TransientFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new CodecFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new RawFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new AnyFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new UnwrappedFormat())); + assertThrows(ForyJsonException.class, () -> json.toJson(new ValueFormat())); + } + + private static void assertFormattedFields(FormattedFields value) { + assertEquals(value.dayFirst, LocalDate.of(2024, 1, 2)); + assertEquals(value.monthFirst, LocalDate.of(2025, 3, 4)); + assertEquals(value.unicode, LocalDateTime.of(2026, 5, 6, 7, 8)); + } + + private static void assertWrapperFields(WrapperFields value) { + assertEquals(value.optional, Optional.of(LocalDate.of(2024, 1, 2))); + assertEquals(value.list, Arrays.asList(LocalDate.of(2024, 1, 3), null)); + assertEquals(value.set, new LinkedHashSet<>(Arrays.asList(LocalDate.of(2024, 1, 4), null))); + Map expectedMap = new LinkedHashMap<>(); + expectedMap.put("a", LocalDate.of(2024, 1, 5)); + expectedMap.put("b", null); + assertEquals(value.map, expectedMap); + assertEquals(value.array, new LocalDate[] {LocalDate.of(2024, 1, 6), null}); + assertEquals(value.atomic.get(), LocalDate.of(2024, 1, 7)); + assertEquals(value.atomicArray.length(), 2); + assertEquals(value.atomicArray.get(0), LocalDate.of(2024, 1, 8)); + assertEquals(value.atomicArray.get(1), null); + } + + private static TemporalFields temporalFields() { + TemporalFields value = new TemporalFields(); + Instant instant = Instant.parse("2024-01-02T03:04:05.006Z"); + value.localDate = LocalDate.of(2024, 1, 2); + value.localTime = LocalTime.of(3, 4, 5); + value.localDateTime = LocalDateTime.of(2024, 1, 2, 3, 4, 5); + value.instant = instant; + value.zonedDateTime = ZonedDateTime.of(value.localDateTime, ZoneId.of("Europe/Paris")); + value.year = Year.of(2024); + value.yearMonth = YearMonth.of(2024, 1); + value.monthDay = MonthDay.of(1, 2); + value.offsetTime = OffsetTime.of(value.localTime, ZoneOffset.ofHoursMinutes(5, 30)); + value.offsetDateTime = OffsetDateTime.of(value.localDateTime, ZoneOffset.ofHours(-4)); + value.hijrahDate = HijrahDate.from(value.localDate); + value.japaneseDate = JapaneseDate.from(value.localDate); + value.minguoDate = MinguoDate.from(value.localDate); + value.thaiBuddhistDate = ThaiBuddhistDate.from(value.localDate); + return value; + } + + private static void assertTemporalFields(TemporalFields actual, TemporalFields expected) { + assertEquals(actual.localDate, expected.localDate); + assertEquals(actual.localTime, expected.localTime); + assertEquals(actual.localDateTime, expected.localDateTime); + assertEquals(actual.instant, expected.instant); + assertEquals(actual.zonedDateTime, expected.zonedDateTime); + assertEquals(actual.year, expected.year); + assertEquals(actual.yearMonth, expected.yearMonth); + assertEquals(actual.monthDay, expected.monthDay); + assertEquals(actual.offsetTime, expected.offsetTime); + assertEquals(actual.offsetDateTime, expected.offsetDateTime); + assertEquals(actual.hijrahDate, expected.hijrahDate); + assertEquals(actual.japaneseDate, expected.japaneseDate); + assertEquals(actual.minguoDate, expected.minguoDate); + assertEquals(actual.thaiBuddhistDate, expected.thaiBuddhistDate); + } + + public static final class FormattedFields { + @JsonFormat(pattern = DATE_PATTERN) + public LocalDate dayFirst; + + @JsonFormat(pattern = "MM-dd-uuuu") + public LocalDate monthFirst; + + @JsonFormat(pattern = "uuuu'年'MM'月'dd'日' HH:mm") + public LocalDateTime unicode; + } + + public static final class WrapperFields { + @JsonFormat(pattern = DATE_PATTERN) + public Optional optional; + + @JsonFormat(pattern = DATE_PATTERN) + public List list; + + @JsonFormat(pattern = DATE_PATTERN) + public Set set; + + @JsonFormat(pattern = DATE_PATTERN) + public Map map; + + @JsonFormat(pattern = DATE_PATTERN) + public LocalDate[] array; + + @JsonFormat(pattern = DATE_PATTERN) + public AtomicReference atomic; + + @JsonFormat(pattern = DATE_PATTERN) + public AtomicReferenceArray atomicArray; + } + + public static final class TemporalFields { + @JsonFormat(pattern = DATE_PATTERN) + public LocalDate localDate; + + @JsonFormat(pattern = "HH-mm-ss") + public LocalTime localTime; + + @JsonFormat(pattern = "uuuuMMdd-HHmmss") + public LocalDateTime localDateTime; + + @JsonFormat(pattern = "uuuu/MM/dd HH:mm:ss.SSS") + public Instant instant; + + @JsonFormat(pattern = "uuuu/MM/dd HH:mm:ss VV") + public ZonedDateTime zonedDateTime; + + @JsonFormat(pattern = "uuuu") + public Year year; + + @JsonFormat(pattern = "uuuu/MM") + public YearMonth yearMonth; + + @JsonFormat(pattern = "MM/dd") + public MonthDay monthDay; + + @JsonFormat(pattern = "HH:mm:ssXXX") + public OffsetTime offsetTime; + + @JsonFormat(pattern = "uuuu/MM/dd HH:mm:ssXXX") + public OffsetDateTime offsetDateTime; + + @JsonFormat(pattern = DATE_PATTERN) + public HijrahDate hijrahDate; + + @JsonFormat(pattern = DATE_PATTERN) + public JapaneseDate japaneseDate; + + @JsonFormat(pattern = DATE_PATTERN) + public MinguoDate minguoDate; + + @JsonFormat(pattern = DATE_PATTERN) + public ThaiBuddhistDate thaiBuddhistDate; + } + + public static final class CreatorField { + @JsonFormat(pattern = DATE_PATTERN) + public final LocalDate value; + + @JsonCreator({"value"}) + public CreatorField(LocalDate value) { + this.value = value; + } + } + + public static final class MixinTarget { + public LocalDate value; + } + + @JsonMixin(target = MixinTarget.class) + public abstract static class FormatMixin { + @JsonFormat(pattern = DATE_PATTERN) + LocalDate value; + } + + public static final class IntrinsicTarget { + @JsonFormat(pattern = DATE_PATTERN) + public LocalDate value; + } + + @JsonMixin(target = IntrinsicTarget.class) + public abstract static class RemoveFormatMixin { + @JsonMixinRemove(JsonFormat.class) + LocalDate value; + } + + public static final class AlwaysFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonProperty(include = JsonProperty.Include.ALWAYS) + public LocalDate value; + } + + public static final class NestedWrapper { + @JsonFormat(pattern = DATE_PATTERN) + public List> value; + } + + public static final class MapKeyFormat { + @JsonFormat(pattern = DATE_PATTERN) + public Map value; + } + + @SuppressWarnings("rawtypes") + public static final class RawWrapper { + @JsonFormat(pattern = DATE_PATTERN) + public List value; + } + + public static final class WildcardWrapper { + @JsonFormat(pattern = DATE_PATTERN) + public List value; + } + + public static final class ValueList extends ArrayList { + public ValueList() {} + + @JsonCreator + public ValueList(String value) {} + + @JsonValue + public String value() { + return "value"; + } + } + + public static final class ValueWrapperField { + @JsonFormat(pattern = DATE_PATTERN) + public ValueList value; + } + + public static final class ValueWrapperTarget { + public ValueList value; + } + + @JsonMixin(target = ValueWrapperTarget.class) + public abstract static class ValueWrapperMixin { + @JsonFormat(pattern = DATE_PATTERN) + ValueList value; + } + + public static final class EmptyPattern { + @JsonFormat(pattern = "") + public LocalDate value; + } + + public static final class InvalidPattern { + @JsonFormat(pattern = "invalid") + public LocalDate value; + } + + public static final class StringFormat { + @JsonFormat(pattern = DATE_PATTERN) + public String value; + } + + public static final class DurationFormat { + @JsonFormat(pattern = DATE_PATTERN) + public Duration value; + } + + public static final class LegacyDateFormat { + @JsonFormat(pattern = DATE_PATTERN) + public java.util.Date value; + } + + public static final class CalendarFormat { + @JsonFormat(pattern = DATE_PATTERN) + public java.util.Calendar value; + } + + public static final class SqlDateFormat { + @JsonFormat(pattern = DATE_PATTERN) + public Date value; + } + + public static final class StaticFormat { + @JsonFormat(pattern = DATE_PATTERN) + public static LocalDate value; + } + + public static final class TransientFormat { + @JsonFormat(pattern = DATE_PATTERN) + public transient LocalDate value; + } + + public static final class CodecFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonCodec(Base64ByteArrayCodec.class) + public LocalDate value; + } + + public static final class RawFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonRawValue + public LocalDate value; + } + + public static final class AnyFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonAnyProperty + public Map value; + } + + public static final class UnwrappedFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonUnwrapped + public LocalDate value; + } + + public static final class ValueFormat { + @JsonFormat(pattern = DATE_PATTERN) + @JsonValue + public LocalDate value; + } +}