|
parcel 0.2.2
Wrappable, wire-transferable C++23 value system with JSON serialization
|
Wrappable, wire-transferable C++23 value system with JSON serialization.
A cell is a typed value that knows how to serialize itself to JSON and back. parcel ships a small family of cells — primitives, lists, maps, hash-maps, structs, unions — that all share one wire shape: {"k": <kind>, "v": <value>, "d": <optional display info>}. A ParcelRegistry dispatches incoming JSON back to the right cell type so heterogeneous payloads round-trip safely.
It is not an RPC framework. It just gives you the value layer: typed C++ values on one side, self-describing JSON on the wire, and a registry that glues the two together.
<parcel/parcel.h>.{k, v, d}) for every cell.StructCell base for your own structs."k" tag is verified against the static kind_id.try_* parsing surface returning std::expected<T, ParcelError> (when <expected> is available).std::format, std::hash, operator<=>, std::generator walk helpers, and std::expected integration.std::chrono, std::filesystem::path, std::array, std::deque, std::list, std::set, std::unordered_map<std::string, T>, std::variant, and std::optional adapters out of the box.nlohmann_json 3.12.0 and GoogleTest 1.15.2 are fetched automatically; you do not need to install them.parcel is header-only. Pull it into your CMake project with FetchContent, pinned to a release tarball:
A complete worked example, including the override pattern used for local development, lives in examples/consumers/fetch_content/.
parcel ships a meson.build so it can be consumed as a Meson subproject. Drop a wrap file in subprojects/parcel.wrap:
Then in your meson.build:
nlohmann_json is pulled in transitively via parcel's own wrap, the same way CMake's FetchContent pulls it for that build.
Construct a primitive cell, serialize it, and read it back through the registry. *(The exact snippet below lives at examples/hello.cpp and runs via make example; see examples/primitive_demo.cpp for a longer tour.)*
<parcel/parcel.h> is the only include you need for the public API. It pulls in the umbrella registry, the std-adapter cells (chrono, filesystem, hash maps), and the formatting and walking helpers.
Every cell serializes to {"k": <kind>, "v": <value>, "d": <display info>}. The "d" block is omitted when no display info is set. Inside a struct cell, every field is itself a {k, v} cell:
128-bit integers are encoded as decimal strings because JSON has no native integer type wider than 53 bits. Deserialization is strict: every documented field must be present and "k" is verified against the cell's static kind_id. Run make docs for the full reference.
A default-constructed ParcelRegistry already contains every primitive, the heterogeneous and typed list/map/hash-map families, plus the chrono and filesystem adapter cells. Pass a BuiltinsOptions{} to opt out of any of those four batches.
| C++ type | cell alias | wire kind | notes |
|---|---|---|---|
bool | BoolCell | bool | |
char | CharCell | char | one-character JSON string |
std::int8_t | I8Cell | i8 | |
std::int16_t | I16Cell | i16 | |
std::int32_t | I32Cell | i32 | |
std::int64_t | I64Cell | i64 | |
parcel::i128 | I128Cell | i128 | encoded as a decimal string (JSON has no 128-bit ints) |
std::uint8_t | U8Cell | u8 | |
std::uint16_t | U16Cell | u16 | |
std::uint32_t | U32Cell | u32 | |
std::uint64_t | U64Cell | u64 | |
parcel::u128 | U128Cell | u128 | encoded as a decimal string |
float | FloatCell | f32 | |
double | DoubleCell | f64 | |
std::string | StringCell | string |
The 128-bit primitives are gated on COMMONS_HAS_INT128 (from commons) and are present for any compiler that exposes __int128.
For each primitive T listed above, the corresponding aliases exist:
| family | example aliases | wire kind |
|---|---|---|
| typed list | I32ListCell, StringListCell, BoolListCell, … | l:<elem> |
| typed map | I32MapCell, StringMapCell, BoolMapCell, … | m:<elem> |
| typed hash map | I32HashMapCell, StringHashMapCell, BoolHashMapCell, … | hm:<elem> |
| heterogeneous list | ListCell | l |
| heterogeneous map | MapCell (string keys, ordered) | m |
| heterogeneous hash | HashMapCell (string keys, unordered storage) | hm |
Hash-map cells use std::unordered_map storage. Iteration in storage is unspecified, but the wire form is canonical (sorted keys) so equal maps always serialize identically.
| cell | wire kind | wire shape |
|---|---|---|
SystemTimePointCell | time:sys_seconds | i64 epoch seconds |
UnixMillisCell | time:unix_ms | i64 epoch milliseconds |
TimestampMsCell | time:unix_ms | alias of UnixMillisCell |
DurationMsCell | time:ms | i64 milliseconds |
YmdCell | time:ymd | ISO-8601 "YYYY-MM-DD" string |
PathCell | fs:path | UTF-8 portable path string |
Adapters for the aurimasniekis/cpp-commons value types live in <parcel/commons.h> and are pre-registered when BuiltinsOptions::commons is true (the default):
| cell | wire kind | wire shape |
|---|---|---|
ColorCell | color | hex string |
IconCell | icon | "set:name" string |
DisplayInfoCell | display_info | JSON object |
FlagCell | flag | flag name string |
FlagSetCell | flag_set | array of flag names |
SemVerCell | semver | canonical version string |
VersionConstraintCell | version_constraint | npm-style range string |
OriginCell | origin | {"kind", …fields} object |
SemVerCell / VersionConstraintCell carry comms::SemVer / comms::VersionConstraint; a malformed range throws on decode (commons' parse throws), matching parcel's strict-deserialization policy. OriginCell carries a move-only comms::OriginPtr and resolves the "kind" discriminator against comms::GlobalOriginRegistry on decode (an unknown kind throws).
User-defined cells live in two more namespaces: structs under s: (e.g. s:person) and unions under u: with the alternatives joined by commas (e.g. u:i32,string).
Each section quotes a tight version of the matching examples/*.cpp. Open the file for the full runnable form.
You write the data in plain C++ and a small wrapper class teaches parcel how to serialize it. StructCell is a CRTP base — Curiously Recurring Template Pattern, where the wrapper passes itself in as a template arg so the base can call back into it. The third template arg is the bare kind id; parcel prepends s:, so the wire kind below is "s:person".
std::optional<T> becomes an optional field — omitted from the wire when empty. *(See examples/struct_demo.cpp.)*
FieldsBuilder infers each field's cell type from its C++ type via default_cell_for. The set of supported defaults is documented under Standard library interop below.
Required statics on a StructCell subclass:
| static | required? | what it does |
|---|---|---|
kind_id | auto | synthesized as "s:" + StructId — never declare it manually |
field_descriptors() | yes | returns the result of a FieldsBuilder<Payload>{}.field<…>(…).build() |
display_info() | optional | cell-level display info; defaults to an empty parcel::DisplayInfo{} |
allow_extra_fields | optional | true opts into lenient deserialization; defaults to false |
When Derived::allow_extra_fields is true, unknown JSON keys are routed through the registry and retained in extras (a std::map<std::string, parcel::cell_t>). The on-the-wire round trip is preserved. *(See examples/struct_extras_demo.cpp.)*
Without that flag, an unknown key throws — matching parcel's strict deserialization stance.
FieldsBuilder::field<MemberPtr>(key) looks the cell type up via parcel::default_cell_for<FieldType> so most ordinary members need no explicit cell argument. The default-cell resolver covers:
PrimitiveCell,std::vector<T>, std::array<T, N>, std::deque<T>, std::list<T>, and std::set<T> → TypedListCell<…>,std::map<std::string, T> → TypedMapCell<…>,std::unordered_map<std::string, T> → TypedMapCell<…> (sorted on the wire),std::optional<T> → the same wrapper as T, with optionality handled by struct-field absence,std::variant<Ts...> → UnionCell<default_cell_for_t<Ts>...>,PARCEL_DEFAULT_CELL.*(See examples/defaults_demo.cpp and examples/std_interop_demo.cpp.)*
Adding a new primitive is a short recipe: define a storage type, give it JSON conversions, derive BaseCell, declare a kind_id, and register it. *(See examples/custom_primitive_demo.cpp.)*
PARCEL_DEFAULT_CELL(CellT) is a one-line macro that specializes parcel::default_cell_for<CellT::storage_t>. Then registry.register_kind(UuidCell::descriptor()) and UuidCell is a fully wire-capable cell on the same footing as I32Cell or StringCell.
The registry is what turns "some JSON" into "the right cell". A default- constructed ParcelRegistry already contains every built-in primitive, list, map, hash map, and the std-adapter cells; you only register your own kinds. Pass a BuiltinsOptions{} if you want a leaner registry — the four flags (primitives, collections, typed_collections, std) toggle each batch independently. *(See examples/registry_demo.cpp.)*
The registry also supports introspection and schema export:
Variadic registration helpers are also available:
Two convenience factories build cell_t (a.k.a. std::shared_ptr<ICell>) without spelling out std::make_shared:
parcel::cell(v) looks the wrapper up via default_cell_for<T>; every built-in primitive plus any cell registered with PARCEL_DEFAULT_CELL is eligible. Cell::of(args...) skips the lookup and forwards straight to std::make_shared<Cell>. Cell::unique(args...) is the std::unique_ptr equivalent for callers who want sole ownership. *(See examples/cell_handle_demo.cpp for more on cell_t ownership.)*
Every cell can carry a small display-info block — name, description, icon, color — that travels with the value under "d". Builders are immutable: each returns a fresh cell. *(See examples/display_info_demo.cpp.)*
with_display_info(DisplayInfo{...}) replaces the whole block at once (the accessor and builder are named for the DisplayInfo they carry; the wire key stays the terse "d"). Reading goes through cell->overridden_display_info(), which returns a std::optional<DisplayInfo>. Comparison and hashing both ignore display info — two cells with the same k/v but different overridden_display_info() are equivalent.
Two flavours: typed (homogeneous, raw scalars on the wire) and generic (heterogeneous, full cells on the wire). The free helper parcel::cell(x) wraps any built-in type in the right cell. *(See examples/list_demo.cpp, examples/map_demo.cpp.)*
Struct cells can splice another struct's fields in, override one by key, or drop one. The wire stays flat — every field, inherited or not, lives at the top of the cell's "v" object. *(See examples/struct_inheritance_demo.cpp.)*
A UnionCell<Ts...> is a closed-set polymorphic cell — it holds exactly one of a fixed list of alternatives. The wire "k" lists every alternative in template order (e.g. "u:i32,string,bool") and the inner "v" is itself a {k,v} cell so the active alternative is always recoverable. *(See examples/union_demo.cpp and examples/union_visit_demo.cpp.)*
u.get<I>() retrieves by alternative index; u.get<S>() retrieves by the underlying storage type; u.get_if<I>() / u.get_if<S>() return a pointer (or nullptr) instead of throwing. Free visit, get, and get_if overloads mirror the member-function counterparts.
Strict deserialization throws on malformed input. When <expected> is available, every parsing entry point also has a non-throwing twin that returns std::expected<…, parcel::ParcelError>. ParcelError carries a coarse code (InvalidJson, KindMismatch, UnknownKind, MissingField, TypeError), a message, and (when relevant) the offending kind and field. *(See examples/error_handling_demo.cpp and examples/format_io_demo.cpp.)*
The full non-throwing surface:
| function | header |
|---|---|
ParcelRegistry::try_cell_from_json | <parcel/registry.h> |
ParcelRegistry::try_cell_from_string | <parcel/registry.h> |
parcel::try_cell_cast<C> | <parcel/cell.h> |
parcel::try_cell_from_stream | <parcel/json_io.h> |
parcel::try_cell_from_bytes | <parcel/json_io.h> |
For library authors who want to ship a CRTP base that owns common fields and carves its own kind-id namespace, use SelfStructCell (the deriving class is itself the payload) together with id_join_lit_v to compose the prefix. Concrete subclasses then declare a tiny event_field_descriptors hook. *(See examples/intrusive_struct_demo.cpp.)*
This is heavier C++ than the rest — reach for it only when you genuinely want a shared base across many struct cells.
Required statics on a SelfStructCell subclass:
| static | required? | what it does |
|---|---|---|
kind_id | yes | declared by the deriving class (often via id_join_lit_v in the CRTP base) |
field_descriptors() | yes | returns the result of a FieldsBuilder<Derived>{}.field<…>(…).build() |
display_info() | optional | cell-level display info; defaults to an empty parcel::DisplayInfo{} |
allow_extra_fields | optional | true opts into lenient deserialization; defaults to false |
If you ship a library and want to offer parcel Cell wrappers without forcing every consumer to depend on parcel, ship them in a separate adapter header (e.g. <mylib/parcel.h>) gated on __has_include:
Conventions: namespace your wire kind ids ("mylib.color" not "color") so multiple libraries coexist in one registry, and always #define MYLIB_HAS_PARCEL to 0 or 1 so consumers can #if on it without -Wundef warnings. A complete worked example — examples/consumers/optional_adapter/ — builds in both modes from one tree.
parcel ships C++23 adapter headers and free helpers that lean into the standard library so you don't have to wrap every value yourself. All of them are pulled in by <parcel/parcel.h>.
Every cell supports operator== and operator<=> (std::partial_ordering, because some storage types — like double with NaN — aren't totally ordered). std::hash<parcel::cell_t> and std::hash<parcel::ICell> are specialized too, so cells drop into std::set / std::unordered_set. Comparison and hashing both ignore display info.
cell_from_stream, cell_from_bytes, and cell_to_stream skip the std::string round-trip when reading/writing JSON. Each has a non-throwing try_* counterpart that returns std::expected.
parcel::walk_to_vector(root) returns every (json-pointer-path, cell) pair in a ListCell / MapCell tree, depth-first. When <generator> is available, parcel::walk(root) returns a truly lazy std::generator of the same shape — each pull advances exactly one node, so the tree is never fully materialized. StructCell and UnionCell are leaves in both walks; descend into struct fields explicitly via descriptor introspection if you need to.
TypedListCell constructs from any std::ranges::input_range (via std::from_range) and exposes as_span() for read or write views over its storage. TypedMapCell constructs from a paired range and exposes keys() / values() views (also on MapCell).
SystemTimePointCell, UnixMillisCell (alias TimestampMsCell), DurationMsCell, and YmdCell cover the typical wire shapes (see Std-adapter cells above). All four are pre-registered when BuiltinsOptions::std is true (the default).
PathCell wraps std::filesystem::path as a portable UTF-8 string via path::generic_string(). Wire kind: fs:path. Pre-registered when BuiltinsOptions::std is true.
UlidCell wraps ulid::Ulid as a 26-character Crockford base32 string. Wire kind: ulid. Unlike the other adapters the ULID dependency is opt-in — the header is gated on PARCEL_HAS_ULID (auto-detected via __has_include(<ulid/ulid.h>), or predefined by the build option), so it is inert unless you turn it on:
-DPARCEL_WITH_ULID=ON fetches aurimasniekis/cpp-ulid and defines PARCEL_HAS_ULID=1.-Dulid=true pulls the ulid wrap and adds the same define.When enabled, UlidCell is pre-registered alongside the commons cells (toggle with BuiltinsOptions::ulid); the flag is present but inert when ULID is off.
TypedHashMapCell<T> and HashMapCell are std::unordered_map-backed siblings of TypedMapCell / MapCell, with wire kinds hm:<elem> and hm. Iteration in storage is unspecified, but the wire form is canonical (sorted keys) so two equal maps always serialize identically. They are registered alongside the ordered maps when BuiltinsOptions::collections and typed_collections are on.
Cells plug into std::format: std::format("{}", cell) produces the compact to_string() form, and std::format("{:#}", cell) produces the multi-line to_formatted_string() form. The same specializations cover cell_t (a null cell_t renders as "<null>"):
| spec | output |
|---|---|
{} | compact to_string() |
{:#} | multi-line to_formatted_string() |
{:j} | compact JSON via to_json().dump() |
{:j2} | pretty JSON via to_json().dump(2) |
{:k} | kind id only |
operator<< for std::ostream uses the compact form.
When <print> is available (__cpp_lib_print), parcel::print(...) and parcel::println(...) thinly wrap std::print / std::println so cell values can flow into stdout with the same format specs above without pulling std::print into every translation unit.
The CMake build is the source of truth; the Makefile just memorizes the common invocations.
| option | default | effect |
|---|---|---|
PARCEL_BUILD_TESTS | top-level ON | Build parcel_tests and register with CTest |
PARCEL_BUILD_EXAMPLES | top-level ON | Build parcel_*_demo example targets |
PARCEL_BUILD_DOCS | OFF | Add the parcel_docs Doxygen target |
PARCEL_ENABLE_CLANG_TIDY | OFF | Run clang-tidy during the build |
PARCEL_ENABLE_SANITIZERS | OFF | Compile with AddressSanitizer + UBSan |
PARCEL_ENABLE_COVERAGE | OFF | Compile with Clang source-based coverage |
PARCEL_WARNINGS_AS_ERRORS | top-level ON | -Werror / /WX |
PARCEL_INSTALL | top-level ON | Generate install rules and CMake package files |
PARCEL_WITH_ULID | OFF | Enable the ULID cell (fetches aurimasniekis/cpp-ulid) |
Configure presets in CMakePresets.json: debug, release, relwithdebinfo, minsizerel. Each has a matching build and test preset so cmake --preset release && cmake --build --preset release && ctest --preset release works out of the box.
| target | what it does |
|---|---|
make build | configure + build under build/ |
make test | configure + build + ctest |
make example | run build/examples/parcel_hello |
make sanitize | configure + build + test in build-san/ with ASan + UBSan |
make tidy | configure + build in build-tidy/ with clang-tidy |
make tidy-fix | same as tidy but with -DPARCEL_CLANG_TIDY_FIX=ON |
make release | configure + build + test in build-release/ (Release) |
make coverage | configure + build + test in build-coverage/, emit HTML |
make docs | build the Doxygen reference into build-docs/docs/html/ |
make format | clang-format -i over include/, tests/, examples/ |
make ci | full pre-push gate (format + tidy + test + ASan + Release) |
compile_commands.json is exported automatically into the build directory for editor tooling.
meson.options exposes tests, examples, and ulid toggles (all default false for downstream consumers). To build everything locally:
-Dulid=true enables the ULID cell (resolves the ulid wrap and defines PARCEL_HAS_ULID=1). Wraps for nlohmann_json, commons, ulid, and gtest are declared under subprojects/ so Meson can build offline once fetched.
.github/workflows/ci.yml runs five jobs on every push and pull request:
examples/consumers/fetch_content/ against the in-tree checkout to catch downstream breakage.make sanitize.make tidy.make format-check.Run the full test suite with make test (or ctest --test-dir build --output-on-failure). The 11 GoogleTest suites under tests/ cover:
test_primitive.cpp — every primitive cell, including 128-bit ints.test_list.cpp, test_map.cpp — typed and heterogeneous containers.test_struct.cpp — StructCell, optional/vector fields, inheritance, allow_extra_fields.test_union.cpp — UnionCell, active tracking, get<I> / get<S>.test_registry.cpp — registry dispatch, introspection, schema export.test_display_info.cpp — DisplayInfo and immutable with_* builders.test_compare.cpp — operator==, operator<=>, std::hash.test_cell_helpers.cpp — parcel::cell(), Cell::of(), cell_cast, as, value_or.test_ergonomics.cpp — make_list / make_map, ranges, keys() / values(), variadic registration helpers.test_std_interop.cpp — chrono cells, PathCell, hash-backed maps, default-cell inference for std::array / std::deque / std::list / std::set.test_parcel.cpp — core wire format and round-trip.Contributions to the library are welcome! If you encounter any issues or have suggestions for improvements, please feel free to submit a pull request or open an issue on the project's repository.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.