Schema-driven JSON codecs with declared serialization profiles for Smithy shapes
///|
test "one model with two representations" {
let model = @contract.loader()
.add_idl(
"expr.smithy",
(
#|$version: "2"
#|namespace example
#|use smithy.serialization#jsonProfiles
#|use smithy.serialization#jsonEncoding
#|
#|@jsonProfiles([
#| {id: "object"}
#| {id: "array", defaults: {union: {style: "tagArray"}}}
#|])
#|union Expr {
#| Num: Count
#| Add: Add
#|}
#|
#|@range(min: 0, max: 100)
#|integer Count
#|
#|@jsonEncoding(profiles: {array: {tuple: {members: ["left", "right"]}}})
#|structure Add {
#| @required
#| left: Expr
#| @required
#| right: Expr
#|}
),
)
.assemble().model.unwrap()
let root = @smithy.ShapeId::parse("example#Expr").unwrap()
let object = @codec.PreparedCodec::prepare(
model,
root,
profile=Some(@contract.ProfileId::parse("object")),
)
let array = @codec.PreparedCodec::prepare(
model,
root,
profile=Some(@contract.ProfileId::parse("array")),
)
let value = object.decode(
"{\"Add\":{\"left\":{\"Num\":1},\"right\":{\"Num\":2}}}",
)
inspect(array.encode(value), content="[\"Add\",[\"Num\",1],[\"Num\",2]]")
// A finding names the semantic shape and the original JSON location.
let result = array.check("[\"Add\",[\"Num\",1],[\"Num\",101]]")
inspect(result.findings[0].code, content="Range")
inspect(result.findings[0].path.pointer(), content="/2/1")
}///|
test "build a value and encode it" {
let model = @contract.loader()
.add_idl(
"pair.smithy", "$version: \"2\"\nnamespace example\nstructure Pair { @required left: BigDecimal, right: String }",
)
.assemble().model.unwrap()
let codec = @codec.PreparedCodec::prepare(
model,
@smithy.ShapeId::parse("example#Pair").unwrap(),
)
let value = codec.value(
Structure([
("left", @codec.LogicalValue::number("12345678901234567890.000000001")),
]),
)
inspect(
codec.encode(value),
content="{\"left\":12345678901234567890.000000001}",
)
// An incomplete structure is refused before any output exists.
try codec.value(Structure([("right", String("r"))])) catch {
@codec.CodecFailure(result) =>
inspect(result.findings[0].code, content="RequiredMember")
} noraise {
_ => fail("expected a refusal")
}
}| Trait | On | Meaning |
|---|---|---|
| jsonProfiles | a root shape | Declares profiles: id, optional ruleSet (use another profile's rules), writer (canonical or expanded), unknownMembers (reject or ignore), defaults (union, dictionary) and requires (capabilities). |
| jsonEncoding | a shape or member | A base rule for every profile and profiles rules by profile identifier. |
| untagged | a union | The base rule {union: {style: "untagged"}}. |
| emptyAsAbsent | an optional member | An empty structure, list or map is the same logical value as absence. |
| jsonBundle | a structure | A versioned document bundle; see below. |
| Rule | JSON | Preparation checks |
|---|---|---|
| union: {style: "external"} | {"Add": {...}} | The standard layout. |
| union: {style: "internal", tag: "kind"} | {"kind": "Add", "left": 1} | Each member targets an object structure, and no payload field has the tag's name. |
| union: {style: "adjacent", tag: "kind", content: "value"} | {"kind": "Add", "value": ...} | Distinct tag and content fields. A Unit member has no content field. |
| union: {style: "tagArray"} | ["Add", 1, 2] | The tag is element 0. A payload structure with a tuple rule is spliced after the tag; another payload takes one element; a Unit member is ["Tag"], or ["Tag", {}] with unit: "slot". |
| union: {style: "untagged"} | 1 or "a" | The JSON kinds that the members accept are disjoint, after adapters and timestamp formats. Two numeric members are refused even with different ranges. |
| tuple: {members: [...], rest, optional} | ["a", 1] | Explicit positions; every member has one. An optional position needs optional: "omit" or "null"; a required position after an omitted one is a hole and is refused. rest is a final list member. |
| dictionary: {layout: "object" or "entries", key, order} | {"k": v} or [[k, v]] | A key adapter gives logical keys. Two entries with the same logical key are refused. order is key (code-unit order), adapter or preserve. A null value of a @sparse map, like a null element of a @sparse list, stays null. |
| adapter | any declared kind | A host adapter with the declared input and output kinds. |
///|
test "positions keep their source paths" {
let model = @contract.loader()
.add_idl(
"entry.smithy",
(
#|$version: "2"
#|namespace example
#|use smithy.serialization#jsonEncoding
#|
#|@jsonEncoding(base: {tuple: {members: ["head"], rest: "tail"}})
#|structure Call {
#| @required
#| head: String
#| tail: Sizes
#|}
#|
#|list Sizes {
#| member: Size
#|}
#|
#|@range(max: 9)
#|integer Size
),
)
.assemble().model.unwrap()
let codec = @codec.PreparedCodec::prepare(
model,
@smithy.ShapeId::parse("example#Call").unwrap(),
)
inspect(codec.canonicalize("[\"f\",1,2]"), content="[\"f\",1,2]")
let result = codec.check("[\"f\",1,20]")
inspect(result.findings[0].path.pointer(), content="/2")
}@jsonBundle(
id: "doc"
version: {field: "formatVersion", adapter: "test.version"}
releases: [
{since: "1.0.0", until: "2.0.0", root: V1, profiles: ["v1"]}
{since: "2.0.0", until: "2.1.0", root: V2, profiles: ["v2", "v2-expanded"]}
]
)
structure Documents {}pub struct AdapterContext {
// private fields
}pub struct AdapterRegistry {
// private fields
}fn AdapterRegistry::register(self : AdapterRegistry, adapter : ScalarAdapter) -> AdapterRegistry raise ConfigurationErrorpub(all) enum CodecCode {
Constraint(FindingCode)
InvalidJson
DuplicateName
KindMismatch
UnknownMember
MissingMember
MemberCollision
UnknownTag
Arity
AmbiguousForm
AdapterFailure
DuplicateKey
IncompatibleValue
UnrepresentableValue
MissingVersion
InvalidVersion
UnsupportedVersion
VersionConflict
LimitExceeded
} derive(Eq, Debug)pub(all) struct CodecFinding {
code : CodecCode
path : InstancePath
shape : ShapeId
message : String
} derive(Eq, Debug)impl ToJson for CodecFindingfn CodecLimits::new(input_bytes? : Int, output_bytes? : Int, depth? : Int, work? : Int, findings? : Int) -> CodecLimits raise ConfigurationErrorimpl ToJson for CodecResultimpl Eq for CodecValuepub(all) enum LogicalValue {
Null
Boolean(Bool)
Number(NodeNumber)
String(String)
List(Array[LogicalValue])
Structure(Array[(String, LogicalValue)])
Union(String, LogicalValue)
Map(Array[(String, LogicalValue)])
Object(Array[(String, LogicalValue)])
} derive(Debug)impl Eq for LogicalValuepub struct PreparedBundle {
// private fields
}fn PreparedBundle::canonicalize(self : PreparedBundle, text : String, profile? : ProfileId?, output? : ProfileId?) -> String raise CodecFailurefn PreparedBundle::check(self : PreparedBundle, text : String, profile? : ProfileId?) -> CodecResultfn PreparedBundle::codec(self : PreparedBundle, release : Release, profile? : ProfileId?) -> PreparedCodec?fn PreparedBundle::decode(self : PreparedBundle, text : String, profile? : ProfileId?) -> VersionedValue raise CodecFailurefn PreparedBundle::encode(self : PreparedBundle, value : VersionedValue, profile? : ProfileId?) -> String raise CodecFailurefn PreparedBundle::prepare(model : Model, bundle : BundleId, adapters? : AdapterRegistry, limits? : CodecLimits) -> PreparedBundle raise ContractErrorpub struct PreparedCodec {
// private fields
}fn PreparedCodec::prepare(model : Model, root : ShapeId, profile? : ProfileId?, adapters? : AdapterRegistry, limits? : CodecLimits) -> PreparedCodec raise ContractErrorfn PreparedCodec::value(self : PreparedCodec, value : LogicalValue) -> CodecValue raise CodecFailurefn ScalarAdapter::new(id : AdapterId, inputs~ : Array[JsonKind], output~ : JsonKind, read~ : (Json, AdapterContext) -> Result[ScalarValue, AdapterFailure], write~ : (ScalarValue, AdapterContext) -> Result[Json, AdapterFailure], compare? : (String, String) -> Int?) -> ScalarAdapter raise IdentityErrorimpl Show for SemanticRootInstall
Download zipSchema-driven JSON codecs with declared serialization profiles for Smithy shapes