Skip to content
pooriayousefiPublic

About

A single-header, dependency-free C++23 JSON and JSON-RPC 2.0 library with exception-free parsing (std::expected), strict/lenient modes, and native variant-backed value semantics.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PooriJSON & PooriJSONRPC

A pair of single-header, dependency-free libraries for C++23. poorijson.hpp provides a value-semantic JSON type, a strict RFC 8259 parser, and a tolerant parser for messy "LLM-style" input. poorijsonrpc.hpp adds JSON-RPC 2.0 message builders and a message classifier on top of it — all without exceptions for parse errors (std::expected), without dependencies, and in just two files.

#include "poorijsonrpc.hpp"

namespace pj = pooriayousefi::json;

pj::JSON app;
app["name"] = "demo";
app["features"].push_back("parse");
std::cout << app.dump(2) << '\n';

pj::JSON req = pj::rpc::make_request("initialize", pj::JSON(1));
std::cout << req.dump() << '\n';

Table of contents


Features

  • Zero dependencies. Drop the headers into your include path and you're done.
  • Value semantics. A JSON object owns its whole tree; copies are deep, moves are cheap.
  • Exception-free error reporting. The parser and checked accessors return std::expected<T, JSONError>; malformed input never throws.
  • Strict parsing. Full RFC 8259 grammar: correct number rules, escape sequences, and \uXXXX decoding including UTF-16 surrogate pairs.
  • Lenient parsing. parse_lenient() strips Markdown code fences and leading prose, and repairs unclosed containers — handy for model-generated output.
  • Pretty-printing. Compact or indented output, locale-independent number formatting via std::to_chars.
  • Transparent object lookups. operator[], at(), contains(), and erase() on objects use std::string_view transparently — zero allocation for existing keys.
  • JSON-RPC 2.0 helpers. Build requests, notifications, responses, errors, and batches; classify incoming messages by structural type.

Requirements

Requires C++23 (std::expected, std::format, <ranges>, <concepts>). Tested with GCC 13+, Clang 16+, and MSVC 19.34+.

Project Structure

poorimcp/
├── bin/
├── include/
│   ├── poorijson.hpp
│   └── poorijsonrpc.hpp
├── src/
│   └── main.cpp
└── README.md

Building

Mac/Linux:

mkdir -p bin
clang++ -std=c++23 -O3 -I include src/main.cpp -o bin/poorijson_test
./bin/poorijson_test

Windows (PowerShell):

if (-not (Test-Path bin)) { New-Item -ItemType Directory bin }
cl /std:c++23 /EHsc /I include src/main.cpp /out:bin\poorijson_test.exe
.\bin\poorijson_test.exe

The value model

A JSON instance holds exactly one of six types, backed by a std::variant:

JSON type C++ type Alias
null std::monostate (default) —
boolean bool pj::JSONBool
number double pj::JSONNumber
string std::string (UTF-8, unescaped internally) pj::JSONString
array std::vector<JSON> pj::JSONArray
object std::unordered_map with transparent hash/eq pj::JSONObject

All numbers are doubles (like JavaScript): integers beyond ±2⁵³ lose precision.

The Type enumerators use SCREAMING_SNAKE_CASE: NULL_TYPE, BOOLEAN, NUMBER, STRING, ARRAY, OBJECT. NULL_TYPE avoids the C NULL macro. A static_assert verifies that these indices match the std::variant alternative order at compile time.

Creating values

Implicit constructors cover the common cases:

pj::JSON a;                  // null
pj::JSON b = nullptr;        // null
pj::JSON c = true;           // boolean
pj::JSON d = 42;             // number (int → double)
pj::JSON e = 3.14;           // number
pj::JSON f = "hello";        // string (const char*)
pj::JSON g = std::string_view("view"); // string from string_view
pj::JSON h = pj::JSONArray{1, "two", false};   // array
pj::JSON i = pj::JSONObject{{"k", "v"}};       // object

Braced initialization

