#![allow(clippy::doc_markdown)]
use reqwest::Client;
use secrecy::{ExposeSecret as _, SecretString};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::error::MdmError;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MaloIdentResultPositive {
pub malo_id: String,
pub nb_mp_id: String,
pub msb_mp_id: Option<String>,
pub sender_market_partner_id: String,
pub bilanzierungsgebiet: Option<String>,
pub netzgebiet: Option<String>,
pub sparte: String,
}
#[derive(Debug)]
pub struct ForwardCommand {
pub command: String,
pub marktrolle: Option<String>,
pub malo_id: Option<String>,
pub melo_id: Option<String>,
pub payload: serde_json::Value,
}
impl serde::Serialize for ForwardCommand {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut merged = match &self.payload {
serde_json::Value::Object(m) => m.clone(),
_ => serde_json::Map::new(),
};
if let Some(ref id) = self.malo_id {
merged
.entry("malo_id")
.or_insert_with(|| serde_json::Value::String(id.clone()));
}
if let Some(ref id) = self.melo_id {
merged
.entry("melo_id")
.or_insert_with(|| serde_json::Value::String(id.clone()));
}
let field_count = if self.marktrolle.is_some() { 3 } else { 2 };
let mut map = serializer.serialize_map(Some(field_count))?;
map.serialize_entry("command", &self.command)?;
if let Some(ref role) = self.marktrolle {
map.serialize_entry("marktrolle", role)?;
}
map.serialize_entry("payload", &serde_json::Value::Object(merged))?;
map.end()
}
}
#[derive(Debug, Deserialize)]
pub struct CommandAccepted {
pub process_id: uuid::Uuid,
pub command: String,
pub idempotency_key: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MakodPartner {
pub mp_id: String,
pub display_name: Option<String>,
pub marktrolle: Option<rubo4e::current::Marktrolle>,
pub channels: serde_json::Value,
}
#[derive(Clone)]
pub struct MakodClient {
client: Client,
base_url: String,
api_key: SecretString,
}
impl std::fmt::Debug for MakodClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MakodClient")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl MakodClient {
pub fn new(base_url: impl Into<String>, api_key: SecretString) -> Self {
Self {
client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client construction is infallible"),
base_url: base_url.into(),
api_key,
}
}
pub async fn put_malo(
&self,
malo_id: &str,
record: &MaloIdentResultPositive,
) -> Result<(), MdmError> {
let url = format!("{}/admin/malo/{malo_id}", self.base_url);
debug!(malo_id, "pushing MaLo to makod admin cache");
let mut nb_operators = Vec::new();
if !record.nb_mp_id.is_empty() {
let nb_i64 = record
.nb_mp_id
.parse::<rubo4e::identifiers::MarktpartnerId>()
.map(|id| id.to_i64())
.unwrap_or(0);
nb_operators.push(serde_json::json!({
"marketPartnerId": nb_i64,
"executionTimeFrom": "2000-01-01T00:00:00Z"
}));
}
let mut mpo = Vec::new();
if let Some(msb) = &record.msb_mp_id
&& !msb.is_empty()
{
let msb_i64 = msb
.parse::<rubo4e::identifiers::MarktpartnerId>()
.map(|id| id.to_i64())
.unwrap_or(0);
mpo.push(serde_json::json!({
"marketPartnerId": msb_i64,
"executionTimeFrom": "2000-01-01T00:00:00Z"
}));
}
let body = serde_json::json!({
"result": {
"dataMarketLocation": {
"maloId": record.malo_id,
"energyDirection": "consumption",
"measurementTechnologyClassification": "conventionalMeasuringSystem",
"optionalChangeForecastBasis": "notPossible",
"dataMarketLocationProperties": [],
"dataMarketLocationNetworkOperators": nb_operators,
"dataMarketLocationTransmissionSystemOperators": [],
"dataMarketLocationMeasuringPointOperators": mpo
}
},
"source": "mdm-sync"
});
let resp = self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
warn!(malo_id, status, %body, "makod PUT /admin/malo failed");
Err(MdmError::MakodSync(format!(
"PUT /admin/malo/{malo_id} returned HTTP {status}: {body}"
)))
}
}
pub async fn put_partner(&self, mp_id: &str, partner: &MakodPartner) -> Result<(), MdmError> {
let url = format!("{}/admin/partners/{mp_id}", self.base_url);
debug!(mp_id, "pushing partner to makod admin directory");
let resp = self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(partner)
.send()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
warn!(mp_id, status, %body, "makod PUT /admin/partners failed");
Err(MdmError::MakodSync(format!(
"PUT /admin/partners/{mp_id} returned HTTP {status}: {body}"
)))
}
}
pub async fn post_command(
&self,
idempotency_key: &str,
cmd: &ForwardCommand,
) -> Result<CommandAccepted, MdmError> {
let url = format!("{}/api/v1/commands", self.base_url);
debug!(command = %cmd.command, idempotency_key, "forwarding command to makod");
let resp = self
.client
.post(&url)
.bearer_auth(self.api_key.expose_secret())
.header("Idempotency-Key", idempotency_key)
.json(cmd)
.send()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))?;
if resp.status().is_success() {
resp.json::<CommandAccepted>()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))
} else if resp.status() == reqwest::StatusCode::CONFLICT {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let outcome = classify_conflict(&body, &cmd.command, idempotency_key);
if let Ok(ref accepted) = outcome {
debug!(
idempotency_key,
process_id = %accepted.process_id,
"makod returned 409 duplicate_process — adopting the existing process"
);
}
outcome
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
warn!(status, %body, "makod POST /api/v1/commands failed");
Err(MdmError::MakodSync(format!(
"POST /api/v1/commands returned HTTP {status}: {body}"
)))
}
}
pub async fn get_invoic_rechnung(
&self,
process_id: uuid::Uuid,
) -> Result<Option<serde_json::Value>, MdmError> {
let url = format!("{}/api/v1/invoic/{process_id}/rechnung", self.base_url);
debug!(%process_id, "fetching WiM rechnung from makod");
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))?;
if resp.status().is_success() {
let value: serde_json::Value = resp
.json()
.await
.map_err(|e| MdmError::MakodSync(e.to_string()))?;
Ok(Some(value))
} else if resp.status() == reqwest::StatusCode::NOT_FOUND {
Ok(None)
} else {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
warn!(status, %process_id, %body, "makod GET invoic/rechnung failed");
Err(MdmError::MakodSync(format!(
"GET /api/v1/invoic/{process_id}/rechnung returned HTTP {status}: {body}"
)))
}
}
}
fn classify_conflict(
body: &serde_json::Value,
command: &str,
idempotency_key: &str,
) -> Result<CommandAccepted, MdmError> {
let kind = body.get("error").and_then(|v| v.as_str()).unwrap_or("");
let detail = body
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or("(no detail)")
.to_owned();
let process_id = body
.get("process_id")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<uuid::Uuid>().ok());
match (kind, process_id) {
("duplicate_process", Some(process_id)) => Ok(CommandAccepted {
process_id,
command: command.to_owned(),
idempotency_key: Some(idempotency_key.to_owned()),
}),
("duplicate_process", None) => Err(MdmError::MakodConflict {
kind: "duplicate_process_without_id".to_owned(),
detail: format!(
"makod reported duplicate_process but the body carried no parseable \
process_id, so the existing process cannot be correlated: {detail}"
),
}),
_ => Err(MdmError::MakodConflict {
kind: if kind.is_empty() {
"unknown".to_owned()
} else {
kind.to_owned()
},
detail,
}),
}
}
#[cfg(test)]
mod conflict_tests {
use super::{MdmError, classify_conflict};
use serde_json::json;
#[test]
fn duplicate_process_with_an_id_is_an_idempotent_success() {
let id = "018f3c1e-0000-7000-8000-0000000000aa";
let accepted = classify_conflict(
&json!({ "error": "duplicate_process", "process_id": id }),
"gpke.lieferbeginn.anmelden",
"k-1",
)
.expect("duplicate_process with an id is a success");
assert_eq!(accepted.process_id.to_string(), id);
assert_eq!(accepted.idempotency_key.as_deref(), Some("k-1"));
}
#[test]
fn invalid_state_is_an_error_not_a_nil_uuid_success() {
let err = classify_conflict(
&json!({
"error": "invalid_state",
"detail": "cannot bestaetigen a process in state Abgeschlossen",
}),
"gpke.lieferbeginn.bestaetigen",
"k-2",
)
.expect_err("invalid_state must not be reported as success");
match err {
MdmError::MakodConflict { kind, detail } => {
assert_eq!(kind, "invalid_state");
assert!(detail.contains("Abgeschlossen"), "detail lost: {detail}");
}
other => panic!("expected MakodConflict, got {other:?}"),
}
}
#[test]
fn duplicate_process_without_a_usable_id_is_an_error() {
for body in [
json!({ "error": "duplicate_process" }),
json!({ "error": "duplicate_process", "process_id": "not-a-uuid" }),
] {
let err = classify_conflict(&body, "cmd", "k")
.expect_err("an uncorrelatable duplicate is not a success");
assert!(
matches!(err, MdmError::MakodConflict { ref kind, .. }
if kind == "duplicate_process_without_id"),
"unexpected: {err:?}"
);
}
}
#[test]
fn an_unrecognised_conflict_body_is_an_error() {
let err = classify_conflict(&json!({}), "cmd", "k").expect_err("unknown 409 is an error");
assert!(
matches!(err, MdmError::MakodConflict { ref kind, .. } if kind == "unknown"),
"unexpected: {err:?}"
);
}
#[test]
fn a_command_conflict_is_reported_as_409() {
let err = MdmError::MakodConflict {
kind: "invalid_state".to_owned(),
detail: "x".to_owned(),
};
assert_eq!(err.status_u16(), 409);
assert_eq!(err.error_code(), "makod_conflict");
}
}