use std::collections::HashMap;
use rubo4e::current::{PreisblattMessung, PreisblattNetznutzung};
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::repository::{
ConsentDecision, ConsentPerspective, EsaMessproduktPreis, GrundversorgerRecord, MaloGridRecord,
MaloTypedFields, PreisblattDienstleistungRecord, PreisblattHardwareRecord, PreisblattKaRecord,
VersorgungsStatusRecord,
};
const CACHE_TTL_SECS: i64 = 3_600;
const CB_FAILURE_THRESHOLD: u32 = 3;
const CB_COOLDOWN_SECS: i64 = 30;
#[derive(Debug, thiserror::Error)]
pub enum MarktdClientError {
#[error("marktd request failed: {0}")]
Http(String),
#[error("marktd response deserialization failed: {0}")]
Deserialization(String),
}
impl From<reqwest::Error> for MarktdClientError {
fn from(e: reqwest::Error) -> Self {
Self::Http(e.to_string())
}
}
#[derive(Debug, Serialize)]
pub struct SubscriptionRequest<'a> {
pub webhook_url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_secret: Option<&'a str>,
pub event_types: &'a [&'a str],
#[serde(skip_serializing_if = "<[_]>::is_empty")]
pub makopid_filter: &'a [u32],
pub active: bool,
}
struct CbInner {
cache: HashMap<(String, time::Date), CacheEntry<PreisblattNetznutzung>>,
cache_messung: HashMap<(String, time::Date), CacheEntry<PreisblattMessung>>,
cb_failures: u32,
cb_open_until: Option<OffsetDateTime>,
}
struct CacheEntry<T> {
sheet: Option<T>,
expires_at: OffsetDateTime,
}
impl CbInner {
fn is_cb_open(&self, now: OffsetDateTime) -> bool {
self.cb_open_until.is_some_and(|t| now < t)
}
fn record_success(&mut self) {
self.cb_failures = 0;
self.cb_open_until = None;
}
fn record_failure(&mut self, now: OffsetDateTime) {
self.cb_failures += 1;
if self.cb_failures >= CB_FAILURE_THRESHOLD {
self.cb_open_until = Some(now + time::Duration::seconds(CB_COOLDOWN_SECS));
}
}
#[allow(clippy::option_option)] fn cache_lookup<T: Clone>(
map: &HashMap<(String, time::Date), CacheEntry<T>>,
mp_id: &str,
date: time::Date,
) -> Option<Option<T>> {
map.get(&(mp_id.to_owned(), date))
.filter(|e| OffsetDateTime::now_utc() < e.expires_at)
.map(|e| e.sheet.clone())
}
fn cache_store<T>(
map: &mut HashMap<(String, time::Date), CacheEntry<T>>,
mp_id: &str,
date: time::Date,
sheet: Option<T>,
) {
let expires_at = OffsetDateTime::now_utc() + time::Duration::seconds(CACHE_TTL_SECS);
map.insert((mp_id.to_owned(), date), CacheEntry { sheet, expires_at });
}
}
#[derive(Clone)]
pub struct MarktdClient {
client: reqwest::Client,
base_url: String,
api_key: SecretString,
cb: std::sync::Arc<Mutex<CbInner>>,
}
impl std::fmt::Debug for MarktdClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MarktdClient")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ZaehlerSummary {
pub zaehler_id: String,
#[serde(default)]
pub zaehler_typ: Option<String>,
#[serde(default, rename = "data")]
pub daten: Option<ZaehlerDaten>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ZaehlerDaten {
#[serde(default)]
pub zaehlwerke: Vec<ZaehlwerkDaten>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ZaehlwerkDaten {
#[serde(default)]
pub richtung: Option<String>,
#[serde(default, rename = "obisKennzahl")]
pub obis_kennzahl: Option<String>,
}
impl ZaehlwerkDaten {
#[must_use]
pub fn ist_verbrauch(&self) -> bool {
self.richtung.as_deref() == Some("AUSSP")
}
#[must_use]
pub fn ist_erzeugung(&self) -> bool {
self.richtung.as_deref() == Some("EINSP")
}
#[must_use]
pub fn ist_blindarbeit(&self) -> bool {
let Some(obis) = self.obis_kennzahl.as_deref() else {
return false;
};
obis.rsplit(':')
.next()
.and_then(|rest| rest.split('.').next())
.and_then(|c| c.parse::<u8>().ok())
.is_some_and(|c| (3..=8).contains(&c))
}
}
impl ZaehlerSummary {
pub const IMSYS: &'static str = "INTELLIGENTES_MESSSYSTEM";
#[must_use]
pub fn ist_imsys(&self) -> bool {
self.zaehler_typ.as_deref() == Some(Self::IMSYS)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
pub struct EsaProduktAngebot {
pub pflicht: bool,
pub als_abo: Option<bool>,
pub als_einmalig: Option<bool>,
pub im_katalog: bool,
}
impl MarktdClient {
#[must_use]
pub fn new(
base_url: impl Into<String>,
api_key: SecretString,
client: reqwest::Client,
) -> Self {
Self {
client,
base_url: base_url.into(),
api_key,
cb: std::sync::Arc::new(Mutex::new(CbInner {
cache: HashMap::new(),
cache_messung: HashMap::new(),
cb_failures: 0,
cb_open_until: None,
})),
}
}
pub async fn get_versorgung(
&self,
malo_id: &str,
) -> Result<Option<VersorgungsStatusRecord>, MarktdClientError> {
let url = format!("{}/api/v1/versorgung/{}", self.base_url, malo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_nb_contract_for_malo(
&self,
malo_id: &str,
on: time::Date,
) -> Result<Option<crate::repository::NbContractView>, MarktdClientError> {
let fmt = time::macros::format_description!("[year]-[month]-[day]");
let url = format!(
"{}/api/v1/nb-contracts/by-malo/{}?on={}",
self.base_url,
malo_id,
on.format(fmt).unwrap_or_default(),
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_grundversorger(
&self,
nb_mp_id: &str,
sparte: crate::domain::Sparte,
) -> Result<Option<GrundversorgerRecord>, MarktdClientError> {
let url = format!(
"{}/api/v1/grundversorger/{}?sparte={}",
self.base_url, nb_mp_id, sparte
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_malo(
&self,
malo_id: &str,
) -> Result<Option<MaloTypedFields>, MarktdClientError> {
let url = format!("{}/api/v1/malos/{}", self.base_url, malo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json::<MaloTypedFields>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_melo_msb_at(
&self,
melo_id: &str,
at: time::Date,
) -> Result<Option<String>, MarktdClientError> {
let url = format!("{}/api/v1/melos/{}/msb?at={}", self.base_url, melo_id, at);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(body
.get("msb_mp_id")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned))
}
pub async fn esa_consent_check(
&self,
esa_mp_id: &str,
msb_mp_id: &str,
location_id: &str,
perspective: ConsentPerspective,
) -> Result<ConsentDecision, MarktdClientError> {
let perspective = match perspective {
ConsentPerspective::MsbInbound => "msb_inbound",
ConsentPerspective::EsaOutbound => "esa_outbound",
};
let url = format!("{}/api/v1/esa/consent-check", self.base_url);
let resp = self
.client
.get(&url)
.query(&[
("esa_mp_id", esa_mp_id),
("msb_mp_id", msb_mp_id),
("location_id", location_id),
("perspective", perspective),
])
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json::<ConsentDecision>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn esa_consent_valid(
&self,
esa_mp_id: &str,
msb_mp_id: &str,
location_id: &str,
) -> Result<Option<bool>, MarktdClientError> {
use crate::repository::ConsentCode;
let decision = self
.esa_consent_check(
esa_mp_id,
msb_mp_id,
location_id,
ConsentPerspective::MsbInbound,
)
.await?;
Ok(match decision.code {
ConsentCode::Active => Some(true),
ConsentCode::Revoked => Some(false),
ConsentCode::SelfAssertion
| ConsentCode::NoConsent
| ConsentCode::FrameworkRejected => None,
})
}
pub async fn esa_messprodukt_angebot(
&self,
msb_mp_id: &str,
messprodukt: &str,
at: time::Date,
) -> Result<Option<EsaProduktAngebot>, MarktdClientError> {
let url = format!(
"{}/api/v1/esa/messprodukte/{msb_mp_id}/{messprodukt}",
self.base_url
);
let resp = self
.client
.get(&url)
.query(&[("at", at.to_string())])
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn esa_messprodukt_of_bestellung(
&self,
bestellung_ref: &str,
) -> Result<Option<String>, MarktdClientError> {
let url = format!(
"{}/api/v1/esa/subscriptions/{bestellung_ref}",
self.base_url
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(body
.get("messprodukt")
.and_then(|v| v.as_str())
.map(str::to_owned))
}
pub async fn esa_preise(
&self,
msb_mp_id: &str,
esa_mp_id: &str,
at: time::Date,
) -> Result<Vec<EsaMessproduktPreis>, MarktdClientError> {
let url = format!(
"{}/api/v1/esa/preise/{msb_mp_id}/{esa_mp_id}",
self.base_url
);
let resp = self
.client
.get(&url)
.query(&[("at", at.to_string())])
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn put_esa_preise(
&self,
msb_mp_id: &str,
esa_mp_id: &str,
body: &serde_json::Value,
) -> Result<(), MarktdClientError> {
let url = format!(
"{}/api/v1/esa/preise/{msb_mp_id}/{esa_mp_id}",
self.base_url
);
self.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(body)
.send()
.await?
.error_for_status()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
Ok(())
}
pub async fn esa_framework_established(
&self,
msb_mp_id: &str,
esa_mp_id: &str,
) -> Result<bool, MarktdClientError> {
#[derive(serde::Deserialize)]
struct Framework {
#[serde(default)]
edi_agreement: bool,
#[serde(default)]
cert_state: String,
}
let url = format!(
"{}/api/v1/esa/framework/{msb_mp_id}/{esa_mp_id}",
self.base_url
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let f: Framework = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
let cert_negative = matches!(f.cert_state.as_str(), "rejected" | "revoked" | "suspended");
Ok(f.edi_agreement && !cert_negative)
}
pub async fn msb_serves_whole_buendel(
&self,
malo_id: &str,
msb_mp_id: &str,
at: time::Date,
) -> Result<Option<bool>, MarktdClientError> {
#[derive(serde::Deserialize)]
struct Buendel {
#[serde(default)]
messlokationen: Vec<String>,
}
let url = format!("{}/api/v1/malos/{malo_id}/buendel", self.base_url);
let resp = self
.client
.get(&url)
.query(&[("at", at.to_string())])
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let buendel: Buendel = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
if buendel.messlokationen.is_empty() {
return Ok(None);
}
for melo in &buendel.messlokationen {
match self.get_melo_msb_at(melo, at).await? {
Some(other) if other != msb_mp_id => return Ok(Some(false)),
Some(_) => {}
None => return Ok(None),
}
}
Ok(Some(true))
}
pub async fn upsert_netzzugang_antrag(
&self,
antrag: &crate::repository::NetzzugangAntrag,
) -> Result<uuid::Uuid, MarktdClientError> {
#[derive(serde::Deserialize)]
struct IdBody {
id: uuid::Uuid,
}
let url = format!("{}/api/v1/netzzugang/antraege", self.base_url);
let resp = self
.client
.put(&url)
.json(antrag)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json::<IdBody>()
.await
.map(|b| b.id)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn set_netzzugang_status(
&self,
id: uuid::Uuid,
status: crate::repository::NetzzugangStatus,
platform_ref: Option<&str>,
) -> Result<(), MarktdClientError> {
let url = format!("{}/api/v1/netzzugang/antraege/{id}/status", self.base_url);
let resp = self
.client
.patch(&url)
.json(&serde_json::json!({
"status": status,
"platform_ref": platform_ref,
}))
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
Ok(())
}
pub async fn get_malo_grid(
&self,
malo_id: &str,
) -> Result<Option<MaloGridRecord>, MarktdClientError> {
let url = format!("{}/api/v1/malos/{}/grid", self.base_url, malo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn put_malo_grid(
&self,
malo_id: &str,
nb_mp_id: &str,
bilanzierungsgebiet: Option<&str>,
netzgebiet: Option<&str>,
sparte: &str,
source: &str,
) -> Result<(), MarktdClientError> {
let url = format!("{}/api/v1/malos/{}/grid", self.base_url, malo_id);
let body = serde_json::json!({
"nb_mp_id": nb_mp_id,
"bilanzierungsgebiet": bilanzierungsgebiet,
"netzgebiet": netzgebiet,
"sparte": sparte,
"source": source,
});
let resp = self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if !resp.status().is_success() {
warn!(malo_id, status = %resp.status(), "put_malo_grid: HTTP error");
return Err(MarktdClientError::Http(format!(
"HTTP {}",
resp.status().as_u16()
)));
}
Ok(())
}
pub async fn partner_known(&self, mp_id: &str) -> Result<bool, MarktdClientError> {
let url = format!("{}/api/v1/partners/{}", self.base_url, mp_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
let status = resp.status();
if status.is_success() {
return Ok(true);
}
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
warn!(mp_id, %status, "partner_known: HTTP error");
Err(MarktdClientError::Http(format!("HTTP {}", status.as_u16())))
}
pub async fn get_preisblatt(
&self,
nb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattNetznutzung>, MarktdClientError> {
self.get_sheet_with_cb(
"preisblaetter",
nb_mp_id,
billing_date,
|inner| CbInner::cache_lookup(&inner.cache, nb_mp_id, billing_date),
|inner, sheet| CbInner::cache_store(&mut inner.cache, nb_mp_id, billing_date, sheet),
)
.await
}
pub async fn get_preisblatt_messung(
&self,
msb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattMessung>, MarktdClientError> {
self.get_sheet_with_cb(
"preisblaetter-messung",
msb_mp_id,
billing_date,
|inner| CbInner::cache_lookup(&inner.cache_messung, msb_mp_id, billing_date),
|inner, sheet| {
CbInner::cache_store(&mut inner.cache_messung, msb_mp_id, billing_date, sheet);
},
)
.await
}
async fn get_sheet_with_cb<T, L, S>(
&self,
endpoint: &str,
mp_id: &str,
billing_date: time::Date,
cache_lookup: L,
cache_store: S,
) -> Result<Option<T>, MarktdClientError>
where
T: serde::de::DeserializeOwned + Clone,
L: FnOnce(&CbInner) -> Option<Option<T>>,
S: FnOnce(&mut CbInner, Option<T>),
{
let now = OffsetDateTime::now_utc();
{
let inner = self.cb.lock().await;
if let Some(cached) = cache_lookup(&inner) {
return Ok(cached);
}
if inner.is_cb_open(now) {
warn!(
mp_id,
%billing_date,
endpoint,
"MarktdClient: circuit open — degrading to structural checks only"
);
return Ok(None);
}
}
let date_str = billing_date.to_string(); let url = format!("{}/api/v1/{}/{}", self.base_url, endpoint, mp_id);
let result = self
.client
.get(&url)
.query(&[("date", &date_str)])
.bearer_auth(self.api_key.expose_secret())
.send()
.await;
let mut inner = self.cb.lock().await;
match result {
Err(e) => {
inner.record_failure(now);
warn!(%e, mp_id, endpoint, "MarktdClient: preisblatt fetch failed");
Err(MarktdClientError::Http(e.to_string()))
}
Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => {
inner.record_success();
cache_store(&mut inner, None);
Ok(None)
}
Ok(resp) if !resp.status().is_success() => {
inner.record_failure(now);
let status = resp.status().as_u16();
warn!(
mp_id,
status, endpoint, "MarktdClient: preisblatt returned non-2xx"
);
Err(MarktdClientError::Http(format!("HTTP {status}")))
}
Ok(resp) => match resp.json::<T>().await {
Ok(sheet) => {
inner.record_success();
cache_store(&mut inner, Some(sheet.clone()));
Ok(Some(sheet))
}
Err(e) => {
inner.record_failure(now);
Err(MarktdClientError::Deserialization(e.to_string()))
}
},
}
}
pub async fn get_preisblatt_ka(
&self,
nb_mp_id: &str,
billing_date: time::Date,
sparte: &str,
kundengruppe_ka: Option<&str>,
) -> Result<Option<PreisblattKaRecord>, MarktdClientError> {
let date_str = billing_date.to_string();
let mut query = vec![("date", date_str.as_str()), ("sparte", sparte)];
let kg;
if let Some(kg_str) = kundengruppe_ka {
kg = kg_str.to_owned();
query.push(("kundengruppe", kg.as_str()));
}
let url = format!("{}/api/v1/preisblaetter-ka/{}", self.base_url, nb_mp_id);
let resp = self
.client
.get(&url)
.query(&query)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
nb_mp_id,
status = s,
"MarktdClient: preisblatt-ka returned non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
resp.json::<PreisblattKaRecord>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_preisblatt_dienstleistung(
&self,
msb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattDienstleistungRecord>, MarktdClientError> {
let date_str = billing_date.to_string();
let url = format!(
"{}/api/v1/preisblaetter-dienstleistung/{}",
self.base_url, msb_mp_id
);
let resp = self
.client
.get(&url)
.query(&[("date", &date_str)])
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
msb_mp_id,
status = s,
"MarktdClient: preisblatt-dienstleistung non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
resp.json::<PreisblattDienstleistungRecord>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_preisblatt_hardware(
&self,
msb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattHardwareRecord>, MarktdClientError> {
let date_str = billing_date.to_string();
let url = format!(
"{}/api/v1/preisblaetter-hardware/{}",
self.base_url, msb_mp_id
);
let resp = self
.client
.get(&url)
.query(&[("date", &date_str)])
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
msb_mp_id,
status = s,
"MarktdClient: preisblatt-hardware non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
resp.json::<PreisblattHardwareRecord>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_as4_address(
&self,
mp_id: &str,
) -> Result<Option<Vec<String>>, MarktdClientError> {
let url = format!("{}/api/v1/partners/{}/as4-address", self.base_url, mp_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(MarktdClientError::Http(format!(
"HTTP {}",
resp.status().as_u16()
)));
}
let body = resp
.json::<serde_json::Value>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
let addrs = body["makoadresse"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect()
})
.unwrap_or_default();
Ok(Some(addrs))
}
pub async fn get_lokationen(
&self,
root_id: &str,
root_typ: &str,
at_date: Option<&str>,
) -> Result<Vec<crate::repository::LokationszuordnungEdge>, MarktdClientError> {
let path = match root_typ {
"melo" => format!("{}/api/v1/melos/{}/lokationen", self.base_url, root_id),
_ => format!("{}/api/v1/malos/{}/lokationen", self.base_url, root_id),
};
let mut req = self
.client
.get(&path)
.bearer_auth(self.api_key.expose_secret());
if let Some(d) = at_date {
req = req.query(&[("at", d)]);
}
let resp = req
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if !resp.status().is_success() {
warn!(root_id, root_typ, status = %resp.status(), "get_lokationen: HTTP error");
return Err(MarktdClientError::Http(format!(
"HTTP {}",
resp.status().as_u16()
)));
}
resp.json::<Vec<crate::repository::LokationszuordnungEdge>>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_technische_ressource(
&self,
tr_id: &str,
) -> Result<Option<crate::repository::TechnischeRessourceRecord>, MarktdClientError> {
let url = format!("{}/api/v1/technische-ressourcen/{}", self.base_url, tr_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
warn!(tr_id, status = %resp.status(), "get_technische_ressource: HTTP error");
return Err(MarktdClientError::Http(format!(
"HTTP {}",
resp.status().as_u16()
)));
}
let record = resp
.json::<crate::repository::TechnischeRessourceRecord>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(Some(record))
}
pub async fn get_steuerbare_ressource(
&self,
sr_id: &str,
) -> Result<Option<serde_json::Value>, MarktdClientError> {
let url = format!("{}/api/v1/steuerbare-ressourcen/{}", self.base_url, sr_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
warn!(sr_id, status = %resp.status(), "get_steuerbare_ressource: HTTP error");
return Err(MarktdClientError::Http(format!(
"HTTP {}",
resp.status().as_u16()
)));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(Some(body))
}
pub async fn put_subscription(&self, subscriber_id: &str, req: &SubscriptionRequest<'_>) {
let url = format!("{}/api/v1/subscriptions/{}", self.base_url, subscriber_id);
let body = serde_json::json!({
"webhook_url": req.webhook_url,
"webhook_secret": req.webhook_secret,
"roles": [],
"event_types": req.event_types,
"makopid_filter": req.makopid_filter,
"active": req.active,
});
match self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
info!(subscriber_id, "MarktdClient: subscription registered");
}
Ok(resp) => {
warn!(
subscriber_id,
status = resp.status().as_u16(),
"MarktdClient: subscription registration returned non-2xx"
);
}
Err(e) => {
warn!(%e, subscriber_id, "MarktdClient: subscription registration failed");
}
}
}
pub async fn get_mmma_gas(
&self,
year: i32,
month: u8,
marktgebiet: &str,
) -> Result<Option<crate::repository::MmmaPreisGasRecord>, MarktdClientError> {
let url = format!("{}/api/v1/mmma-preise/gas/{year}/{month}", self.base_url);
let resp = self
.client
.get(&url)
.query(&[("marktgebiet", marktgebiet)])
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
year,
month,
status = s,
"MarktdClient: mmma-gas returned non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
resp.json::<crate::repository::MmmaPreisGasRecord>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_mmm_strom(
&self,
year: i32,
month: u8,
) -> Result<Option<crate::repository::MmmPreisStromRecord>, MarktdClientError> {
let url = format!("{}/api/v1/mmm-preise/strom/{year}/{month}", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
year,
month,
status = s,
"MarktdClient: mmm-strom returned non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
resp.json::<crate::repository::MmmPreisStromRecord>()
.await
.map(Some)
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn get_konfigurationsprodukte(
&self,
sr_id: &str,
) -> Result<Option<Vec<serde_json::Value>>, MarktdClientError> {
let url = format!(
"{}/api/v1/steuerbare-ressourcen/{}/konfigurationsprodukte",
self.base_url, sr_id
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
let products = body
.get("konfigurationsprodukte")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(Some(products))
}
pub async fn patch_melo_standorteigenschaften(
&self,
melo_id: &str,
standorteigenschaften: &serde_json::Value,
) -> Result<(), MarktdClientError> {
let url = format!(
"{}/api/v1/melos/{}/standorteigenschaften",
self.base_url, melo_id
);
let resp = self
.client
.patch(&url)
.bearer_auth(self.api_key.expose_secret())
.json(standorteigenschaften)
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
melo_id,
status = s,
"MarktdClient: patch standorteigenschaften non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
info!(
melo_id,
"MarktdClient: standorteigenschaften updated from WiM Stammdaten"
);
Ok(())
}
pub async fn get_melo_standorteigenschaften(
&self,
melo_id: &str,
) -> Result<Option<serde_json::Value>, MarktdClientError> {
let url = format!(
"{}/api/v1/melos/{}/standorteigenschaften",
self.base_url, melo_id
);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body = resp
.json::<serde_json::Value>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(Some(body))
}
pub async fn list_zaehler_ids(&self, melo_id: &str) -> Result<Vec<String>, MarktdClientError> {
let url = format!("{}/api/v1/melos/{}/zaehler", self.base_url, melo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body = resp
.json::<Vec<serde_json::Value>>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(body
.iter()
.filter_map(|z| z.get("zaehler_id").and_then(|v| v.as_str()))
.map(str::to_owned)
.collect())
}
pub async fn list_zaehler(
&self,
melo_id: &str,
) -> Result<Vec<ZaehlerSummary>, MarktdClientError> {
let url = format!("{}/api/v1/melos/{}/zaehler", self.base_url, melo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
resp.json::<Vec<ZaehlerSummary>>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))
}
pub async fn melo_known(&self, melo_id: &str) -> Result<bool, MarktdClientError> {
let url = format!("{}/api/v1/melos/{}", self.base_url, melo_id);
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
Ok(true)
}
pub async fn put_zaehler_register(
&self,
zaehler_id: &str,
rec: &crate::repository::ZaehlzeitRegisterRecord,
) -> Result<(), MarktdClientError> {
let url = format!("{}/api/v1/zaehler/{}/register", self.base_url, zaehler_id);
let resp = self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(rec)
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
zaehler_id,
status = s,
"MarktdClient: put_zaehler_register non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
info!(
zaehler_id,
bezeichnung = %rec.bezeichnung,
"MarktdClient: ZaehlzeitRegister upserted from WiM Stammdaten"
);
Ok(())
}
pub async fn put_zaehler_saison(
&self,
register_id: uuid::Uuid,
rec: &crate::repository::ZaehlzeitSaisonRecord,
) -> Result<(), MarktdClientError> {
let url = format!(
"{}/api/v1/zaehler-register/{}/saisons",
self.base_url, register_id
);
let resp = self
.client
.put(&url)
.bearer_auth(self.api_key.expose_secret())
.json(rec)
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if !resp.status().is_success() {
let s = resp.status().as_u16();
warn!(
%register_id,
status = s,
"MarktdClient: put_zaehler_saison non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {s}")));
}
Ok(())
}
#[allow(clippy::doc_markdown)]
pub async fn get_nb_energiemix(
&self,
nb_mp_id: &str,
year: Option<i16>,
) -> Result<Option<crate::repository::NbEnergiemixRecord>, MarktdClientError> {
let mut url = format!("{}/api/v1/energiemix/{}", self.base_url, nb_mp_id);
if let Some(y) = year {
use std::fmt::Write as _;
let _ = write!(url, "?year={y}");
}
let resp = self
.client
.get(&url)
.bearer_auth(self.api_key.expose_secret())
.send()
.await
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
resp.error_for_status_ref()
.map_err(|e| MarktdClientError::Http(e.to_string()))?;
let body = resp
.json::<crate::repository::NbEnergiemixRecord>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(Some(body))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unreachable_client() -> MarktdClient {
MarktdClient::new(
"http://127.0.0.1:9",
SecretString::from("test-key"),
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(200))
.timeout(std::time::Duration::from_millis(400))
.build()
.expect("client"),
)
}
#[tokio::test]
async fn preisblatt_messung_circuit_opens_after_threshold() {
let client = unreachable_client();
let date = time::Date::from_calendar_date(2026, time::Month::July, 1).expect("date");
for _ in 0..CB_FAILURE_THRESHOLD {
let r = client.get_preisblatt_messung("9900000000001", date).await;
assert!(r.is_err(), "closed circuit surfaces the network error");
}
let r = client.get_preisblatt_messung("9900000000001", date).await;
assert!(matches!(r, Ok(None)), "open circuit degrades to Ok(None)");
let r = client.get_preisblatt("9900000000001", date).await;
assert!(
matches!(r, Ok(None)),
"breaker is shared with get_preisblatt"
);
}
async fn one_shot_server(status: &'static str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
use tokio::io::AsyncWriteExt as _;
let _ = sock
.write_all(
format!(
"HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
)
.as_bytes(),
)
.await;
let _ = sock.shutdown().await;
}
});
format!("http://{addr}")
}
fn client_for(base: &str) -> MarktdClient {
MarktdClient::new(base, SecretString::from("test-key"), reqwest::Client::new())
}
#[tokio::test]
async fn partner_known_reports_absent_on_404() {
let base = one_shot_server("404 Not Found").await;
let r = client_for(&base).partner_known("9900000000001").await;
assert!(matches!(r, Ok(false)));
}
#[tokio::test]
async fn partner_known_errors_on_server_failure() {
let base = one_shot_server("500 Internal Server Error").await;
let r = client_for(&base).partner_known("9900000000001").await;
assert!(
matches!(r, Err(MarktdClientError::Http(_))),
"5xx must not read as an unknown partner"
);
}
#[test]
fn the_obis_c_group_tells_wirkarbeit_from_blindarbeit() {
let werk = |obis: &str, richtung: &str| ZaehlwerkDaten {
richtung: Some(richtung.to_owned()),
obis_kennzahl: Some(obis.to_owned()),
};
assert!(!werk("1-1:1.29.0", "AUSSP").ist_blindarbeit());
assert!(!werk("1-1:2.29.0", "EINSP").ist_blindarbeit());
for obis in ["1-1:3.29.0", "1-1:4.29.0", "1-1:5.8.0", "1-1:8.8.0"] {
assert!(werk(obis, "AUSSP").ist_blindarbeit(), "{obis}");
}
assert!(!werk("1-1:9.8.0", "AUSSP").ist_blindarbeit());
assert!(werk("1-1:1.29.0", "AUSSP").ist_verbrauch());
assert!(!werk("1-1:1.29.0", "AUSSP").ist_erzeugung());
assert!(werk("1-1:2.29.0", "EINSP").ist_erzeugung());
let ohne = ZaehlwerkDaten {
richtung: Some("AUSSP".to_owned()),
obis_kennzahl: None,
};
assert!(!ohne.ist_blindarbeit());
}
#[test]
fn the_summary_projects_the_registers_off_the_wire() {
let wire = serde_json::json!({
"zaehler_id": "1ESA0000000001",
"zaehler_typ": "INTELLIGENTES_MESSSYSTEM",
"data": {
"zaehlwerke": [
{ "obisKennzahl": "1-1:1.29.0", "richtung": "AUSSP" },
{ "obisKennzahl": "1-1:2.29.0", "richtung": "EINSP" }
]
}
});
let s: ZaehlerSummary = serde_json::from_value(wire).expect("summary");
assert!(s.ist_imsys());
let werke = &s.daten.expect("registers").zaehlwerke;
assert_eq!(werke.len(), 2);
assert!(werke.iter().any(ZaehlwerkDaten::ist_verbrauch));
assert!(werke.iter().any(ZaehlwerkDaten::ist_erzeugung));
let bare: ZaehlerSummary =
serde_json::from_value(serde_json::json!({ "zaehler_id": "X" })).expect("summary");
assert!(bare.daten.is_none());
}
}