Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
846b164
Initial partial JSON encoder implementation
puremourning Nov 25, 2025
74604e2
Implement support for distriminators and nested prefixes; use the tes…
puremourning Nov 25, 2025
07575a0
Implement renaming of enum values
puremourning Nov 25, 2025
ed499bf
Test Inf/NaN
puremourning Nov 25, 2025
dba8cc4
Test string encoding
puremourning Nov 25, 2025
6da310e
Tidy API; take any reader type
puremourning Nov 25, 2025
2a9ca68
Nested Data encoding
puremourning Nov 25, 2025
0d13e42
Slightly less dump annotation parser
puremourning Nov 26, 2025
1b5fb8b
Add basic parsing with no unflattening and no unions
puremourning Nov 27, 2025
11edd94
Slight parsing refactor
puremourning Nov 27, 2025
1bb665a
Very dumb, but seemingly working, union parsing
puremourning Dec 3, 2025
7597620
Test for renaming with anon unions
puremourning Dec 3, 2025
c2ce793
Factor out duplicated struct member parsing
puremourning Dec 3, 2025
23222de
Move to capnp-json crate
puremourning Dec 3, 2025
e53551b
Split into more files because people like files
puremourning Dec 3, 2025
5a795af
Better test coverage
puremourning Dec 7, 2025
df79246
cargo fmt --all
puremourning Dec 7, 2025
631b96e
Remve a couple of lints
puremourning Dec 7, 2025
3ecea6a
Cargo clippy
puremourning Dec 8, 2025
a3d6558
Remove trailing whitespace from test that breaks flaky rustfmt
puremourning Dec 8, 2025
9b5ab3f
More clippy
puremourning Dec 8, 2025
d3e3d54
Don't encode Void/null values in discriminated unions
puremourning Dec 11, 2025
20339ea
Test for discriminator without a name
puremourning Dec 11, 2025
8516b73
Remove redundant argument
puremourning Dec 11, 2025
f316fe4
Add tests that round trip data using 'capnp convert' to prove compati…
puremourning Dec 12, 2025
be70432
Reduce copy paste in the roundtrip test
puremourning Dec 12, 2025
f99ddcd
Add failing test showing difference in encoding for named union membe…
puremourning Dec 12, 2025
f7ecd75
Allow missing discriminator as this is required for c++ compat
puremourning Dec 12, 2025
2cea57f
Don't output a discriminator for unflattened unnamed-discriminator union
puremourning Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"capnpc",
"capnp-futures",
"capnp-rpc",
"capnp-json",

# testing and examples
"async-byte-channel",
Expand All @@ -30,6 +31,7 @@ members = [
"example/addressbook",
"example/addressbook_send",
"example/fill_random_values",
"capnp-json/test",
]
default-members = [
"capnp",
Expand Down
20 changes: 20 additions & 0 deletions capnp-json/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]

name = "capnp-json"
version = "0.23.0"
authors = [ "Ben Jackson <puremourning@gmail.com>" ]
license = "MIT"
description = "implementation of the Cap'n Proto JSON codec in rust"
repository = "https://github.com/capnproto/capnproto-rust"
documentation = "https://docs.rs/capnp-rpc/"
categories = ["network-programming"]
autoexamples = false
edition = "2021"

readme = "README.md"

[dependencies]
capnp = {version = "0.23.0", path = "../capnp"}

#[lints]
#workspace = true
127 changes: 127 additions & 0 deletions capnp-json/json.capnp
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright (c) 2015 Sandstorm Development Group, Inc. and contributors
# Licensed under the MIT License:
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

@0x8ef99297a43a5e34;

$import "/capnp/c++.capnp".namespace("capnp::json");

struct Value {
union {
null @0 :Void;
boolean @1 :Bool;
number @2 :Float64;
string @3 :Text;
array @4 :List(Value);
object @5 :List(Field);
# Standard JSON values.

call @6 :Call;
# Non-standard: A "function call", applying a named function (named by a single identifier)
# to a parameter list. Examples:
#
# BinData(0, "Zm9vCg==")
# ISODate("2015-04-15T08:44:50.218Z")
#
# Mongo DB users will recognize the above as exactly the syntax Mongo uses to represent BSON
# "binary" and "date" types in text, since JSON has no analog of these. This is basically the
# reason this extension exists. We do NOT recommend using `call` unless you specifically need
# to be compatible with some silly format that uses this syntax.

raw @7 :Text;
# Used to indicate that the text should be written directly to the output without
# modifications. Use this if you have an already serialized JSON value and don't want
# to feel the cost of deserializing the value just to serialize it again.
#
# The parser will never produce a `raw` value -- this is only useful for serialization.
#
# WARNING: You MUST ensure that the value is valid stand-alone JSOn. It will not be verified.
# Invalid JSON could mjake the whole message unparsable. Worse, a malicious raw value could
# perform JSON injection attacks. Make sure that the value was produced by a trustworthy JSON
# encoder.
}

struct Field {
name @0 :Text;
value @1 :Value;
}

struct Call {
function @0 :Text;
params @1 :List(Value);
}
}

# ========================================================================================
# Annotations to control parsing. Typical usage:
#
# using Json = import "/capnp/compat/json.capnp";
#
# And then later on:
#
# myField @0 :Text $Json.name("my_field");

