Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/guide/java/android-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ annotations, and the Base64 codec constructor. Without `@JsonType`, these annota
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.
and one-wrapper-level behavior as on the JVM, including `timezone` for `Instant`, `ZonedDateTime`,
and `OffsetDateTime`.

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
Expand Down
7 changes: 4 additions & 3 deletions docs/guide/java/graalvm-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,10 @@ 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. `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.
`byte[]` values as on the JVM. `JsonFormat` date/time fields use the same direct-field,
one-wrapper-level, and `timezone` 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.
Expand Down
14 changes: 14 additions & 0 deletions docs/guide/java/json-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ Use `JsonFormat` on a date/time field to select its JSON text pattern in both di
use `DateTimeFormatter` syntax and the root locale:

```java
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
Expand All @@ -675,6 +676,9 @@ public final class Schedule {

@JsonFormat(pattern = "dd/MM/uuuu")
public Map<String, LocalDate> daysByName;

@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public Instant timestamp;
}
```

Expand All @@ -691,6 +695,16 @@ Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`,
offset carried by the value. The pattern must contain enough information to reconstruct the
declared type.

Set `timezone` to a valid `ZoneId` identifier to format and parse `Instant`, `ZonedDateTime`, or
`OffsetDateTime` in that zone. For example, the `timestamp` field above writes
`Instant.parse("2024-01-02T03:04:05Z")` as `"2024-01-02 11:04:05 +08:00"`. The parsed value keeps
the same instant for matching timezone text. The configured zone supplies missing zone or offset
information during parsing; an explicit zone or offset in the JSON text participates in the usual
`DateTimeFormatter` resolution. Include an offset in the pattern when an exact instant must survive
a daylight saving time overlap. Omitting `timezone` preserves the default behavior described
above. Invalid zone identifiers and a non-empty `timezone` on other supported date/time types are
rejected.