A braced list is inspected to guess the container kind:

  • If every element is a two-element array whose first element is a string — { {"key", value}, ... } — an object is built.
  • Otherwise (including mixed or empty lists) an array is built.
pj::JSON obj = { {"host", "localhost"}, {"port", 8080} };
// {"host":"localhost","port":8080}

pj::JSON arr = { 1, 2, 3 };
// [1,2,3]

pj::JSON mixed = { {"a", 1}, 2 };
// not every element is a pair → array: [["a",1],2]

pj::JSON empty_arr = pj::JSONArray{};   // unambiguous empty array
pj::JSON empty_obj = pj::JSONObject{};  // unambiguous empty object

Reading values

Type predicates

const auto& v = doc["port"];
v.is_null(); v.is_boolean(); v.is_number();
v.is_string(); v.is_array(); v.is_object();
v.get_type();   // pj::JSON::Type enum

Checked access — never throws

as_bool(), as_number(), as_string() return std::expected<..., JSONError>:

if (auto port = doc["port"].as_number())
    std::cout << "port = " << *port << '\n';
else
    std::cout << "not a number (error " << static_cast<int>(port.error()) << ")\n";

Checked element access — at()

at() returns std::expected<std::reference_wrapper<JSON>, JSONError> — call .get() on the value to obtain the JSON&:

// Object key access
if (auto v = doc.at("port"))
    std::cout << "port = " << v->get().get_number() << '\n';
else
    std::cout << "error: " << static_cast<int>(v.error()) << '\n';
// Possible errors: NOT_AN_OBJECT, KEY_NOT_FOUND

// Array index access
if (auto e = arr.at(0))
    std::cout << "elem[0] = " << e->get().dump() << '\n';
// Possible errors: NOT_AN_ARRAY, INDEX_OUT_OF_RANGE

Unchecked access — can throw

doc["name"].get_string();   // throws std::bad_expected_access on type mismatch
doc["port"].get_number();   // same
doc.get_array();            // throws std::bad_variant_access if not an array
doc.get_object();           // throws std::bad_variant_access if not an object

The operator[] split — important

Overload Missing key / index Side effects
operator[](std::string_view) const returns a reference to a shared null value none — safe for probing
operator[](std::string_view) non-const inserts a null member and returns it mutates!
operator[](std::size_t) const returns shared null none
operator[](std::size_t) non-const grows the array with nulls mutates!

All object overloads use transparent hash/eq — lookups by std::string_view incur zero allocation for existing keys. A new key is allocated only when inserting (non-const operator[] on a missing key).

pj::JSON j = pj::parse(R"({"a":1})").value();

j.contains("b");        // false — contains() never mutates
const pj::JSON& cj = j;
cj["missing"].is_null(); // true — const access inserts nothing

j["b"];                 // ⚠ inserts "b": null!
j.dump();               // {"a":1,"b":null}

Tip: use contains() for membership tests, and bind a const JSON& before doing read-heavy member access.

Building and mutating

The non-const operator[] auto-vivifies: a null value becomes an object or array as needed, so structure grows on assignment:

pj::JSON j;                        // null
j["user"]["name"] = "Ada";         // {"user":{"name":"Ada"}}
j["user"]["langs"].push_back("C++");
j["scores"][2] = 99;               // [null,null,99] — auto-sized

Other mutation:

j.push_back(value);   // append to array (null → empty array first);
                      // returns a reference to the appended element
j.erase("key");       // remove object member (no-op if absent/not object)

Calling the non-const operator[] on a value that currently holds a scalar (number, string, bool) throws std::bad_variant_access — it only auto-converts from null, it never overwrites existing data.

Serializing

std::string text = doc.dump();    // compact: {"a":1,"b":[1,2]}
std::string text = doc.dump(4);   // pretty: 4 spaces per level
doc.dump(std::cout, 2);           // stream directly
std::ofstream out("file.json");
doc.dump(out, 4);                 // stream to a file

Pretty-printed output looks like:

