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::{
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>,
cb_failures: u32,
cb_open_until: Option<OffsetDateTime>,
}
struct CacheEntry {
sheet: Option<PreisblattNetznutzung>,
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));
}
}
fn get_cached(&self, nb_mp_id: &str, date: time::Date) -> Option<PreisblattNetznutzung> {
let entry = self.cache.get(&(nb_mp_id.to_owned(), date))?;
if OffsetDateTime::now_utc() < entry.expires_at {
entry.sheet.clone()
} else {
None
}
}
fn set_cached(
&mut self,
nb_mp_id: &str,
date: time::Date,
sheet: Option<PreisblattNetznutzung>,
) {
let expires_at = OffsetDateTime::now_utc() + time::Duration::seconds(CACHE_TTL_SECS);
self.cache.insert(
(nb_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()
}
}
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(),
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_malo(
&self,
malo_id: &str,
) -> Result<Option<MaloTypedFields>, MarktdClientError> {
let url = format!("{}/api/v1/malo/{}", 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_malo_grid(
&self,
malo_id: &str,
) -> Result<Option<MaloGridRecord>, MarktdClientError> {
let url = format!("{}/api/v1/malo/{}/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/malo/{}/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?;
Ok(resp.status().is_success())
}
pub async fn get_preisblatt(
&self,
nb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattNetznutzung>, MarktdClientError> {
let now = OffsetDateTime::now_utc();
let inner = self.cb.lock().await;
if inner
.cache
.contains_key(&(nb_mp_id.to_owned(), billing_date))
{
let cached = inner.get_cached(nb_mp_id, billing_date);
return Ok(cached);
}
if inner.is_cb_open(now) {
warn!(
nb_mp_id,
%billing_date,
"MarktdClient: circuit open — degrading to structural checks only"
);
return Ok(None);
}
drop(inner);
let date_str = billing_date.to_string(); let url = format!("{}/api/v1/preisblaetter/{}", self.base_url, nb_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, nb_mp_id, "MarktdClient: preisblatt fetch failed");
Err(MarktdClientError::Http(e.to_string()))
}
Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => {
inner.record_success();
inner.set_cached(nb_mp_id, billing_date, None);
Ok(None)
}
Ok(resp) if !resp.status().is_success() => {
inner.record_failure(now);
let status = resp.status().as_u16();
warn!(
nb_mp_id,
status, "MarktdClient: preisblatt returned non-2xx"
);
Err(MarktdClientError::Http(format!("HTTP {status}")))
}
Ok(resp) => match resp.json::<PreisblattNetznutzung>().await {
Ok(sheet) => {
inner.record_success();
inner.set_cached(nb_mp_id, billing_date, Some(sheet.clone()));
Ok(Some(sheet))
}
Err(e) => {
inner.record_failure(now);
Err(MarktdClientError::Deserialization(e.to_string()))
}
},
}
}
pub async fn get_preisblatt_messung(
&self,
msb_mp_id: &str,
billing_date: time::Date,
) -> Result<Option<PreisblattMessung>, MarktdClientError> {
let date_str = billing_date.to_string();
let url = format!(
"{}/api/v1/preisblaetter-messung/{}",
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 status = resp.status().as_u16();
warn!(
msb_mp_id,
status, "MarktdClient: preisblatt-messung returned non-2xx"
);
return Err(MarktdClientError::Http(format!("HTTP {status}")));
}
let sheet = resp
.json::<PreisblattMessung>()
.await
.map_err(|e| MarktdClientError::Deserialization(e.to_string()))?;
Ok(Some(sheet))
}
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/malo/{}/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,
unb_mp_id: &str,
) -> 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)
.query(&[("unb_mp_id", unb_mp_id)])
.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,
unb_mp_id,
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))
}
#[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))
}
}