mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
* feat: the host-services C ABI a trust-root module needs
capability_module is the last legacy Qt Q_INVOKABLE provider, and it cannot
become an ordinary `interface: universal` module while the two things it does
have no C entry point: reading the token store, and pushing a token to an
ARBITRARY target. This adds both, plus the grant that gates them. Purely
additive — no existing symbol changes behaviour.
lp_token_keys() the module names THIS image's TokenManager
holds. NULL means REFUSED, never "empty" — a
granted call with no tokens answers "[]", and a
known-caller gate needs to tell those apart.
lp_inform_module_token_to() routes to LogosAPIClient::informModuleToken_module,
the 5-arg form. Note the existing
lp_inform_module_token is the WRONG DIRECTION
for this: it reaches a consumer path that
hardcodes requestObject("capability_module"),
i.e. core -> capability, not capability ->
target. That 5-arg method had no C entry point.
lp_grant_host_services() sets the in-image grant over the closed set
{token_registry, token_delivery}. Replaces
rather than merges; NULL/""/"[]" clears. An
unknown name is rejected wholesale and leaves
the existing grant untouched, so a typo can
never silently drop a service.
Why the gate is per-IMAGE, which looks like an odd choice until it doesn't:
the host binary and a module's cdylib each link their own copy of this library,
so they have separate process-global state. A gate "simplified" into the host
would be checked against state the calling image can never set, and would read
as ungranted forever. The grant therefore crosses the module-impl C ABI the
same way the auth token already does — hence the logos_module_grant_host_services
declaration added to logos_module_impl.h, whose generated body and host-side
call land in logos-cpp-sdk and logos-module-loader-qt respectively.
MINOR 2 -> 3; MAJOR unchanged, so the equal-MAJOR compatibility rule is
unaffected. 387/387 tests pass, including 6 new ones covering both gates
closed, both opened, clearing re-closing them, and the unknown-name rejection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tokens): a per-CLIENT token store, selected by origin
TokenManager::instance() is the IMAGE's store, and in a host that loads
plugins in-process it is also an ambient ring: the host writes
`name -> that module's root auth token` for EVERY module it loads. On the
hot path a client asserts no identity at all — invokeRemoteMethod reads
the store first and only mints on a miss — so a plugin asking for target X
finds X's own root token sitting there and presents it. The provider
accepts any token in its image's store, so the call authorizes and no
requestModule is ever logged. Every plugin in that image holds every other
module's authority, and giving a plugin its own ORIGIN STRING changes none
of it, because origin was never consulted on the path taken.
This makes origin SELECT THE STORE rather than merely label the caller.
TokenManager::forIdentity(x) the store to present tokens from when I am x
TokenManager::isolateIdentity(x) give x a private store (host opt-in)
isIsolated / isolatedIdentities / bootstrapKeys / seedBootstrapTokens /
resetIdentity
ADDITIVE BY CONSTRUCTION, not by promise: forIdentity() returns the SAME
OBJECT instance() returns — pointer-identical — for every name until
someone isolates that exact name, so a host that knows nothing about this
is byte-for-byte unchanged. All seven are static member FUNCTIONS: no data
member, no virtual, nothing moc sees. Measured, not asserted: the exported
symbol table of liblogos_protocol.dylib gains exactly 12 names (7 statics +
5 lp_*) and LOSES NONE (736 -> 748). Neither ABI-sensitive private layout
(LogosAPIClient, LogosAPIConsumer) was touched at all.
Construction paths in this repo:
* LogosAPIClient / LogosAPIConsumer: an explicit store still wins; a NULL
store now resolves to forIdentity(origin) instead of being a guaranteed
crash on the first getToken().
* lp_client_create: &TokenManager::forIdentity(origin), not instance().
This is the whole answer to that function's frozen signature — the store
cannot be handed to it, so the origin it already takes must select it.
Bootstrap (constraint 4) survives because a private store is created seeded
with "core" and "capability_module" copied from instance(), and with
NOTHING else — the two keys the first requestModule authenticates with, not
a copy of the ring. resetIdentity() is the unload hook: it clears the
contents and re-seeds, while the store OBJECT stays immortal because a
client holds it by raw pointer from continuations that outlive their caller.
The trust root (constraint 3) is unaffected, and it is checked rather than
argued: lp_token_keys() still reads instance(), isolation only ADDS stores,
and the one thing that moves — an isolated identity's consumer-side CACHE
write — is keyed by TARGET while the known-caller gate consults ORIGIN
names, which the HOST writes and this change never touches.
C ABI grows five additive symbols, each carrying LP_API:
lp_token_isolate_identity, lp_token_identity_is_isolated, lp_token_get_for,
lp_token_save_for, lp_token_reset_identity. Protocol version 0.3.0 -> 0.4.0
(MINOR: additive).
Tests: 439/439 before, 469/469 after. The 30 new cases were validated as
DETECTORS the way this suite requires — against a throwaway build with
forIdentity()'s isolation branch neutered to `if (true)`, i.e. origin as a
label again. 15 go RED there (the walled identity holds the target's root
token; the handshake count is 0 instead of 2; lp_token_keys() lists the
identity's private mint), and the other 15 are pins of behaviour that must
be identical either way. Every escalation case carries an ambient CONTROL
asserting the token IS reachable without isolation.
Hosts are deliberately NOT changed here.
* feat(codec): shape-check the untyped containers
`[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ — LogosList and
LogosMap are aliases of it — so no Codec<T> specialization can tell them apart
and fromJson<T> has nothing to dispatch on. Their SHAPE is still declared,
though, and array-ness / object-ness is the whole of the declared type at that
layer.
jsonRequireArray / jsonRequireObject check exactly that and hand the value on
UNCHANGED, throwing through the codec's own detail::typeError so the message is
the one every other surface already produces ("expected array at arg0, got
string"). The value is not rebuilt from JSON: that would retype nested elements
for no validation gain, which is the same reasoning logos_qt_arg_decode.h gives
for the Qt surface.
This is what logos_codec.h:36 already promised and these two types quietly did
not honour — "shape mismatches throw CodecError … rather than silently
substituting a default, silent defaults are how a mangled value reaches business
logic."
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
563 lines
24 KiB
C++
563 lines
24 KiB
C++
#ifndef LOGOS_CODEC_H
|
|
#define LOGOS_CODEC_H
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// logos_codec.h — THE canonical JSON representation of LIDL values.
|
|
//
|
|
// One implementation, here. Before this header the same encoding lived in six
|
|
// places (the Qt conversion, the plain wire's json_mapping, the lp helper in
|
|
// logos-cpp-sdk, a copy emitted into every generated cdylib module, the Rust
|
|
// SDK and the Python client) and they disagreed on which inputs they accepted.
|
|
// Anything that needs to move a LIDL value in or out of JSON uses these.
|
|
//
|
|
// LEAF TYPES
|
|
// tstr std::string -> json string
|
|
// bstr std::vector<uint8_t> -> {"_bytes": "<base64url, unpadded>"}
|
|
// int any signed integral -> json integer
|
|
// uint any unsigned integral -> json integer
|
|
// float64 float / double -> json number
|
|
// bool bool -> json bool
|
|
// any nlohmann::json (LogosMap/List) -> verbatim; recursion STOPS here
|
|
//
|
|
// COMPOSITION — generic, no whitelist
|
|
// [T] std::vector<T> for any supported T
|
|
// {tstr: T} std::map / std::unordered_map<std::string,T> for any supported T
|
|
// ?T std::optional<T> for any supported T
|
|
// ...at any depth. Bytes stay tagged wherever they occur, so [bstr], [[bstr]],
|
|
// {tstr: [bstr]} and bytes nested inside a map all encode canonically without
|
|
// anything having to enumerate the combination.
|
|
//
|
|
// WHY BYTES ARE TAGGED: JSON has no bytes primitive and a JSON string must be
|
|
// valid UTF-8, so raw binary (embedded NULs, anything >= 0x80) cannot ride a
|
|
// plain string. The single-key object is the wire form every language agrees on.
|
|
// Note the inherent ambiguity: a genuine one-key map named "_bytes" whose value
|
|
// is a string is indistinguishable from bytes. Don't name a map key "_bytes".
|
|
//
|
|
// DECODE STRICTNESS: shape mismatches throw CodecError, which callers surface as
|
|
// a structured error (the generated dispatch turns it into
|
|
// {"code":"dispatch_failed",...}) rather than silently substituting a default —
|
|
// silent defaults are how a mangled value reaches business logic. `bstr` alone
|
|
// keeps a documented lenient form (see bytesFromJsonLenient) because the Qt
|
|
// consumer path and the logoscore CLI's argument auto-typing both produce plain
|
|
// strings and number arrays for byte parameters.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <optional>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <type_traits>
|
|
#include <unordered_map>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace logos {
|
|
|
|
// Thrown by fromJson<T>() when the value does not match the expected LIDL type.
|
|
// `what()` carries the path to the offending element ("[2].payload") so a
|
|
// failure inside a nested container is actionable.
|
|
class CodecError : public std::runtime_error {
|
|
public:
|
|
explicit CodecError(const std::string& message)
|
|
: std::runtime_error(message) {}
|
|
};
|
|
|
|
namespace detail {
|
|
|
|
inline std::string joinPath(const std::string& path, const std::string& step)
|
|
{
|
|
return path.empty() ? step : path + step;
|
|
}
|
|
|
|
[[noreturn]] inline void typeError(const std::string& path, const char* expected,
|
|
const nlohmann::json& got)
|
|
{
|
|
throw CodecError("expected " + std::string(expected)
|
|
+ (path.empty() ? std::string(" at value") : " at " + path)
|
|
+ ", got " + std::string(got.type_name()));
|
|
}
|
|
|
|
} // namespace detail
|
|
|
|
// ── untyped containers: shape is declared even when the element type is not ──
|
|
//
|
|
// `[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ (LogosList /
|
|
// LogosMap are aliases of it), so there is no distinct type for fromJson<T> to
|
|
// dispatch on and no codec specialization can carry the rule. The SHAPE is
|
|
// still declared, though — array-ness and object-ness — and it is the whole of
|
|
// the declared type at this layer.
|
|
//
|
|
// The value is handed on UNCHANGED rather than rebuilt from JSON: rebuilding
|
|
// would retype nested elements for no validation gain, which is the same
|
|
// reasoning logos_qt_arg_decode.h gives for the Qt surface.
|
|
//
|
|
// Returning by reference is safe in the one shape that calls this — a generated
|
|
// dispatch passing the result straight into the impl call, so the referent
|
|
// outlives the full expression.
|
|
inline const nlohmann::json& jsonRequireArray(const nlohmann::json& j,
|
|
const std::string& path)
|
|
{
|
|
if (!j.is_array()) detail::typeError(path, "array", j);
|
|
return j;
|
|
}
|
|
|
|
inline const nlohmann::json& jsonRequireObject(const nlohmann::json& j,
|
|
const std::string& path)
|
|
{
|
|
if (!j.is_object()) detail::typeError(path, "object", j);
|
|
return j;
|
|
}
|
|
|
|
// ── base64url, unpadded ────────────────────────────────────────────────────
|
|
|
|
inline std::string b64UrlEncode(const std::vector<uint8_t>& bytes)
|
|
{
|
|
static const char* alpha =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
std::string out;
|
|
out.reserve((bytes.size() + 2) / 3 * 4);
|
|
size_t i = 0;
|
|
while (i + 3 <= bytes.size()) {
|
|
const 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;
|
|
}
|
|
|
|
// Decodes unpadded base64url. Deliberately tolerant: '=' padding and stray
|
|
// characters (whitespace from a hand-written spec, a padded encoder on the
|
|
// other side) are skipped rather than aborting the whole value — one of the
|
|
// four behaviours the old copies disagreed on.
|
|
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;
|
|
};
|
|
std::vector<uint8_t> out;
|
|
out.reserve(in.size() * 3 / 4);
|
|
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;
|
|
}
|
|
|
|
// Strict variant: returns false on any character outside the alphabet (padding
|
|
// aside) or an impossible length, leaving `out` empty. The plain wire validates
|
|
// its frames with this — a corrupt frame must be rejected, not silently decoded
|
|
// to fewer bytes. Consumer-facing decodes use the tolerant one above.
|
|
inline bool b64UrlDecodeChecked(const std::string& in, std::vector<uint8_t>& out)
|
|
{
|
|
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;
|
|
};
|
|
out.clear();
|
|
std::string body = in;
|
|
while (!body.empty() && body.back() == '=') body.pop_back();
|
|
if (body.size() % 4 == 1) return false;
|
|
|
|
uint32_t buf = 0;
|
|
int bits = 0;
|
|
for (char ch : body) {
|
|
const int v = idx(ch);
|
|
if (v < 0) {
|
|
out.clear();
|
|
return false;
|
|
}
|
|
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 true;
|
|
}
|
|
|
|
// ── tagged bytes ───────────────────────────────────────────────────────────
|
|
|
|
inline nlohmann::json bytesToJson(const std::vector<uint8_t>& bytes)
|
|
{
|
|
return nlohmann::json{{"_bytes", b64UrlEncode(bytes)}};
|
|
}
|
|
|
|
// The canonical form: EXACTLY one key, "_bytes", holding a string. The size
|
|
// check matters — without it a map that merely contains a "_bytes" entry is
|
|
// read as bytes, which is how the lp helper and the plain wire disagreed.
|
|
inline bool isTaggedBytes(const nlohmann::json& j)
|
|
{
|
|
return j.is_object() && j.size() == 1 && j.contains("_bytes")
|
|
&& j["_bytes"].is_string();
|
|
}
|
|
|
|
inline std::vector<uint8_t> bytesFromJson(const nlohmann::json& j)
|
|
{
|
|
if (!isTaggedBytes(j))
|
|
detail::typeError("", "tagged bytes {\"_bytes\": \"...\"}", j);
|
|
return b64UrlDecode(j["_bytes"].get<std::string>());
|
|
}
|
|
|
|
// Canonical form, plus the shapes other layers legitimately produce for a byte
|
|
// parameter:
|
|
// string -> its raw bytes (a Qt consumer passing a QString, a CLI arg)
|
|
// number -> its decimal text as bytes (QVariant(int)->QByteArray parity)
|
|
// array of ints -> those byte values
|
|
// Providers decode arguments with this; everything else uses the canonical form.
|
|
inline std::vector<uint8_t> bytesFromJsonLenient(const nlohmann::json& j,
|
|
const std::string& path = "")
|
|
{
|
|
if (isTaggedBytes(j))
|
|
return b64UrlDecode(j["_bytes"].get<std::string>());
|
|
if (j.is_string()) {
|
|
const std::string s = j.get<std::string>();
|
|
return std::vector<uint8_t>(s.begin(), s.end());
|
|
}
|
|
if (j.is_number()) {
|
|
const std::string s = j.dump();
|
|
return std::vector<uint8_t>(s.begin(), s.end());
|
|
}
|
|
if (j.is_array()) {
|
|
std::vector<uint8_t> out;
|
|
out.reserve(j.size());
|
|
for (const auto& e : j)
|
|
if (e.is_number_integer() || e.is_number_unsigned())
|
|
out.push_back(static_cast<uint8_t>(e.get<int64_t>() & 0xff));
|
|
return out;
|
|
}
|
|
detail::typeError(path, "bytes", j);
|
|
}
|
|
|
|
// ── the generic value codec ────────────────────────────────────────────────
|
|
//
|
|
// Codec<T> is a trait rather than a function template because C++ has no
|
|
// partial specialisation of function templates, and the composition rule
|
|
// (vector<T>, map<string,T>) is inherently partial. An unsupported T leaves
|
|
// Codec<T> incomplete, so the failure is a compile error at the call site
|
|
// naming the type — never a silent fallback.
|
|
|
|
namespace detail {
|
|
|
|
template <class T, class Enable = void> struct Codec;
|
|
|
|
// bstr — a FULL specialisation, so it wins over the generic vector<T> below.
|
|
template <> struct Codec<std::vector<uint8_t>, void> {
|
|
static nlohmann::json to(const std::vector<uint8_t>& v) { return bytesToJson(v); }
|
|
static std::vector<uint8_t> from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
// The path is threaded through so a bad bstr nested in a container
|
|
// reports "[0].payload" rather than a bare "value" — the generated
|
|
// cdylib codec used to carry that detail in its own copy, and this is
|
|
// what lets that copy be deleted without losing the diagnostic.
|
|
return bytesFromJsonLenient(j, path);
|
|
}
|
|
};
|
|
|
|
template <> struct Codec<bool, void> {
|
|
static nlohmann::json to(bool v) { return v; }
|
|
static bool from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (!j.is_boolean()) typeError(path, "bool", j);
|
|
return j.get<bool>();
|
|
}
|
|
};
|
|
|
|
template <> struct Codec<std::string, void> {
|
|
static nlohmann::json to(const std::string& v) { return v; }
|
|
static std::string from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (!j.is_string()) typeError(path, "string", j);
|
|
return j.get<std::string>();
|
|
}
|
|
};
|
|
|
|
// `any` stops the recursion: the value passes through untouched, so a LogosMap
|
|
// or LogosList keeps whatever the peer sent (including tagged bytes nested
|
|
// inside it, which the author decodes with bytesFromJson).
|
|
template <> struct Codec<nlohmann::json, void> {
|
|
static nlohmann::json to(const nlohmann::json& v) { return v; }
|
|
static nlohmann::json from(const nlohmann::json& j, const std::string&) { return j; }
|
|
};
|
|
|
|
// int / uint — every C++ integral spelling, bool excluded (specialised above).
|
|
//
|
|
// Signedness is part of the type, so it is checked. `is_number_integer()` is
|
|
// true for a negative, and `.get<uint64_t>()` on -1 wraps to 18446744073709551615
|
|
// with no exception — a silent sign flip on a nominal value. Range is checked for
|
|
// the same reason: `.get<T>()` on a narrower T truncates rather than throwing.
|
|
//
|
|
// This rejects rather than coerces, matching the rest of the codec: a value the
|
|
// declared type cannot represent must not reach business logic wearing a
|
|
// different one.
|
|
//
|
|
// A WHOLE-VALUED float is not such a value. JSON does not distinguish 3 from
|
|
// 3.0, and every encoder that sees a whole double may emit either — which is
|
|
// exactly the reasoning Codec<double> already gives for accepting an integral
|
|
// number. The two directions have to agree, so 3.0 decodes as 3 while 3.7 is
|
|
// still refused. A CLI that types its arguments by parsing (logoscore's does)
|
|
// produces 3.0 for `3.0`, so refusing it breaks callers over a spelling.
|
|
template <class T>
|
|
struct Codec<T, std::enable_if_t<std::is_integral_v<T> && !std::is_same_v<T, bool>>> {
|
|
static nlohmann::json to(T v) { return v; }
|
|
static T from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (j.is_number_float()) {
|
|
const double d = j.get<double>();
|
|
double intPart = 0.0;
|
|
if (std::modf(d, &intPart) != 0.0)
|
|
typeError(path, "integer", j);
|
|
// Strict bounds: double(int64max) rounds UP to 2^63, so `<=` would
|
|
// admit a value the cast cannot represent (undefined behaviour).
|
|
if constexpr (std::is_unsigned_v<T>) {
|
|
if (d < 0.0 || d >= 18446744073709551616.0) // 2^64
|
|
typeError(path, "unsigned integer in range", j);
|
|
} else {
|
|
if (d < -9223372036854775808.0 || d >= 9223372036854775808.0) // ±2^63
|
|
typeError(path, "signed integer in range", j);
|
|
}
|
|
const auto whole = static_cast<long double>(d);
|
|
if (whole < static_cast<long double>(std::numeric_limits<T>::min()) ||
|
|
whole > static_cast<long double>(std::numeric_limits<T>::max()))
|
|
typeError(path, "integer in range", j);
|
|
return static_cast<T>(d);
|
|
}
|
|
if (!j.is_number_integer() && !j.is_number_unsigned())
|
|
typeError(path, "integer", j);
|
|
|
|
if constexpr (std::is_unsigned_v<T>) {
|
|
// A negative literal parses as number_integer, never number_unsigned.
|
|
if (j.is_number_integer() && !j.is_number_unsigned() && j.get<int64_t>() < 0)
|
|
typeError(path, "unsigned integer", j);
|
|
const uint64_t u = j.get<uint64_t>();
|
|
if (u > static_cast<uint64_t>(std::numeric_limits<T>::max()))
|
|
typeError(path, "unsigned integer in range", j);
|
|
return static_cast<T>(u);
|
|
} else {
|
|
if (j.is_number_unsigned()) {
|
|
const uint64_t u = j.get<uint64_t>();
|
|
if (u > static_cast<uint64_t>(std::numeric_limits<T>::max()))
|
|
typeError(path, "signed integer in range", j);
|
|
return static_cast<T>(u);
|
|
}
|
|
const int64_t i = j.get<int64_t>();
|
|
if (i < static_cast<int64_t>(std::numeric_limits<T>::min()) ||
|
|
i > static_cast<int64_t>(std::numeric_limits<T>::max()))
|
|
typeError(path, "signed integer in range", j);
|
|
return static_cast<T>(i);
|
|
}
|
|
}
|
|
};
|
|
|
|
// float64 — an integral JSON number is accepted (2 and 2.0 are the same value
|
|
// to JSON, and every encoder that sees a whole double may emit either).
|
|
template <class T>
|
|
struct Codec<T, std::enable_if_t<std::is_floating_point_v<T>>> {
|
|
static nlohmann::json to(T v) { return v; }
|
|
static T from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (!j.is_number()) typeError(path, "number", j);
|
|
return j.get<T>();
|
|
}
|
|
};
|
|
|
|
// [T] — generic over the element type, so nesting composes.
|
|
template <class T> struct Codec<std::vector<T>, void> {
|
|
static nlohmann::json to(const std::vector<T>& v)
|
|
{
|
|
nlohmann::json out = nlohmann::json::array();
|
|
for (const T& e : v) out.push_back(Codec<T>::to(e));
|
|
return out;
|
|
}
|
|
static std::vector<T> from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (!j.is_array()) typeError(path, "array", j);
|
|
std::vector<T> out;
|
|
out.reserve(j.size());
|
|
for (size_t i = 0; i < j.size(); ++i)
|
|
out.push_back(Codec<T>::from(j[i], joinPath(path, "[" + std::to_string(i) + "]")));
|
|
return out;
|
|
}
|
|
};
|
|
|
|
// {tstr: T} — both standard associative containers, same rule.
|
|
template <class M> struct MapCodec {
|
|
using T = typename M::mapped_type;
|
|
static nlohmann::json to(const M& v)
|
|
{
|
|
nlohmann::json out = nlohmann::json::object();
|
|
for (const auto& kv : v) out[kv.first] = Codec<T>::to(kv.second);
|
|
return out;
|
|
}
|
|
static M from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (!j.is_object()) typeError(path, "object", j);
|
|
M out;
|
|
for (auto it = j.begin(); it != j.end(); ++it)
|
|
out.emplace(it.key(), Codec<T>::from(it.value(), joinPath(path, "." + it.key())));
|
|
return out;
|
|
}
|
|
};
|
|
|
|
template <class T> struct Codec<std::map<std::string, T>, void>
|
|
: MapCodec<std::map<std::string, T>> {};
|
|
template <class T> struct Codec<std::unordered_map<std::string, T>, void>
|
|
: MapCodec<std::unordered_map<std::string, T>> {};
|
|
|
|
// ?T — an optional slot. TWO-state, never three: a value of T, or empty.
|
|
//
|
|
// Optional is two-state because "one LIDL type <-> one type per language" leaves
|
|
// no room for anything else: every target has exactly ONE empty inhabitant
|
|
// (Rust None, C++ std::nullopt, an invalid QVariant, JS undefined), so a wire
|
|
// form that tried to distinguish "absent" from "present-but-null" would have
|
|
// nowhere to land it. std::nullopt is that inhabitant here.
|
|
//
|
|
// DECODE IS LIBERAL, ENCODE IS CANONICAL — round-tripping CANONICALISES, it does
|
|
// not preserve the input byte for byte:
|
|
// * in — absent and explicit null are the SAME state. A missing record field
|
|
// is materialised as a null json by the record decoder before it ever
|
|
// reaches a Codec, so both spellings arrive here as is_null() and both
|
|
// yield nullopt. (In a REQUIRED slot null keeps meaning "wrong type",
|
|
// because Codec<T> for a non-optional T still rejects it — nothing
|
|
// below changes that.)
|
|
// * out — exactly one spelling: null.
|
|
//
|
|
// LAYERING — key omission is NOT this layer's job, and must not be moved here.
|
|
// Empty is spelled by OMITTING the key where the slot is NAMED (a record field)
|
|
// and by null where the slot is POSITIONAL (a method argument, a return value,
|
|
// an event parameter — those have no key to omit, and arity must never change).
|
|
// A Codec only ever sees a VALUE; it cannot see the slot the value sits in, so
|
|
// it emits the positional spelling. Skipping the key for a nullopt field belongs
|
|
// to the record emitter (logos-cpp-sdk), which is the only code that knows there
|
|
// IS a key. Making `to()` return "nothing" instead would also be unimplementable
|
|
// one level down: an optional inside a [T] must still occupy its array position.
|
|
//
|
|
// A PRESENT VALUE IS STILL TYPE-CHECKED. Optional widens the accepted domain by
|
|
// exactly one inhabitant (empty); it does not switch checking off. Anything that
|
|
// is not null goes through Codec<T> unchanged, so a wrong-typed present value
|
|
// throws CodecError with the same path it would have thrown on in a required
|
|
// slot.
|
|
template <class T> struct Codec<std::optional<T>, void> {
|
|
static nlohmann::json to(const std::optional<T>& v)
|
|
{
|
|
// Nested optionals collapse (optional<optional<T>> has one empty
|
|
// spelling, not two) — which is the two-state rule holding at depth
|
|
// rather than an accident of this implementation.
|
|
if (!v.has_value()) return nlohmann::json(); // null
|
|
return Codec<T>::to(*v);
|
|
}
|
|
static std::optional<T> from(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
if (j.is_null()) return std::nullopt;
|
|
// Path is passed through unchanged: `?T` names no step of its own, so a
|
|
// failure inside an optional field reports the field, not "field.?".
|
|
return std::optional<T>(Codec<T>::from(j, path));
|
|
}
|
|
};
|
|
|
|
} // namespace detail
|
|
|
|
// Encode a LIDL value of static type T into its canonical JSON form.
|
|
template <class T>
|
|
nlohmann::json toJson(const T& value)
|
|
{
|
|
return detail::Codec<T>::to(value);
|
|
}
|
|
|
|
// Decode canonical JSON into a LIDL value of static type T.
|
|
// Throws CodecError on a shape mismatch, naming the path.
|
|
template <class T>
|
|
T fromJson(const nlohmann::json& j)
|
|
{
|
|
return detail::Codec<T>::from(j, std::string());
|
|
}
|
|
|
|
// Same, with a path prefix so a failure inside a nested value reports where it
|
|
// came from ("arg2[0].payload" rather than "[0].payload").
|
|
template <class T>
|
|
T fromJson(const nlohmann::json& j, const std::string& path)
|
|
{
|
|
return detail::Codec<T>::from(j, path);
|
|
}
|
|
|
|
// A JSON value that decodes itself into whatever the callee's parameter type is.
|
|
//
|
|
// Generated dispatch code has the author's signature available only as an
|
|
// overload-resolution target, not as text it can name — and naming it is a trap:
|
|
// spelling `[uint]` as std::vector<uint64_t> (the LIDL mapping) does not bind to
|
|
// an author's `const std::vector<uint32_t>&`, because distinct vector
|
|
// instantiations do not convert. Passing this proxy instead makes the compiler
|
|
// instantiate the conversion with the EXACT parameter type, so the author's own
|
|
// spelling is what gets decoded — any integer width, any supported nesting.
|
|
//
|
|
// An unsupported target type leaves Codec<T> incomplete, so the failure is a
|
|
// compile error naming the type at the call site, never a silent fallback.
|
|
//
|
|
// ONE TARGET IT CANNOT SERVE: std::optional<X>. std::optional's own converting
|
|
// constructor optional(U&&) is a viable candidate for a JsonArg argument, and it
|
|
// binds an RVALUE REFERENCE to the proxy prvalue, which out-ranks this
|
|
// const-lvalue-qualified conversion function before template partial ordering is
|
|
// ever consulted. The compiler therefore decodes X — not std::optional<X> — so
|
|
// an EMPTY optional (null) comes back as a wrong-typed X and throws. Making this
|
|
// operator rvalue-qualified only turns that into an ambiguity, and an operator
|
|
// written specifically for std::optional still loses to the constructor; there
|
|
// is no signature that wins.
|
|
//
|
|
// Optional parameters must therefore be decoded by NAMING the type —
|
|
// logos::fromJson<std::optional<X>>(j, path) — which is what the cdylib backend
|
|
// already emits for every parameter. The test
|
|
// CodecOptional.JsonArgCannotDeliverOptionalsNameTheTypeInstead
|
|
// pins the behaviour so it cannot be rediscovered the hard way.
|
|
class JsonArg {
|
|
public:
|
|
JsonArg(const nlohmann::json& value, std::string path)
|
|
: m_value(value), m_path(std::move(path)) {}
|
|
|
|
template <class T>
|
|
operator T() const // NOLINT(google-explicit-constructor) — that is the point
|
|
{
|
|
return detail::Codec<std::decay_t<T>>::from(m_value, m_path);
|
|
}
|
|
|
|
private:
|
|
const nlohmann::json& m_value;
|
|
std::string m_path;
|
|
};
|
|
|
|
} // namespace logos
|
|
|
|
#endif // LOGOS_CODEC_H
|