Files
logos-cpp-sdk/cpp/logos_lp_client.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

215 lines
8.6 KiB
C++

#pragma once
// Qt-FREE typed consumer client over the logos-protocol C ABI (lp_*).
//
// This is the std/C++ analog of rust-sdk's PluginProxy: it lets a module's
// generated typed wrappers call other modules and subscribe to their events
// WITHOUT touching Qt. The only dependency is logos-protocol's `extern "C"`
// surface (logos_protocol.h) — Qt stays confined to the QRO transport inside
// logos-protocol and to the generated Qt-plugin glue, never the module's own
// translation units.
//
// The generated `<Dep>` wrappers (ApiStyle::Lp) hold a `logos::LpClient` and
// marshal std args -> nlohmann JSON -> lp_invoke -> JSON -> std return. Event
// subscriptions go through lp_subscribe and are owned by an RAII
// `LpSubscription` (mirrors rust-sdk's EventSubscription: unsubscribes on
// destruction so the callback never fires after the owner is gone).
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
#include <vector>
#include "logos_protocol.h" // lp_* C ABI
#include "logos_call_error.h" // logos::CallError
#include "logos_json.h" // logos::bytesToJson / logos::jsonToBytes
#include "logos_result.h" // StdLogosResult
namespace logos {
// JSON -> std helpers used by the generated ApiStyle::Lp wrappers to decode
// return values and event payloads. Lenient: a type mismatch yields the
// default-constructed value (mirrors the Qt path's default-on-failure).
inline std::vector<std::string> jsonToStringVec(const nlohmann::json& j) {
std::vector<std::string> out;
if (j.is_array())
for (const auto& e : j)
if (e.is_string()) out.push_back(e.get<std::string>());
return out;
}
// Binary payloads travel in the canonical tagged form
// {"_bytes": "<base64url, unpadded>"}; logos::bytesToJson / logos::jsonToBytes
// live in logos_json.h and are used by the generated wrappers on both sides.
inline StdLogosResult jsonToStdResult(const nlohmann::json& j) {
StdLogosResult r;
if (j.is_object()) {
if (j.contains("success") && j["success"].is_boolean()) r.success = j["success"].get<bool>();
if (j.contains("value")) r.value = j["value"];
if (j.contains("error") && j["error"].is_string()) r.error = j["error"].get<std::string>();
}
return r;
}
// RAII handle for an lp_subscription. Owns the subscription and the heap
// callback box; unsubscribes (after which no further callbacks fire) and
// frees the box on destruction. Move-only.
class LpSubscription {
public:
LpSubscription() = default;
LpSubscription(lp_subscription* sub, void* cbBox, void (*deleter)(void*))
: m_sub(sub), m_cbBox(cbBox), m_deleter(deleter) {}
LpSubscription(LpSubscription&& o) noexcept { moveFrom(o); }
LpSubscription& operator=(LpSubscription&& o) noexcept {
if (this != &o) { reset(); moveFrom(o); }
return *this;
}
LpSubscription(const LpSubscription&) = delete;
LpSubscription& operator=(const LpSubscription&) = delete;
~LpSubscription() { reset(); }
bool valid() const { return m_sub != nullptr; }
private:
void moveFrom(LpSubscription& o) {
m_sub = o.m_sub; m_cbBox = o.m_cbBox; m_deleter = o.m_deleter;
o.m_sub = nullptr; o.m_cbBox = nullptr; o.m_deleter = nullptr;
}
void reset() {
if (m_sub) { lp_unsubscribe(m_sub); m_sub = nullptr; }
if (m_cbBox && m_deleter) { m_deleter(m_cbBox); m_cbBox = nullptr; }
}
lp_subscription* m_sub = nullptr;
void* m_cbBox = nullptr;
void (*m_deleter)(void*) = nullptr;
};
// Qt-free typed client for one target module. The lp_client is created lazily
// on first use, on behalf of `origin` (the calling module's name, baked by the
// generated umbrella), over the process-default transport with the automatic
// capability/token flow that logos-protocol provides.
class LpClient {
public:
LpClient(std::string target, std::string origin)
: m_target(std::move(target)), m_origin(std::move(origin)) {}
~LpClient() { if (m_client) lp_client_destroy(m_client); }
LpClient(const LpClient&) = delete;
LpClient& operator=(const LpClient&) = delete;
// Blocking call. `args` is a JSON array. Returns the result JSON value
// (null on failure); fills `err` when non-null.
nlohmann::json invoke(const std::string& method,
const nlohmann::json& args,
CallError* err) {
lp_client* c = ensure();
if (!c) {
if (err) { err->code = "object_unavailable";
err->message = "could not create client for " + m_target;
err->origin = m_target; }
return nullptr;
}
const std::string argsStr = args.dump();
char* outRes = nullptr;
char* outErr = nullptr;
const int rc = lp_invoke(c, method.c_str(), argsStr.c_str(), 0, &outRes, &outErr);
nlohmann::json result; // null
if (rc == LP_OK) {
if (err) err->clear();
if (outRes) {
auto parsed = nlohmann::json::parse(outRes, nullptr, /*allow_exceptions=*/false);
if (!parsed.is_discarded()) result = std::move(parsed);
}
} else {
fillErr(err, outErr, rc);
}
if (outRes) lp_string_free(outRes);
if (outErr) lp_string_free(outErr);
return result;
}
// Async call. `cb` fires exactly once with the result JSON (null on
// failure / parse error). Safe to call from any thread.
void invokeAsync(const std::string& method,
const nlohmann::json& args,
std::function<void(nlohmann::json)> cb) {
lp_client* c = ensure();
if (!c) { if (cb) cb(nullptr); return; }
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
const std::string argsStr = args.dump();
lp_invoke_async(c, method.c_str(), argsStr.c_str(), 0,
&LpClient::resultTrampoline, box);
}
// Subscribe to `event`. The payload is delivered as a JSON array. The
// returned handle owns the subscription — keep it alive (the generated
// wrapper stores it) for as long as you want the callback to fire.
LpSubscription subscribe(const std::string& event,
std::function<void(nlohmann::json)> cb) {
lp_client* c = ensure();
if (!c) return {};
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
lp_subscription* sub = lp_subscribe(c, event.c_str(), &LpClient::eventTrampoline, box);
if (!sub) { delete box; return {}; }
return LpSubscription(sub, box, &LpClient::deleteBox);
}
private:
using Box = std::function<void(nlohmann::json)>;
lp_client* ensure() {
if (!m_client)
m_client = lp_client_create(m_target.c_str(), m_origin.c_str(), nullptr, nullptr);
return m_client;
}
static void resultTrampoline(int ok, const char* json, void* ud) {
auto* fn = static_cast<Box*>(ud);
nlohmann::json r; // null
if (ok && json) {
auto parsed = nlohmann::json::parse(json, nullptr, false);
if (!parsed.is_discarded()) r = std::move(parsed);
}
(*fn)(std::move(r));
delete fn; // result callback fires exactly once
}
static void eventTrampoline(const char* /*eventName*/, const char* dataJson, void* ud) {
auto* fn = static_cast<Box*>(ud);
nlohmann::json r = nlohmann::json::array();
if (dataJson) {
auto parsed = nlohmann::json::parse(dataJson, nullptr, false);
if (!parsed.is_discarded()) r = std::move(parsed);
}
(*fn)(std::move(r));
}
static void deleteBox(void* p) { delete static_cast<Box*>(p); }
static void fillErr(CallError* err, const char* errJson, int rc) {
if (!err) return;
err->code = "call_failed";
err->message = "lp_invoke failed (rc=" + std::to_string(rc) + ")";
err->origin.clear();
if (errJson) {
auto j = nlohmann::json::parse(errJson, nullptr, false);
if (!j.is_discarded() && j.is_object()) {
if (j.contains("code") && j["code"].is_string()) err->code = j["code"].get<std::string>();
if (j.contains("message") && j["message"].is_string()) err->message = j["message"].get<std::string>();
if (j.contains("origin") && j["origin"].is_string()) err->origin = j["origin"].get<std::string>();
}
}
}
std::string m_target;
std::string m_origin;
lp_client* m_client = nullptr;
};
} // namespace logos