Sign in

    smithy-codec

    Schema-driven JSON codecs with declared serialization profiles for Smithy shapes

    smithy
    json
    codec
    serialization
    moonbit
    Download zip
    Author
    Version
    0.8.0
    License
    Apache-2.0
    Last updated
    7 hours ago
    Downloads
    6

    #JSON codecs

    moonrockz/smithy-codec checks, decodes and writes application JSON against Smithy data shapes. The model declares the JSON representation in traits and names it with a profile. A caller selects a profile; the caller cannot change its rules. Checking, decoding and encoding use one prepared wire contract, so a complete check without findings agrees with a successful decode.

    • The contract package holds the typed identities, the trait definitions and the detached wire contract. A generator or a schema exporter can read the contract without the runtime.
    • The morphir package holds the scalar adapters of the Morphir IR. General codecs do not import it.
    • Without a selected profile, the base rules apply. moonrockz/smithy-validation, JSON-RPC and the Smithy JSON AST do not change. A profile in a model does not change any protocol that does not select it.

    #Prepare once, check, decode and encode

    PreparedCodec::prepare snapshots the reachable contract of a root under a profile. Later edits to the model do not change the codec. The model loads with @contract.loader(), which knows the smithy.serialization traits.

    ///|
    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")
    }

    check returns a CodecResult with findings and a completeness flag; to_json() gives the structured diagnostic of smithy validate value --format json. decode returns a CodecValue, encode returns text, and canonicalize does both under one request budget. A failed decode, encode or canonicalization raises CodecFailure with the findings and returns no value and no partial output.

    #Values

    A LogicalValue is model-aware: a structure holds its present members by Smithy member name, a union names its selected member, and a map holds logical keys. Equality compares numbers by exact value and compares members and entries without regard to order.

    A CodecValue keeps its semantic root: the root shape and the fingerprint of the logical contract reachable from it. Profiles of one model and root share the root, so a value decoded under one profile can be written under another. A value of another root, such as another version of a model, is refused with IncompatibleValue; convert it explicitly. PreparedCodec::value is the validated builder: it checks a caller's value, copies it into its normal form and returns a CodecValue.

    ///|
    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")
    }
    }

    #Declaring profiles

    The traits are in the smithy.serialization namespace. Their definitions are in @contract.trait_definitions().

    TraitOnMeaning
    jsonProfilesa root shapeDeclares profiles: id, optional ruleSet (use another profile's rules), writer (canonical or expanded), unknownMembers (reject or ignore), defaults (union, dictionary) and requires (capabilities).
    jsonEncodinga shape or memberA base rule for every profile and profiles rules by profile identifier.
    untaggeda unionThe base rule {union: {style: "untagged"}}.
    emptyAsAbsentan optional memberAn empty structure, list or map is the same logical value as absence.
    jsonBundlea structureA versioned document bundle; see below.

    A profile applies to the declaring shape and every shape reachable from it. For each shape the codec uses the selected profile's rule, then the profile's defaults, then the base rules (jsonEncoding.base, untagged and smithy.api#jsonName), then the standard layout. Two different declarations at the same precedence are a ConflictingDeclaration. An unknown profile, a missing adapter or a rule that the runtime does not implement fails preparation and names the shape. The codec never falls back to an object-wrapped union. Rule names are open strings in the trait definitions, so a model can name a rule that a runtime does not implement, and that runtime refuses it.

    A configuration, such as a CLI option or a build plugin setting, selects a profile. It cannot change a tag, a position or a layout.

    #Representations

    RuleJSONPreparation 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.
    adapterany declared kindA host adapter with the declared input and output kinds.

    Member rules set name (the field name), tag (the union tag), aliases (reader-only names and tags), absent: "null" (write absence as null, read null as absence) and flatten: true (read the fields that no other member claims as an external union). A union rule can accept reader-only alternative object layouts; a dictionary rule can accept the other layout. Writers always use the declared layout.

    Tuple projection, tagged-array splicing and dictionary entry order are different things. A tuple rule maps array positions to named members of one structure. A tagged array splices that tuple after its tag, so the second element of ["Add", 1, 2] is position 0 of the payload and its findings name /1. A dictionary entry array is a list of [key, value] pairs whose order comes from the order setting, not from the model.

    ///|
    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")
    }

    #Compact forms and canonical output

    A forms rule declares compact spellings on a union or a structure. Each form has a reader guard and binds members; it can also have a writer guard.

    • read: {kind, adapter, field, absent} matches by JSON kind, and optionally by an adapter that accepts the value, a field that the object contains, or a field that it does not contain.
    • bind: "name" binds the whole value to one member. positions: [...] and rest bind an array. A union form names its member and binds fields of that member's payload; without bind or positions it reads the whole value as the payload.
    • write: {empty: [...], present: [...]} selects the form for canonical output when every empty member is absent and every present member is present and not empty. A present empty value counts as absent only for an emptyAsAbsent member, so a compact form never drops a present empty member. Every member that the form does not bind must be in empty, so the form never drops information.

    Preparation refuses a form that overlaps the shape's own layout or another form unless guards tell them apart. At run time, two forms that both accept a value are an AmbiguousForm finding: declaration order never decides. Two writer forms that both apply are refused in the same way. A profile with writer: "expanded" ignores every form and writes the declared layouts; use it with ruleSet to name an explicit expanded output of the same rules.

    Readers accept every declared form and alias. Writers emit one canonical form chosen from the logical value, not from the input spelling. A document that gives a member under its name and under an alias is refused with MemberCollision. Canonical output keeps declared member order and exact number tokens; whitespace is not kept.

    #Scalar adapters

    A host registers trusted adapters in an AdapterRegistry. A model can name an adapter but cannot load or install one. An adapter declares the JSON kinds that it reads and the logical scalar kind that it produces, and can order dictionary keys. Preparation refuses a missing adapter or a kind that the target cannot hold. At run time:

    • a failure is a finding at the value's path joined with the adapter's relative path;
    • an adapter charges its work through AdapterContext::charge, which shares the request budget;
    • the reader checks the output kind, and the writer checks that the adapter writes a kind that it reads.

    Adapter laws: read(write(x)) == x for every logical value x that the adapter accepts, and write is deterministic. The Morphir adapters are tested against these laws.

    #Versioned bundles

    @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 {}

    PreparedBundle::prepare prepares a codec for each release binding and profile. To read a document it finds the version field in the parsed object, whatever the field order, reads it with the version adapter and selects the one binding whose half-open interval contains the release. Then it decodes the payload with that root and profile only. A missing version, an invalid version, an unsupported release and a release that an explicitly selected profile does not read are the distinct findings MissingVersion, InvalidVersion, UnsupportedVersion and VersionConflict.

    A VersionedValue keeps its exact release. Encoding writes the canonical spelling of that release and refuses a profile that does not write it. The codec never converts a document from one release to another; a conversion between versions is an explicit operation outside this module.

    #Limits

    CodecLimits bounds one request: input bytes, output bytes, depth, work and findings. Parsing, form selection, adapters, constraint checks and output all charge one work meter, and a later phase receives only the work that is left. Text input is limited to 128 levels of JSON nesting. Every traversal uses an explicit stack. When a limit is reached, the result is incomplete, never valid, and no value or output is returned. Preparation visits each reachable shape once.

    #Compatibility

    Smithy value constraints come from moonrockz/smithy-validation: ranges, lengths, patterns, enums, unique items and timestamp and blob formats apply to the logical value, with findings at the original input location. Requests for profile-aware code generation or schema export are refused by the generators and the JSON Schema exporter; they still use their standard contracts.

    CodecFailure

    pub(all) suberror CodecFailure {
    CodecFailure(CodecResult)
    } derive(
    Debug
    )

    A refused check, decode or encode. The result holds the findings; no value or output exists.

    AdapterContext

    pub struct AdapterContext {
    // private fields
    }

    What an adapter may do during one call: charge work to the request.

    AdapterContext::charge

    fn AdapterContext::charge(self : AdapterContext, units : Int) -> Bool

    Charge units of work. False means that the request has no work left; the adapter must then fail.

    AdapterFailure

    pub struct AdapterFailure {
    path :
    InstancePath

    message : String
    } derive(Eq,
    Debug
    )

    An adapter refusal, at a path relative to the value that the adapter reads.

    AdapterFailure::new

    AdapterRegistry

    pub struct AdapterRegistry {
    // private fields
    }

    The adapters that a host trusts, by identifier.

    AdapterRegistry::new

    AdapterRegistry::register

    Register an adapter. Registering a second adapter with the same identifier is refused.

    CodecCode

    pub(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
    )

    The kind of a codec finding. Constraint findings come from Smithy value validation.
    impl Show for CodecCode

    CodecCode::to_string

    fn CodecCode::to_string(self : CodecCode) -> String

    CodecFinding

    One finding. The path is the original input location when reading, and the logical location when encoding a value.

    CodecLimits

    pub struct CodecLimits {
    input_bytes : Int
    output_bytes : Int
    depth : Int
    work : Int
    findings : Int
    } derive(Eq,
    Debug
    )

    Limits of one request: check, decode, encode or canonicalize. Work is one allowance shared by parsing, form selection, adapters, constraint checks and output.

    CodecLimits::default

    fn CodecLimits::default() -> CodecLimits

    CodecLimits::new

    fn CodecLimits::new(input_bytes? : Int, output_bytes? : Int, depth? : Int, work? : Int, findings? : Int) -> CodecLimits raise
    ConfigurationError

    CodecResult

    pub(all) struct CodecResult {
    findings : Array[CodecFinding]
    complete : Bool
    } derive(Eq,
    Debug
    )

    The result of a check. A value is valid only when the check is complete and has no findings.

    CodecResult::is_valid

    fn CodecResult::is_valid(self : CodecResult) -> Bool

    CodecValue

    pub struct CodecValue {
    // private fields
    } derive(
    Debug
    )

    A logical value that a codec has checked against its semantic root. Only decoding and PreparedCodec::value create one.
    impl Eq for CodecValue

    CodecValue::logical

    fn CodecValue::logical(self : CodecValue) -> LogicalValue

    CodecValue::root

    fn CodecValue::root(self : CodecValue) -> SemanticRoot

    LogicalValue

    pub(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
    )

    A model-aware value, independent of its JSON spelling. Structures hold their present members by Smithy member name, a union names its selected member, and maps hold logical keys. Object is a document object, which has no modeled members.
    impl Eq for LogicalValue

    LogicalValue::get

    fn LogicalValue::get(self : LogicalValue, name : String) -> LogicalValue?

    The present member name of a structure, the entry name of a map or document object.

    LogicalValue::number

    A number from its exact JSON text, such as 12345678901234567890.25.

    LogicalValue::selected

    fn LogicalValue::selected(self : LogicalValue) -> (String, LogicalValue)?

    The selected member of a union value.

    PreparedBundle

    pub struct PreparedBundle {
    // private fields
    }

    A prepared versioned document bundle. It selects one root and profile from the document's version before it decodes the payload, and it never converts a document to another release.

    PreparedBundle::canonicalize

    Decode with the document's release and re-encode with profile, or the reading profile. One request budget covers both.

    PreparedBundle::check

    Check a versioned document. An explicit profile must read the document's release.

    PreparedBundle::codec

    The codec of release under profile, or under the release's default profile.

    PreparedBundle::decode

    PreparedBundle::encode

    Encode a versioned value with the canonical spelling of its own release. A profile that does not write that release is refused; no value is converted to another release.

    PreparedCodec

    pub struct PreparedCodec {
    // private fields
    }

    A reusable codec of one model root under one profile. Checking, decoding and encoding use the same detached wire contract, the same adapters and the same Smithy value constraints.

    PreparedCodec::canonicalize

    fn PreparedCodec::canonicalize(self : PreparedCodec, text : String) -> String raise CodecFailure

    Decode and re-encode JSON text in this codec's canonical form. One request budget covers both.

    PreparedCodec::check

    fn PreparedCodec::check(self : PreparedCodec, text : String) -> CodecResult

    Check JSON text. A complete check without findings agrees with a successful decode.

    PreparedCodec::contract

    The detached wire contract, for generators and schema exporters.

    PreparedCodec::decode

    fn PreparedCodec::decode(self : PreparedCodec, text : String) -> CodecValue raise CodecFailure

    Decode JSON text into a value of this codec's semantic root.

    PreparedCodec::encode

    fn PreparedCodec::encode(self : PreparedCodec, value : CodecValue) -> String raise CodecFailure

    Encode a value of this codec's semantic root. The value is checked again before any output exists, and a value of another root is refused.

    PreparedCodec::limits

    PreparedCodec::prepare

    Prepare a codec. Without a profile, the base rules and the standard layouts apply. Preparation fails on an unknown profile, a missing adapter or an inconsistent or unsupported declaration; it never falls back to another layout.

    PreparedCodec::semantic_root

    fn PreparedCodec::semantic_root(self : PreparedCodec) -> SemanticRoot

    PreparedCodec::value

    fn PreparedCodec::value(self : PreparedCodec, value : LogicalValue) -> CodecValue raise CodecFailure

    Check a caller's logical value against this codec's root and return it as a codec value. This is the validated builder: the value is copied into its normalized form.

    ScalarAdapter

    pub struct ScalarAdapter {
    signature :
    AdapterSignature

    // private fields
    }

    A trusted host scalar adapter. It reads its declared JSON kinds into one logical scalar, writes the canonical spelling of that scalar and may order dictionary keys. A model can name it, but only the host can register it.

    ScalarValue

    pub(all) enum ScalarValue {
    Text(String)
    Numeric(
    NodeNumber
    )
    Flag(Bool)
    } derive(Eq,
    Debug
    )

    A logical scalar produced or consumed by an adapter.

    SemanticRoot

    pub struct SemanticRoot {
    shape :
    ShapeId

    fingerprint : String
    } derive(Eq,
    Debug
    )

    The identity of a logical contract: a root shape and the fingerprint of the contract reachable from it. Profiles of one model and root share it; another root or another modeled version does not.

    VersionedValue

    pub struct VersionedValue {
    // private fields
    } derive(Eq,
    Debug
    )

    A decoded document of a versioned bundle. It keeps the exact release that selected its root.

    VersionedValue::profile

    The profile that read the document.

    VersionedValue::value

    json_number

    fn json_number(value : Json) ->
    NodeNumber
    ?

    The exact number of a JSON number, using its retained token when present.

    number_json

    A JSON number that keeps the exact token of n.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io