Files
logos-cpp-sdk/cpp/logos_json.h
Dario LipicarandClaude Opus 4.8 c7444bc29a Follow-ups to #100: lp-consumer bstr decode, Qt-free cdylib event types, binary-event coverage (#102)
* cdylib events: Qt-free types, and drop the unused bytes encoder

Three follow-ups to the bstr event fix, all in the cdylib events sidecar --
a Qt-FREE translation unit:

- An `any`/map event parameter was emitted as a bare QVariant/QVariantMap,
  which does not compile there. Spell those as their nlohmann aliases
  (LogosMap / LogosList) and pull in <logos_json.h> when they appear.
- std::vector<std::vector<uint8_t>> fell through the impl-header parser's
  unknown-type fallback to `any`, so the cdylib gate admitted it and the
  generator then emitted QVariant. Parse it as `[bstr]` so the gate rejects it
  with a message naming the offending parameter.
- The bytes encoder was emitted into every module's sidecar, leaving an unused
  static function (-Wunused-function) wherever no event carries binary data.
  Emit it only when a bstr event parameter exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* lp consumer: decode bstr into std::vector<uint8_t>

The Qt-free (`lp`) consumer wrappers -- what every universal C++ module gets for
its dependencies -- had no QByteArray in their type tables, so a `bstr` event
parameter, method argument, or return degraded to QVariant and then to LogosMap.
A consumer subscribing to a binary event was handed the raw tagged JSON object
{"_bytes": "<base64url>"} instead of the bytes, with no generated decode.

Teach the tables about QByteArray (-> std::vector<uint8_t>) and marshal it
through the canonical tagged form in both directions: logos::bytesToJson on the
way out, logos::jsonToBytes on the way in. Those live in logos_json.h -- Qt-free
and protocol-free, so the generated wrappers and module code can share them.
The Qt apiStyle already did this via QByteArray::toBase64/fromBase64.

Without this, a subscriber written the obvious way --

    onBinaryReady([](const std::string&, const std::vector<uint8_t>& payload) {...})

-- compiles (nlohmann::json has an implicit conversion operator) and then throws
at runtime on every event, so the callback body silently never runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tests: cover binary event payloads by value, not just by source text

The regression test for #99 asserts on generated source text, so it stays green
against an encoder that emits the wrong bytes. Add the value-level half:

- tests/sdk/test_logos_json_bytes.cpp exercises the canonical tagged-bytes codec
  against the RFC 4648 vectors, the URL-safe alphabet, every len%3 tail group,
  embedded NULs and high bytes, a 109,447-byte payload (the size from #99), and
  the lenient/padded decode paths.
- tests/experimental/test_lidl_gen_cdylib.cpp additionally pins the Qt-free
  spelling of JSON event payloads, the rejection of [bstr], and the omission of
  the bytes encoder from modules whose events carry no binary data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* doctests: prove a binary event payload survives the round trip

Neither doc-test covered bytes-in-an-event -- the gap #99 fell through. The
generator round-trip carried `bstr` only as a method argument and return, and
the composition doc-test, which is the one that actually runs two modules under
logoscore and subscribes to an event, carried only a string. So a generator that
dropped every bstr event argument kept both of them green.

- cpp-sdk-module-composition: greeter_module gains a `blobReady(label, payload)`
  event and an `emitBlob(size)` method; orchestrator_module subscribes and
  reports the length AND a checksum of what it received. Length alone would not
  catch a corrupted payload -- a wrong alphabet round-trips to the same size.
- cpp-sdk-generator-roundtrip: sensor_module gains a `capture(id, frame: bstr)`
  event, and a new step shows the generated event body encoding it through
  lidlBytesToJson rather than pushing it raw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* logos_json.h: include <cstddef> for size_t

The tagged-bytes codec uses size_t but relied on it arriving transitively
through the other includes. Include <cstddef> directly so the header is
self-contained. (Copilot review, PR #102.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:50:40 -03:00

92 lines
3.1 KiB
C++

#pragma once
#include <nlohmann/json.hpp>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
// Semantic aliases for nlohmann::json used in universal module impl classes.
// The code generator recognizes these names and emits QVariantMap / QVariantList
// conversions in the Qt glue layer, so impl classes remain Qt-free.
using LogosMap = nlohmann::json;
using LogosList = nlohmann::json;
namespace logos {
// The canonical tagged form for binary payloads on the wire:
//
// {"_bytes": "<base64url, unpadded>"}
//
// It is what logos-protocol emits and expects (logos_json_convert.cpp,
// implementations/plain/json_mapping.cpp), and it is lossless for arbitrary
// bytes — including embedded NULs, which a plain JSON string would not survive.
// The Qt side reaches this form through QByteArray::toBase64/fromBase64 with
// Base64UrlEncoding | OmitTrailingEquals; these are the Qt-free equivalents,
// used by the generated `lp` wrappers and by universal (Qt-free) module code.
inline std::string b64UrlEncode(const std::vector<uint8_t>& bytes)
{
static const char* alpha =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string out;
size_t i = 0;
while (i + 3 <= bytes.size()) {
uint32_t n = (uint32_t(bytes[i]) << 16) | (uint32_t(bytes[i + 1]) << 8)
| uint32_t(bytes[i + 2]);
out += alpha[(n >> 18) & 0x3f]; out += alpha[(n >> 12) & 0x3f];
out += alpha[(n >> 6) & 0x3f]; out += alpha[n & 0x3f];
i += 3;
}
if (i < bytes.size()) {
uint32_t n = uint32_t(bytes[i]) << 16;
if (i + 1 < bytes.size()) n |= uint32_t(bytes[i + 1]) << 8;
out += alpha[(n >> 18) & 0x3f]; out += alpha[(n >> 12) & 0x3f];
if (i + 1 < bytes.size()) out += alpha[(n >> 6) & 0x3f];
}
return out;
}
inline std::vector<uint8_t> b64UrlDecode(const std::string& in)
{
auto idx = [](char ch) -> int {
if (ch >= 'A' && ch <= 'Z') return ch - 'A';
if (ch >= 'a' && ch <= 'z') return ch - 'a' + 26;
if (ch >= '0' && ch <= '9') return ch - '0' + 52;
if (ch == '-') return 62;
if (ch == '_') return 63;
return -1; // skips '=' padding and any stray character
};
std::vector<uint8_t> out;
uint32_t buf = 0;
int bits = 0;
for (char ch : in) {
const int v = idx(ch);
if (v < 0) continue;
buf = (buf << 6) | static_cast<uint32_t>(v);
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<uint8_t>((buf >> bits) & 0xff));
}
}
return out;
}
// Bytes -> the tagged JSON object.
inline nlohmann::json bytesToJson(const std::vector<uint8_t>& bytes)
{
return nlohmann::json{{"_bytes", b64UrlEncode(bytes)}};
}
// The tagged JSON object -> bytes. Lenient, like the rest of the `lp` decode
// path: anything that is not a well-formed tagged-bytes object yields empty.
inline std::vector<uint8_t> jsonToBytes(const nlohmann::json& j)
{
if (!j.is_object() || !j.contains("_bytes") || !j["_bytes"].is_string())
return {};
return b64UrlDecode(j["_bytes"].get<std::string>());
}
} // namespace logos