use rubo4e::Bo4eStrict;
use serde::de::DeserializeOwned;
use super::conformance::Bo4eConformance;
use rubo4e::validation::{ValidationFailure, report_errors};
pub use rubo4e::Bo4eTyped;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Bo4eRejection {
#[error("expected a BO4E {expected}, got _typ '{found}'")]
Discriminator {
expected: &'static str,
found: String,
},
#[error("not a valid BO4E {typ}: {detail}")]
Schema {
typ: &'static str,
detail: String,
},
#[error(
"{typ} carries {} out-of-schema enum value(s) at: {}",
paths.len(),
paths.join(", ")
)]
UnknownEnum {
typ: &'static str,
paths: Vec<String>,
},
#[error(
"{typ} breaks {} BO4E rule(s): {}",
failures.len(),
failures.iter().map(|f| format!("{}: {}", f.path, f.message))
.collect::<Vec<_>>().join("; ")
)]
Rule {
typ: &'static str,
failures: Vec<ValidationFailure>,
},
#[error(
"{typ} carries {} field(s) BO4E does not define at: {}",
paths.len(),
paths.join(", ")
)]
UnknownField {
typ: &'static str,
paths: Vec<String>,
},
}
impl Bo4eRejection {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::Discriminator { .. } => "bo4e.discriminator",
Self::Schema { .. } => "bo4e.schema",
Self::UnknownEnum { .. } => "bo4e.unknown_enum",
Self::Rule { .. } => "bo4e.rule",
Self::UnknownField { .. } => "bo4e.unknown_field",
}
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
let mut body = self.detail();
body.insert("error".into(), self.to_string().into());
serde_json::Value::Object(body)
}
#[must_use]
pub fn detail(&self) -> serde_json::Map<String, serde_json::Value> {
let mut obj = serde_json::Map::new();
obj.insert("code".into(), self.code().into());
match self {
Self::Discriminator { expected, found } => {
obj.insert("expected_typ".into(), (*expected).into());
obj.insert("found_typ".into(), found.clone().into());
}
Self::Schema { .. } => {}
Self::UnknownEnum { paths, .. } | Self::UnknownField { paths, .. } => {
obj.insert("paths".into(), paths.clone().into());
}
Self::Rule { failures, .. } => {
obj.insert(
"failures".into(),
failures
.iter()
.map(|f| serde_json::json!({ "path": f.path, "message": f.message }))
.collect::<Vec<_>>()
.into(),
);
}
}
obj
}
}
pub fn decode<T>(data: serde_json::Value) -> Result<T, Bo4eRejection>
where
T: DeserializeOwned + Bo4eTyped + Bo4eStrict + Bo4eConformance,
T: rubo4e::prelude::Validate<Context = ()> + rubo4e::json::Bo4eJsonExt,
{
let typed: T = decode_structural(data)?;
let failures = rule_failures(&typed);
if failures.is_empty() {
Ok(typed)
} else {
Err(Bo4eRejection::Rule {
typ: T::TYP_WIRE,
failures,
})
}
}
fn rule_failures<T>(value: &T) -> Vec<ValidationFailure>
where
T: rubo4e::prelude::Validate<Context = ()> + Bo4eConformance,
{
let mut failures = match value.validate() {
Ok(()) => Vec::new(),
Err(report) => report_errors(&report),
};
failures.extend(value.residual_rules());
failures
}
pub fn decode_received<T>(
data: serde_json::Value,
) -> Result<(T, Vec<ValidationFailure>), Bo4eRejection>
where
T: DeserializeOwned + Bo4eTyped + Bo4eStrict + Bo4eConformance,
T: rubo4e::prelude::Validate<Context = ()> + rubo4e::json::Bo4eJsonExt,
{
let typed: T = decode_structural(data)?;
let failures = rule_failures(&typed);
Ok((typed, failures))
}
fn decode_structural<T>(data: serde_json::Value) -> Result<T, Bo4eRejection>
where
T: DeserializeOwned + Bo4eTyped + Bo4eStrict + rubo4e::json::Bo4eJsonExt,
{
let expected = T::TYP_WIRE;
let data = match data {
serde_json::Value::Object(mut obj) => {
match obj.get("_typ").and_then(serde_json::Value::as_str) {
None => {
obj.insert("_typ".into(), expected.into());
}
Some(found) if !found.eq_ignore_ascii_case(expected) => {
return Err(Bo4eRejection::Discriminator {
expected,
found: found.to_owned(),
});
}
Some(_) => {}
}
serde_json::Value::Object(obj)
}
other => other,
};
let typed: T = T::from_json_value(data).map_err(|e| Bo4eRejection::Schema {
typ: expected,
detail: e.to_string(),
})?;
Bo4eStrict::ensure_known_enums(&typed).map_err(|e| Bo4eRejection::UnknownEnum {
typ: expected,
paths: e.paths,
})?;
Ok(typed)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the validated BO4E {typ} is not serialisable: {detail}")]
pub struct Bo4eSerialiseError {
pub typ: &'static str,
pub detail: String,
}
pub fn to_canonical_json<T>(value: &T) -> Result<serde_json::Value, Bo4eSerialiseError>
where
T: Bo4eTyped + serde::Serialize,
{
serde_json::to_value(value).map_err(|e| Bo4eSerialiseError {
typ: T::TYP_WIRE,
detail: e.to_string(),
})
}
pub fn ensure_conformant<T>(value: &T) -> Result<(), Bo4eRejection>
where
T: Bo4eTyped + Bo4eStrict + Bo4eConformance + rubo4e::json::Bo4eExtensions,
T: rubo4e::prelude::Validate<Context = ()>,
{
let typ = T::TYP_WIRE;
Bo4eStrict::ensure_known_enums(value).map_err(|e| Bo4eRejection::UnknownEnum {
typ,
paths: e.paths,
})?;
value
.ensure_no_extension_data()
.map_err(|e| Bo4eRejection::UnknownField {
typ,
paths: e.paths,
})?;
let mut failures = rule_failures(value);
failures.extend(value.emission_rules());
if failures.is_empty() {
Ok(())
} else {
Err(Bo4eRejection::Rule { typ, failures })
}
}
#[cfg(test)]
mod tests {
use super::{Bo4eRejection, Bo4eTyped as _, decode, ensure_conformant};
use rubo4e::current::{Betrag, Marktlokation, Rechnung, Waehrungscode, Zaehler};
use rust_decimal::dec;
#[test]
fn the_discriminator_is_the_types_own() {
assert_eq!(Marktlokation::TYP_WIRE, "MARKTLOKATION");
assert_eq!(Zaehler::TYP_WIRE, "ZAEHLER");
assert_eq!(rubo4e::current::Energiemix::TYP_WIRE, "ENERGIEMIX");
}
#[test]
fn an_absent_typ_is_injected() {
let malo: Marktlokation =
decode(serde_json::json!({ "marktlokationsId": "51238696781" })).expect("valid");
assert_eq!(malo.typ, Some(rubo4e::current::BoTyp::Marktlokation));
}
#[test]
fn a_lowercase_typ_is_accepted() {
assert!(decode::<Marktlokation>(serde_json::json!({ "_typ": "marktlokation" })).is_ok());
}
#[test]
fn the_wrong_bo_is_refused_before_it_is_parsed() {
let err = decode::<Marktlokation>(serde_json::json!({ "_typ": "ZAEHLER" }))
.expect_err("a Zaehler is not a Marktlokation");
assert_eq!(err.code(), "bo4e.discriminator");
assert_eq!(err.to_json()["found_typ"], "ZAEHLER");
}
#[test]
fn an_out_of_schema_enum_is_refused_with_its_path() {
let err = decode::<Marktlokation>(serde_json::json!({ "sparte": "STROMM" }))
.expect_err("STROMM is not a Sparte");
assert_eq!(err.code(), "bo4e.unknown_enum");
assert_eq!(err.to_json()["paths"], serde_json::json!(["sparte"]));
}
#[test]
fn a_pathologically_nested_payload_is_refused() {
let mut deep = serde_json::json!(null);
for _ in 0..600 {
deep = serde_json::json!([deep]);
}
let err = decode::<Marktlokation>(serde_json::json!({ "tief": deep }))
.expect_err("600 levels is past the cap");
assert_eq!(err.code(), "bo4e.schema");
}
#[test]
fn an_ordinary_payload_is_not_caught_by_the_depth_cap() {
assert!(
decode::<Marktlokation>(serde_json::json!({
"marktlokationsId": "51238696781",
"lokationsadresse": { "strasse": "Musterstraße", "hausnummer": "1" }
}))
.is_ok()
);
}
#[test]
fn a_value_the_type_cannot_hold_is_a_schema_error() {
let err = decode::<Marktlokation>(serde_json::json!({ "zaehlwerke": 7 }))
.expect_err("a number is not a list of Zaehlwerk");
assert_eq!(err.code(), "bo4e.schema");
}
#[test]
fn a_bo4e_rule_violation_is_reported_with_its_path() {
let payload = serde_json::json!({
"gesamtnetto": { "wert": "300.00", "waehrung": "EUR" },
"gesamtsteuer": { "wert": "57.00", "waehrung": "EUR" },
"gesamtbrutto": { "wert": "358.00", "waehrung": "EUR" },
});
let err = decode::<Rechnung>(payload).expect_err("357 != 358");
assert_eq!(err.code(), "bo4e.rule");
let body = err.to_json();
let failures = body["failures"].as_array().expect("failures list");
assert_eq!(failures.len(), 1);
assert!(
failures[0]["message"]
.as_str()
.is_some_and(|m| m.contains("gesamtbrutto")),
"{failures:?}"
);
}
#[test]
fn a_residual_mako_rule_reports_in_the_same_shape() {
let payload = serde_json::json!({
"gesamtnetto": { "wert": "300.00", "waehrung": "EUR" },
"rechnungspositionen": [{ "gesamtpreis": { "wert": "299.00", "waehrung": "EUR" } }],
});
let err = decode::<Rechnung>(payload).expect_err("299 != 300");
assert_eq!(err.code(), "bo4e.rule");
let failures = err.to_json();
let failures = failures["failures"].as_array().expect("failures list");
assert_eq!(failures[0]["path"], "gesamtnetto");
}
#[test]
fn rubo4e_and_mako_failures_arrive_together() {
let payload = serde_json::json!({
"gesamtnetto": { "wert": "300.00", "waehrung": "EUR" },
"gesamtsteuer": { "wert": "57.00", "waehrung": "EUR" },
"gesamtbrutto": { "wert": "358.00", "waehrung": "EUR" },
"istStorno": true,
"rechnungspositionen": [{ "gesamtpreis": { "wert": "299.00", "waehrung": "EUR" } }],
});
let err = decode::<Rechnung>(payload).expect_err("three rules broken");
let body = err.to_json();
let failures = body["failures"].as_array().expect("failures list");
assert_eq!(
failures.len(),
3,
"rubo4e's gesamtbrutto plus mako's gesamtnetto and storno: {failures:?}"
);
}
#[test]
fn both_decimal_spellings_decode() {
let as_string = decode::<Rechnung>(serde_json::json!({
"gesamtbrutto": { "wert": "119.00" }
}))
.expect("string spelling");
let as_number = decode::<Rechnung>(serde_json::json!({
"gesamtbrutto": { "wert": 119.00 }
}))
.expect("number spelling");
let wert = |r: &Rechnung| r.gesamtbrutto.as_ref().and_then(|b| b.wert);
assert_eq!(wert(&as_string), wert(&as_number));
}
#[test]
fn a_nested_value_may_omit_its_typ() {
use rubo4e::current::ZeitvariablePreisposition;
let zvp: ZeitvariablePreisposition =
decode(serde_json::json!({ "zaehlzeitregister": "HT" }))
.expect("a nested COM need not stamp _typ");
assert_eq!(zvp.zaehlzeitregister.as_deref(), Some("HT"));
assert_eq!(
zvp.typ,
Some(rubo4e::current::ComTyp::ZeitvariablePreisposition),
"the gate injects the discriminant rather than leaving the COM \
distinguishable from one the reference implementation produced"
);
}
#[test]
fn a_nested_value_may_not_misname_itself() {
use rubo4e::current::ZeitvariablePreisposition;
let err =
decode::<ZeitvariablePreisposition>(serde_json::json!({ "_typ": "MARKTLOKATION" }))
.expect_err("a Marktlokation is not a ZeitvariablePreisposition");
assert_eq!(err.code(), "bo4e.discriminator");
assert_eq!(err.to_json()["found_typ"], "MARKTLOKATION");
}
#[test]
fn canonical_serialisation_round_trips_and_names_its_type() {
let malo = Marktlokation {
marktlokations_id: Some(
rubo4e::identifiers::MaloId::new("51238696781").expect("a real MaLo"),
),
..Default::default()
};
let json = super::to_canonical_json(&malo).expect("a generated BO always serialises");
assert_eq!(json["_typ"], "MARKTLOKATION");
assert_eq!(json["marktlokationsId"], "51238696781");
let json = super::to_canonical_json(&Betrag {
wert: Some(dec!(1.00)),
waehrung: Some(Waehrungscode::Eur),
..Default::default()
})
.expect("a generated COM always serialises");
assert_eq!(json["_typ"], "BETRAG");
}
#[test]
fn the_outbound_gate_catches_a_document_mako_built() {
let eur = |w| {
Some(Betrag {
wert: Some(w),
waehrung: Some(Waehrungscode::Eur),
..Default::default()
})
};
let r = Rechnung {
gesamtnetto: eur(dec!(100.00)),
gesamtsteuer: eur(dec!(19.00)),
gesamtbrutto: eur(dec!(120.00)),
..Default::default()
};
assert!(matches!(
ensure_conformant(&r),
Err(Bo4eRejection::Rule { .. })
));
}
#[test]
fn every_gated_discriminator_is_a_schema_value() {
use rubo4e::current::{BoTyp, ComTyp};
for wire in [
Marktlokation::TYP_WIRE,
Zaehler::TYP_WIRE,
Rechnung::TYP_WIRE,
rubo4e::current::Energiemix::TYP_WIRE,
rubo4e::current::Zahlungsinformation::TYP_WIRE,
] {
assert!(
BoTyp::from_wire(wire).is_ok() || ComTyp::from_wire(wire).is_ok(),
"`{wire}` is neither a BoTyp nor a ComTyp"
);
}
}
}