`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
Expand Down
1 change: 1 addition & 0 deletions integration_tests/android_tests/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
public static void generatedPlainRules();
public static void generatedRecord();
public static void generatedValueRecord();
public static void generatedFormatTimezone();
public static void manualCodecs();
public static void generatedCodecs();
public static void generatedUnwrapped();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public void generatedValueRecordJson() {
AndroidJsonScenarios.generatedValueRecord();
}

@Test
public void jsonFormatTimezone() {
AndroidJsonScenarios.generatedFormatTimezone();
}

@Test
public void manualJsonCodecs() {
AndroidJsonScenarios.manualCodecs();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@

package org.apache.fory.android;

import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicReferenceArray;
import org.apache.fory.json.ForyJson;
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.JsonProperty;
import org.apache.fory.json.annotation.JsonPropertyOrder;
Expand Down Expand Up @@ -104,6 +107,23 @@ public static void generatedValueRecord() {
json.fromJson("\"decoded-value\"", GeneratedJsonValueRecord.class).value());
}

public static void generatedFormatTimezone() {
ForyJson json = ForyJson.builder().build();
Instant instant = Instant.parse("2024-01-02T03:04:05Z");
FormatTimezoneModel value = new FormatTimezoneModel();
value.instant = instant;
value.instants = Arrays.asList(instant, instant.plusSeconds(3600));
String encoded = json.toJson(value);
checkEquals(
"{\"instant\":\"2024-01-02 11:04:05 +08:00\","
+ "\"instants\":[\"2024-01-02 11:04:05 +08:00\","
+ "\"2024-01-02 12:04:05 +08:00\"]}",
encoded);
FormatTimezoneModel decoded = json.fromJson(encoded, FormatTimezoneModel.class);
checkEquals(instant, decoded.instant);
checkEquals(value.instants, decoded.instants);
}

public static void manualCodecs() {
ForyJson json = ForyJson.builder().build();
ManualJsonModel value = new ManualJsonModel();
Expand Down Expand Up @@ -428,6 +448,15 @@ public abstract static class GeneratedJsonMixinValueRecordAnnotations {
GeneratedJsonMixinValueRecordAnnotations(String value) {}
}

@JsonType
public static final class FormatTimezoneModel {
@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public Instant instant;

@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public List<Instant> instants;
}

private static void check(boolean condition) {
if (!condition) {
throw new AssertionError("check failed");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.sql.Date;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
Expand All @@ -50,6 +51,7 @@
import org.apache.fory.json.annotation.JsonBase64;
import org.apache.fory.json.annotation.JsonCodec;
import org.apache.fory.json.annotation.JsonCreator;
import org.apache.fory.json.annotation.JsonFormat;
import org.apache.fory.json.annotation.JsonIgnore;
import org.apache.fory.json.annotation.JsonMixin;
import org.apache.fory.json.annotation.JsonProperty;
Expand Down Expand Up @@ -105,6 +107,7 @@ public static void main(String[] args) {
testMixinCodec();
testBigDecimal();
testSqlTypes();
testFormatTimezone();
testClosedPackage();
} finally {
System.setOut(originalOut);
Expand Down Expand Up @@ -540,6 +543,24 @@ private static void testSqlTypes() {
Preconditions.checkArgument(decoded.timestamp.getTime() == 3_000L);
}

private static void testFormatTimezone() {
ForyJson json = ForyJson.builder().build();
Instant instant = Instant.parse("2024-01-02T03:04:05Z");
FormatTimezoneValues value = new FormatTimezoneValues();
value.instant = instant;
value.instants = List.of(instant, instant.plusSeconds(3600));
String expected =
"{\"instant\":\"2024-01-02 11:04:05 +08:00\","
+ "\"instants\":[\"2024-01-02 11:04:05 +08:00\","
+ "\"2024-01-02 12:04:05 +08:00\"]}";
Preconditions.checkArgument(json.toJson(value).equals(expected));
byte[] bytes = json.toJsonBytes(value);
Preconditions.checkArgument(new String(bytes, StandardCharsets.UTF_8).equals(expected));
FormatTimezoneValues decoded = json.fromJson(bytes, FormatTimezoneValues.class);
Preconditions.checkArgument(decoded.instant.equals(instant));
Preconditions.checkArgument(decoded.instants.equals(value.instants));
}

public interface InheritedJsonConfig {
default ForyJson duplicateConfiguration() {
return newProviderJson();
Expand Down Expand Up @@ -1201,6 +1222,15 @@ public static final class SqlValues {
public Timestamp timestamp;
}

@JsonType
public static final class FormatTimezoneValues {
@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public Instant instant;

@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public List<Instant> instants;
}

@JsonMixin(target = JsonMixinTarget.class)
@JsonPropertyOrder({"id", "address"})
public interface JsonMixinModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -790,20 +790,22 @@ public void formatRecordPipeline() throws Exception {
compile(
"test.FormatRecord",
"package test;\n"
+ "import java.time.LocalDate;\n"
+ "import java.time.Instant;\n"
+ "import org.apache.fory.json.annotation.*;\n"
+ "@JsonType public record FormatRecord(\n"
+ " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value) {}\n");
+ " @JsonFormat(pattern = \"uuuu-MM-dd HH:mm:ss XXX\", "
+ "timezone = \"Asia/Shanghai\") Instant 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));
Instant instant = Instant.parse("2024-01-02T03:04:05Z");
Object value = type.getConstructor(Instant.class).newInstance(instant);
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));
assertEquals(json.toJson(value), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}");
Object decoded = json.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", type);
assertEquals(type.getMethod("value").invoke(decoded), instant.plusSeconds(3600));
}
}

Expand All @@ -813,11 +815,12 @@ public void formatMixinPipeline() throws Exception {
compile(
"test.FormatTarget",
"package test;\n"
+ "import java.time.LocalDate;\n"
+ "import java.time.Instant;\n"
+ "import org.apache.fory.json.annotation.*;\n"
+ "public final class FormatTarget { public LocalDate value; }\n"
+ "public final class FormatTarget { public Instant value; }\n"
+ "@JsonMixin(target = FormatTarget.class) abstract class FormatMixin {\n"
+ " @JsonFormat(pattern = \"dd/MM/uuuu\") LocalDate value;\n"
+ " @JsonFormat(pattern = \"uuuu-MM-dd HH:mm:ss XXX\", "
+ "timezone = \"Asia/Shanghai\") Instant value;\n"
+ "}\n");
assertTrue(result.success, result.diagnostics());
String base = "FormatMixin_ForyJsonMixin_test_x2e_FormatTarget";
Expand All @@ -828,7 +831,8 @@ public void formatMixinPipeline() throws Exception {
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));
Instant instant = Instant.parse("2024-01-02T03:04:05Z");
target.getField("value").set(value, instant);
for (boolean codegen : new boolean[] {false, true}) {
ForyJson json =
ForyJson.builder()
Expand All @@ -837,9 +841,9 @@ public void formatMixinPipeline() throws Exception {
.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));
assertEquals(json.toJson(value), "{\"value\":\"2024-01-02 11:04:05 +08:00\"}");
Object decoded = json.fromJson("{\"value\":\"2024-01-02 12:04:05 +08:00\"}", target);
assertEquals(target.getField("value").get(decoded), instant.plusSeconds(3600));
}
}

Expand Down
14 changes: 14 additions & 0 deletions java/fory-json/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ Use `JsonFormat` on a date/time field to select its JSON text pattern in both di
use `DateTimeFormatter` syntax and the root locale:

```java
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
Expand All @@ -743,6 +744,9 @@ public final class Schedule {

@JsonFormat(pattern = "dd/MM/uuuu")
public Map<String, LocalDate> daysByName;

@JsonFormat(pattern = "uuuu-MM-dd HH:mm:ss XXX", timezone = "Asia/Shanghai")
public Instant timestamp;
}
```

Expand All @@ -759,6 +763,16 @@ Supported values are exact `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`,
offset carried by the value. The pattern must contain enough information to reconstruct the
declared type.

Set `timezone` to a valid `ZoneId` identifier to format and parse `Instant`, `ZonedDateTime`, or
`OffsetDateTime` in that zone. For example, the `timestamp` field above writes
`Instant.parse("2024-01-02T03:04:05Z")` as `"2024-01-02 11:04:05 +08:00"`. The parsed value keeps
the same instant for matching timezone text. The configured zone supplies missing zone or offset
information during parsing; an explicit zone or offset in the JSON text participates in the usual
`DateTimeFormatter` resolution. Include an offset in the pattern when an exact instant must survive
a daylight saving time overlap. Omitting `timezone` preserves the default behavior described
above. Invalid zone identifiers and a non-empty `timezone` on other supported date/time types are
rejected.

`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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
* 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.
* UTC; zoned and offset values use the zone or offset carried by the value. An explicit {@link
* #timezone()} overrides that behavior for {@code Instant}, {@code ZonedDateTime}, and {@code
* OffsetDateTime}; it is rejected for other date/time types. 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
Expand All @@ -50,4 +52,12 @@
public @interface JsonFormat {
/** Returns the required date/time pattern. */
String pattern();

/**
* Returns the optional {@link java.time.ZoneId} identifier used for formatting and parsing {@link
* java.time.Instant}, {@link java.time.ZonedDateTime}, and {@link java.time.OffsetDateTime}
* values. An empty value keeps the declared value's zone or offset, except that {@code Instant}
* continues to use UTC.
*/
String timezone() default "";
}
Loading
Loading