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