Add typed canonical JSON encoding
This commit is contained in:
1 parent
2bedaa0969
commit
2abe7e2f96
4 files changed
+660
-57
No files matched your search
@@ -8,15 +8,22 @@ Lyng now has two distinct JSON-facing layers:
|
||||
- canonical JSON round-trip format:
|
||||
- `Json.encode(value)`
|
||||
- `Json.decode(text)`
|
||||
- typed canonical JSON round-trip format:
|
||||
- `Json.encodeAs(Type, value)`
|
||||
- `Json.decodeAs(Type, text)`
|
||||
|
||||
Use the first when you need ordinary JSON for interop.
|
||||
|
||||
Use the second when you need Lyng value round-trip semantics through JSON text.
|
||||
Use the second when you need Lyng value round-trip semantics through JSON text with no schema.
|
||||
|
||||
Use the third when both sides already know the Lyng type and you want the same round-trip semantics with fewer type
|
||||
tags in the JSON.
|
||||
|
||||
This distinction is intentional:
|
||||
|
||||
- plain JSON projection is optimized for compatibility with ordinary JSON tooling
|
||||
- canonical `Json.encode()` is optimized for semantic fidelity to Lyng and Lynon
|
||||
- canonical `Json.encode()` is optimized for semantic fidelity to Lyng and Lynon and stays self-describing
|
||||
- typed canonical `Json.encodeAs()` is optimized for the same fidelity when the schema is provided externally
|
||||
- these goals conflict for values such as sets, exceptions, singleton objects, buffers, and maps with non-string keys
|
||||
|
||||
## Plain JSON projection in Lyng
|
||||
@@ -93,6 +100,9 @@ Please note that `toJsonString` should be used to get serialized string represen
|
||||
|
||||
They still use JSON text, but they add Lyng-specific type tags where plain JSON would otherwise lose information.
|
||||
|
||||
When a map already fits ordinary JSON object rules, canonical JSON keeps that traditional object shape. In particular,
|
||||
maps with string keys are still serialized as JSON objects, not as tagged entry lists.
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
@@ -124,6 +134,41 @@ The plain `toJson()` projection is intended for ordinary JSON interop.
|
||||
Canonical `Json.encode()` should be read as the JSON analogue of `Lynon.encode()`: when Lynon already preserves a
|
||||
Lyng distinction, canonical JSON tries to preserve it too, using tags only where ordinary JSON is insufficient.
|
||||
|
||||
## Typed canonical Json round-trip format
|
||||
|
||||
`Json.encodeAs(Type, value)` and `Json.decodeAs(Type, text)` use the same canonical rules, but with a declared target
|
||||
type available during the whole traversal.
|
||||
|
||||
This changes one thing only: type tags may be omitted when the declared type is already exact enough to restore the
|
||||
value unambiguously.
|
||||
|
||||
The same map rule still applies here: `Map<String, T>` stays a normal JSON object, while non-string-key maps fall back
|
||||
to canonical entry encoding.
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
import lyng.serialization
|
||||
|
||||
closed class Point(x: Int, y: Int)
|
||||
closed class Segment(a: Point, b: Point)
|
||||
|
||||
val value = Segment(Point(0, 1), Point(2, 3))
|
||||
val encoded = Json.encodeAs(Segment, value)
|
||||
|
||||
assertEquals("{\"a\":{\"x\":0,\"y\":1},\"b\":{\"x\":2,\"y\":3}}", encoded)
|
||||
assertEquals(value, Json.decodeAs(Segment, encoded))
|
||||
```
|
||||
|
||||
Subtype information is still preserved when the declared type is wider than the runtime one. For example, if a field is
|
||||
declared as `Base` but contains `Derived`, canonical subtype tags remain in that field.
|
||||
|
||||
This is why the APIs are split:
|
||||
|
||||
- `toJson()` stays plain and interop-friendly
|
||||
- `Json.encode()` stays fully self-describing and safe to decode without a schema
|
||||
- `Json.encodeAs()` uses the supplied schema to reduce noise, but only where that schema is sufficient
|
||||
|
||||
## Kotlin side interfaces
|
||||
|
||||
The "Batteries included" principle is also applied to serialization.
|
||||
@@ -241,6 +286,17 @@ This format can also round-trip:
|
||||
- non-finite reals
|
||||
- `void`
|
||||
|
||||
### Typed canonical `Json.encodeAs`
|
||||
|
||||
This format round-trips the same value space as canonical `Json.encode`, but it can emit simpler JSON for:
|
||||
|
||||
- closed classes and other exactly-known class fields
|
||||
- enums when the enum type is known
|
||||
- typed collections whose element types are known
|
||||
- nested object graphs where declared field types are precise
|
||||
|
||||
It still falls back to canonical tagged encoding when exact runtime type information would otherwise be lost.
|
||||
|
||||
It does so by adding Lyng-specific type tags only when necessary.
|
||||
|
||||
## Kotlin-side extension point for more formats
|
||||
|
||||
+35
-2
@@ -24,6 +24,13 @@ For the built-in formats:
|
||||
- `Json.encode(x)` returns `String`
|
||||
- `Json.decode(jsonString)` returns the original Lyng value
|
||||
|
||||
`Json` also provides a typed canonical mode:
|
||||
|
||||
- `Json.encodeAs(Type, value)` returns `String`
|
||||
- `Json.decodeAs(Type, jsonString)` returns the original Lyng value of the specified type
|
||||
|
||||
This is still canonical JSON, but it is schema-driven instead of fully self-describing.
|
||||
|
||||
## Lynon
|
||||
|
||||
Lynon is LYng Object Notation. It is typed, binary, bit-effective, implements caching, automatic compression,
|
||||
@@ -91,9 +98,10 @@ There are two JSON-related APIs and they serve different purposes:
|
||||
|
||||
- `Json.encode()` / `Json.decode()`
|
||||
- produce JSON text too
|
||||
- but use Lyng-specific type tags where needed
|
||||
- use Lyng-specific type tags so the payload is self-describing
|
||||
- intended for round-tripping Lyng values
|
||||
- intended to match Lynon semantics where JSON can carry them
|
||||
- still keep ordinary string-key maps in traditional JSON object form
|
||||
- can preserve values that plain JSON cannot represent directly, such as:
|
||||
- maps with non-string keys
|
||||
- sets
|
||||
@@ -106,12 +114,23 @@ There are two JSON-related APIs and they serve different purposes:
|
||||
- non-finite reals
|
||||
- `void`
|
||||
|
||||
- `Json.encodeAs(Type, value)` / `Json.decodeAs(Type, text)`
|
||||
- also round-trip Lyng values through JSON text
|
||||
- use the declared or requested type as decoding schema
|
||||
- recursively omit type tags when the declared type is already exact enough
|
||||
- keep canonical tags when the runtime value is more specific than the declared type
|
||||
- produce less noisy JSON for closed and otherwise precisely-typed object graphs
|
||||
- still keep ordinary `Map<String, ...>` values in traditional JSON object form
|
||||
|
||||
Why this split exists:
|
||||
|
||||
- plain `toJson()` must remain ordinary JSON so it stays convenient for external JSON systems and Kotlin
|
||||
`kotlinx.serialization`
|
||||
- canonical `Json.encode()` is for Lyng-to-Lyng transport through JSON text, so it must preserve Lyng runtime
|
||||
- canonical `Json.encode()` is for Lyng-to-Lyng transport through JSON text without any external schema, so it must
|
||||
remain self-describing and preserve Lyng runtime
|
||||
distinctions whenever possible
|
||||
- `Json.encodeAs()` exists for the cases where a schema is known on both sides and we want canonical round-trip
|
||||
behavior with fewer tags
|
||||
- one API cannot optimize for both goals at once: either you get too many Lyng tags for ordinary JSON interop, or you
|
||||
get lossy round-trips
|
||||
|
||||
@@ -137,6 +156,20 @@ Example:
|
||||
assertEquals(x, Json.decode(Json.encode(x)))
|
||||
>>> void
|
||||
|
||||
Typed canonical example:
|
||||
|
||||
import lyng.serialization
|
||||
|
||||
closed class Point(x: Int, y: Int)
|
||||
closed class Segment(a: Point, b: Point)
|
||||
|
||||
val value = Segment(Point(0,1), Point(2,3))
|
||||
val json = Json.encodeAs(Segment, value)
|
||||
|
||||
assertEquals("{\"a\":{\"x\":0,\"y\":1},\"b\":{\"x\":2,\"y\":3}}", json)
|
||||
assertEquals(value, Json.decodeAs(Segment, json))
|
||||
>>> void
|
||||
|
||||
## Adding more formats from Kotlin modules
|
||||
|
||||
External modules can add new formats on the Kotlin side.
|
||||
|
||||
Reference in new issue
Block a user