{
    "compact": true,
    "n": [
        1,
        2,
        3
    ]
}

Behavior details:

  • Serialization builds directly into a pre-reserved std::string — no std::ostringstream overhead. Control characters use a lookup table, not std::format, for speed.
  • Numbers are formatted with std::to_chars — shortest form that round-trips, and locale-independent (no , decimal separators).
  • NaN serializes as null; ±∞ serializes as ±1e309 (JSON has no literals for non-finite values; 1e309 parses back to infinity).
  • Strings are fully escaped: ", \, \b, \f, \n, \r, \t, and control characters below 0x20 as \u00XX. Existing UTF-8 bytes pass through unchanged.
  • Object key order is unspecified (std::unordered_map), and may differ between builds. Do not rely on it.

Parsing

pj::parse() runs the strict parser over a complete document:

auto r = pj::parse(R"({"name":"ada","tags":["math","pioneer"],"age":36})");
if (r) {
    std::cout << r->get_object().size() << " members\n";
} else {
    std::cout << "error: " << static_cast<int>(r.error()) << '\n';
}

The parser validates the full RFC 8259 grammar:

  • Numbers: no leading zeros (01 rejected), fractions and exponents require digits, +/- only after e/E.
  • Strings: all standard escapes; unknown escapes are errors; raw control characters below 0x20 are rejected.
  • Unicode: \uXXXX escapes, including surrogate pairs, are decoded and re-encoded as UTF-8. Both from_chars calls (high and low surrogate) are error-checked.
  • Objects/arrays: separators enforced; trailing commas rejected; only whitespace may follow the top-level value.
  • Duplicate object keys are allowed; the last value wins.

No exceptions are thrown for bad input — every failure comes back as a JSONError.

Lenient parsing

parse_lenient() handles "dirty" text — Markdown fences, leading prose, and missing closing brackets/braces — before handing off to the strict parser:

std::string_view llm = R"(```json
{
    "answer": 42,
    "notes": ["fully", "quoted"]
)";
auto r = pj::parse_lenient(llm);
// → {"answer":42,"notes":["fully","quoted"]}
// (the missing closing brace and bracket were repaired)
std::string_view chatty = R"(Here is the configuration you requested:
{"retries": 3, "timeout_ms": 250)";
auto r = pj::parse_lenient(chatty);
// → {"retries":3,"timeout_ms":250}

