Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Getting started

cbor_serialization is used to parse CBOR directly into Nim types and to encode them back as Cbor efficiently.

Let's start with a simple CBOR-RPC example based on JSON-RPC:

rpc-message = {
  cborrpc: tstr .eq "2.0",
  method: tstr .eq "subtract",
  params: [ int, int ],
  id: int
}

Imports and exports

Before we can use cbor_serialization, we have to import the library.

If you put your custom serialization code in a separate module, make sure to re-export cbor_serialization:

{.push gcsafe, raises: [].} # Encourage exception handling hygiene in procedures!

import cbor_serialization
export cbor_serialization

A common way to organize serialization code is to use a separate module named either after the library (mylibrary_cbor_serialization) or the flavor (myflavor_cbor_serialization).

For types that mainly exist to interface with CBOR, custom serializers can also be placed together with the type definitions.

Re-exports

When importing a module that contains custom serializers, make sure to re-export it or you might end up with cryptic compiler errors or worse, the default serializers being used!

Simple reader

Looking at the example, we'll define a Nim object to hold the request data, with matching field names and types:

type Request = object
  cborrpc: string
  `method`: string # Quote Nim keywords
  params: seq[int] # Map CBOR array to `seq`
  id: int

Cbor.encode can now turn our Request into a CBOR blob:

# Encode a Request type into a CBOR blob
let encoded =
  Cbor.encode(Request(cborrpc: "2.0", `method`: "subtract", params: @[42, 3], id: 1))

Cbor.decode can now turn our CBOR input back into a Request:

# Decode the CBOR blob into our Request type
let decoded = Cbor.decode(encoded, Request)

doAssert decoded.id == 1

Replace decode/encode with loadFile/saveFile to read and write a file instead!

Handling errors

Of course, someone might give us some invalid data - cbor_serialization will raise an exception when that happens:

try:
  # Oops, a string was used for the `id` field!
  discard Cbor.decode(Cbor.encode((id: "test")), Request)
  doAssert false
except CborError as exc:
  # "<string>" helps identify the source of the data - this can be a
  # filename, URL or something else that helps the user find the error
  echo "Failed to parse data: ", exc.formatMsg("<string>")

Custom parsing

Happy we averted a crisis by adding the forgotten exception handler, we go back to the JSON-RPC specification and notice that strings are actually allowed in the id field - further, the only thing we have to do with id is to pass it back in the response - we don't really care about its contents.

We'll define a helper type to deal with this situation and attach some custom parsing code to it that checks the type. Using CborBytes as underlying storage is an easy way to pass around snippets of CBOR whose contents we don't need.

The custom code is added to readValue/writeValue procedures that take the stream and our custom type as arguments:

type CborRpcId = distinct CborBytes

proc readValue*(
    r: var Cbor.Reader, val: var CborRpcId
) {.raises: [IOError, CborReaderError].} =
  let ckind = r.parser.cborKind()
  case ckind
  of CborValueKind.Unsigned, CborValueKind.Negative, CborValueKind.String,
      CborValueKind.Null:
    # Keep the original value without further processing
    var raw: CborBytes
    r.parseValue(raw)
    val = CborRpcId(raw)
  else:
    r.parser.raiseUnexpectedValue("Invalid RequestId, got " & $ckind)

proc writeValue*(w: var Cbor.Writer, val: CborRpcId) {.raises: [IOError].} =
  w.writeValue(CborBytes(val)) # Preserve the original content

Usage example:

type Request = object
  cborrpc: string
  `method`: string
  params: seq[int]
  id: CborRpcId # CBOR blob

let encoded = Cbor.encode(Request(id: Cbor.encode("test").CborRpcId))
let decoded = Cbor.decode(encoded, Request)
doAssert Cbor.decode(decoded.id.CborBytes.toBytes(), string) == "test"

Flavors and strictness

While the defaults that cbor_serialization offers are sufficient to get started, implementing CBOR-based standards often requires more fine-grained control, such as what to do when a field is missing, unknown or has high-level requirements for parsing and output.

We use createCborFlavor to declare the new flavor passing to it the customization options that we're interested in:

createCborFlavor CrpcSys,
  automaticObjectSerialization = false,
  requireAllFields = true,
  omitOptionalFields = true, # Don't output `none` values when writing
  allowUnknownFields = false

CrpcSys.defaultSerialization(Result)

Required and optional fields

In the CBOR-RPC example, both the cborrpc version tag and method are required while parameters and id can be omitted. Our flavor required all fields to be present except those explicitly optional - we use Opt from results to select the optional ones:

type Request = object
  cborrpc: string
  `method`: string
  params: Opt[seq[int]]
  id: Opt[CborRpcId]

Automatic object conversion

The default Cbor flavor allows any object to be converted to CBOR. If you define a custom serializer and someone forgets to import it, the compiler might end up using the default instead resulting in a nasty runtime surprise.

automaticObjectSerialization = false forces a compiler error for any type that has not opted in to be serialized:

# Allow serializing the `Request` type - serializing other types will result in
# a compile-time error because `automaticObjectSerialization` is false!
CrpcSys.defaultSerialization Request

With all that work done, we can finally use our custom flavor to encode and decode the Request:

let cbor = Cbor.encode(
  Request(
    cborrpc: "2.0",
    `method`: "subtract",
    params: Opt.some(@[42, 3]),
    id: Opt.some(Cbor.encode(1).CborRpcId),
  )
)

let decoded = CrpcSys.decode(cbor, Request)
echo decoded

More examples

Further examples of how to use cbor_serialization can be found in the tests folder.