Files

6.1 KiB
Raw Permalink Blame History

Description

encoding.cbor is an RFC 8949 Concise Binary Object Representation codec.

CBOR is a compact, schema-free binary format that supports the same value model as JSON (numbers, strings, arrays, maps) plus byte strings, tagged items, IEEE 754 floats at three widths, and a small set of "simple" values (true, false, null, undefined). It is used by COSE/CWT (IETF security stack), WebAuthn/FIDO2, the Matter smart-home protocol, and many IoT stacks because messages are typically 30–60 % smaller than JSON and parse without quoting/escaping.

Three layers of API are available:

  • encode[T] / decode[T] — comptime-driven generic API. Works on primitives, strings, arrays, maps, structs (with @[cbor: 'name'], @[skip], @[cbor_rename_all: 'snake_case']), enums, time.Time (auto-tagged), and any type implementing Marshaler / Unmarshaler.
  • Packer / Unpacker — manual streaming API. Use when the schema isn't known at compile time, or when you need full control over tags, indefinite-length items and simple values.
  • Value sumtype — dynamic representation for round-tripping unknown payloads or inspecting tagged data.

Defaults follow RFC 8949 preferred serialisation (§4.2.2): floats shrink to the shortest IEEE 754 width that preserves their value, and every length argument uses the shortest encoding. Set EncodeOpts.canonical = true to additionally sort map keys for hash/signature stability (§4.2.1, deterministic encoding). Set EncodeOpts.validate_utf8 = true if callers may build strings from raw bytes (e.g. bytestr()) — the streaming pack_text trusts its input for performance, but encode[T] will then refuse to emit non-UTF-8 text strings the strict-by-default decoder would reject on the way back.

Usage

encode[T] / decode[T]

import encoding.cbor
import time

struct Person {
	name     string
	age      int
	email    ?string
	birthday time.Time
}

fn main() {
	bob := Person{
		name:     'Bob'
		age:      30
		birthday: time.now()
	}

	bytes := cbor.encode[Person](bob, cbor.EncodeOpts{})!
	// bytes is []u8 — wire-ready CBOR

	back := cbor.decode[Person](bytes, cbor.DecodeOpts{})!
	assert back.name == 'Bob'
}

Optional fields (?T) encode as CBOR null when set to none. Enums encode as their underlying integer.

Struct attributes

@[cbor_rename_all: 'kebab-case']
struct Login {
	user_name string @[cbor: 'u'] // emit/read key "u" (overrides rename_all)
	password  string @[skip]      // never serialise
	remember  bool // becomes "remember"
	is_admin  bool // becomes "is-admin"
}

The @[cbor_rename_all: '...'] attribute on a struct applies a global rename strategy to every field that doesn't have an explicit @[cbor: '...'] override — supported strategies: snake_case, camelCase, PascalCase, kebab-case, SCREAMING_SNAKE_CASE. Use @[cbor: '-'] as an alternative to @[skip].

Manual streaming with Packer / Unpacker

Use this when the schema is dynamic or when you need access to CBOR features that don't map directly to V types (tags, indefinite-length strings, custom simple values):

import encoding.cbor

fn main() {
	mut p := cbor.new_packer(cbor.EncodeOpts{})
	p.pack_array_header(3)
	p.pack_uint(42)
	p.pack_text('hello')
	p.pack_bool(true)
	bytes := p.bytes()

	mut u := cbor.new_unpacker(bytes, cbor.DecodeOpts{})
	n := u.unpack_array_header()! // 3
	a := u.unpack_uint()! // 42
	b := u.unpack_text()! // 'hello'
	c := u.unpack_bool()! // true
	_ = n
	_ = a
	_ = b
	_ = c
}

Dynamic values with Value

When the payload schema is unknown at compile time, decode into cbor.Value and walk the sumtype:

import encoding.cbor

fn main() {
	bytes := cbor.encode[map[string]int]({
		'a': 1
		'b': 2
	}, cbor.EncodeOpts{})!

	v := cbor.decode[cbor.Value](bytes, cbor.DecodeOpts{})!
	if val := v.get('a') {
		if i := val.as_int() {
			assert i == 1
		}
	}
}

Value covers every CBOR type: IntNum, FloatNum, Text, Bytes, Array, Map, Tag, Bool, Null, Undefined, Simple. Re-encoding a Value round-trips bit-for-bit when the source was already in preferred form.

Custom Marshaler / Unmarshaler

For types that need a custom on-wire representation, implement either side of the interface:

import encoding.cbor

struct Color {
mut:
	r u8
	g u8
	b u8
}

pub fn (c Color) to_cbor() []u8 {
	mut p := cbor.new_packer(cbor.EncodeOpts{})
	p.pack_array_header(3)
	p.pack_uint(c.r)
	p.pack_uint(c.g)
	p.pack_uint(c.b)
	return p.bytes().clone()
}

pub fn (mut c Color) from_cbor(data []u8) ! {
	mut u := cbor.new_unpacker(data, cbor.DecodeOpts{})
	n := u.unpack_array_header()!
	if n != 3 {
		return error('Color expects 3 elements')
	}
	c.r = u8(u.unpack_uint()!)
	c.g = u8(u.unpack_uint()!)
	c.b = u8(u.unpack_uint()!)
}

to_cbor must return exactly one well-formed CBOR data item — the generic encoder copies the bytes verbatim. from_cbor receives a slice already trimmed to one item.

Canonical (deterministic) encoding

For hashing or signing, set canonical: true so that map keys are sorted by length-then-lexicographic order (RFC 8949 §4.2.1):

import encoding.cbor

bytes := cbor.encode[map[string]int]({
	'b': 2
	'a': 1
}, cbor.EncodeOpts{ canonical: true })!
// keys are emitted in the order "a", "b" regardless of input order

Tags and time.Time

Values of type time.Time round-trip losslessly: whole-second values use tag 1 (epoch seconds, integer) for the smallest canonical wire, and sub-second values use tag 0 (RFC 3339 string with nanosecond precision) — necessary because a tag-1 float can't carry both a 10-digit unix epoch and 9 fractional digits. Decode accepts tag 0 (RFC 3339 text, any sub-second precision) or tag 1 (integer or float). Custom tags can be emitted/read via pack_tag / unpack_tag or by constructing a Value with cbor.new_tag(number, content).

Conformance

The test suite (vlib/encoding/cbor/tests/) covers every vector from RFC 8949 Appendix A, plus indefinite-length strings, depth limits, malformed-input rejection, UTF-8 validation, canonical ordering, and tagged time round-trips.

v test vlib/encoding/cbor/tests/