use std::collections::HashMap;
use rubo4e::v202501::PreisblattNetznutzung;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::repository::{MaloGridRecord, 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_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 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 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");
}
}
}
}