annotation name @0xfa5b1fd61c2e7c3d (field, enumerant, method, group, union) :Text;
# Define an alternative name to use when encoding the given item in JSON. This can be used, for
# example, to use snake_case names where needed, even though Cap'n Proto uses strictly camelCase.
#
# (However, because JSON is derived from JavaScript, you *should* use camelCase names when
# defining JSON-based APIs. But, when supporting a pre-existing API you may not have a choice.)

annotation flatten @0x82d3e852af0336bf (field, group, union) :FlattenOptions;
# Specifies that an aggregate field should be flattened into its parent.
#
# In order to flatten a member of a union, the union (or, for an anonymous union, the parent
# struct type) must have the $jsonDiscriminator annotation.
#
# TODO(someday): Maybe support "flattening" a List(Value.Field) as a way to support unknown JSON
# fields?

struct FlattenOptions {
prefix @0 :Text = "";
# Optional: Adds the given prefix to flattened field names.
}

annotation discriminator @0xcfa794e8d19a0162 (struct, union) :DiscriminatorOptions;
# Specifies that a union's variant will be decided not by which fields are present, but instead
# by a special discriminator field. The value of the discriminator field is a string naming which
# variant is active. This allows the members of the union to have the $jsonFlatten annotation, or
# to all have the same name.

struct DiscriminatorOptions {
name @0 :Text;
# The name of the discriminator field. Defaults to matching the name of the union.

valueName @1 :Text;
# If non-null, specifies that the union's value shall have the given field name, rather than the
# value's name. In this case the union's variant can only be determined by looking at the
# discriminant field, not by inspecting which value field is present.
#
# It is an error to use `valueName` while also declaring some variants as $flatten.
}

annotation base64 @0xd7d879450a253e4b (field) :Void;
# Place on a field of type `Data` to indicate that its JSON representation is a Base64 string.

annotation hex @0xf061e22f0ae5c7b5 (field) :Void;
# Place on a field of type `Data` to indicate that its JSON representation is a hex string.

annotation notification @0xa0a054dea32fd98c (method) :Void;
# Indicates that this method is a JSON-RPC "notification", meaning it expects no response.
120 changes: 120 additions & 0 deletions capnp-json/src/data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// We don't want to pull in base64 crate just for this. So hand-rolling a
// base64 codec.
pub mod base64 {
const BASE64_CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

pub fn encode(data: &[u8]) -> String {
let mut encoded = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
#[allow(clippy::get_first)]
let b0 = chunk.get(0).copied().unwrap_or(0);
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
let c0 = BASE64_CHARS[((n >> 18) & 0x3F) as usize];
let c1 = BASE64_CHARS[((n >> 12) & 0x3F) as usize];
let c2 = if chunk.len() > 1 {
BASE64_CHARS[((n >> 6) & 0x3F) as usize]
} else {
b'='
};
let c3 = if chunk.len() > 2 {
BASE64_CHARS[(n & 0x3F) as usize]
} else {
b'='
};
encoded.push(c0 as char);
encoded.push(c1 as char);
encoded.push(c2 as char);
encoded.push(c3 as char);
}
encoded
}

pub fn decode(data: &str) -> capnp::Result<Vec<u8>> {
let bytes = data.as_bytes();
if !bytes.len().is_multiple_of(4) {
return Err(capnp::Error::failed(
"Base64 string length must be a multiple of 4".into(),
));
}
let mut decoded = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let mut n: u32 = 0;
let mut padding = 0;
for &c in chunk {
n <<= 6;
match c {
b'A'..=b'Z' => n |= (c - b'A') as u32,
b'a'..=b'z' => n |= (c - b'a' + 26) as u32,
b'0'..=b'9' => n |= (c - b'0' + 52) as u32,
b'+' => n |= 62,
b'/' => n |= 63,
b'=' => {
n |= 0;
padding += 1;
}
_ => {
return Err(capnp::Error::failed(format!(
"Invalid base64 character: {}",
c as char
)));
}
}
}
decoded.push(((n >> 16) & 0xFF) as u8);
if padding < 2 {
decoded.push(((n >> 8) & 0xFF) as u8);
}
if padding < 1 {
decoded.push((n & 0xFF) as u8);
}
}
Ok(decoded)
}
}

// We don't want to pull in hex crate just for this. So hand-rolling a
// hex codec.
pub mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
fn hex_char_to_value(c: u8) -> capnp::Result<u8> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(capnp::Error::failed(format!(
"Invalid hex character: {}",
c as char
))),
}
}

pub fn encode(data: &[u8]) -> String {
let mut encoded = String::with_capacity(data.len() * 2);
for &byte in data {
let high = HEX_CHARS[(byte >> 4) as usize];
let low = HEX_CHARS[(byte & 0x0F) as usize];
encoded.push(high as char);
encoded.push(low as char);
}
encoded
}

pub fn decode(data: &str) -> capnp::Result<Vec<u8>> {
if !data.len().is_multiple_of(2) {
return Err(capnp::Error::failed(
"Hex string must have even length".into(),
));
}
let mut decoded = Vec::with_capacity(data.len() / 2);
let bytes = data.as_bytes();
for i in (0..data.len()).step_by(2) {
let high = hex_char_to_value(bytes[i])?;
let low = hex_char_to_value(bytes[i + 1])?;
decoded.push((high << 4) | low);
}
Ok(decoded)
}
}
Loading
Loading