Serialization
German camelCase, snake_case, and canonical JSON output; round-trip preservation of unknown fields; and the limits that make parsing untrusted payloads safe.
rubo4e supports three JSON output modes and ensures that unknown fields from external payloads survive a round-trip.
Required feature: json (implies serde)
πThree Output Modes
πSide-by-Side Example
Given the same Vertrag value, the three methods produce:
to_json_german() β BO4E wire format (default):
{
"_typ": "VERTRAG",
"_version": "202607.1.0",
"sparte": "STROM",
"marktlokationsId": "51238696781"
}to_json_snake_case() β snake_case BO4E keys:
{
"_typ": "VERTRAG",
"_version": "202607.1.0",
"sparte": "STROM",
"marktlokations_id": "51238696781"
}to_json_canonical() β deterministic, sorted keys:
{
"_typ": "VERTRAG",
"_version": "202607.1.0",
"marktlokationsId": "51238696781",
"sparte": "STROM"
}πThe _typ and _version metadata keys
Both are populated for you when you construct a value β via Default::default(), the typed builder, or ..Default::default() struct-update syntax. Both are read from the schema, so neither can drift from what the standard declares:
_typis set on every BO and COM β each BO4E schema pins its discriminant with a JSON Schemaconst, and every reference implementation stamps it, nestedBetragandAdressecomponents included._versionis set on every BO and COM to the release the schema declares.
π_version has no v
BO4E tags its schema releases v202607.1.0, but the _version value inside a payload is 202607.1.0. Bo4eTyped::SCHEMA_VERSION is the wire spelling, so it compares directly against a _version read off a message.
Do not hardcode either string β a literal goes stale on the next schema series.
Deserialization never overwrites it. _version records the provenance of the data, so a payload that arrives stamped 202501.0.0 keeps that value through a round-trip, and a payload that arrives without _version stays without one. Only construction fills it in. The setter remains available if you need to re-stamp a value deliberately.
πDecimal amounts are written as JSON strings
Betrag.wert, Preis.wert, Menge.wert and every other Decimal field serializes as a quoted string:
{ "_typ": "BETRAG", "wert": "119.00", "waehrung": "EUR" }This matches the reference implementation: BO4E-python models these fields as decimal.Decimal, and pydantic v2 serializes Decimal to a string in JSON mode. It also avoids the precision loss an IEEE-754 double would introduce.
Two consequences:
- The published BO4E JSON Schema says
"type": "number"here, because pydantic generates it in validation mode. Output from BO4E-python and from rubo4e therefore both fail strict validation against BO4E's own schema β an upstream inconsistency, not one to work around. - Deserialization accepts both spellings. A producer writing
"wert": 119.00as a JSON number (go-bo4e does) is read fine;tests/compat/covers both.
πβ¦but only the string spelling is exact
Serde's data model has no arbitrary-precision number, so a JSON number is already an f64 before any deserializer here is called:
| Wire | Result |
|---|---|
"wert": "119.00" | 119.00 β scale kept |
"wert": 119.00 | 119 β scale lost |
"wert": "12345678901234567890.12" | exact β 28 significant digits fit |
"wert": 12345678901234567890.12 | 12345678901234567000 β rounded |
"wert": 9007199254740993 | exact β integers skip the f64 path |
No amount in the German energy market reaches 15 significant digits, so this is a fidelity question rather than a correctness one: a relayed go-bo4e payload comes out as "119" where the sender wrote 119.00, and the two compare equal as Decimal.
The loss is unrecoverable, so the spelling is made visible instead:
use rubo4e::decimal_serde::decimal_from_json_number_count;
// Process-wide and monotonic; also exported as
// `bo4e_decimal_from_json_number_total` with the `metrics` feature.
// Zero means every producer on this link spells decimals as strings.
gauge("bo4e_decimal_from_json_number", decimal_from_json_number_count());The counter measures the spelling, not the damage. An integer number is exact β 119 reaches the visitor as a u64, never as an f64 β and is counted anyway, because Go marshals a whole amount exactly that way. A counter that only saw the lossy fractional path would read a steady zero against the very producer it exists to identify. The fractional case is the lossy one, and with the tracing feature it also emits a debug! naming the value.
Without the decimal feature the field is a String holding the lexical form, so "119.00" survives as written β at the cost of having no arithmetic.
πWhen to Use Each Mode
| Mode | Use case |
|---|---|
to_json_german() | BO4E ecosystem interoperability (Python, Go, .NET), EDIFACT-adjacent systems |
to_json_snake_case() | Rust-centric APIs and internal integration formats |
to_json_canonical() | Content-addressed signing, payload hashing, event sourcing, diffing, caching |
Note on
to_json_canonicaland RFC 8785 (JCS): This method sorts object keys recursively β through every serde shape, including sequences, tuples, and enum variants β and produces deterministic output, but it is not a full RFC 8785 implementation. Keys are sorted by UTF-8 byte order (not UTF-16 as JCS requires), and numeric values use serde_json formatting (not IEEE 754 as JCS requires). For BO4E data β ASCII-only field names andDecimal-as-string amounts β these differences are irrelevant in practice.
πAPI Reference
// Requires `json` feature
impl Vertrag {
pub fn to_json_german(&self) -> Result<String, serde_json::Error>;
pub fn to_json_snake_case(&self) -> Result<String, serde_json::Error>;
pub fn to_json_canonical(&self) -> Result<String, serde_json::Error>;
}All three methods:
- Return valid JSON as a
String - Skip
Nonefields (nonullvalues in output) - Recursively serialize nested BO/COM types
There is no runtime SerializeConfig object. Mode is chosen at the call site.
πDeserialization
// Requires `json` feature
let vertrag: Vertrag = serde_json::from_str(&json_string)?;Deserialization accepts both:
- BO4E German camelCase (
from_json_german,from_json_german_bytes) - Snake_case key form (
from_json_snake_case,from_json_snake_case_bytes) - An already-parsed
serde_json::Value(from_json_value) β German keys only; aValuein snake form is exotic enough that the round-trip through a string is the honest path for it
Snake_case mode transforms key style only. It is not a German->English translation.
πHow snake_case keys are mapped
The mapping is an exact table the code generator emits from the same field data it uses to emit the structs β not a runtime heuristic. to_json_snake_case() followed by from_json_snake_case() therefore returns the value you started with, for every generated type.
An algorithmic inverse cannot achieve that, because several BO4E names collapse onto a snake form that maps back to a different camelCase name:
| Wire key | snake_case | What a heuristic maps back to |
|---|---|---|
hoechstpreisHT | hoechstpreis_ht | hoechstpreisHt β |
kundengruppeKA | kundengruppe_ka | kundengruppeKa β |
A (Sigmoidparameter) | a | a β |
With a heuristic those fields deserialize into _additional instead of their typed field β a silent data loss that the table removes by construction.
Two kinds of key are deliberately not rewritten, in either direction:
- BO4E metadata keys β
_typ,_version,_idkeep their leading underscore in every output mode. They are wire metadata, not Rust field names. - Extension keys β anything the schema does not define is never renamed, so unknown fields round-trip under the names their producer chose rather than under something it would not recognise.
Because every lookup resolves to a &'static str, renaming a key allocates on neither the serialize nor the deserialize path.
πThe transform stops at the edge of the schema
The second bullet holds for the whole subtree under an extension key, not just the key itself:
{ "_typ": "MARKTLOKATION", "vendorBlob": { "a": 3, "marktlokations_id": "x" } }a and marktlokations_id come back out spelled exactly that way, even though A is Sigmoidparameter's field and marktlokations_id is Marktlokation's. Keys are renamed as the parser yields them, before serde knows which struct they belong to, so an unscoped transform would rewrite the producer's own JSON into names it does not use. It therefore descends only under keys the schema defines, and switches off for the rest of that subtree.
πβ¦with two ambiguities it cannot resolve
Both follow from the same root β the transform runs before serde knows the type:
- A top-level extension key that is a field's snake spelling. A
Marktlokationcarrying an unknown top-levelmarktlokations_idis indistinguishable from the real field once written in snake form, sofrom_json_snake_casereads it as the field β and rejects the payload if the value is not a valid MaLo-ID. ZusatzAttribut.wert. Its value is free-form JSON, butwertis alsoBetrag's decimal andMesswert's nested COM, so the name cannot be excluded from the schema-key set without breaking the last of those. Object keys inside aZusatzAttribut.wertare renamed like schema keys.
Use to_json_german / from_json_german whenever extension data matters. The German mode renames nothing, so neither ambiguity exists there.
πAnyBo goes through the same pipeline
AnyBo cannot know its concrete type until it has read "_typ", so it buffers the payload first. It buffers through the deserializer it was handed, which is what puts it on the same footing as a concrete BO type: the key transform above applies to it, and so does the nesting-depth limit described below.
That costs an intermediate buffer. Deserializing a concrete BO type skips it, so prefer the concrete type on hot paths where you already know it:
use rubo4e::current::{AnyBo, Marktlokation};
use rubo4e::json::Bo4eJsonExt;
// Polymorphic ingest β type decided at runtime by `_typ`.
let bo = AnyBo::from_json_german(body)?;
// Known type β no buffering.
let malo = Marktlokation::from_json_german(body)?;πHardened Deserialization for Untrusted Inputs
For untrusted external payloads, use the hardened APIs with explicit limits:
use rubo4e::json::{Bo4eJsonExt, JsonParseLimits};
use rubo4e::current::Vertrag;
// `untrusted_defaults()` sets all four caps to conservative values.
let vertrag = Vertrag::from_json_german_hardened(
&json_string,
JsonParseLimits::untrusted_defaults(),
)?;
// Or start from a profile and narrow it. `JsonParseLimits` is
// `#[non_exhaustive]`: new caps get added as new amplification paths are found,
// and a struct literal would make every one of those a breaking change.
let limits = JsonParseLimits::untrusted_defaults()
.with_max_payload_bytes(Some(64 * 1024))
.with_max_extension_field_count(Some(0)); // reject any unknown field
let vertrag = Vertrag::from_json_german_hardened(&json_string, limits)?;
// `unlimited()` turns every cap off β useful as a base when exactly one matters.
let depth_only = JsonParseLimits::unlimited().with_max_nesting_depth(Some(16));Available hardened variants:
from_json_german_hardenedfrom_json_snake_case_hardenedfrom_json_german_bytes_hardenedfrom_json_snake_case_bytes_hardened
πAnyBo is a hardened entry point too
All four budgets survive the _typ dispatch, so a gateway that does not know what is arriving still gets them:
use rubo4e::current::AnyBo;
let bo = AnyBo::from_json_german_hardened(&body, JsonParseLimits::untrusted_defaults())?;
match bo {
AnyBo::Marktlokation(m) => { /* β¦ */ }
AnyBo::Unknown { typ, .. } => { /* a BO type this build does not know */ }
_ => {}
}That holds because AnyBo's Deserialize buffers through the caller's deserializer rather than re-parsing with serde_json::from_str, which keeps the depth limiter and the snake_case key transform in the path. It costs one intermediate serde_json::Value, so deserialize the concrete BO type on a hot path where you know it. tests/any_bo.rs pins each limit through the dispatch.
πWhat each limit means
| Limit | Scope | Enforced |
|---|---|---|
max_payload_bytes | whole input | before parsing starts |
max_nesting_depth | whole document | inline, during the single parse pass |
max_extension_value_bytes | cumulative across every struct in the payload | charged per extension field as it is parsed |
max_extension_field_count | per struct, at every nesting level | checked as each struct's extension fields are read |
The two extension limits apply at every nesting level, not just the root object. Extension data hidden inside a nested COM β say marktlokation.lokationsadresse β is charged to the same budget as extension data on the root, and both are checked as that struct's extension fields are read rather than after the whole object tree has been built.
They bound what a payload leaves retained, not what parsing it allocates: #[serde(flatten)] routes unknown keys into the extension map by buffering a struct's unrecognised entries into an intermediate Content first, so those fields exist in memory before the count cap fires. max_payload_bytes is the only cap applied before any parsing, which makes it the one that bounds peak memory β set it first.
Independently of these opt-in limits, two hard caps always apply, on every deserialization path including the non-hardened ones:
MAX_EXTENSION_FIELDS(128) β extension fields per structMAX_EXTENSION_KEY_LEN(256) β bytes per extension field key
max_extension_field_count can only tighten the 128 cap, never loosen it.
Counters for every limit that has fired are available process-wide via json_limit_hit_counters(), and are exported to the metrics ecosystem when the metrics feature is on.
πWhat these limits do not bound
They bound the parser, not the object graph it produces. A payload well inside max_payload_bytes can still expand by a large factor: [{},{},{}β¦] is three bytes per element on the wire and one fully-sized struct per element in memory, so a 1 MB body can allocate on the order of a hundred megabytes of Vec.
Size max_payload_bytes against the expanded cost rather than the wire cost, and put a concurrency limit in front of the endpoint β a per-request cap does not bound what a thousand concurrent requests hold at once.
πRound-Trip Safety (ExtensionData)
Every BO and COM struct carries an _additional field that captures any JSON keys not recognized by the struct definition:
pub struct Vertrag {
// ... known fields ...
#[serde(flatten)]
#[serde(skip_serializing_if = "crate::json::ext_map_is_empty")]
pub _additional: crate::LimitedExtensionMap,
}This means a payload with custom extension fields (common in BO4E implementations that extend the standard) survives a full round-trip:
let json = r#"{
"_typ": "VERTRAG",
"_version": "202607.1.0",
"_customExtension": "some-value"
}"#;
let vertrag: Vertrag = serde_json::from_str(json)?;
assert!(rubo4e::json::Bo4eExtensionData::extension_data(&vertrag)
.contains_key("_customExtension"));
let roundtripped = vertrag.to_json_german()?;
assert!(roundtripped.contains("_customExtension"));indexmap::IndexMap is used (not std::collections::HashMap) so the top-level extension keys keep the order they arrived in.
Everything nested under an extension key keeps its names and values, and is never renamed β see the transform's scoping rule. Key order inside a nested object is not kept: below the top level a value is a serde_json::Value, whose objects are a sorted map, so {"b":1,"a":2} comes back as {"a":2,"b":1}. Enable serde_json's preserve_order in your own Cargo.toml if that ordering matters; feature unification applies it here too.
πA decode does not validate field names
Serde ignores keys a struct does not declare; this crate keeps them. So a misspelled or renamed key does not fail a decode β it lands in _additional, the decode returns Ok, and the field it was meant to fill reads back as None.
let body = serde_json::json!({
"_typ": "KOSTEN",
"kostenbloecke": [{ "kostenblockBEZEICHNUNG": "x" }] // misspelled
});
let kosten: Kosten = serde_json::from_value(body.clone())?; // cannot fail
assert_eq!(kosten.kostenbloecke.unwrap()[0].kostenblockbezeichnung, None);Assembling a BO4E document as a serde_json::Value, decoding it "to validate" and sending the literal therefore validates nothing. has_extension_data() does not rescue it: it is shallow, and answers false at the root because the stray key sits one level down.
πBo4eExtensions β the recursive check
use rubo4e::json::Bo4eExtensions;
assert_eq!(kosten.extension_paths(), ["kostenbloecke[0].kostenblockBEZEICHNUNG"]);
kosten.ensure_no_extension_data()?; // Err(UnknownFieldError { paths })It descends through every nested BO, COM, Option and Vec. Only the top-level key of each extension entry counts β everything under it is opaque by design, so a vendor blob {"vendorX": {"a": 1, "b": 2}} is one finding.
Paths are dotted with bracketed array indices, like Bo4eStrict's. Extension keys come off the wire and can contain the characters the path syntax uses, so anything that is not a plain [A-Za-z0-9_] identifier is bracket-quoted: parent["a.b"].
Order is deterministic: a struct's own undefined keys first, then its children's, depth-first in field order. Within one struct they follow _additional's order β arrival order from text, sorted from a serde_json::Value, whose objects are a BTreeMap.
πβ¦or make the decode itself the check
use rubo4e::json::{Bo4eJsonExt, JsonParseLimits};
let closed = JsonParseLimits::unlimited().with_max_extension_field_count(Some(0));
let kosten = Kosten::from_json_value_hardened(body, closed)?; // Err on any stray keyfrom_json_value and from_json_value_hardened are the serde_json::Value counterparts of the &str readers, with the same depth and extension budgets. max_payload_bytes does not apply β there are no bytes left to cap β and is ignored rather than rejected, so one JsonParseLimits serves both paths.
πTwo questions, two calls
A payload can leave the schema in two ways, and neither check sees the other's finding:
| Question | Call | Trait |
|---|---|---|
| Does it use a value this schema version does not define? | ensure_known_enums() | Bo4eStrict |
| Does it use a field this schema version does not define? | ensure_no_extension_data() | Bo4eExtensions |
// `sparte` is a defined field carrying an undefined value.
let malo: Marktlokation = serde_json::from_value(json!({"sparte": "PLASMA"}))?;
assert_eq!(malo.unknown_enum_paths(), ["sparte"]);
assert!(malo.extension_paths().is_empty());
// `spartee` is an undefined field. No enum was ever reached.
let malo: Marktlokation = serde_json::from_value(json!({"spartee": "STROM"}))?;
assert_eq!(malo.extension_paths(), ["spartee"]);
assert!(malo.unknown_enum_paths().is_empty());Rejecting an unknown value is usually right at an ingest boundary. Rejecting an unknown field usually is not β that is how a counterparty one schema release ahead reaches you, and refusing it throws away the forward compatibility _additional exists to provide. Run the field check on documents you produce, and inbound only where a closed field set is contractually agreed.
πOr do not decode to check at all
Construct the value typed, and a field rename is a compile error:
let kosten = Kosten {
kostenbloecke: Some(vec![Kostenblock {
kostenblockbezeichnung: Some("x".into()),
..Default::default()
}]),
..Default::default()
};A hand-written "_typ" in a json! literal is a reliable marker for the decode-to-check habit; a CI grep for one is a cheap guard.
πZusatzAttribut has no _typ
It is the single BO4E schema that declares none β it has exactly name and wert, and ComTyp has no variant for it. A producer that stamps "_typ": "ZUSATZATTRIBUT" on one by analogy with every other COM is sending a field BO4E does not define, and the check says so. The reference implementation emits no such key either.
πTwo caps you cannot turn off
Preserving unknown fields is a memory-growth surface, so the extension map enforces two hard limits on every deserialization path, hardened or not: MAX_EXTENSION_FIELDS (128) per struct, and MAX_EXTENSION_KEY_LEN (256 bytes) per key. JsonParseLimits::max_extension_field_count can tighten the first, never loosen it.
The same caps apply to programmatic writes: LimitedExtensionMap::try_insert returns Err(ExtensionInsertError) rather than growing past either, and no &mut IndexMap is exposed anywhere β handing one out would make both advisory. Replacing an existing key is always allowed, even at capacity, since it does not grow the map.
πZusatzAttribute and namespaces
ExtensionData above is about fields BO4E does not define β keys that arrived and had nowhere to go. zusatzAttribute is the opposite: the list BO4E does define, on every GeschΓ€ftsobjekt and every component, for exactly the values a sender wants to carry and the standard has no field for.
BO4E defines the list and then says nothing about how two systems writing into it stay out of each other's way. Two facts follow, and both are why rubo4e supplies a convention rather than leaving the field bare:
- A collision is silent.
"id"written by a market-communication layer and by a household model is one entry, and the second write wins. - A receiver may drop it. BO4E states no obligation to round-trip
zusatzAttribute, so a namespaced value is a hint you re-derive, never the system of record.
rubo4e::zusatz_attribut is a namespace:key convention, a small registry of the prefixes the ecosystem has claimed, and accessors that read and write through it. ZusatzAttributeExt is blanket-implemented over a generated trait, so it is available on every BO4E type that declares the field:
use rubo4e::current::SteuerbareRessource;
use rubo4e::zusatz_attribut::{Namespace, ZusatzAttributeExt};
let mut sr = SteuerbareRessource::default();
sr.set_zusatz_attribut_in(&Namespace::HEMS, "eebus-ski", "d1e2β¦d9e0");
sr.set_zusatz_attribut_in(&Namespace::MAKO, "vorgangsnummer", "V-2026-0001");
assert_eq!(sr.zusatz_attribut_str_in(&Namespace::HEMS, "eebus-ski"), Some("d1e2β¦d9e0"));
assert_eq!(sr.zusatz_attribut_namespaces(), ["hems", "mako"]);The wire form is the flat BO4E name β {"name": "hems:eebus-ski", "wert": "β¦"} β so any BO4E reader still sees an ordinary ZusatzAttribut, and a foreign prefix you do not recognise round-trips untouched.
πRegistered prefixes
| Prefix | For |
|---|---|
mako | market communication β Vorgang metadata, message references, sender/receiver context |
hems | home energy management β device keys, Β§ 14a steering, anything the household model knows that BO4E does not |
edmd | Energiedaten-Management β series provenance, replacement-value procedure, back-end keys |
mabis | settlement facts fixed by a published BNetzA or BDEW document β the keys this crate itself registers, see below |
mabis names a standard where the other three name systems, and that is deliberate: a namespace is a wire-level provenance tag, read by a consumer deciding whether an attribute means anything to them β "this came from the MaBiS rules" is the useful answer. (A module named for a standard is the opposite β see Beyond the Schema.) The other three name systems because that data has no published provenance to name instead.
It is a convention, not an enforcement: Namespace::new takes any well-formed prefix ([A-Za-z0-9_-]+, no :), and is_registered() says whether a given one is on the list. Claiming a new prefix means adding it to Namespace::REGISTERED and shipping it, so a collision is caught in review.
πTyped keys, and the keys this crate registers
A namespace stops two systems colliding on a name. It does not stop them disagreeing about what the value behind that name is β one writing a string where the other reads an object. AttributKey<T> closes that too: the key and its type travel together, as one const both sides import.
use rubo4e::zusatz_attribut::{AttributKey, Namespace, ZusatzAttributeExt};
const LADEPUNKT: AttributKey<Ladepunkt> = AttributKey::new(Namespace::HEMS, "ladepunkt");
malo.set_zusatz_attribut_key(&LADEPUNKT, &ladepunkt)?;
let read: Ladepunkt = malo.zusatz_attribut_key(&LADEPUNKT).unwrap()?;
assert_eq!(LADEPUNKT.name(), "hems:ladepunkt");rubo4e::zusatz_attribut::well_known holds the keys this crate registers, so two crates carrying the same fact carry it under the same name:
| Key | Type | Why BO4E has no field |
|---|---|---|
mabis:zaehlpunkt | identifiers::Zaehlpunkt | BO4E has one field for a ZΓ€hlpunktbezeichnung and assumes it is always a Messlokation; BK6-20-160 Β§1.6.2 says the ZΓ€hlpunkt (eMob) is not |
The list is short on purpose. A key here is part of the public API, and the bar for adding one is high: the value must qualify a BO4E field that cannot express the distinction on its own. A domain aggregate of some other standard does not qualify however useful it is β it belongs in the crate that owns that standard, registering its own key in its own namespace with AttributKey::new. See Beyond the Schema β the test.
πTyped values
With the json feature the value is a serde_json::Value, so anything Serialize round-trips β including a code list BO4E has not published and your crate has:
use rubo4e::current::TechnischeRessource;
use rubo4e::zusatz_attribut::{Namespace, ZusatzAttributeExt};
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum Steuerungsvariante { Direktansteuerung, Ems }
let mut tr = TechnischeRessource::default();
tr.set_zusatz_attribut_as_in(&Namespace::HEMS, "steuerungsvariante", &Steuerungsvariante::Ems)
.unwrap();
let read: Steuerungsvariante = tr
.zusatz_attribut_as_in(&Namespace::HEMS, "steuerungsvariante")
.unwrap()
.unwrap();
assert_eq!(read, Steuerungsvariante::Ems);πWhat BO4E v202607 does not model
Two things the Β§ 14a EnWG case needs, checked against the schema this crate is generated from:
| Wanted | In v202607.1.0? | Nearest field |
|---|---|---|
| Steuerungsvariante β Direktansteuerung vs. Steuerung ΓΌber ein EMS | no | none; SteuerkanalLeistungsbeschreibung is AN_AUS / GESTUFT, which says what the channel can do, not who steers it |
| EEBUS SKI / identifier of the Steuerungseinrichtung | no | none; SteuerbareRessource.steuerbareRessourceId is the BDEW SR-ID, a market identifier, not a device key |
Both therefore belong in a namespace until BO4E models them. rubo4e deliberately does not define the values: shipping a Steuerungsvariante enum here would be inventing a code list the market has not published, and every consumer would then have to keep it in step with a document that does not exist. The mechanism is the crate's job; the code list is yours.
Where BO4E does model something, use the field β a control channel's characteristic is SteuerkanalLeistungsbeschreibung, not a namespaced string.
πOrdering and duplicates
The list is a Vec and stays one. Entries keep insertion order, and set_* replaces the first entry with that name rather than appending a second. A payload that arrived with duplicates keeps them; the getters read the first, which is what a reader that ignores the problem would also see.
remove_zusatz_attribute_in(&namespace) strips one system's entries and returns them β the call to make before handing a document to a partner who has no business seeing another system's internals.
πWhy there is no SIMD backend
A SIMD JSON parser does not help this crate, and measurement says so at every payload size from 265 bytes to 166 KB:
| Payload | serde_json | simd-json |
|---|---|---|
| 1.7 KB | 5.65 Β΅s | 8.89 Β΅s |
| 16.7 KB | 55.7 Β΅s | 75.6 Β΅s |
| 166 KB | 544 Β΅s | 676 Β΅s |
The reason is structural. Every generated struct carries #[serde(flatten)] for its extension map, so deserialization is dominated by serde's Content buffering, not by the tokenizer SIMD accelerates. simd-json's mutable-slice API then forces a Vec<u8> copy of every payload, and its parser cannot wrap a visitor, so the nesting-depth guard needs a second pass over the bytes.
benches/json_perf.rs tracks the serde_json path; re-run it before assuming this conclusion still holds for your payload shapes.
πScope Note
This library does not provide HTTP handler code. There are no Axum extractors, no Actix-web request guards, and no framework-specific FromRequest implementations.
Consumers integrate rubo4e types into their own HTTP layer. For example, with Axum:
// In your application code (not in rubo4e):
async fn create_vertrag(
axum::extract::Json(body): axum::extract::Json<serde_json::Value>,
) -> Result<axum::Json<Vertrag>, AppError> {
let vertrag: Vertrag = serde_json::from_value(body)?;
vertrag.validate()?;
Ok(axum::Json(vertrag))
}