morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
action_log_json.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <glaze/glaze.hpp>
5#include <string>
6#include <string_view>
7
8#include "action_log.hpp"
9
10/// @file
11/// @brief The `LogEntry` JSON codec, kept apart from `action_log.hpp`.
12///
13/// `action_log.hpp` defines `LogEntry`, `Outcome`, `IActionLog` and the
14/// process-wide log slot; `core/model.hpp` includes it for `IActionLog` alone.
15/// Were the codec declared there too, every consumer that reaches a model would
16/// compile `<glaze/glaze.hpp>` whether or not it ever serialised anything, and
17/// that is a measured cliff rather than a tidiness point: with glaze included,
18/// `action_log.hpp` is 252,559 preprocessed lines and 2.67 CPU-s, against
19/// `core/strand.hpp`'s 127,217 and 1.20.
20///
21/// Include this header instead when you need `toJson`/`fromJson`. Inside
22/// morph, exactly one header does: `file_action_log.hpp`.
23
24/// @brief Reflects `Outcome` as the strings `"Succeeded"`/`"Failed"` rather than
25/// its underlying `0`/`1`, so a journal line stays readable without
26/// cross-referencing the enum. `LogEntry` otherwise needs no `glz::meta` at
27/// all (see `toJson`'s doc comment) -- this is the one field that does.
28template <>
29struct glz::meta<morph::journal::Outcome> {
30 using enum morph::journal::Outcome;
31 static constexpr auto value = glz::enumerate(Succeeded, Failed);
32};
33
34namespace morph::journal {
35
36namespace detail {
37
38/// @brief Converts a Glaze error into a `SerializationError`, or does nothing
39/// if @p errCode reports success.
40///
41/// Shared by `toJson` and `fromJson` on purpose, not just for DRY: this is one
42/// non-template function, so both call through the exact same compiled branch.
43/// `fromJson`'s failure path is easy to exercise for real (malformed JSON is
44/// everyday input); `toJson`'s is not — Glaze's buffer-writer has no reachable
45/// failure mode for a flat struct of strings/integers like `LogEntry` (its
46/// only write-relevant error codes are for recursion-depth limits `LogEntry`
47/// can't hit, and `dump_int_error`, which nothing in Glaze's own source ever
48/// actually raises). Routing both through here means `toJson`'s error branch
49/// is the same branch `fromJson`'s test already exercises, rather than a
50/// second, structurally-unreachable copy of the same three lines.
51/// @param errCode Result of a `glz::write_json`/`glz::read_json` call.
52/// @param context Buffer or input passed to `glz::format_error` for the message.
53inline void throwOnGlazeError(const glz::error_ctx& errCode, std::string_view context) {
54 if (errCode) {
55 throw SerializationError{glz::format_error(errCode, context)};
56 }
57}
58
59/// @brief Write options that escape ASCII control bytes as `\\uXXXX` sequences.
60///
61/// glaze 7.4 leaves control bytes (0x00-0x1F) unescaped by default, which
62/// breaks a `LogEntry` carrying one in `entityKey`/`payload`/`error`/`principal`/
63/// `idempotencyKey` two ways: RFC 8259 requires those bytes escaped, so the raw
64/// byte alone yields JSON `fromJson`'s `glz::read` throws on; worse, once the
65/// same string also contains an escaped `\` or `"`, glaze's chunked writer path
66/// silently rewrites the control byte as two 0x00 bytes, corrupting the payload
67/// before it ever reaches disk. This mirrors `morph::wire::detail::EscapingWriteOpts`
68/// (`core/wire.hpp`) exactly; duplicated here (rather than shared) so this header
69/// stays free of a `core/` dependency for `MORPH_CLIENT_ONLY`-style consumers
70/// that only want the journal. Escaping is lossless, so any such byte still
71/// round-trips through `fromJson` unchanged.
72struct EscapingWriteOpts : glz::opts {
73 /// @brief Emit control bytes as `\\uXXXX` rather than raw.
74 // NOLINTNEXTLINE(readability-identifier-naming) — glaze's option name, matched by name.
76};
77
78} // namespace detail
79
80/// @brief Encodes @p entry as JSON.
81///
82/// `LogEntry` is a plain aggregate, so Glaze reflects it without a `glz::meta`
83/// specialisation of its own — the same automatic reflection
84/// `BRIDGE_REGISTER_ACTION` relies on for user action structs. (Its `outcome`
85/// field is `Outcome`, which does have a `glz::meta` — see above — so it reads
86/// back as `"Succeeded"`/`"Failed"`, not `0`/`1`.) Used by sinks that need an
87/// opaque string representation (`FileActionLog`).
88///
89/// Writes with `detail::EscapingWriteOpts` so a raw ASCII control byte in any
90/// string field (`entityKey`, `payload`, `error`, `principal`, `idempotencyKey`)
91/// round-trips through `fromJson` instead of producing invalid or silently
92/// corrupted JSON — see that struct's doc comment.
93/// @param entry Entry to encode.
94/// @return The entry as a single line of JSON.
95/// @throws SerializationError on encode failure (see `detail::throwOnGlazeError`
96/// for why this is not realistically reachable for `LogEntry`).
97inline std::string toJson(const LogEntry& entry) {
98 std::string out;
99 detail::throwOnGlazeError(glz::write<detail::EscapingWriteOpts{}>(entry, out), out);
100 return out;
101}
102
103/// @brief Decodes @p json into a `LogEntry`.
104///
105/// Reads leniently: `glz::read<glz::opts{.error_on_unknown_keys = false}>`,
106/// the same stance `morph::wire::decode` takes (`include/morph/core/wire.hpp`) —
107/// an unknown/extra JSON key (e.g. an additive field written by a newer morph
108/// build) is ignored rather than rejected. Same duplicate-key caveat as
109/// `wire::decode`: last-wins, not a security boundary (glaze offers no reject
110/// option). Syntactically malformed JSON still throws. After a successful
111/// decode, also enforces the line-format version rule: `v <= kLogFormatVersion`
112/// decodes normally, `v` greater than this build's `kLogFormatVersion` throws
113/// — a build refuses to guess at a line format newer than any it has seen.
114/// @param json One JSON-encoded entry, as `toJson` wrote it.
115/// @return The decoded entry.
116/// @throws SerializationError if @p json is not valid JSON, does not decode
117/// into a `LogEntry`, or decodes with `v` greater than
118/// `kLogFormatVersion`.
119inline LogEntry fromJson(std::string_view json) {
120 LogEntry entry{};
121 // `null_terminated = false`: `json` is a caller-supplied view (a line read from a
122 // journal file, with no guaranteed trailing '\0') — see the identical rationale on
123 // `morph::wire::decode` (`wire.hpp`), whose fuzz harness found the resulting
124 // heap-buffer-overflow in glaze's `skip_ws`.
125 static constexpr glz::opts kLenient{.null_terminated = false, .error_on_unknown_keys = false};
126 detail::throwOnGlazeError(glz::read<kLenient>(entry, json), json);
127 if (entry.v > kLogFormatVersion) {
128 throw SerializationError{
129 "journal::fromJson: line format v" + std::to_string(entry.v) +
130 " is newer than this build supports (kLogFormatVersion = " + std::to_string(kLogFormatVersion) + ")"};
131 }
132 return entry;
133}
134
135} // namespace morph::journal
LogEntry fromJson(std::string_view json)
Decodes json into a LogEntry.
std::string toJson(const LogEntry &entry)
Encodes entry as JSON.
void throwOnGlazeError(const glz::error_ctx &errCode, std::string_view context)
Converts a Glaze error into a SerializationError, or does nothing if errCode reports success.
One recorded execution of an action against a model instance.
std::uint32_t v
Line-format version this entry was written at.
Thrown by toJson/fromJson when LogEntry (de)serialisation fails.
Write options that escape ASCII control bytes as \\uXXXX sequences.
bool escape_control_characters
Emit control bytes as \\uXXXX rather than raw.