You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fory.apache.org by ch...@apache.org on 2026/08/03 03:07:46 UTC
(fory) branch main updated: feat(java): add JSON date/time format annotation (#3908)
This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new b7bccc116 feat(java): add JSON date/time format annotation (#3908)
b7bccc116 is described below
commit b7bccc1162f4abfa6de88163a6b2deb540fbc5cf
Author: Shawn Yang <sh...@gmail.com>
AuthorDate: Mon Aug 3 11:07:41 2026 +0800
feat(java): add JSON date/time format annotation (#3908)
## Why?
Fory JSON date/time fields currently use only their built-in textual
representations. Applications
need a field-owned way to select a deterministic `DateTimeFormatter`
pattern without registering a
complete custom codec for every occurrence.
## What does this PR do?
- adds the field-only `JsonFormat(pattern = ...)` annotation;
- applies the pattern to direct supported date/time values and one
direct wrapper child, including
`Optional`, arrays, `Collection`/`List`/`Set`, `AtomicReference`,
`AtomicReferenceArray`, and Map
values;
- rejects nested or ambiguous wrapper shapes, Map keys, complete wrapper
representations, and
conflicting representation annotations;
- keeps formatter construction and annotation resolution on the cold
metadata path, reuses the
existing generated/interpreted codec capability path, writes without an
intermediate String, and
parses ordinary ASCII input through the reader's reusable view;
- supports runtime and processor Mixins, generated companions, Android,
and GraalVM retention;
- documents the exact supported types, root-locale behavior, UTC
`Instant` behavior, and unsupported
cases.
## Related issues
None.
## AI Contribution Checklist
、
## Does this PR introduce any user-facing change?
- [x] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
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 ++-
.../apache/fory/json/annotation/JsonFormat.java | 53 ++
.../org/apache/fory/json/annotation/JsonMixin.java | 12 +-
.../fory/json/codec/DateTimeFormatCodec.java | 251 ++++++++++
.../apache/fory/json/codec/ObjectCodecBuilder.java | 121 ++++-
.../org/apache/fory/json/codec/ScalarCodecs.java | 10 +
.../fory/json/meta/JsonCreatorFieldInfo.java | 18 +-
.../org/apache/fory/json/meta/JsonFieldInfo.java | 9 +-
.../org/apache/fory/json/reader/JsonReader.java | 15 +
.../fory/json/resolver/JsonMixinAnnotations.java | 2 +
.../fory/json/resolver/JsonTypeResolver.java | 122 ++++-
.../fory/json/resolver/JsonValueDeclaration.java | 2 +
.../apache/fory/json/JsonAndroidRuntimeTest.java | 20 +
.../apache/fory/json/JsonFormatAnnotationTest.java | 531 +++++++++++++++++++++
19 files changed, 1330 insertions(+), 46 deletions(-)
diff --git a/docs/guide/java/android-support.md b/docs/guide/java/android-support.md
index 0a45194ae..0a90942f7 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 7da3e7a2a..a41068a98 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 1dbeab5b9..73b9c9657 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<LocalDate> optionalDay;
+
+ @JsonFormat(pattern = "dd/MM/uuuu")
+ public List<LocalDate> days;
+
+ @JsonFormat(pattern = "dd/MM/uuuu")
+ public Map<String, LocalDate> 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 0c0d414e9..25ea6b01f 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 e1940089b..0c19450e7 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.net.URLClassLoader;
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 class JsonTypeProcessorTest {
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 944a99c6e..eb7aa1d08 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<LocalDate> optionalDay;
+
+ @JsonFormat(pattern = "dd/MM/uuuu")
+ public List<LocalDate> days;
+
+ @JsonFormat(pattern = "dd/MM/uuuu")
+ public Map<String, LocalDate> 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 000000000..329d554ef
--- /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.
+ *
+ * <p>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.
+ *
+ * <p>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 89e93a62c..7992c3947 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 @@ import java.lang.annotation.Target;
* access, invocation, and value.
*
* <p>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.
*
* <p>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 000000000..49faf931c
--- /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<Object> {
+ 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 ce6998826..a872d05f9 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.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.JsonProperty;
import org.apache.fory.json.annotation.JsonPropertyOrder;
@@ -117,6 +118,13 @@ final class ObjectCodecBuilder {
+ " 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<Declaration> creatorOnlyUnwrapped = new ArrayList<>();
JsonCreatorInfo creatorInfo =
record
@@ -1051,7 +1059,8 @@ final class ObjectCodecBuilder {
resolved,
rawTypes[i],
builder.codecAnnotation(),
- builder.valueCodecClass()));
+ builder.valueCodecClass(),
+ builder.formatAnnotation()));
}
JsonCreatorFieldInfo[] fieldArray = fields.toArray(new JsonCreatorFieldInfo[0]);
rejectCreatorHashCollisions(fieldArray);
@@ -1144,7 +1153,8 @@ final class ObjectCodecBuilder {
resolved,
rawTypes[i],
codecAnnotation,
- valueCodecClass));
+ valueCodecClass,
+ builder.formatAnnotation()));
}
}
} else {
@@ -1201,12 +1211,13 @@ final class ObjectCodecBuilder {
: builder.codecAnnotation();
Class<? extends JsonValueCodec<?>> 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 @@ final class ObjectCodecBuilder {
} else {
fields.add(
new JsonCreatorFieldInfo(
- jsonName, i, resolved, rawTypes[i], codecAnnotation, valueCodecClass));
+ jsonName,
+ i,
+ resolved,
+ rawTypes[i],
+ codecAnnotation,
+ valueCodecClass,
+ formatAnnotation));
}
}
}
@@ -1356,6 +1373,9 @@ final class ObjectCodecBuilder {
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 @@ final class ObjectCodecBuilder {
}
}
+ 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 @@ final class ObjectCodecBuilder {
private int creatorArgumentIndex = -1;
private JsonCodec codecAnnotation;
private Class<? extends JsonValueCodec<?>> valueCodecClass;
+ private JsonFormat formatAnnotation;
+ private AnnotatedElement formatSource;
private AnnotatedElement codecSource;
private JsonUnwrapped unwrappedAnnotation;
private AnnotatedElement unwrappedSource;
@@ -2205,6 +2245,7 @@ final class ObjectCodecBuilder {
if (readSink) {
readField = field;
}
+ mergeFormat(field);
mergeAnnotation(type, field);
if (annotations.has(field, JsonAnyProperty.class)) {
if (!writeSource && !readSink) {
@@ -2285,6 +2326,7 @@ final class ObjectCodecBuilder {
|| codecAnnotation != null
|| rawValueSource != null
|| valueCodecClass != null
+ || formatAnnotation != null
|| unwrappedAnnotation != null;
}
@@ -2364,7 +2406,7 @@ final class ObjectCodecBuilder {
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 @@ final class ObjectCodecBuilder {
ownerType,
codecAnnotation,
valueCodecClass,
+ formatAnnotation,
rawValue);
}
@@ -2403,7 +2446,10 @@ final class ObjectCodecBuilder {
+ "."
+ 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 @@ final class ObjectCodecBuilder {
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 @@ final class ObjectCodecBuilder {
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 @@ final class ObjectCodecBuilder {
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 @@ final class ObjectCodecBuilder {
}
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 @@ final class ObjectCodecBuilder {
}
}
+ 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 dd1ad5e80..c71e4113c 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<Object> {
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 e0f223438..bb6b2a98c 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 java.lang.reflect.Type;
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<? extends JsonValueCodec<?>> valueCodecClass;
+ private final JsonFormat formatAnnotation;
private JsonTypeInfo typeInfo;
public JsonCreatorFieldInfo(
@@ -48,7 +50,8 @@ public final class JsonCreatorFieldInfo {
Type type,
Class<?> rawType,
JsonCodec codecAnnotation,
- Class<? extends JsonValueCodec<?>> valueCodecClass) {
+ Class<? extends JsonValueCodec<?>> valueCodecClass,
+ JsonFormat formatAnnotation) {
this.name = name;
nameHash = JsonFieldNameHash.hash(name);
this.argumentIndex = argumentIndex;
@@ -56,6 +59,7 @@ public final class JsonCreatorFieldInfo {
this.rawType = rawType;
this.codecAnnotation = codecAnnotation;
this.valueCodecClass = valueCodecClass;
+ this.formatAnnotation = formatAnnotation;
}
public String name() {
@@ -65,7 +69,13 @@ public final class JsonCreatorFieldInfo {
/** 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 final class JsonCreatorFieldInfo {
? 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 176db70e7..82d146de4 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.Collection;
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<? extends JsonValueCodec<?>> valueCodecClass;
+ private final JsonFormat formatAnnotation;
private JsonFieldKind writeKind;
private JsonFieldKind readKind;
private int writeKindId;
@@ -147,6 +149,7 @@ public final class JsonFieldInfo {
TypeRef<?> ownerType,
JsonCodec codecAnnotation,
Class<? extends JsonValueCodec<?>> 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 final class 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 final class JsonFieldInfo {
ownerType,
codecAnnotation,
valueCodecClass,
+ formatAnnotation,
writesRawString());
copy.setReadIndex(readIndex());
return copy;
@@ -386,7 +391,9 @@ public final class JsonFieldInfo {
? 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 590a72d5e..53ac2cd30 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 abstract class JsonReader {
return tryReadNull() ? null : readString();
}
+ /**
+ * Reads nullable date/time text without materializing an ASCII string.
+ *
+ * <p>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 74b19be92..159c06a45 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.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;
@@ -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 8b59697c6..c908b6f72 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.IdentityMap;
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 final class JsonTypeResolver {
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 @@ public final class JsonTypeResolver {
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<?>, 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 @@ public final class JsonTypeResolver {
}
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 @@ public final class JsonTypeResolver {
}
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 85b67d2e6..b4e3fb698 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.JsonAnyGetter;
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 @@ final class JsonValueDeclaration {
}
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 d188a660b..22d8649be 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.File;
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 java.util.List;
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 @@ public class JsonAndroidRuntimeTest {
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<String> javaCommand(String classPath, Class<?> mainClass) {
@@ -211,4 +223,12 @@ public class JsonAndroidRuntimeTest {
@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<LocalDate> 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 000000000..1eb837d6a
--- /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<String, LocalDate> 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<LocalDate> optional;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public List<LocalDate> list;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public Set<LocalDate> set;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public Map<String, LocalDate> map;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public LocalDate[] array;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public AtomicReference<LocalDate> atomic;
+
+ @JsonFormat(pattern = DATE_PATTERN)
+ public AtomicReferenceArray<LocalDate> 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<Optional<LocalDate>> value;
+ }
+
+ public static final class MapKeyFormat {
+ @JsonFormat(pattern = DATE_PATTERN)
+ public Map<LocalDate, String> 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<? extends LocalDate> value;
+ }
+
+ public static final class ValueList extends ArrayList<LocalDate> {
+ 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<String, LocalDate> 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;
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org