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';- PooriJSON & PooriJSONRPC
- Zero dependencies. Drop the headers into your include path and you're done.
- Value semantics. A
JSONobject 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
\uXXXXdecoding 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(), anderase()on objects usestd::string_viewtransparently — zero allocation for existing keys. - JSON-RPC 2.0 helpers. Build requests, notifications, responses, errors, and batches; classify incoming messages by structural type.
Requires C++23 (std::expected, std::format, <ranges>, <concepts>).
Tested with GCC 13+, Clang 16+, and MSVC 19.34+.
poorimcp/
├── bin/
├── include/
│ ├── poorijson.hpp
│ └── poorijsonrpc.hpp
├── src/
│ └── main.cpp
└── README.md
Mac/Linux:
mkdir -p bin
clang++ -std=c++23 -O3 -I include src/main.cpp -o bin/poorijson_test
./bin/poorijson_testWindows (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.exeA 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.
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"}}; // objectA 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 objectconst 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 enumas_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";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_RANGEdoc["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| 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.
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-sizedOther 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.
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 filePretty-printed output looks like:
{
"compact": true,
"n": [
1,
2,
3
]
}Behavior details:
- Serialization builds directly into a pre-reserved
std::string— nostd::ostringstreamoverhead. Control characters use a lookup table, notstd::format, for speed. - Numbers are formatted with
std::to_chars— shortest form that round-trips, and locale-independent (no,decimal separators). NaNserializes asnull;±∞serializes as±1e309(JSON has no literals for non-finite values;1e309parses back to infinity).- Strings are fully escaped:
",\,\b,\f,\n,\r,\t, and control characters below0x20as\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.
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 (
01rejected), fractions and exponents require digits,+/-only aftere/E. - Strings: all standard escapes; unknown escapes are errors; raw
control characters below
0x20are rejected. - Unicode:
\uXXXXescapes, including surrogate pairs, are decoded and re-encoded as UTF-8. Bothfrom_charscalls (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.
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:
- Strip a leading
```json/```fence and a trailing```(only when the text starts/ends with one). - Trim surrounding whitespace (whitespace-only input →
null). - Discard any prose before the first
{or[. - Track unmatched containers using a stack, ignoring braces/brackets inside string literals and after backslash escapes.
- Append the missing
}/]characters in reverse nesting order so closures match the opening order. - 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.
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).
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.
Know these before depending on the library:
- Numbers are doubles. Integers beyond ±2⁵³ (e.g.
9007199254740993) do not round-trip exactly. There is no int64/bigint/string-number mode. - Object key order is unspecified (
std::unordered_map) and duplicates: last one wins. Pretty-printed output ordering can vary between runs/builds. - Auto-vivification can bite. Non-const
j["key"]inserts; a typo in a read path silently grows your object. Prefercontains(),at(), andconst JSON&for reads. - Deep nesting recurses. The parser and serializer are recursive descent; adversarially deep input can overflow the stack (no depth limit is enforced).
- Non-finite numbers serialize as
null(NaN) /±1e309(±∞); the parser rejects out-of-range literals like1e999outright. Neither is legal JSON — this is a pragmatic compromise. - Lenient repair is heuristic. See the limits listed in Lenient parsing.
- No streaming/SAX. The whole document must be in memory.
- No comments, no trailing commas, no
NaN/Infinityliterals — strict RFC 8259 only. - Thread safety: distinct
JSONinstances are independent. Sharing one instance across threads for concurrent writes needs external synchronization (as with any standard container). at()returnsstd::reference_wrapper: checked element access viaat()returnsstd::expected<std::reference_wrapper<JSON>, JSONError>, not a direct reference (C++23'sstd::expectedcannot hold reference types). Call.get()on the value to obtain theJSON&.
Apache License 2.0
Author: Pooria Yousefi