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/09/21 13:34:47 UTC
(fory) branch main updated: feat(json): add global byte array formats and Base16 support (#4075)
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 99fd6b3a4 feat(json): add global byte array formats and Base16 support (#4075)
99fd6b3a4 is described below
commit 99fd6b3a4d23ca78f99070d3fe8555bb8fa48afa
Author: Shawn Yang <sh...@gmail.com>
AuthorDate: Mon Sep 21 21:34:41 2026 +0800
feat(json): add global byte array formats and Base16 support (#4075)
## Why?
## What does this PR do?
## Related issues
## AI Contribution Checklist
- [ ] Substantial AI assistance was used in this PR: `yes` / `no`
- [ ] If `yes`, I included a completed [AI Contribution
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
in this PR description and the required `AI Usage Disclosure`.
- [ ] If `yes`, my PR description includes the required `ai_review`
summary and screenshot evidence or equivalent persisted links of the
final clean AI review results from both fresh reviewers described in
`AI_POLICY.md`, the Fory-guided reviewer and the independent general
reviewer, on the current PR diff or current HEAD after the latest code
changes.
## Does this PR introduce any user-facing change?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
.agents/languages/java.md | 8 +-
.../json/byte-array-formats-results.json | 625 +++++++++++++++++++++
docs/benchmarks/json/byte-array-formats.md | 105 ++++
docs/json/annotations.md | 16 +-
docs/json/graalvm.md | 6 +-
docs/json/kotlin.md | 18 +
docs/json/object-mapping.md | 50 +-
docs/json/scala.md | 19 +
docs/json/security.md | 2 +-
.../org/apache/fory/graalvm/ForyJsonExample.java | 63 +++
.../kotlin/json/corpus/PlatformCorpusChecks.kt | 2 +
.../kotlin/json/corpus/PlatformModels.kt | 2 +
.../kotlin/json/corpus/KspRetentionResourceTest.kt | 1 +
.../annotation/processing/JsonTypeProcessor.java | 4 +-
.../processing/JsonTypeProcessorTest.java | 53 ++
.../java/org/apache/fory/json/ForyJsonBuilder.java | 19 +
.../main/java/org/apache/fory/json/JsonConfig.java | 9 +
.../apache/fory/json/annotation/JsonByteArray.java | 7 +-
.../org/apache/fory/json/codec/ArrayCodec.java | 116 ++--
.../fory/json/codec/Base16ByteArrayCodec.java | 66 +++
.../apache/fory/json/codec/ObjectCodecBuilder.java | 4 +-
.../org/apache/fory/json/reader/JsonReader.java | 75 +++
.../apache/fory/json/reader/Latin1JsonReader.java | 5 +
.../apache/fory/json/reader/Utf8JsonReader.java | 13 +-
.../json/resolver/GeneratedCodecKeyBuilder.java | 2 +
.../fory/json/resolver/JsonSharedRegistry.java | 9 +
.../fory/json/resolver/JsonTypeResolver.java | 6 +-
.../org/apache/fory/json/writer/JsonWriter.java | 16 +
.../apache/fory/json/writer/StringJsonWriter.java | 53 ++
.../apache/fory/json/writer/Utf8JsonWriter.java | 28 +
.../apache/fory/json/ForyJsonGraalVMFeature.java | 5 +-
.../fory-json/native-image.properties | 1 +
.../json/ForyJsonGraalVMFeatureJarVerifier.java | 1 +
.../apache/fory/json/JsonAsyncCompilationTest.java | 2 +
.../fory/json/JsonByteArrayAnnotationTest.java | 236 ++++++++
.../org/apache/fory/json/JsonContainerTest.java | 25 +
.../java/org/apache/fory/json/JsonScalarTest.java | 30 +
.../java/org/apache/fory/json/JsonTestSupport.java | 2 +
.../apache/fory/json/kotlin/ksp/KspModelBuilder.kt | 8 +-
.../fory/json/kotlin/KotlinBuiltInCodecsTest.kt | 53 ++
.../apache/fory/json/scala/ScalaJsonSuite.scala | 47 +-
41 files changed, 1730 insertions(+), 82 deletions(-)
diff --git a/.agents/languages/java.md b/.agents/languages/java.md
index deaa98a2e..f54e4cf1f 100644
--- a/.agents/languages/java.md
+++ b/.agents/languages/java.md
@@ -107,11 +107,13 @@ Load this file when changing anything under `java/` or when Java drives a cross-
and locale types, `Float16`, `BFloat16`, and user-defined types remain registerable. Field/type
`@JsonCodec`, `@JsonFormat`, and semantic metadata remain separate from exact registry mutation
and are fixed by the target class or effective Mixin.
-- Fory JSON `byte[]` defaults to a Base64 string. `@JsonByteArray` selects required
- `Format.BASE64` or `Format.ARRAY` for an exact byte-array field or getter in both directions.
+- Fory JSON `byte[]` defaults to a Base64 string; `byteArrayFormat` can select BASE64, BASE16,
+ or signed ARRAY globally. `@JsonByteArray` overrides that default for an exact byte-array field
+ or getter in both directions.
Keep selection in the existing property codec path, including Mixin, Java processor, Kotlin
KSP, and GraalVM handling. Numeric arrays use signed-byte semantics and graph-memory accounting;
- Base64 values remain binary leaves outside that budget.
+ Base64 and Base16 values remain binary leaves outside that budget. Resolve the format during
+ codec selection, not on each write; semantic unsigned arrays retain their module-owned format.
- Fory JSON `ObjectCodec` instances are resolver-owned and must not be registered directly. A
language module that supplies a custom object model must use a `JsonCodecFactory`. A configurable
factory's stable key must cover every option that can change its created codec class, object
diff --git a/docs/benchmarks/json/byte-array-formats-results.json b/docs/benchmarks/json/byte-array-formats-results.json
new file mode 100644
index 000000000..ca2f84727
--- /dev/null
+++ b/docs/benchmarks/json/byte-array-formats-results.json
@@ -0,0 +1,625 @@
+{
+ "environment": {
+ "cpu": "Apple M4 Pro",
+ "logical_cpus": 12,
+ "os": "macOS 15.7.2 arm64",
+ "jdk": "OpenJDK 25.0.3",
+ "jmh": "1.37"
+ },
+ "source": {
+ "fory": "13739263ebf31af5fc962184aaccf2833f8ffc9a",
+ "fory_baseline": "e1d2f9d67800901392755b1133968d448ab076b8",
+ "jsoniter_scala_base": "84cb277f2c2a16b4018bc61fdeec3eed62252a1e",
+ "jsoniter_scala_adapters": "71b2ba16f1d5261e2be4e98d5dfe4be452d0e136",
+ "jsoniter_scala_version": "2.40.2-SNAPSHOT",
+ "benchmark_scala": "3.9.0",
+ "fory_scala": "3.3.8"
+ },
+ "settings": {
+ "threads": 1,
+ "forks_per_run": 1,
+ "warmup_iterations": 3,
+ "measurement_iterations": 5,
+ "iteration_seconds": 1,
+ "jvm_args": ["-Xms1g", "-Xmx1g", "-XX:+UseParallelGC"],
+ "profiler": "gc"
+ },
+ "results": [
+ {
+ "case": "ArrayOfBytesReading",
+ "size": 512,
+ "pairs": [
+ {
+ "pair": 1,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 718561.6343142863,
+ "iterations_ops_s": [
+ 720966.2774700475, 722148.059950197, 723968.2127171038,
+ 699675.4454108089, 726050.1760232744
+ ],
+ "allocation_bytes_op": 528.0081562652304,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 644154.4427726491,
+ "iterations_ops_s": [
+ 642739.6225877892, 656549.9366009792, 647274.0194844017,
+ 629323.8870815604, 644884.7481085152
+ ],
+ "allocation_bytes_op": 1128.0091134734967,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.1155114155874863
+ },
+ {
+ "pair": 2,
+ "order": ["fory", "jsoniter_scala"],
+ "fory": {
+ "throughput_ops_s": 712768.7484642493,
+ "iterations_ops_s": [
+ 715809.6255148505, 712322.2180004838, 719427.3859759014,
+ 706451.1582493099, 709833.3545807011
+ ],
+ "allocation_bytes_op": 528.0082231131366,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 646910.5376279108,
+ "iterations_ops_s": [
+ 653642.6650007655, 648076.5626362266, 639063.9666087512,
+ 648406.7871787745, 645362.7067150369
+ ],
+ "allocation_bytes_op": 1128.0090703040241,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.1018042016718836
+ },
+ {
+ "pair": 3,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 713855.8384418925,
+ "iterations_ops_s": [
+ 713930.6941047948, 699719.8311706063, 713989.776513808,
+ 721117.7876197997, 720521.1028004538
+ ],
+ "allocation_bytes_op": 528.0082200551103,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 648694.3313125791,
+ "iterations_ops_s": [
+ 640046.1536157951, 651947.475615739, 650393.8228952556,
+ 649302.6479094238, 651781.5565266822
+ ],
+ "allocation_bytes_op": 1128.0090437194845,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.100450249037115
+ }
+ ],
+ "median_fory_ops_s": 713855.8384418925,
+ "median_jsoniter_ops_s": 646910.5376279108,
+ "median_paired_ratio": 1.1018042016718836,
+ "minimum_paired_ratio": 1.100450249037115,
+ "maximum_paired_ratio": 1.1155114155874863,
+ "geometric_paired_ratio": 1.1059010882137206
+ },
+ {
+ "case": "ArrayOfBytesWriting",
+ "size": 512,
+ "pairs": [
+ {
+ "pair": 1,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 3350580.997475249,
+ "iterations_ops_s": [
+ 3354256.676965812, 3350235.8910908336, 3370583.284923328,
+ 3343149.3809342943, 3334679.7534619793
+ ],
+ "allocation_bytes_op": 1888.0017545395815,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 1534400.3964775414,
+ "iterations_ops_s": [
+ 1555002.3973013056, 1551437.3792732242, 1483784.9963905278,
+ 1539026.116856106, 1542751.092566543
+ ],
+ "allocation_bytes_op": 1888.003826423861,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 2.1836419002282828
+ },
+ {
+ "pair": 2,
+ "order": ["fory", "jsoniter_scala"],
+ "fory": {
+ "throughput_ops_s": 3355366.2425028966,
+ "iterations_ops_s": [
+ 3371208.1427841824, 3370052.5561299818, 3367852.548591301,
+ 3335320.999305273, 3332396.965703745
+ ],
+ "allocation_bytes_op": 1888.0017485208652,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 1539684.6759449516,
+ "iterations_ops_s": [
+ 1530588.7989645593, 1536345.1128779924, 1550446.53671775,
+ 1520990.2236222902, 1560052.7075421656
+ ],
+ "allocation_bytes_op": 1888.0038163834627,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 2.179255463748514
+ },
+ {
+ "pair": 3,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 3328563.302552803,
+ "iterations_ops_s": [
+ 3320520.682048541, 3331924.359155337, 3319451.2998794424,
+ 3332810.7688661655, 3338109.40281453
+ ],
+ "allocation_bytes_op": 1888.0017662799612,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 1540486.9964760381,
+ "iterations_ops_s": [
+ 1537960.3534940826, 1560478.5204790654, 1537766.2635790894,
+ 1527368.4693392226, 1538861.3754887313
+ ],
+ "allocation_bytes_op": 1888.0038109181107,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 2.1607214537786446
+ }
+ ],
+ "median_fory_ops_s": 3350580.997475249,
+ "median_jsoniter_ops_s": 1539684.6759449516,
+ "median_paired_ratio": 2.179255463748514,
+ "minimum_paired_ratio": 2.1607214537786446,
+ "maximum_paired_ratio": 2.1836419002282828,
+ "geometric_paired_ratio": 2.174516874590141
+ },
+ {
+ "case": "Base16Reading",
+ "size": 512,
+ "pairs": [
+ {
+ "pair": 1,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 3359808.747011424,
+ "iterations_ops_s": [
+ 3170890.4021431804, 2835783.848635741, 3599267.745232154,
+ 3600313.8419093373, 3592787.8971367073
+ ],
+ "allocation_bytes_op": 528.0017648087484,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 2914929.3631949537,
+ "iterations_ops_s": [
+ 2894217.0461341827, 2909705.468348089, 2928398.5845325943,
+ 2897313.7225826643, 2945011.9943772373
+ ],
+ "allocation_bytes_op": 528.0020149494615,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.1526209826672622
+ },
+ {
+ "pair": 2,
+ "order": ["fory", "jsoniter_scala"],
+ "fory": {
+ "throughput_ops_s": 3585325.1970511647,
+ "iterations_ops_s": [
+ 3606779.6047478, 3620212.096465545, 3585192.9164911993,
+ 3573313.8467803146, 3541127.5207709647
+ ],
+ "allocation_bytes_op": 528.0016362855811,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 2873552.418737786,
+ "iterations_ops_s": [
+ 2761830.775159276, 2916270.0082977437, 2909785.4844934223,
+ 2912280.347573207, 2867595.47816528
+ ],
+ "allocation_bytes_op": 528.0020441965414,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.2476978577707751
+ },
+ {
+ "pair": 3,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 3603439.30158346,
+ "iterations_ops_s": [
+ 3626219.544144616, 3602839.8185009575, 3602282.722974174,
+ 3598315.7871311214, 3587538.6351664294
+ ],
+ "allocation_bytes_op": 528.0016283749771,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 2949382.2375222584,
+ "iterations_ops_s": [
+ 2947324.1011699555, 2941788.9030553037, 2943400.2053664513,
+ 2947093.167103884, 2967304.8109156964
+ ],
+ "allocation_bytes_op": 528.001988912211,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.2217606981354399
+ }
+ ],
+ "median_fory_ops_s": 3585325.1970511647,
+ "median_jsoniter_ops_s": 2914929.3631949537,
+ "median_paired_ratio": 1.2217606981354399,
+ "minimum_paired_ratio": 1.1526209826672622,
+ "maximum_paired_ratio": 1.2476978577707751,
+ "geometric_paired_ratio": 1.2066853323390903
+ },
+ {
+ "case": "Base16Writing",
+ "size": 512,
+ "pairs": [
+ {
+ "pair": 1,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 9639285.749072284,
+ "iterations_ops_s": [
+ 9720176.667163966, 9484401.322246242, 9672723.887067826,
+ 9634169.371241517, 9684957.497641867
+ ],
+ "allocation_bytes_op": 1048.0006091523976,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 8472009.567249341,
+ "iterations_ops_s": [
+ 8479680.230866075, 8457165.337729625, 8507324.055039847,
+ 8446205.415500265, 8469672.797110893
+ ],
+ "allocation_bytes_op": 1048.0006932682013,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.137780319127039
+ },
+ {
+ "pair": 2,
+ "order": ["fory", "jsoniter_scala"],
+ "fory": {
+ "throughput_ops_s": 9790192.385472855,
+ "iterations_ops_s": [
+ 9729910.203410858, 9798489.073830001, 9801086.328943448,
+ 9821711.761606097, 9799764.559573878
+ ],
+ "allocation_bytes_op": 1048.0005986565332,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 8444372.230910677,
+ "iterations_ops_s": [
+ 8454419.247091914, 8498853.198057437, 8439041.593527095,
+ 8413092.78031926, 8416454.335557679
+ ],
+ "allocation_bytes_op": 1048.0006945067705,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.159374802266034
+ },
+ {
+ "pair": 3,
+ "order": ["jsoniter_scala", "fory"],
+ "fory": {
+ "throughput_ops_s": 9627140.75202604,
+ "iterations_ops_s": [
+ 9571136.694928922, 9670291.685970405, 9650637.061303275,
+ 9649359.818529114, 9594278.499398485
+ ],
+ "allocation_bytes_op": 1048.0006093169627,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 8299340.122327629,
+ "iterations_ops_s": [
+ 8304739.0318363365, 8098181.555887745, 8329959.2642635,
+ 8378818.139460061, 8385002.620190503
+ ],
+ "allocation_bytes_op": 1048.0007070897,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "throughput_ratio": 1.1599886991167216
+ }
+ ],
+ "median_fory_ops_s": 9639285.749072284,
+ "median_jsoniter_ops_s": 8444372.230910677,
+ "median_paired_ratio": 1.159374802266034,
+ "minimum_paired_ratio": 1.137780319127039,
+ "maximum_paired_ratio": 1.1599886991167216,
+ "geometric_paired_ratio": 1.1523347999694993
+ }
+ ],
+ "numeric_read_development_pairs": [
+ {
+ "pair": 1,
+ "measurement_iterations": 3,
+ "baseline": {
+ "throughput_ops_s": 532673.8573387515,
+ "iterations_ops_s": [
+ 525513.9417793049, 542316.8084152596, 530190.8218216901
+ ],
+ "allocation_bytes_op": 1656.0110539563302,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "fory": {
+ "throughput_ops_s": 688445.4281281951,
+ "iterations_ops_s": [
+ 661077.1182975627, 700984.16167637, 703275.0044106527
+ ],
+ "allocation_bytes_op": 528.0085360717871,
+ "artifact_sha256": {
+ "fory_json": "3dc8c8cce674855b39faca03156bf57048bb28d1503513cd5d5a85c6935ea696",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 633958.6015719158,
+ "iterations_ops_s": [
+ 617689.5090250478, 649548.5363109286, 634637.7593797712
+ ],
+ "allocation_bytes_op": 1128.0092592375615,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ }
+ },
+ {
+ "pair": 2,
+ "measurement_iterations": 3,
+ "baseline": {
+ "throughput_ops_s": 540338.0918875013,
+ "iterations_ops_s": [
+ 536275.7264876695, 526926.7419483826, 557811.8072264518
+ ],
+ "allocation_bytes_op": 1656.0108723773349,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "fory": {
+ "throughput_ops_s": 720624.0504748118,
+ "iterations_ops_s": [
+ 725308.188246871, 717928.044117385, 718635.9190601795
+ ],
+ "allocation_bytes_op": 528.0081332921432,
+ "artifact_sha256": {
+ "fory_json": "3dc8c8cce674855b39faca03156bf57048bb28d1503513cd5d5a85c6935ea696",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "jsoniter_scala": {
+ "throughput_ops_s": 637647.9827981751,
+ "iterations_ops_s": [
+ 633147.7919315896, 637860.5415264402, 641935.6149364957
+ ],
+ "allocation_bytes_op": 1128.0092273109979,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ }
+ }
+ ],
+ "integer_read_control": [
+ {
+ "pair": 1,
+ "baseline": {
+ "throughput_ops_s": 491577.15519360424,
+ "iterations_ops_s": [
+ 493226.1424904787, 487426.7022866886, 491108.85466615774,
+ 489525.76150114706, 496598.3150235495
+ ],
+ "allocation_bytes_op": 4176.011942924268,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "fory": {
+ "throughput_ops_s": 498496.5149580714,
+ "iterations_ops_s": [
+ 500744.575380113, 500638.4030852225, 493489.2902459867,
+ 498268.2111324434, 499342.09494659153
+ ],
+ "allocation_bytes_op": 4176.011777540917,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ }
+ },
+ {
+ "pair": 2,
+ "baseline": {
+ "throughput_ops_s": 487399.4866039612,
+ "iterations_ops_s": [
+ 492882.917488924, 491449.6032085084, 492271.10849723214,
+ 483111.02830760553, 477282.77551753627
+ ],
+ "allocation_bytes_op": 4176.012066509457,
+ "artifact_sha256": {
+ "fory_json": "0b175646200572bd67817e5ff6350ca9b41ecf8d9c5fedb46ef90677fe335014",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ },
+ "fory": {
+ "throughput_ops_s": 492338.3696801558,
+ "iterations_ops_s": [
+ 482311.38343038387, 493766.54553907254, 493700.9130787423,
+ 495583.89678214624, 496329.1095704343
+ ],
+ "allocation_bytes_op": 4176.01193743859,
+ "artifact_sha256": {
+ "fory_json": "a60312c784247481c49a6a7a2bf128979fdba070c305a0e1f95966673237883b",
+ "fory_scala": "69bce71e74891f86f0a2b1a607b2a86743955e5378386dbc3ab72e64c30f62cd",
+ "fory_core": "1d743236d0529f1e0c082bba9abc36bf502167add84905441ad917f7143b5b9a",
+ "benchmark": "606d03f7f6fb4bbf217b3e2d30ae82054d702aa937148328b1987191bd63e3b4"
+ }
+ }
+ }
+ ]
+}
diff --git a/docs/benchmarks/json/byte-array-formats.md b/docs/benchmarks/json/byte-array-formats.md
new file mode 100644
index 000000000..51b915f1c
--- /dev/null
+++ b/docs/benchmarks/json/byte-array-formats.md
@@ -0,0 +1,105 @@
+# JVM JSON byte-array format measurements
+
+This comparison covers numeric byte arrays and Base16 strings on the unchanged
+jsoniter-scala `ArrayOfBytesReading`, `ArrayOfBytesWriting`, `Base16Reading`, and
+`Base16Writing` workloads at `size=512`. Higher throughput is better.
+
+All 12 final adjacent comparison pairs exceed 10%. Numeric-array reading has a
+narrow margin: its smallest measured improvement is 10.05%. These are local
+steady-state measurements, not a guarantee for other sizes, data distributions,
+machines, or JVMs.
+
+## Results
+
+| Workload | jsoniter-scala median, ops/s | Fory median, ops/s | Median paired improvement | Paired range |
+| -------------------------- | ---------------------------: | -----------------: | ------------------------: | -------------------: |
+| Numeric byte-array reading | 646,911 | 713,856 | +10.18% | +10.05% to +11.55% |
+| Numeric byte-array writing | 1,539,685 | 3,350,581 | +117.93% | +116.07% to +118.36% |
+| Base16 reading | 2,914,929 | 3,585,325 | +22.18% | +15.26% to +24.77% |
+| Base16 writing | 8,444,372 | 9,639,286 | +15.94% | +13.78% to +16.00% |
+
+Each pair compares the means of five measurement iterations. The improvement
+column is the median of those paired ratios, not the ratio of independent medians.
+All final samples are retained. Earlier, shorter numeric-reading comparisons
+measured +8.59% and +13.01%; they are also included in the evidence and illustrate
+why the final narrow margin should not be generalized.
+
+Allocation per operation is approximately 528 B for Fory numeric-array reading
+and 1,128 B for jsoniter-scala. Both libraries allocate about 1,888 B for numeric
+array output, 528 B for Base16 input, and 1,048 B for Base16 output. Both use APIs
+that return independent arrays; neither uses the preallocated-output benchmark.
+Startup and retained lookup-table memory are outside these steady-state allocation
+figures. Fory's Base16 output table occupies 256 KiB after its first use.
+
+The affected integer-reading path was also compared with the pre-optimization
+Fory revision in two adjacent pairs. Changes were +1.41% and +1.01%, within the
+range normally treated as noise; no regression was observed in those runs.
+
+[Iteration samples, allocations, paired results, and artifact hashes](byte-array-formats-results.json)
+are available as JSON.
+
+## Workloads and configuration
+
+The input bytes are the benchmark's original sequence
+`(1 to size).map(_.toByte).toArray`. Numeric arrays keep the original signed decimal
+JSON representation. Base16 keeps the original lowercase hexadecimal string.
+Models, inputs, expected outputs, and jsoniter-scala implementations are unchanged.
+Only the missing Fory adapters and their existing correctness specs are enabled.
+
+Fory uses separate instances configured through its public builder:
+
+```java
+import org.apache.fory.json.ForyJson;
+import org.apache.fory.json.annotation.JsonByteArray;
+
+ForyJson arrays = ForyJson.builder().byteArrayFormat(JsonByteArray.Format.ARRAY).build();
+ForyJson hex = ForyJson.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build();
+```
+
+Each reading adapter calls `fromJson(jsonBytes, classOf[Array[Byte]])`; each writing
+adapter calls `toJsonBytes(obj)`. These configurations also apply to nested byte
+arrays; they are not benchmark-specific codecs.
+
+## Environment and revisions
+
+- Date: September 21, 2026.
+- Apple M4 Pro, 12 logical CPUs, 48 GiB RAM; macOS 15.7.2 arm64.
+- OpenJDK 25.0.3; JMH 1.37.
+- Fory: `13739263ebf31af5fc962184aaccf2833f8ffc9a`, version `1.8.0-SNAPSHOT`.
+- Fory pre-optimization baseline: `e1d2f9d67800901392755b1133968d448ab076b8`.
+- jsoniter-scala: `2.40.2-SNAPSHOT`, checkout
+ `71b2ba16f1d5261e2be4e98d5dfe4be452d0e136`; its Fory adapter additions are based on
+ `84cb277f2c2a16b4018bc61fdeec3eed62252a1e`.
+- Benchmark Scala: 3.9.0; Fory JSON Scala: 3.3.8.
+- One thread and one fork per run; three 1-second warmups and five 1-second
+ measurements; `-Xms1g -Xmx1g -XX:+UseParallelGC`; JMH GC profiler.
+- Three adjacent pairs per workload. Order is jsoniter/Fory, Fory/jsoniter,
+ jsoniter/Fory. Runs are serial; normal desktop background activity remains.
+
+The runtime, core, Scala, and benchmark jars are frozen during measurement. Only
+the Fory JSON runtime jar changes in baseline/current controls.
+
+## Reproduction
+
+Install Fory core, JSON, and JSON Scala artifacts before building the benchmark
+assembly. Enable Maven Local when using locally published snapshots:
+
+```bash
+sbt --java-home "$JAVA_HOME" \
+ 'set ThisBuild / resolvers += Resolver.mavenLocal' \
+ '++3.9.0!' 'jsoniter-scala-benchmarkJVM/assembly'
+```
+
+Run each method as a separate process, alternating the library order between
+pairs. Use the same frozen classpath for both methods:
+
+```bash
+java -cp "$FORY_JSON_JAR:$FORY_SCALA_JAR:$FORY_CORE_JAR:$BENCHMARK_JAR" \
+ org.openjdk.jmh.Main ".*\.${CASE}\.${METHOD}$" \
+ -t 1 -f 1 -wi 3 -i 5 -w 1s -r 1s -p size=512 \
+ -jvmArgs '-Xms1g -Xmx1g -XX:+UseParallelGC' -foe true -prof gc \
+ -rf json -rff "$RESULT_FILE"
+```
+
+`CASE` is one of the four workload names above; `METHOD` is `fory` or
+`jsoniterScala`. No GraalVM or native-image performance is measured here.
diff --git a/docs/json/annotations.md b/docs/json/annotations.md
index e6975f463..be2adefc8 100644
--- a/docs/json/annotations.md
+++ b/docs/json/annotations.md
@@ -177,7 +177,7 @@ With an empty `items` list, this object writes `{}`. Explicit property inclusion
class and builder defaults. Java empty-value checks run directly, even with a custom codec.
Other types use the selected codec's `isEmpty(writer, value)`, which defaults to `false`.
A custom codec must override it to define empty values; writing `""` or `{}` alone does not make
-an ordinary object empty. An empty `byte[]` is empty with either Base64 or numeric-array
+an ordinary object empty. An empty `byte[]` is empty with Base64, Base16, or numeric-array
representation. See [Custom codecs](custom-codecs.md#custom-empty-values).
Filtering is shallow: `0`, `false`, a list containing null, a list containing an empty list, and a
@@ -477,8 +477,8 @@ as a trusted raw root value. That combination is serialization-only: the ordinar
## `JsonByteArray`
-Unannotated `byte[]` values use quoted standard Base64 JSON strings. `JsonByteArray` selects
-`BASE64` or `ARRAY` for one exact `byte[]` field or getter, in both reading and writing:
+`JsonByteArray` selects `BASE64`, `BASE16`, or `ARRAY` for one exact `byte[]` field or getter, in
+both reading and writing. It overrides the builder's `byteArrayFormat`, whose default is `BASE64`:
```java
import org.apache.fory.json.annotation.JsonByteArray;
@@ -489,10 +489,16 @@ public final class Attachment {
@JsonByteArray(JsonByteArray.Format.BASE64)
public byte[] content;
+
+ @JsonByteArray(JsonByteArray.Format.BASE16)
+ public byte[] hex;
}
```
-For bytes `{1, -2, 3}`, `numbers` is written as `[1,-2,3]` and `content` as `"Af4D"`.
+For bytes `{1, -2, 3}`, `numbers` is written as `[1,-2,3]`, `content` as `"Af4D"`, and `hex` as
+`"01fe03"`. `BASE16` writes lowercase hexadecimal digits without a prefix or separators; it reads
+uppercase or lowercase digits, including JSON string escapes, and rejects odd-length or invalid
+hexadecimal strings.
`ARRAY` reads JSON arrays using the signed byte range `[-128, 127]`; `BASE64` reads standard
Base64 strings and preserves padding when writing. Each representation also accepts JSON null,
and null output follows the property's normal inclusion rule. The default Base64 codec does not
@@ -503,7 +509,7 @@ property, not to container elements or map values. Mixin declarations can select
It cannot share a logical property with `JsonRawValue`, an occurrence `JsonCodec`, `JsonFormat`,
or an Any declaration. Conflicting formats on the field and getter of one property are rejected.
-Base64 values are binary leaves excluded from the graph-memory budget. Numeric arrays count their
+Base64 and Base16 values are binary leaves excluded from the graph-memory budget. Numeric arrays count their
array storage against that budget; see [Security](security.md#depth-and-graph-memory-limits).
## `JsonFormat`
diff --git a/docs/json/graalvm.md b/docs/json/graalvm.md
index 22d48b3f9..7a67321e4 100644
--- a/docs/json/graalvm.md
+++ b/docs/json/graalvm.md
@@ -209,8 +209,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 `JsonByteArray` fields and getters select Base64 strings or numeric
-byte arrays as on the JVM. `JsonFormat` date/time fields use the same direct-field,
+getters support trusted raw String values, and `JsonByteArray` fields and getters select Base64 strings,
+Base16 strings, or numeric byte arrays as on the JVM. The builder's `byteArrayFormat` controls the
+default representation; include that setting in a provider when using generated object codecs.
+`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 selected byte-array
codec constructor.
diff --git a/docs/json/kotlin.md b/docs/json/kotlin.md
index cc7a35576..63a70d24e 100644
--- a/docs/json/kotlin.md
+++ b/docs/json/kotlin.md
@@ -93,6 +93,24 @@ collection and map values, nullable values, Kotlin value classes backed by them,
the Java Long-like wrappers supported by the core JSON runtime. Readers accept both quoted and
unquoted integer tokens.
+`ByteArray` uses Base64 strings by default. The standard builder's `byteArrayFormat` also controls
+root arrays and arrays nested in properties, collections, and maps:
+
+```kotlin
+import org.apache.fory.json.annotation.JsonByteArray
+import org.apache.fory.json.kotlin.ForyJsonKotlin
+import org.apache.fory.json.kotlin.jsonTypeRef
+
+val hexJson = ForyJsonKotlin.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build()
+val bytesType = jsonTypeRef<ByteArray>()
+val text = hexJson.toJson(byteArrayOf(1, -2, 3), bytesType) // "\"01fe03\""
+val bytes = hexJson.fromJson(text, bytesType)
+```
+
+`@field:JsonByteArray` or `@get:JsonByteArray`, including a Mixin declaration, overrides the default
+for that property. `UByteArray` retains its unsigned numeric-array representation. See
+[byte-array formats](object-mapping.md#builder-configuration) for accepted input and null behavior.
+
`jsonTypeRef<T>()` is a type token, not a codec lookup. Construct it once and reuse it. A Java
`Class` or ordinary Java `TypeRef` cannot express distinctions such as `List<Account?>`, `UInt`, or
a logical value class lowered to a primitive carrier.
diff --git a/docs/json/object-mapping.md b/docs/json/object-mapping.md
index 63382d318..4cd8a405d 100644
--- a/docs/json/object-mapping.md
+++ b/docs/json/object-mapping.md
@@ -229,20 +229,42 @@ for supported sources, construction/evaluation effects, and errors. An explicit
overrides `JsonInclude` on the class, which overrides the builder default. See
[Property inclusion](annotations.md#jsonproperty) for the empty-value definitions and boundaries.
-| Builder method | Default | User-visible effect |
-| -------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
-| `defaultPropertyInclusion(Include)` | `NON_NULL` | Default inclusion of object properties |
-| `writeNullFields(boolean)` | `false` | Select `ALWAYS` when true or `NON_NULL` when false |
-| `writeLongAsString(boolean)` | `false` | Write built-in 64-bit integer values as decimal strings |
-| `withCodegen(boolean)` | `true` | Enable generated object codecs |
-| `withAsyncCompilation(boolean)` | `true` | Compile generated codecs asynchronously |
-| `withFieldMode(boolean)` | `false` | When true, discover fields without getters/setters |
-| `withPropertyNamingStrategy(strategy)` | `LOWER_CAMEL_CASE` | Name properties without an explicit `JsonProperty` name |
-| `withMaxCachedFieldNames(int)` | `DEFAULT_MAX_CACHED_FIELD_NAMES` (`8192`) | Field-name cache entries per reader; zero disables caching |
-| `withConcurrencyLevel(int)` | `max(1, 2 * processors)` | Maximum concurrent root operations |
-| `withBufferSizeLimitBytes(int)` | 2 MiB | Maximum retained output/string-decoding buffer capacity |
-| `registerCodec(type, codec)` | None | Replace an eligible exact class's complete JSON codec |
-| `registerMixin(mixinType)` | None | Apply one annotation Mixin to its exact declared target |
+| Builder method | Default | User-visible effect |
+| --------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
+| `defaultPropertyInclusion(Include)` | `NON_NULL` | Default inclusion of object properties |
+| `writeNullFields(boolean)` | `false` | Select `ALWAYS` when true or `NON_NULL` when false |
+| `writeLongAsString(boolean)` | `false` | Write built-in 64-bit integer values as decimal strings |
+| `byteArrayFormat(JsonByteArray.Format)` | `BASE64` | Default byte-array representation for reading and writing |
+| `withCodegen(boolean)` | `true` | Enable generated object codecs |
+| `withAsyncCompilation(boolean)` | `true` | Compile generated codecs asynchronously |
+| `withFieldMode(boolean)` | `false` | When true, discover fields without getters/setters |
+| `withPropertyNamingStrategy(strategy)` | `LOWER_CAMEL_CASE` | Name properties without an explicit `JsonProperty` name |
+| `withMaxCachedFieldNames(int)` | `DEFAULT_MAX_CACHED_FIELD_NAMES` (`8192`) | Field-name cache entries per reader; zero disables caching |
+| `withConcurrencyLevel(int)` | `max(1, 2 * processors)` | Maximum concurrent root operations |
+| `withBufferSizeLimitBytes(int)` | 2 MiB | Maximum retained output/string-decoding buffer capacity |
+| `registerCodec(type, codec)` | None | Replace an eligible exact class's complete JSON codec |
+| `registerMixin(mixinType)` | None | Apply one annotation Mixin to its exact declared target |
+
+Use `byteArrayFormat` to choose one representation for ordinary `byte[]` roots, unannotated
+properties, and container values, including nested arrays, collections, maps, and optionals:
+
+```java
+import org.apache.fory.json.ForyJson;
+import org.apache.fory.json.annotation.JsonByteArray;
+
+ForyJson hexJson = ForyJson.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build();
+String text = hexJson.toJson(new byte[] {1, -2, 3}); // "\"01fe03\""
+byte[] bytes = hexJson.fromJson(text, byte[].class);
+```
+
+`BASE64` is the default and writes padded standard Base64 strings. `BASE16` writes lowercase
+hexadecimal strings without prefixes or separators; reading accepts either case and JSON string
+escapes. Odd-length strings and non-hexadecimal characters are rejected. `ARRAY` uses signed
+numbers in `[-128, 127]`. Empty arrays become `""` for either string format or `[]` for `ARRAY`.
+All three formats accept JSON `null`. The reader uses the configured representation and does not
+guess another format. A [property's `JsonByteArray` annotation](annotations.md#jsonbytearray),
+including one supplied by a Mixin, overrides the global default. Custom occurrence codecs retain
+their complete representation; Kotlin unsigned semantic arrays remain numeric arrays.
Enable `writeLongAsString(true)` when 64-bit integer values must pass through JavaScript without
`Number` precision loss. The setting writes built-in `long`/`Long`, `AtomicLong`,
diff --git a/docs/json/scala.md b/docs/json/scala.md
index 2054d0ec9..2f31e3efb 100644
--- a/docs/json/scala.md
+++ b/docs/json/scala.md
@@ -47,6 +47,25 @@ wrappers as quoted decimal strings. Readers accept both quoted and unquoted inte
Use `ScalaTypeRef` when a parameterized declaration contains `Long` because normal JVM signatures
can erase Scala value-type arguments to `Object`.
+## Byte-array formats
+
+Scala `Array[Byte]` uses Base64 strings by default. Use the standard builder's
+`byteArrayFormat` to select numeric arrays or hexadecimal strings, including arrays inside
+`Option`, Scala collections, and maps:
+
+```scala
+import org.apache.fory.json.annotation.JsonByteArray
+import org.apache.fory.json.scala.{ForyJsonScala, ScalaTypeRef}
+
+val hexJson = ForyJsonScala.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build()
+val bytesType = ScalaTypeRef[Array[Byte]]
+val text = hexJson.toJson(Array[Byte](1, -2, 3), bytesType) // "\"01fe03\""
+val bytes = hexJson.fromJson(text, bytesType)
+```
+
+`JsonByteArray` on a field or getter, including a Mixin, overrides the default for that property.
+See [byte-array formats](object-mapping.md#builder-configuration) for the read/write contract.
+
## Case classes and annotations
Case classes are decoded by calling their full primary constructor. Fory invokes Scala's generated
diff --git a/docs/json/security.md b/docs/json/security.md
index deecb49d4..3de92c2f6 100644
--- a/docs/json/security.md
+++ b/docs/json/security.md
@@ -101,7 +101,7 @@ is also charged once; a cached empty Optional singleton is not a new graph owner
Dedicated leaf codecs are excluded from graph accounting: null, strings,
characters, booleans, numeric values including arbitrary-precision numbers,
enums, temporal and other scalar values, and binary values. A `byte[]` handled
-by a binary or Base64 codec remains a binary leaf; the same Java carrier read
+by a Base64 or Base16 codec remains a binary leaf; the same Java carrier read
from a JSON numeric array is a primitive-array owner. Byte-availability and
grammar checks still apply independently of graph accounting.
diff --git a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
index 1cbd7fad3..c596eb207 100644
--- a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
+++ b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
@@ -104,6 +104,7 @@ public final class ForyJsonExample {
testInclusion();
testCodecs();
testValueAnnotations();
+ testByteArrayFormats();
testSubtypes();
testSubtypeMixin();
testContainerRoots();
@@ -594,6 +595,53 @@ public final class ForyJsonExample {
json.fromJson("{\"value\":\"AQID\"}", Base64Bytes.class).value, new byte[] {1, 2, 3}));
}
+ private static void testByteArrayFormats() {
+ byte[] bytes = {1, -2, 3};
+ JsonByteArray.Format[] formats = JsonByteArray.Format.values();
+ String[] encodings = {"\"Af4D\"", "[1,-2,3]", "\"01fe03\""};
+ for (int i = 0; i < formats.length; i++) {
+ ForyJson json = ForyJson.builder().byteArrayFormat(formats[i]).build();
+ String encoded = encodings[i];
+ Preconditions.checkArgument(json.toJson(bytes).equals(encoded));
+ Preconditions.checkArgument(
+ new String(json.toJsonBytes(bytes), StandardCharsets.UTF_8).equals(encoded));
+ Preconditions.checkArgument(Arrays.equals(json.fromJson(encoded, byte[].class), bytes));
+ Preconditions.checkArgument(
+ Arrays.equals(json.fromJson(json.toPrettyJsonBytes(bytes), byte[].class), bytes));
+ if (GraalvmSupport.isGraalRuntime()) {
+ CodegenProbeCodec.expect(CodegenProbeModel.class, true, true);
+ CodegenProbeModel model = new CodegenProbeModel();
+ model.bytes = bytes;
+ for (String probe : new String[] {"probe", "汉"}) {
+ model.probe = new CodegenProbeValue(probe);
+ String text = json.toJson(model);
+ Preconditions.checkArgument(text.contains("\"bytes\":" + encoded));
+ Preconditions.checkArgument(
+ new String(json.toJsonBytes(model), StandardCharsets.UTF_8).equals(text));
+ Preconditions.checkArgument(
+ Arrays.equals(json.fromJson(text, CodegenProbeModel.class).bytes, bytes));
+ Preconditions.checkArgument(
+ Arrays.equals(
+ json.fromJson(json.toPrettyJsonBytes(model), CodegenProbeModel.class).bytes,
+ bytes));
+ }
+ }
+ }
+ HexBytes value = new HexBytes();
+ value.value = bytes;
+ for (boolean codegen : new boolean[] {false, true}) {
+ ForyJson json = ForyJson.builder().withCodegen(codegen).withAsyncCompilation(false).build();
+ String encoded = "{\"value\":\"01fe03\"}";
+ Preconditions.checkArgument(json.toJson(value).equals(encoded));
+ Preconditions.checkArgument(
+ new String(json.toJsonBytes(value), StandardCharsets.UTF_8).equals(encoded));
+ Preconditions.checkArgument(
+ Arrays.equals(json.fromJson(encoded, HexBytes.class).value, bytes));
+ Preconditions.checkArgument(
+ Arrays.equals(json.fromJson(json.toPrettyJsonBytes(value), HexBytes.class).value, bytes));
+ }
+ }
+
private static void testSubtypes() {
ForyJson json = ForyJson.builder().build();
Shape value = new Circle(9);
@@ -879,6 +927,14 @@ public final class ForyJsonExample {
default ForyJson nonEmptyConfiguration() {
return ForyJson.builder().defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY).build();
}
+
+ default ForyJson base16Configuration() {
+ return ForyJson.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build();
+ }
+
+ default ForyJson byteArrayConfiguration() {
+ return ForyJson.builder().byteArrayFormat(JsonByteArray.Format.ARRAY).build();
+ }
}
@JsonType
@@ -893,6 +949,7 @@ public final class ForyJsonExample {
@JsonType
public static final class CodegenProbeModel {
public int id;
+ public byte[] bytes;
@JsonCodec(CodegenProbeCodec.class)
public CodegenProbeValue probe;
@@ -1472,6 +1529,12 @@ public final class ForyJsonExample {
public byte[] value;
}
+ @JsonType
+ public static final class HexBytes {
+ @JsonByteArray(JsonByteArray.Format.BASE16)
+ public byte[] value;
+ }
+
@JsonType
public static final class ConfigValue {
public String camelName;
diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt
index 754d82f3f..f42d79e7f 100644
--- a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt
+++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt
@@ -32,6 +32,7 @@ public object PlatformCorpusChecks {
check(text.contains("\"display_label\":\"mixin\""))
check(text.contains("\"numbers\":[1,-2,3]"))
check(text.contains("\"binary\":\"Af4D\""))
+ check(text.contains("\"hex\":\"01fe03\""))
check(text.contains("\"defaultBytes\":\"Af4D\""))
verifyRoot(json.fromJson(text, type))
verifyRoot(json.fromJson(json.toJsonBytes(decoded, type), type))
@@ -48,6 +49,7 @@ public object PlatformCorpusChecks {
check(actual.box == expected.box)
check(actual.numbers.contentEquals(expected.numbers))
check(actual.binary.contentEquals(expected.binary))
+ check(actual.hex.contentEquals(expected.hex))
check(actual.defaultBytes.contentEquals(expected.defaultBytes))
}
}
diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt
index a3c81ac68..1467665cc 100644
--- a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt
+++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt
@@ -125,6 +125,8 @@ public data class PlatformRoot(
public val numbers: ByteArray = byteArrayOf(1, -2, 3),
@get:JsonByteArray(JsonByteArray.Format.BASE64)
public val binary: ByteArray = byteArrayOf(1, -2, 3),
+ @field:JsonByteArray(JsonByteArray.Format.BASE16)
+ public val hex: ByteArray = byteArrayOf(1, -2, 3),
public val defaultBytes: ByteArray = byteArrayOf(1, -2, 3),
)
diff --git a/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KspRetentionResourceTest.kt b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KspRetentionResourceTest.kt
index dd887edcc..2b3760f97 100644
--- a/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KspRetentionResourceTest.kt
+++ b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KspRetentionResourceTest.kt
@@ -89,6 +89,7 @@ public class KspRetentionResourceTest {
root,
)
assertConstructor(root, "org.apache.fory.json.codec.Base64ByteArrayCodec")
+ assertConstructor(root, "org.apache.fory.json.codec.Base16ByteArrayCodec")
assertConstructor(root, "org.apache.fory.json.codec.ArrayCodec\$SignedByteArrayCodec")
assertConstructor(
rules("PlatformDirectOverride"),
diff --git a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java
index 083cb22cd..197916aea 100644
--- a/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java
+++ b/java/fory-annotation-processor/src/main/java/org/apache/fory/annotation/processing/JsonTypeProcessor.java
@@ -575,7 +575,9 @@ final class JsonTypeProcessor {
model.codecTypes.add(
format.getSimpleName().contentEquals("ARRAY")
? JSON_PACKAGE + ".codec.ArrayCodec$SignedByteArrayCodec"
- : BASE64_CODEC);
+ : format.getSimpleName().contentEquals("BASE16")
+ ? JSON_PACKAGE + ".codec.Base16ByteArrayCodec"
+ : BASE64_CODEC);
}
}
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 029396add..323948bb1 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
@@ -51,6 +51,7 @@ import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import org.apache.fory.json.ForyJson;
import org.apache.fory.json.ForyJsonException;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.codec.GeneratedJsonCodec;
import org.apache.fory.json.meta.JsonAnySetterAccessor;
import org.apache.fory.json.meta.JsonFieldAccessor;
@@ -967,6 +968,53 @@ public class JsonTypeProcessorTest {
new Object[] {5, "mutable", extra});
}
+ @Test
+ public void byteArrayMixinPipeline() throws Exception {
+ CompilationResult result =
+ compile(
+ "test.BinaryTarget",
+ "package test;\n"
+ + "import org.apache.fory.json.annotation.*;\n"
+ + "public final class BinaryTarget { public byte[] value; public byte[] other; }\n"
+ + "@JsonMixin(target = BinaryTarget.class) abstract class BinaryMixin {\n"
+ + " @JsonByteArray(JsonByteArray.Format.BASE16) byte[] value;\n"
+ + "}\n");
+ assertTrue(result.success, result.diagnostics());
+ String companion = "test.BinaryMixin_ForyJsonMixin_test_x2e_BinaryTarget_ForyJsonCodec";
+ ClassLoader loader = result.classLoader();
+ GeneratedJsonCodec<?> generated = generatedCodec(loader, companion);
+ Class<?> target = loader.loadClass("test.BinaryTarget");
+ Class<?> mixin = loader.loadClass("test.BinaryMixin");
+ byte[] bytes = {1, -2};
+ Object value = target.getConstructor().newInstance();
+ target.getField("value").set(value, bytes);
+ target.getField("other").set(value, bytes);
+ assertEquals(generated.type(), target);
+ assertEquals(fieldAccessor(generated.fieldAccessors(), "value").getObject(value), bytes);
+ String rules = result.generatedResource(MIXIN_RULE_PREFIX + "test.BinaryMixin.pro");
+ assertTrue(
+ rules.contains(
+ "class org.apache.fory.json.codec.Base16ByteArrayCodec { public <init>(); }"),
+ rules);
+ for (boolean codegen : new boolean[] {false, true}) {
+ ForyJson json =
+ ForyJson.builder()
+ .withCodegen(codegen)
+ .withAsyncCompilation(false)
+ .withClassLoader(loader)
+ .registerMixin(mixin)
+ .byteArrayFormat(JsonByteArray.Format.ARRAY)
+ .build();
+ String text = json.toJson(value);
+ assertTrue(text.contains("\"value\":\"01fe\""), text);
+ assertTrue(text.contains("\"other\":[1,-2]"), text);
+ assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), text);
+ Object decoded = json.fromJson(json.toPrettyJsonBytes(value), target);
+ assertEquals(target.getField("value").get(decoded), bytes);
+ assertEquals(target.getField("other").get(decoded), bytes);
+ }
+ }
+
@Test
public void encodedRecordPipeline() throws Exception {
assumeJava16Source();
@@ -2015,6 +2063,7 @@ public class JsonTypeProcessorTest {
+ " @JsonRawValue public String body;\n"
+ " @JsonByteArray(JsonByteArray.Format.BASE64) public byte[] bytes;\n"
+ " @JsonByteArray(JsonByteArray.Format.ARRAY) public byte[] numbers;\n"
+ + " @JsonByteArray(JsonByteArray.Format.BASE16) public byte[] hex;\n"
+ " private String other;\n"
+ " @JsonRawValue public String getOther() { return other; }\n"
+ " public void setOther(String other) { this.other = other; }\n"
@@ -2032,6 +2081,10 @@ public class JsonTypeProcessorTest {
valueRules.contains("@interface org.apache.fory.json.annotation.JsonRawValue"), valueRules);
String rawRules = result.generatedResource(RULE_PREFIX + "test.RawModel.pro");
+ assertTrue(
+ rawRules.contains(
+ "class org.apache.fory.json.codec.Base16ByteArrayCodec { public <init>(); }"),
+ rawRules);
assertTrue(
rawRules.contains(
"class org.apache.fory.json.codec.ArrayCodec$SignedByteArrayCodec { public <init>(); }"),
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
index 2a0a94a9d..4c6cee91a 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
@@ -24,6 +24,7 @@ import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonMixin;
import org.apache.fory.json.annotation.JsonProperty.Include;
import org.apache.fory.json.codec.JsonValueCodec;
@@ -52,6 +53,7 @@ import org.apache.fory.platform.GraalvmSupport;
public final class ForyJsonBuilder {
private Include defaultPropertyInclusion = Include.NON_NULL;
private boolean writeLongAsString;
+ private JsonByteArray.Format byteArrayFormat = JsonByteArray.Format.BASE64;
private boolean codegenEnabled = true;
private boolean asyncCompilationEnabled = true;
private boolean propertyDiscoveryEnabled = true;
@@ -128,6 +130,22 @@ public final class ForyJsonBuilder {
return this;
}
+ /**
+ * Sets the default representation for {@code byte[]} roots, properties, and container values in
+ * both reading and writing. The default is {@link JsonByteArray.Format#BASE64}.
+ *
+ * <p>{@link JsonByteArray} on a property or its Mixin overrides this setting. Custom occurrence
+ * codecs and language-module unsigned arrays retain their own representation. Base16 writes
+ * lowercase hexadecimal digits without a prefix and accepts either case when reading. Numeric
+ * arrays use signed values in {@code [-128, 127]}. Formats are not detected automatically.
+ *
+ * @throws NullPointerException if format is null
+ */
+ public ForyJsonBuilder byteArrayFormat(JsonByteArray.Format format) {
+ byteArrayFormat = Objects.requireNonNull(format, "format");
+ return this;
+ }
+
/**
* Enables generated object codecs for supported classes. Enabled by default and automatically
* disabled on Android. A GraalVM Native Image includes generated codecs for the default
@@ -346,6 +364,7 @@ public final class ForyJsonBuilder {
return new JsonConfig(
defaultPropertyInclusion,
writeLongAsString,
+ byteArrayFormat,
effectiveCodegen,
effectiveAsyncCompilation,
propertyDiscoveryEnabled,
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
index 3adb638f4..d271e97e4 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
@@ -25,6 +25,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.apache.fory.annotation.Internal;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonProperty.Include;
import org.apache.fory.json.resolver.CodecRegistry;
@@ -39,6 +40,7 @@ public final class JsonConfig {
private final Include defaultPropertyInclusion;
private final boolean writeLongAsString;
+ private final JsonByteArray.Format byteArrayFormat;
private final boolean codegenEnabled;
private final boolean asyncCompilationEnabled;
private final boolean propertyDiscoveryEnabled;
@@ -59,6 +61,7 @@ public final class JsonConfig {
JsonConfig(
Include defaultPropertyInclusion,
boolean writeLongAsString,
+ JsonByteArray.Format byteArrayFormat,
boolean codegenEnabled,
boolean asyncCompilationEnabled,
boolean propertyDiscoveryEnabled,
@@ -76,6 +79,7 @@ public final class JsonConfig {
JsonTypeChecker typeChecker) {
this.defaultPropertyInclusion = defaultPropertyInclusion;
this.writeLongAsString = writeLongAsString;
+ this.byteArrayFormat = Objects.requireNonNull(byteArrayFormat, "byteArrayFormat");
this.codegenEnabled = codegenEnabled;
this.asyncCompilationEnabled = asyncCompilationEnabled;
this.propertyDiscoveryEnabled = propertyDiscoveryEnabled;
@@ -109,6 +113,11 @@ public final class JsonConfig {
return writeLongAsString;
}
+ /** Returns the default byte-array representation for both reading and writing. */
+ public JsonByteArray.Format byteArrayFormat() {
+ return byteArrayFormat;
+ }
+
public boolean codegenEnabled() {
return codegenEnabled;
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonByteArray.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonByteArray.java
index b221093bd..6efd7cc30 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonByteArray.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonByteArray.java
@@ -27,7 +27,8 @@ import java.lang.annotation.Target;
/**
* Selects the JSON representation of one exact {@code byte[]} field or getter for both reading and
- * writing. Unannotated byte arrays use a quoted standard Base64 string.
+ * writing. Overrides {@link org.apache.fory.json.ForyJsonBuilder#byteArrayFormat(Format)}, whose
+ * default is a quoted standard Base64 string.
*
* <p>Null inclusion and omission follow the property's normal configuration, and an included null
* is written as JSON {@code null}. This annotation cannot be combined with {@link JsonCodec} on the
@@ -45,6 +46,8 @@ public @interface JsonByteArray {
/** A quoted standard Base64 string with padding. */
BASE64,
/** A JSON array of signed byte values in the range {@code [-128, 127]}. */
- ARRAY
+ ARRAY,
+ /** A quoted lowercase hexadecimal string. Reading also accepts uppercase hexadecimal digits. */
+ BASE16
}
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java
index 99cfa784c..a560a4a3a 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java
@@ -23,6 +23,7 @@ import java.lang.reflect.Array;
import java.util.Arrays;
import org.apache.fory.annotation.Internal;
import org.apache.fory.json.ForyJsonException;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.reader.JsonReader;
import org.apache.fory.json.reader.Latin1JsonReader;
import org.apache.fory.json.reader.Utf16JsonReader;
@@ -67,11 +68,12 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
Class<?> componentType = arrayType.getComponentType();
TypeRef<?> componentTypeRef = arrayTypeRef.getComponentType();
JsonTypeInfo componentTypeInfo = resolver.getTypeInfo(componentTypeRef);
- return create(arrayType, componentTypeInfo);
+ return create(arrayType, componentTypeInfo, resolver.sharedRegistry().byteArrayFormat());
}
@Internal
- public static <T> JsonValueCodec<T> create(Class<T> arrayType, JsonTypeInfo componentTypeInfo) {
+ public static <T> JsonValueCodec<T> create(
+ Class<T> arrayType, JsonTypeInfo componentTypeInfo, JsonByteArray.Format byteArrayFormat) {
if (!arrayType.isArray()) {
throw new ForyJsonException("Unsupported JSON array type " + arrayType);
}
@@ -91,7 +93,14 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
&& componentCodec == ScalarCodecs.ShortCodec.PRIMITIVE) {
return bind(ShortArrayCodec.INSTANCE);
} else if (componentType == byte.class && componentCodec == ScalarCodecs.ByteCodec.PRIMITIVE) {
- return bind(Base64ByteArrayCodec.INSTANCE);
+ switch (byteArrayFormat) {
+ case ARRAY:
+ return bind(ByteArrayCodec.SIGNED);
+ case BASE16:
+ return bind(Base16ByteArrayCodec.INSTANCE);
+ default:
+ return bind(Base64ByteArrayCodec.INSTANCE);
+ }
} else if (componentType == char.class && componentCodec == ScalarCodecs.CharCodec.PRIMITIVE) {
return bind(CharArrayCodec.INSTANCE);
} else if (componentType == float.class
@@ -1237,10 +1246,30 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
}
public abstract static class ByteArrayCodec extends ArrayCodec<byte[]> {
+ // Built-in byte elements parse directly from input, including quoted scalars. They cannot
+ // borrow the string decode buffer, so arrays may use it until the detached result is copied.
+ private static final long[] SIGNED_TOKENS = decimalTokens(false);
+ private static final long[] UNSIGNED_TOKENS = decimalTokens(true);
+ private static final ByteArrayCodec SIGNED = new SignedByteArrayCodec();
private static final ByteArrayCodec UNSIGNED = new UnsignedByteArrayCodec();
+ private final long[] tokens;
- private ByteArrayCodec() {
+ private ByteArrayCodec(long[] tokens) {
super(byte.class);
+ this.tokens = tokens;
+ }
+
+ private static long[] decimalTokens(boolean unsigned) {
+ long[] tokens = new long[256];
+ for (int i = 0; i < tokens.length; i++) {
+ String text = Integer.toString(unsigned ? i : (byte) i) + ',';
+ long token = (long) text.length() << 56;
+ for (int j = 0; j < text.length(); j++) {
+ token |= (long) text.charAt(j) << (j << 3);
+ }
+ tokens[i] = token;
+ }
+ return tokens;
}
abstract void writeElement(StringJsonWriter writer, byte value);
@@ -1276,10 +1305,34 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
}
byte[] array = value;
writer.writeArrayStart();
- for (int i = 0; i < array.length; i++) {
- writer.writeComma(i);
- writeElement(writer, array[i]);
+ if (writer.prettyPrint()) {
+ for (int i = 0; i < array.length; i++) {
+ writer.writeComma(i);
+ writeElement(writer, array[i]);
+ }
+ writer.writeArrayEnd();
+ return;
}
+ byte[] bytes = writer.getBuffer();
+ int pos = writer.getPosition();
+ long[] tokens = this.tokens;
+ int i = 0;
+ while (i < array.length) {
+ int end = i + Math.min(64, array.length - i);
+ // Five bytes cover a signed byte and comma; the last wide store needs seven spare bytes.
+ int additional = (end - i) * 5 + Long.BYTES - 1;
+ if (additional > bytes.length - pos) {
+ writer.setPosition(pos);
+ writer.grow(additional);
+ bytes = writer.getBuffer();
+ }
+ for (; i < end; i++) {
+ long token = tokens[array[i] & 255];
+ LittleEndian.putInt64(bytes, pos, token);
+ pos += (int) (token >>> 56);
+ }
+ }
+ writer.setPosition(array.length == 0 ? pos : pos - 1);
writer.writeArrayEnd();
}
@@ -1294,15 +1347,14 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
finishPrimitiveArray(reader, 0, Byte.BYTES);
return new byte[0];
}
- byte[] values = new byte[8];
+ byte[] values = reader.getStringDecodeBuffer();
int size = 0;
do {
- rejectNull(reader);
if ((size & ARRAY_BATCH_MASK) == ARRAY_BATCH_MASK) {
reader.reserveGraphMemory(ARRAY_BATCH_SIZE * Byte.BYTES);
}
if (size == values.length) {
- values = Arrays.copyOf(values, values.length << 1);
+ values = Arrays.copyOf(values, Math.max(8, values.length << 1));
}
values[size++] = readElement(reader);
} while (reader.consumeNextToken(','));
@@ -1322,15 +1374,14 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
finishPrimitiveArray(reader, 0, Byte.BYTES);
return new byte[0];
}
- byte[] values = new byte[8];
+ byte[] values = reader.getStringDecodeBuffer();
int size = 0;
do {
- rejectNull(reader);
if ((size & ARRAY_BATCH_MASK) == ARRAY_BATCH_MASK) {
reader.reserveGraphMemory(ARRAY_BATCH_SIZE * Byte.BYTES);
}
if (size == values.length) {
- values = Arrays.copyOf(values, values.length << 1);
+ values = Arrays.copyOf(values, Math.max(8, values.length << 1));
}
values[size++] = readElement(reader);
} while (reader.consumeNextToken(','));
@@ -1350,15 +1401,14 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
finishPrimitiveArray(reader, 0, Byte.BYTES);
return new byte[0];
}
- byte[] values = new byte[8];
+ byte[] values = reader.getStringDecodeBuffer();
int size = 0;
do {
- rejectNull(reader);
if ((size & ARRAY_BATCH_MASK) == ARRAY_BATCH_MASK) {
reader.reserveGraphMemory(ARRAY_BATCH_SIZE * Byte.BYTES);
}
if (size == values.length) {
- values = Arrays.copyOf(values, values.length << 1);
+ values = Arrays.copyOf(values, Math.max(8, values.length << 1));
}
values[size++] = readElement(reader);
} while (reader.consumeNextToken(','));
@@ -1370,7 +1420,9 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
/** A complete {@code byte[]} codec using a JSON array of signed byte values. */
public static final class SignedByteArrayCodec extends ByteArrayCodec {
- public SignedByteArrayCodec() {}
+ public SignedByteArrayCodec() {
+ super(ByteArrayCodec.SIGNED_TOKENS);
+ }
@Override
void writeElement(StringJsonWriter writer, byte value) {
@@ -1399,6 +1451,10 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
}
private static final class UnsignedByteArrayCodec extends ByteArrayCodec {
+ private UnsignedByteArrayCodec() {
+ super(ByteArrayCodec.UNSIGNED_TOKENS);
+ }
+
@Override
void writeElement(StringJsonWriter writer, byte value) {
writer.writeInt(Byte.toUnsignedInt(value));
@@ -3738,30 +3794,6 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
writer.writeNull();
}
- private static void rejectNull(JsonReader reader) {
- if (reader.tryReadNull()) {
- throw new ForyJsonException("Cannot read null into primitive array element");
- }
- }
-
- private static void rejectNull(Latin1JsonReader reader) {
- if (reader.tryReadNullToken()) {
- throw new ForyJsonException("Cannot read null into primitive array element");
- }
- }
-
- private static void rejectNull(Utf16JsonReader reader) {
- if (reader.tryReadNullToken()) {
- throw new ForyJsonException("Cannot read null into primitive array element");
- }
- }
-
- private static void rejectNull(Utf8JsonReader reader) {
- if (reader.tryReadNullToken()) {
- throw new ForyJsonException("Cannot read null into primitive array element");
- }
- }
-
private static short readShort(int value) {
if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
throw new ForyJsonException("Short overflow");
@@ -3770,7 +3802,7 @@ public abstract class ArrayCodec<T> implements JsonValueCodec<T> {
}
private static byte readByte(int value) {
- if (value < Byte.MIN_VALUE || value > Byte.MAX_VALUE) {
+ if (value != (byte) value) {
throw new ForyJsonException("Byte overflow");
}
return (byte) value;
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/Base16ByteArrayCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/Base16ByteArrayCodec.java
new file mode 100644
index 000000000..46f803d82
--- /dev/null
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/Base16ByteArrayCodec.java
@@ -0,0 +1,66 @@
+/*
+ * 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 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;
+
+/** A complete {@code byte[]} codec using a quoted lowercase hexadecimal JSON string. */
+public final class Base16ByteArrayCodec implements JsonValueCodec<byte[]> {
+ public static final Base16ByteArrayCodec INSTANCE = new Base16ByteArrayCodec();
+
+ public Base16ByteArrayCodec() {}
+
+ @Override
+ public void writeString(StringJsonWriter writer, byte[] value) {
+ if (value == null) {
+ writer.writeNull();
+ } else {
+ writer.writeBase16(value);
+ }
+ }
+
+ @Override
+ public void writeUtf8(Utf8JsonWriter writer, byte[] value) {
+ if (value == null) {
+ writer.writeNull();
+ } else {
+ writer.writeBase16(value);
+ }
+ }
+
+ @Override
+ public byte[] readLatin1(Latin1JsonReader reader) {
+ return reader.readBase16();
+ }
+
+ @Override
+ public byte[] readUtf16(Utf16JsonReader reader) {
+ return reader.readBase16();
+ }
+
+ @Override
+ public byte[] readUtf8(Utf8JsonReader reader) {
+ return reader.readBase16();
+ }
+}
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 dfc9ba2a8..fd7e2a388 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
@@ -3573,7 +3573,9 @@ final class ObjectCodecBuilder {
Class<? extends JsonValueCodec<?>> codecClass =
byteArray.value() == JsonByteArray.Format.ARRAY
? ArrayCodec.SignedByteArrayCodec.class
- : Base64ByteArrayCodec.class;
+ : byteArray.value() == JsonByteArray.Format.BASE16
+ ? Base16ByteArrayCodec.class
+ : Base64ByteArrayCodec.class;
if (valueCodecClass != null && valueCodecClass != codecClass) {
throw new ForyJsonException(
"Conflicting @JsonByteArray declarations for property " + name);
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 cc9a99052..62841c326 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
@@ -523,6 +523,81 @@ public abstract class JsonReader {
}
}
+ /** Reads a nullable hexadecimal JSON string, accepting uppercase and lowercase digits. */
+ public byte[] readBase16() {
+ if (tryReadNullToken()) {
+ return null;
+ }
+ int start = position + 1;
+ int end = scanStringEnd(position) - 1;
+ position = end + 1;
+ int length = end - start;
+ if (length == 0) {
+ return EMPTY_BYTES;
+ }
+ if ((length & 1) != 0) {
+ return decodeBase16(decodeQuotedText(start, end));
+ }
+ byte[] decoded = new byte[length >>> 1];
+ for (int i = 0; i < decoded.length; i++) {
+ char first = charAt(start + (i << 1));
+ char second = charAt(start + (i << 1) + 1);
+ if ((first | second) > 255) {
+ throw error("Invalid hex digit");
+ }
+ int value = HEX_PAIRS[first | (second << 8)];
+ if (value < 0) {
+ return decodeBase16(decodeQuotedText(start, end));
+ }
+ decoded[i] = (byte) value;
+ }
+ return decoded;
+ }
+
+ /** Decodes a proven quoted byte range directly; escaped strings use the common decoder. */
+ protected final byte[] readBase16(byte[] bytes) {
+ if (tryReadNullToken()) {
+ return null;
+ }
+ int start = position + 1;
+ int end = scanStringEnd(position) - 1;
+ position = end + 1;
+ int length = end - start;
+ if (length == 0) {
+ return EMPTY_BYTES;
+ }
+ if ((length & 1) != 0) {
+ return decodeBase16(decodeQuotedText(start, end));
+ }
+ // The scan proves the complete source range before allocating binary storage.
+ byte[] decoded = new byte[length >>> 1];
+ short[] pairs = HEX_PAIRS;
+ for (int i = 0; i < decoded.length; i++) {
+ int offset = start + (i << 1);
+ int value = pairs[(bytes[offset] & 255) | ((bytes[offset + 1] & 255) << 8)];
+ if (value < 0) {
+ return decodeBase16(decodeQuotedText(start, end));
+ }
+ decoded[i] = (byte) value;
+ }
+ return decoded;
+ }
+
+ private byte[] decodeBase16(CharSequence encoded) {
+ int encodedLength = encoded.length();
+ if ((encodedLength & 1) != 0) {
+ throw error("Invalid Base16 JSON string length");
+ }
+ if (encodedLength == 0) {
+ return EMPTY_BYTES;
+ }
+ byte[] decoded = new byte[encodedLength >>> 1];
+ for (int i = 0, j = 0; i < decoded.length; i++, j += 2) {
+ decoded[i] = (byte) ((hexValue(encoded.charAt(j)) << 4) | hexValue(encoded.charAt(j + 1)));
+ }
+ return decoded;
+ }
+
public String readCharSequence() {
return readString();
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java
index ac6c5b48f..86c453613 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java
@@ -164,6 +164,11 @@ public final class Latin1JsonReader extends JsonReader {
throw errorAt("Unterminated string", cursor);
}
+ @Override
+ public byte[] readBase16() {
+ return readBase16(input);
+ }
+
@Override
protected long scanStringHash(int start, int end) {
long hash = JsonFieldNameHash.MAGIC_HASH_CODE;
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java
index 1545751d4..828deeee7 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java
@@ -221,6 +221,11 @@ public final class Utf8JsonReader extends JsonReader {
throw errorAt("Unterminated string", cursor);
}
+ @Override
+ public byte[] readBase16() {
+ return readBase16(input);
+ }
+
@Override
protected long scanStringHash(int start, int end) {
long hash = JsonFieldNameHash.MAGIC_HASH_CODE;
@@ -928,13 +933,13 @@ public final class Utf8JsonReader extends JsonReader {
offset++;
int safeEnd = Math.min(offset + 8, inputLimit);
while (offset < safeEnd) {
- ch = bytes[offset];
- if (ch < '0' || ch > '9') {
+ int digit = bytes[offset] - '0';
+ if (Integer.compareUnsigned(digit, 9) > 0) {
// A non-digit already ends the magnitude; only exhausting the digit bound needs the tail.
position = offset;
return negative ? -result : result;
}
- result = result * 10 + (ch - '0');
+ result = result * 10 + digit;
offset++;
}
if (offset < inputLimit) {
@@ -4797,7 +4802,7 @@ public final class Utf8JsonReader extends JsonReader {
private void rejectFractionOrExponentFast() {
if (position < inputLimit) {
int ch = input[position];
- if (ch == '.' || ch == 'e' || ch == 'E') {
+ if (ch == '.' || (ch | 0x20) == 'e') {
throw error("Expected integer");
}
}
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
index d6c1c8f01..1d7c3689e 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
@@ -77,6 +77,8 @@ final class GeneratedCodecKeyBuilder {
keyParts.add(registry.defaultPropertyInclusion());
keyParts.add(registry.writeLongAsString());
}
+ // Both readers and writers store concrete byte-array codecs selected by this default.
+ keyParts.add(registry.byteArrayFormat());
keyParts.add(registry.propertyDiscoveryEnabled());
keyParts.add(registry.propertyNamingStrategy());
addModelInputs(typeInfo, owner.unwrappedInfo(), registry);
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
index 940370af3..2cbd98e27 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
@@ -102,6 +102,7 @@ import org.apache.fory.json.JsonConfig;
import org.apache.fory.json.JsonTypeCheckContext;
import org.apache.fory.json.JsonTypeChecker;
import org.apache.fory.json.PropertyNamingStrategy;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonCodec;
import org.apache.fory.json.annotation.JsonProperty.Include;
import org.apache.fory.json.annotation.JsonSubTypes;
@@ -189,6 +190,7 @@ public final class JsonSharedRegistry {
private final PropertyNamingStrategy propertyNamingStrategy;
private final Include defaultPropertyInclusion;
private final boolean writeLongAsString;
+ private final JsonByteArray.Format byteArrayFormat;
private final ClassLoader classLoader;
private final JsonMixinAnnotations mixinAnnotations;
private final IdentityHashMap<Class<?>, JsonSubTypesInfo> subTypesCache;
@@ -246,6 +248,7 @@ public final class JsonSharedRegistry {
propertyNamingStrategy = config.propertyNamingStrategy();
defaultPropertyInclusion = config.defaultPropertyInclusion();
writeLongAsString = config.writeLongAsString();
+ byteArrayFormat = config.byteArrayFormat();
classLoader = config.classLoader();
mixinAnnotations = new JsonMixinAnnotations(config);
exactCodecs = new IdentityHashMap<>();
@@ -1227,6 +1230,12 @@ public final class JsonSharedRegistry {
return writeLongAsString;
}
+ /** Returns the default representation used when selecting ordinary byte-array codecs. */
+ @Internal
+ public JsonByteArray.Format byteArrayFormat() {
+ return byteArrayFormat;
+ }
+
ClassLoader classLoader() {
return classLoader;
}
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 4a5f8c440..bec684578 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
@@ -558,7 +558,8 @@ public final class JsonTypeResolver {
TypeRef<?> elementType = typeRef.getComponentType();
requireConcreteChild(elementType.getType(), rawType, "elementCodec");
JsonTypeInfo elementInfo = annotationTypeInfo(elementType, elementCodec);
- return newTypeInfo(declaredType, ArrayCodec.create(rawType, elementInfo));
+ return newTypeInfo(
+ declaredType, ArrayCodec.create(rawType, elementInfo, sharedRegistry.byteArrayFormat()));
}
if (rawType == AtomicReferenceArray.class) {
requireSlots(rawType, hasElement, !hasContent && !hasKey && !hasMapValue, "elementCodec");
@@ -643,7 +644,8 @@ public final class JsonTypeResolver {
TypeRef<?> elementType = typeRef.getComponentType();
requireConcreteChild(elementType.getType(), rawType, "element", "@JsonFormat");
JsonTypeInfo elementInfo = formatTypeInfo(elementType, annotation);
- return newTypeInfo(declaredType, ArrayCodec.create(rawType, elementInfo));
+ return newTypeInfo(
+ declaredType, ArrayCodec.create(rawType, elementInfo, sharedRegistry.byteArrayFormat()));
}
if (rawType == AtomicReferenceArray.class) {
TypeRef<?> elementType = directElementType(typeRef, rawType, "element", "@JsonFormat");
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java
index 2e1ce6791..b4039b4f9 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java
@@ -57,6 +57,22 @@ import org.apache.fory.json.resolver.JsonTypeResolver;
* reparsing.
*/
public abstract class JsonWriter {
+ // Initialized only for Base16 writes, and shared by both output representations.
+ protected static final class HexDigits {
+ static final int[] QUADS = new int[65536];
+
+ static {
+ String digits = "0123456789abcdef";
+ for (int i = 0; i < QUADS.length; i++) {
+ QUADS[i] =
+ digits.charAt((i >>> 4) & 15)
+ | (digits.charAt(i & 15) << 8)
+ | (digits.charAt(i >>> 12) << 16)
+ | (digits.charAt((i >>> 8) & 15) << 24);
+ }
+ }
+ }
+
private static final long MIN_ISO_INSTANT_SECOND = -31_557_014_167_219_200L;
private static final long MAX_ISO_INSTANT_SECOND = 31_556_889_864_403_199L;
private final JsonTypeResolver typeResolver;
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java
index f08506216..f681dba21 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java
@@ -80,6 +80,7 @@ public final class StringJsonWriter extends JsonWriter implements Appendable {
private static final long DECIMAL_8 = 100_000_000L;
private static final int[] DIGIT_TRIPLES = new int[1000];
private static final int[] DIGIT_QUADS = new int[10000];
+ private static final int[] HEX_PAIRS = new int[256];
private static final long[] UTF16_DIGIT_QUADS = new long[10000];
private static final long UTF16_BYTE_MASK = 0x00FF00FF00FF00FFL;
private static final long UTF16_PAIR_MASK = 0x0000FFFF0000FFFFL;
@@ -96,6 +97,10 @@ public final class StringJsonWriter extends JsonWriter implements Appendable {
private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN;
static {
+ String hexDigits = "0123456789abcdef";
+ for (int i = 0; i < HEX_PAIRS.length; i++) {
+ HEX_PAIRS[i] = hexDigits.charAt(i >>> 4) | (hexDigits.charAt(i & 15) << 8);
+ }
for (int i = 0; i < 1000; i++) {
int c0 = '0' + i / 100;
int c1 = '0' + (i / 10) % 10;
@@ -1560,6 +1565,54 @@ public final class StringJsonWriter extends JsonWriter implements Appendable {
writeRawUtf16Value(index == 0 ? utf16NamePrefix : utf16CommaNamePrefix);
}
+ /** Writes a byte array as a quoted lowercase hexadecimal string without intermediate storage. */
+ public void writeBase16(byte[] value) {
+ int pos = position;
+ long additional = (value.length * 2L + 2) << coder;
+ if (additional > Integer.MAX_VALUE - (long) pos) {
+ throw new ForyJsonException("Byte array is too large for Base16 JSON output");
+ }
+ if (pos + additional > buffer.length) {
+ grow((int) additional);
+ }
+ byte[] target = buffer;
+ int[] words = HexDigits.QUADS;
+ if (coder == LATIN1) {
+ target[pos++] = '"';
+ int index = 0;
+ for (; index <= value.length - 2; index += 2) {
+ int bits = (value[index] & 0xff) | ((value[index + 1] & 0xff) << 8);
+ LittleEndian.putInt32(target, pos, words[bits]);
+ pos += 4;
+ }
+ if (index < value.length) {
+ int pair = HEX_PAIRS[value[index] & 0xff];
+ target[pos++] = (byte) pair;
+ target[pos++] = (byte) (pair >>> 8);
+ }
+ target[pos++] = '"';
+ } else {
+ pos = putUtf16Byte(target, pos, (byte) '"');
+ int index = 0;
+ for (; index <= value.length - 2; index += 2) {
+ int bits = (value[index] & 0xff) | ((value[index + 1] & 0xff) << 8);
+ long chars = words[bits] & 0xffffffffL;
+ chars = (chars | (chars << 16)) & 0x0000ffff0000ffffL;
+ chars = (chars | (chars << 8)) & 0x00ff00ff00ff00ffL;
+ LittleEndian.putInt64(target, pos, LITTLE_ENDIAN ? chars : chars << 8);
+ pos += 8;
+ }
+ if (index < value.length) {
+ int pair = HEX_PAIRS[value[index] & 0xff];
+ int chars = (pair & 0xff) | ((pair & 0xff00) << 8);
+ LittleEndian.putInt32(target, pos, LITTLE_ENDIAN ? chars : chars << 8);
+ pos += 4;
+ }
+ pos = putUtf16Byte(target, pos, (byte) '"');
+ }
+ position = pos;
+ }
+
/** Writes a byte array as a quoted Base64 JSON string without an intermediate String. */
public void writeBase64(byte[] value) {
int encodedLength = base64Length(value.length);
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java
index 168800aa9..fdfb0e332 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java
@@ -1924,6 +1924,34 @@ public final class Utf8JsonWriter extends JsonWriter implements Appendable {
}
}
+ /** Writes a byte array as a quoted lowercase hexadecimal string without intermediate storage. */
+ public void writeBase16(byte[] value) {
+ int pos = position;
+ long additional = value.length * 2L + 2;
+ if (additional > Integer.MAX_VALUE - (long) pos) {
+ throw new ForyJsonException("Byte array is too large for Base16 JSON output");
+ }
+ if (pos + additional > buffer.length) {
+ grow((int) additional);
+ }
+ byte[] target = buffer;
+ target[pos++] = '"';
+ int[] words = HexDigits.QUADS;
+ int index = 0;
+ for (; index <= value.length - 2; index += 2) {
+ int bits = (value[index] & 0xff) | ((value[index + 1] & 0xff) << 8);
+ LittleEndian.putInt32(target, pos, words[bits]);
+ pos += 4;
+ }
+ if (index < value.length) {
+ int pair = HEX_PAIRS[value[index] & 0xff];
+ target[pos++] = (byte) pair;
+ target[pos++] = (byte) (pair >>> 8);
+ }
+ target[pos++] = '"';
+ position = pos;
+ }
+
/** Writes a byte array as a quoted Base64 JSON string without an intermediate String. */
public void writeBase64(byte[] value) {
int encodedLength = base64Length(value.length);
diff --git a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
index d5f986899..e575bda4c 100644
--- a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
+++ b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java
@@ -61,6 +61,7 @@ import org.apache.fory.json.annotation.JsonUnwrapped;
import org.apache.fory.json.annotation.JsonValidator;
import org.apache.fory.json.annotation.JsonValue;
import org.apache.fory.json.codec.ArrayCodec;
+import org.apache.fory.json.codec.Base16ByteArrayCodec;
import org.apache.fory.json.codec.Base64ByteArrayCodec;
import org.apache.fory.json.codec.JsonUnwrappedInfo;
import org.apache.fory.json.codec.ObjectCodec;
@@ -1005,7 +1006,9 @@ final class ForyJsonGraalVMFeature implements Feature {
registerCodec(
byteArray.value() == JsonByteArray.Format.ARRAY
? ArrayCodec.SignedByteArrayCodec.class
- : Base64ByteArrayCodec.class);
+ : byteArray.value() == JsonByteArray.Format.BASE16
+ ? Base16ByteArrayCodec.class
+ : Base64ByteArrayCodec.class);
}
}
diff --git a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
index a14675575..58f794d23 100644
--- a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
+++ b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
@@ -29,6 +29,7 @@ Args=--features=org.apache.fory.json.ForyJsonGraalVMFeature \
org.apache.fory.json.JsonConfig,\
org.apache.fory.json.JsonTypeCheckContext,\
org.apache.fory.json.PropertyNamingStrategy,\
+ org.apache.fory.json.annotation.JsonByteArray$Format,\
org.apache.fory.json.annotation.JsonProperty$Include,\
org.apache.fory.util.function.ObjBooleanConsumer,\
org.apache.fory.util.function.ObjByteConsumer,\
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
index 02fa3a501..1bb3d669a 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
@@ -63,6 +63,7 @@ public final class ForyJsonGraalVMFeatureJarVerifier {
+ "org.apache.fory.json.JsonConfig,"
+ "org.apache.fory.json.JsonTypeCheckContext,"
+ "org.apache.fory.json.PropertyNamingStrategy,"
+ + "org.apache.fory.json.annotation.JsonByteArray$Format,"
+ "org.apache.fory.json.annotation.JsonProperty$Include,"
+ "org.apache.fory.util.function.ObjBooleanConsumer,"
+ "org.apache.fory.util.function.ObjByteConsumer,"
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
index 4b9126779..62cd018fd 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
@@ -57,6 +57,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.fory.collection.IdentityMap;
import org.apache.fory.json.annotation.JsonAnyProperty;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonCodec;
import org.apache.fory.json.annotation.JsonCreator;
import org.apache.fory.json.annotation.JsonProperty.Include;
@@ -1366,6 +1367,7 @@ public class JsonAsyncCompilationTest {
new JsonConfig(
Include.NON_NULL,
false,
+ JsonByteArray.Format.BASE64,
true,
true,
true,
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonByteArrayAnnotationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonByteArrayAnnotationTest.java
index a069b443f..2693a9c71 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonByteArrayAnnotationTest.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonByteArrayAnnotationTest.java
@@ -24,6 +24,10 @@ import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertThrows;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
import java.util.Map;
import org.apache.fory.json.annotation.JsonAnyGetter;
import org.apache.fory.json.annotation.JsonAnyProperty;
@@ -31,11 +35,13 @@ import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonCodec;
import org.apache.fory.json.annotation.JsonCreator;
import org.apache.fory.json.annotation.JsonIgnore;
+import org.apache.fory.json.annotation.JsonMixin;
import org.apache.fory.json.annotation.JsonProperty;
import org.apache.fory.json.annotation.JsonPropertyOrder;
import org.apache.fory.json.annotation.JsonRawValue;
import org.apache.fory.json.codec.Base64ByteArrayCodec;
import org.apache.fory.platform.JdkVersion;
+import org.apache.fory.reflect.TypeRef;
import org.testng.SkipException;
import org.testng.annotations.Factory;
import org.testng.annotations.Test;
@@ -46,6 +52,186 @@ public class JsonByteArrayAnnotationTest extends ForyJsonTestModels {
super(codegen);
}
+ @Test
+ public void globalFormats() {
+ byte[] bytes = {0, 127, -128, -1};
+ String[] encodings = {"\"AH+A/w==\"", "\"007f80ff\"", "[0,127,-128,-1]"};
+ JsonByteArray.Format[] formats = {
+ JsonByteArray.Format.BASE64, JsonByteArray.Format.BASE16, JsonByteArray.Format.ARRAY
+ };
+ ForyJsonBuilder builder = newJsonBuilder();
+ ForyJson defaultJson = builder.build();
+ for (int i = 0; i < formats.length; i++) {
+ ForyJson json = builder.byteArrayFormat(formats[i]).build();
+ String encoded = encodings[i];
+ assertEquals(json.toJson(bytes), encoded);
+ assertEquals(json.toJson(bytes, TypeRef.of(Object.class)), encoded);
+ assertEquals(new String(json.toJsonBytes(bytes), StandardCharsets.UTF_8), encoded);
+ assertEquals(json.fromJson(encoded, byte[].class), bytes);
+ assertEquals(json.fromJson(encoded.getBytes(StandardCharsets.UTF_8), byte[].class), bytes);
+ assertNull(json.fromJson("null", byte[].class));
+ assertNull(json.fromJson("null".getBytes(StandardCharsets.UTF_8), byte[].class));
+
+ BinaryValues value = new BinaryValues();
+ value.bytes = bytes;
+ value.list = Arrays.asList(bytes, null);
+ value.map = Collections.singletonMap("data", bytes);
+ value.nested = new byte[][] {bytes};
+ String expected =
+ "{\"label\":\"汉\",\"bytes\":"
+ + encoded
+ + ",\"list\":["
+ + encoded
+ + ",null],\"map\":{\"data\":"
+ + encoded
+ + "},\"nested\":["
+ + encoded
+ + "]}";
+ assertEquals(json.toJson(value), expected);
+ assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), expected);
+ String pretty = json.toPrettyJson(value);
+ assertEquals(new String(json.toPrettyJsonBytes(value), StandardCharsets.UTF_8), pretty);
+ for (String text : new String[] {expected, pretty}) {
+ assertBinaryValues(json.fromJson(text, BinaryValues.class), bytes);
+ assertBinaryValues(
+ json.fromJson(text.getBytes(StandardCharsets.UTF_8), BinaryValues.class), bytes);
+ }
+ TypeRef<List<byte[]>> listType = new TypeRef<List<byte[]>>() {};
+ assertEquals(json.toJson(value.list, listType), "[" + encoded + ",null]");
+ assertEquals(json.fromJson("[" + encoded + ",null]", listType).get(0), bytes);
+ TypeRef<Map<String, byte[]>> mapType = new TypeRef<Map<String, byte[]>>() {};
+ assertEquals(json.toJson(value.map, mapType), "{\"data\":" + encoded + "}");
+ assertEquals(json.fromJson("{\"data\":" + encoded + "}", mapType).get("data"), bytes);
+ assertGeneratedWhenSupported(json, BinaryValues.class, codegenEnabled());
+ }
+ assertEquals(defaultJson.toJson(bytes), encodings[0]);
+ assertThrows(NullPointerException.class, () -> builder.byteArrayFormat(null));
+ }
+
+ private static void assertBinaryValues(BinaryValues value, byte[] bytes) {
+ assertEquals(value.label, "汉");
+ assertEquals(value.bytes, bytes);
+ assertEquals(value.list.get(0), bytes);
+ assertNull(value.list.get(1));
+ assertEquals(value.map.get("data"), bytes);
+ assertEquals(value.nested[0], bytes);
+ }
+
+ @Test
+ public void base16Overrides() {
+ ForyJson json = newJsonBuilder().byteArrayFormat(JsonByteArray.Format.BASE16).build();
+ Base64Field base64 = new Base64Field();
+ base64.bytes = new byte[] {1, -2};
+ assertEquals(json.toJson(base64), "{\"bytes\":\"Af4=\"}");
+ assertEquals(json.fromJson(json.toJsonBytes(base64), Base64Field.class).bytes, base64.bytes);
+ ArrayField array = new ArrayField();
+ array.bytes = base64.bytes;
+ assertEquals(json.toJson(array), "{\"bytes\":[1,-2]}");
+ assertEquals(json.fromJson(json.toJsonBytes(array), ArrayField.class).bytes, base64.bytes);
+ DirectCodecBase64 custom = new DirectCodecBase64();
+ custom.bytes = base64.bytes;
+ assertEquals(json.toJson(custom), "{\"bytes\":\"Af4=\"}");
+
+ ForyJson mixinJson =
+ newJsonBuilder()
+ .byteArrayFormat(JsonByteArray.Format.ARRAY)
+ .registerMixin(HexMixin.class)
+ .build();
+ assertEquals(mixinJson.toJson(base64), "{\"bytes\":\"01fe\"}");
+ assertEquals(
+ mixinJson.fromJson(mixinJson.toJsonBytes(base64), Base64Field.class).bytes, base64.bytes);
+ assertGeneratedWhenSupported(mixinJson, Base64Field.class, codegenEnabled());
+
+ HexField hex = new HexField();
+ hex.bytes = base64.bytes;
+ assertEquals(newJson().toJson(hex), "{\"bytes\":\"01fe\"}");
+ hex.bytes = null;
+ assertEquals(newJson().toJson(hex), "{}");
+ assertEquals(newJsonBuilder().writeNullFields(true).build().toJson(hex), "{\"bytes\":null}");
+ }
+
+ @Test
+ public void base16Contents() {
+ ForyJson json = newJsonBuilder().byteArrayFormat(JsonByteArray.Format.BASE16).build();
+ for (int size : new int[] {0, 1, 2, 3, 4, 7, 8, 9, 255, 256, 257, 1025, 131072}) {
+ byte[] bytes = new byte[size];
+ StringBuilder expected = new StringBuilder("\"");
+ for (int i = 0; i < size; i++) {
+ // Cover every two-byte combination as well as odd tails and buffer growth.
+ int value = size == 131072 ? (i >>> ((i & 1) == 0 ? 1 : 9)) & 255 : i & 255;
+ bytes[i] = (byte) value;
+ expected.append(Character.forDigit(value >>> 4, 16));
+ expected.append(Character.forDigit(value & 15, 16));
+ }
+ String text = expected.append('"').toString();
+ assertEquals(json.toJson(bytes), text);
+ assertEquals(new String(json.toJsonBytes(bytes), StandardCharsets.UTF_8), text);
+ for (String input : new String[] {text, text.toUpperCase(Locale.ROOT)}) {
+ assertEquals(json.fromJson(input, byte[].class), bytes);
+ assertEquals(json.fromJson(input.getBytes(StandardCharsets.UTF_8), byte[].class), bytes);
+ }
+ // The prefix widens String output and forces UTF16 input before the binary value.
+ String unicode = "{\"label\":\"汉\",\"bytes\":" + text + "}";
+ UnicodeHex value = new UnicodeHex();
+ value.bytes = bytes;
+ assertEquals(json.toJson(value), unicode);
+ assertEquals(json.fromJson(unicode, UnicodeHex.class).bytes, bytes);
+ }
+ for (String escaped : new String[] {"\"0\\u0061Ff\"", "\"\\u0030\\u0061Ff\""}) {
+ assertEquals(json.fromJson(escaped, byte[].class), new byte[] {10, -1});
+ assertEquals(
+ json.fromJson(escaped.getBytes(StandardCharsets.UTF_8), byte[].class),
+ new byte[] {10, -1});
+ String unicode = "{\"label\":\"汉\",\"bytes\":" + escaped + "}";
+ assertEquals(json.fromJson(unicode, UnicodeHex.class).bytes, new byte[] {10, -1});
+ assertEquals(
+ json.fromJson(unicode.getBytes(StandardCharsets.UTF_8), UnicodeHex.class).bytes,
+ new byte[] {10, -1});
+ }
+ for (String input :
+ new String[] {
+ "\"0\"", "\"gg\"", "\"0g\"", "\"汉0\"", "\"0 00\"", "\"00", "[0]", "\"\\u0030\""
+ }) {
+ assertThrows(ForyJsonException.class, () -> json.fromJson(input, byte[].class));
+ assertThrows(
+ ForyJsonException.class,
+ () -> json.fromJson(input.getBytes(StandardCharsets.UTF_8), byte[].class));
+ assertEquals(json.fromJson("\"00\"", byte[].class), new byte[] {0});
+ }
+ ForyJson leaf =
+ newJsonBuilder()
+ .byteArrayFormat(JsonByteArray.Format.BASE16)
+ .withMaxGraphMemoryBytes(1)
+ .build();
+ assertEquals(leaf.fromJson(leaf.toJsonBytes(new byte[1024]), byte[].class), new byte[1024]);
+ }
+
+ @JsonPropertyOrder({"label", "bytes", "list", "map", "nested"})
+ public static final class BinaryValues {
+ public String label = "汉";
+ public byte[] bytes;
+ public List<byte[]> list;
+ public Map<String, byte[]> map;
+ public byte[][] nested;
+ }
+
+ public static final class HexField {
+ @JsonByteArray(JsonByteArray.Format.BASE16)
+ public byte[] bytes;
+ }
+
+ @JsonPropertyOrder({"label", "bytes"})
+ public static final class UnicodeHex {
+ public String label = "汉";
+ public byte[] bytes;
+ }
+
+ @JsonMixin(target = Base64Field.class)
+ public abstract static class HexMixin {
+ @JsonByteArray(JsonByteArray.Format.BASE16)
+ public byte[] bytes;
+ }
+
@Test
public void arrayRoundTrip() {
ForyJson json = newJson();
@@ -82,6 +268,56 @@ public class JsonByteArrayAnnotationTest extends ForyJsonTestModels {
assertEquals(json.fromJson("{\"bytes\":[1,-2]}", ArrayGetter.class).bytes, value.bytes);
}
+ @Test
+ public void arrayContents() {
+ ForyJson json = newJsonBuilder().byteArrayFormat(JsonByteArray.Format.ARRAY).build();
+ for (int size : new int[] {0, 7, 8, 9, 1023, 1024, 1025, 4097}) {
+ byte[] expected = new byte[size];
+ StringBuilder array = new StringBuilder("[");
+ for (int i = 0; i < size; i++) {
+ expected[i] = (byte) i;
+ if (i != 0) {
+ array.append(",\n");
+ }
+ array.append('"').append(expected[i]).append('"');
+ }
+ String text = array.append(']').toString();
+ String compact = Arrays.toString(expected).replace(" ", "");
+ assertEquals(new String(json.toJsonBytes(expected), StandardCharsets.UTF_8), compact);
+ assertEquals(json.fromJson(json.toPrettyJsonBytes(expected), byte[].class), expected);
+ byte[] first = json.fromJson(text, byte[].class);
+ byte[] second = json.fromJson(text.getBytes(StandardCharsets.UTF_8), byte[].class);
+ String nested = "{\"ignored\":\"汉\\u6c49\",\"bytes\":" + text + "}";
+ assertEquals(json.fromJson(nested, ArrayField.class).bytes, expected);
+ json.fromJson("[-1,-2,-3]", byte[].class);
+ assertEquals(first, expected);
+ assertEquals(second, expected);
+ }
+ String text =
+ "{\"label\":\"汉\\u6c49\",\"bytes\":[1,2],\"list\":[[3],[4,5]],"
+ + "\"map\":{\"a\":[6],\"b\":[7,8]},\"nested\":[[9],[10,11]]}";
+ for (BinaryValues value :
+ new BinaryValues[] {
+ json.fromJson(text, BinaryValues.class),
+ json.fromJson(text.getBytes(StandardCharsets.UTF_8), BinaryValues.class)
+ }) {
+ assertEquals(value.label, "汉汉");
+ assertEquals(value.bytes, new byte[] {1, 2});
+ assertEquals(value.list.get(0), new byte[] {3});
+ assertEquals(value.list.get(1), new byte[] {4, 5});
+ assertEquals(value.map.get("a"), new byte[] {6});
+ assertEquals(value.map.get("b"), new byte[] {7, 8});
+ assertEquals(value.nested, new byte[][] {{9}, {10, 11}});
+ }
+ for (String input : new String[] {"[0,null]", "[0,128]", "[-129]", "[a,0]"}) {
+ assertThrows(ForyJsonException.class, () -> json.fromJson(input, byte[].class));
+ assertThrows(
+ ForyJsonException.class,
+ () -> json.fromJson(input.getBytes(StandardCharsets.UTF_8), byte[].class));
+ assertEquals(json.fromJson("[1,2]", byte[].class), new byte[] {1, 2});
+ }
+ }
+
@Test
public void conflictingFormats() {
assertThrows(ForyJsonException.class, () -> newJson().toJson(new ConflictingFormat()));
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
index 8bf51f717..b039d26ff 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java
@@ -325,6 +325,31 @@ public class JsonContainerTest extends ForyJsonTestModels {
assertThrows(ForyJsonException.class, () -> uint16.readUtf16(newUtf16Reader("[4294967295]")));
}
+ @Test
+ public void byteArrayTokens() {
+ JsonValueCodec<byte[]> codec =
+ ArrayCodec.createUnsignedPrimitive(byte[].class, Types.UINT8_ARRAY, false);
+ for (int size : new int[] {0, 1, 63, 64, 65, 257}) {
+ byte[] values = new byte[size];
+ StringBuilder expected = new StringBuilder("[[");
+ for (int i = 0; i < size; i++) {
+ values[i] = (byte) i;
+ if (i != 0) {
+ expected.append(',');
+ }
+ expected.append(i & 255);
+ }
+ expected.append("],\"tail\"]");
+ Utf8JsonWriter writer = newUtf8Writer(new byte[1]);
+ writer.writeArrayStart();
+ codec.writeUtf8(writer, values);
+ writer.writeComma(1);
+ writer.writeString("tail");
+ writer.writeArrayEnd();
+ assertEquals(new String(writer.toJsonBytes(), StandardCharsets.UTF_8), expected.toString());
+ }
+ }
+
@Test
public void readTypeRefList() {
ForyJson json = newJson();
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java
index aca9b4a7e..a79a9dfe1 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java
@@ -477,6 +477,36 @@ public class JsonScalarTest extends ForyJsonTestModels {
new String(json.toJsonBytes(new NaturalValues()), StandardCharsets.UTF_8), expected);
}
+ @Test
+ public void writeBase16() {
+ byte[] value = {0, 1, 15, 16, 127, -128, -1};
+ String expected = "[\"汉\",\"00010f107f80ff\",0]";
+ for (int capacity : new int[] {1, 16, 31, 64}) {
+ StringJsonWriter string = newStringWriter(new byte[capacity]);
+ string.writeArrayStart();
+ string.writeString("汉");
+ string.writeComma(1);
+ string.writeBase16(value);
+ string.writeComma(2);
+ string.writeInt(0);
+ string.writeArrayEnd();
+ assertEquals(string.toJson(), expected);
+ string.reset();
+ string.writeBase16(value);
+ assertEquals(string.toJson(), "\"00010f107f80ff\"");
+
+ Utf8JsonWriter utf8 = newUtf8Writer(new byte[capacity]);
+ utf8.writeArrayStart();
+ utf8.writeString("汉");
+ utf8.writeComma(1);
+ utf8.writeBase16(value);
+ utf8.writeComma(2);
+ utf8.writeInt(0);
+ utf8.writeArrayEnd();
+ assertEquals(new String(utf8.toJsonBytes(), StandardCharsets.UTF_8), expected);
+ }
+ }
+
@Test
public void writeBase64() {
ForyJson json = newJson();
diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
index aa08a0989..f204fd93d 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
@@ -26,6 +26,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Map;
+import org.apache.fory.json.annotation.JsonByteArray;
import org.apache.fory.json.annotation.JsonProperty.Include;
import org.apache.fory.json.codec.JsonValueCodec;
import org.apache.fory.json.reader.Latin1JsonReader;
@@ -45,6 +46,7 @@ final class JsonTestSupport {
new JsonConfig(
Include.NON_NULL,
false,
+ JsonByteArray.Format.BASE64,
false,
false,
true,
diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt
index 2a1b03c41..cc55baa3a 100644
--- a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt
+++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt
@@ -1151,9 +1151,11 @@ internal class KspModelBuilder(
val format =
annotation.arguments.first { it.name?.asString() == "value" }.value as KSClassDeclaration
result.codecs +=
- if (format.simpleName.asString() == "ARRAY")
- "org.apache.fory.json.codec.ArrayCodec\$SignedByteArrayCodec"
- else BASE64_CODEC
+ when (format.simpleName.asString()) {
+ "ARRAY" -> "org.apache.fory.json.codec.ArrayCodec\$SignedByteArrayCodec"
+ "BASE16" -> "org.apache.fory.json.codec.Base16ByteArrayCodec"
+ else -> BASE64_CODEC
+ }
}
JSON_SUB_TYPES -> collectSubtypeTypes(annotation, result.types)
else ->
diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt
index 256be3bc1..4eefe7ccc 100644
--- a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt
+++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt
@@ -21,6 +21,7 @@ package org.apache.fory.json.kotlin
import java.lang.reflect.Modifier
import kotlin.test.Test
+import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
@@ -35,6 +36,7 @@ import kotlin.time.TimedValue
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import org.apache.fory.json.ForyJsonException
+import org.apache.fory.json.annotation.JsonByteArray
import org.apache.fory.json.codec.DirectUnboxedValueCodec
import org.apache.fory.json.reader.JsonReader
import org.apache.fory.json.writer.JsonWriter
@@ -86,6 +88,57 @@ class KotlinBuiltInCodecsTest {
private val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build()
+ data class BinaryFields(
+ val label: String,
+ val bytes: ByteArray,
+ val nested: List<ByteArray>,
+ val unsigned: UByteArray
+ )
+
+ @Test
+ fun byteArrayFormats() {
+ val bytes = byteArrayOf(1, -2, 3)
+ val formats =
+ mapOf(
+ JsonByteArray.Format.BASE64 to "\"Af4D\"",
+ JsonByteArray.Format.BASE16 to "\"01fe03\"",
+ JsonByteArray.Format.ARRAY to "[1,-2,3]"
+ )
+ for (codegen in listOf(false, true)) {
+ for ((format, encoded) in formats) {
+ val json =
+ ForyJsonKotlin.builder()
+ .byteArrayFormat(format)
+ .withCodegen(codegen)
+ .withAsyncCompilation(false)
+ .build()
+ assertEquals(encoded, json.toJson(bytes, jsonTypeRef<ByteArray>()))
+ assertContentEquals(bytes, json.fromJson(encoded, jsonTypeRef<ByteArray>()))
+ assertContentEquals(
+ bytes,
+ json.fromJson(encoded.encodeToByteArray(), jsonTypeRef<ByteArray>())
+ )
+ val value = BinaryFields("汉", bytes, listOf(bytes), ubyteArrayOf(1u, 254u, 3u))
+ val text = json.toJson(value)
+ assertTrue(text.contains("\"bytes\":$encoded"), text)
+ assertTrue(text.contains("\"nested\":[$encoded]"), text)
+ assertTrue(text.contains("\"unsigned\":[1,254,3]"), text)
+ assertEquals(text, json.toJsonBytes(value).decodeToString())
+ for (input in listOf(text, json.toPrettyJson(value))) {
+ for (decoded in
+ listOf(
+ json.fromJson(input, BinaryFields::class.java),
+ json.fromJson(input.encodeToByteArray(), BinaryFields::class.java)
+ )) {
+ assertContentEquals(bytes, decoded.bytes)
+ assertContentEquals(bytes, decoded.nested.single())
+ assertContentEquals(value.unsigned, decoded.unsigned)
+ }
+ }
+ }
+ }
+ }
+
@Test
fun products() {
val fory = ForyJsonKotlin.builder().writeNullFields(true).withAsyncCompilation(false).build()
diff --git a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
index 9b567cc9b..d9a5bcd31 100644
--- a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
+++ b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
@@ -24,7 +24,7 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import org.apache.fory.json.{ForyJson, ForyJsonException, JsonCodecFactory}
-import org.apache.fory.json.annotation.{JsonCodec, JsonFormat, JsonIgnore, JsonInclude, JsonMixin, JsonProperty, JsonRawValue, JsonSubTypes, JsonUnwrapped}
+import org.apache.fory.json.annotation.{JsonByteArray, JsonCodec, JsonFormat, JsonIgnore, JsonInclude, JsonMixin, JsonProperty, JsonRawValue, JsonSubTypes, JsonUnwrapped}
import org.apache.fory.json.codec.{AbstractJsonValueCodec, MapKeyCodec, ObjectCodec}
import org.apache.fory.json.reader.JsonReader
import org.apache.fory.json.resolver.{JsonTypeResolver, UnsupportedJsonTypeException}
@@ -41,6 +41,14 @@ case class Node(value: Int, next: Option[Node])
case class BigIntFields(value: BigInt, values: Vector[BigInt])
+case class BinaryFields(
+ label: String,
+ bytes: Array[Byte],
+ nested: List[Array[Byte]],
+ mapped: Map[String, Array[Byte]],
+ optional: Option[Array[Byte]]
+)
+
case class StringScalarFields(
@JsonFormat(shape = JsonFormat.Shape.STRING) active: Boolean,
@JsonFormat(shape = JsonFormat.Shape.STRING) count: Int,
@@ -521,6 +529,43 @@ class ScalaJsonSuite extends AnyFunSuite {
} finally resolver.unlockJIT()
}
+ test("byte array formats") {
+ val bytes = Array[Byte](1, -2, 3)
+ val formats = Seq(
+ JsonByteArray.Format.BASE64 -> "\"Af4D\"",
+ JsonByteArray.Format.BASE16 -> "\"01fe03\"",
+ JsonByteArray.Format.ARRAY -> "[1,-2,3]"
+ )
+ for (codegen <- Seq(false, true); (format, encoded) <- formats) {
+ val json = ForyJsonScala.builder().byteArrayFormat(format)
+ .withCodegen(codegen).withAsyncCompilation(false).build()
+ val byteType = ScalaTypeRef[Array[Byte]]
+ assert(json.toJson(bytes, byteType) == encoded)
+ assert(json.fromJson(encoded, byteType).sameElements(bytes))
+ assert(json.fromJson(encoded.getBytes(UTF_8), byteType).sameElements(bytes))
+ val value = BinaryFields("汉", bytes, List(bytes), Map("data" -> bytes), Some(bytes))
+ val text = json.toJson(value)
+ assert(text.contains("\"bytes\":" + encoded))
+ assert(text.contains("\"nested\":[" + encoded + "]"))
+ assert(text.contains("\"mapped\":{\"data\":" + encoded + "}"))
+ assert(text.contains("\"optional\":" + encoded))
+ assert(new String(json.toJsonBytes(value), UTF_8) == text)
+ for {
+ input <- Seq(text, json.toPrettyJson(value))
+ decoded <- Seq(
+ json.fromJson(input, classOf[BinaryFields]),
+ json.fromJson(input.getBytes(UTF_8), classOf[BinaryFields])
+ )
+ } {
+ assert(decoded.bytes.sameElements(bytes))
+ assert(decoded.nested.head.sameElements(bytes))
+ assert(decoded.mapped("data").sameElements(bytes))
+ assert(decoded.optional.get.sameElements(bytes))
+ }
+ assertWriterGeneration(json, classOf[BinaryFields], codegen)
+ }
+ }
+
test("scalar string fields and Mixins") {
for (codegen <- Seq(false, true)) {
val json = ForyJsonScala.builder().registerMixin(classOf[ArtifactStateMixin])
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org