The repair pipeline, in order:

  1. Strip a leading ```json / ``` fence and a trailing ``` (only when the text starts/ends with one).
  2. Trim surrounding whitespace (whitespace-only input → null).
  3. Discard any prose before the first { or [.
  4. Track unmatched containers using a stack, ignoring braces/brackets inside string literals and after backslash escapes.
  5. Append the missing } / ] characters in reverse nesting order so closures match the opening order.
  6. Run the strict parser on the result.

How the stack works: each { or [ is pushed onto a stack; each matching } or ] pops it. At the end, remaining stack entries are closed in reverse (last-opened first). This means both [{"a":1 → [{"a":1}] and {"a":[1,2 → {"a":[1,2]} are repaired correctly.

Limits of the repair: it only appends closers — it never deletes trailing junk after the value, fixes missing commas, or repairs truncated strings. When repair isn't enough, you simply get the strict parser's error for the (still broken) text.

JSON-RPC 2.0

Include poorijsonrpc.hpp to build and classify spec-compliant messages:

namespace pj = pooriayousefi::json;

// A request (params optional — omitted when null)
pj::JSON req = pj::rpc::make_request("subtract", pj::JSON{42, 23}, pj::JSON(1));
// {"jsonrpc":"2.0","method":"subtract","params":[42,23],"id":1}

pj::JSON req2 = pj::rpc::make_request("ping", pj::JSON(2));
// {"jsonrpc":"2.0","method":"ping","id":2}   (no "params" field)

// A notification (no id — server MUST NOT respond)
pj::JSON notif = pj::rpc::make_notification("notifications/initialized");
// {"jsonrpc":"2.0","method":"notifications/initialized"}

// A success response
pj::JSON ok = pj::rpc::make_response(pj::JSON(19), pj::JSON(1));
// {"jsonrpc":"2.0","result":19,"id":1}

// An error response (optional structured "data" member)
pj::JSON err = pj::rpc::make_error(
    pj::rpc::ErrorCode::INVALID_PARAMS,
    "Invalid params", pj::JSON(1), pj::JSON("/dev/sda1"));
// {"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params",
//  "data":"/dev/sda1"},"id":1}

// A batch (array of messages)
pj::JSON batch = pj::rpc::make_batch({req, notif, ok, err});
// [ {...}, {...}, {...}, {...} ]

Reserved error codes (pj::rpc::ErrorCode) — pass directly to make_error without manual static_cast<int>:

Code Enumerator
-32700 PARSE_ERROR
-32600 INVALID_REQUEST
-32601 METHOD_NOT_FOUND
-32602 INVALID_PARAMS
-32603 INTERNAL_ERROR

The data member is omitted entirely when the value is null (or not supplied).

Message classification

classify() determines the structural type of an incoming message:

pj::JSON msg = pj::parse(raw_text).value();
switch (pj::rpc::classify(msg))
{
case pj::rpc::MessageType::REQUEST:          // has "method" + "id"
    handle_request(msg);
    break;
case pj::rpc::MessageType::NOTIFICATION:     // has "method", no "id"
    handle_notification(msg);                // do NOT send a response
    break;
case pj::rpc::MessageType::RESPONSE_SUCCESS: // has "result" + "id"
    handle_response(msg);
    break;
case pj::rpc::MessageType::RESPONSE_ERROR:   // has "error" + "id"
    handle_error(msg);
    break;
case pj::rpc::MessageType::BATCH:            // is a JSON array
    for (const auto& sub : msg.get_array())
        /* recurse on each element */;
    break;
default:                                     // INVALID
    send_error(pj::rpc::ErrorCode::INVALID_REQUEST, "Invalid request");
    break;
}

The classifier checks mutual exclusivity of method, result, and error, and the presence of id for requests and responses. It is a structural check — it does not validate the jsonrpc version string or field contents.


Limitations and gotchas

Know these before depending on the library:

  1. Numbers are doubles. Integers beyond ±2⁵³ (e.g. 9007199254740993) do not round-trip exactly. There is no int64/bigint/string-number mode.
  2. Object key order is unspecified (std::unordered_map) and duplicates: last one wins. Pretty-printed output ordering can vary between runs/builds.
  3. Auto-vivification can bite. Non-const j["key"] inserts; a typo in a read path silently grows your object. Prefer contains(), at(), and const JSON& for reads.
  4. Deep nesting recurses. The parser and serializer are recursive descent; adversarially deep input can overflow the stack (no depth limit is enforced).
  5. Non-finite numbers serialize as null (NaN) / ±1e309 (±∞); the parser rejects out-of-range literals like 1e999 outright. Neither is legal JSON — this is a pragmatic compromise.
  6. Lenient repair is heuristic. See the limits listed in Lenient parsing.
  7. No streaming/SAX. The whole document must be in memory.
  8. No comments, no trailing commas, no NaN/Infinity literals — strict RFC 8259 only.
  9. Thread safety: distinct JSON instances are independent. Sharing one instance across threads for concurrent writes needs external synchronization (as with any standard container).
  10. at() returns std::reference_wrapper: checked element access via at() returns std::expected<std::reference_wrapper<JSON>, JSONError>, not a direct reference (C++23's std::expected cannot hold reference types). Call .get() on the value to obtain the JSON&.

License

Apache License 2.0


Author: Pooria Yousefi


About

A single-header, dependency-free C++23 JSON and JSON-RPC 2.0 library with exception-free parsing (std::expected), strict/lenient modes, and native variant-backed value semantics.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages