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, 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()
}
}
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_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/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_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 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/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> {
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/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,
vnb_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(&[("vnb_mp_id", vnb_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,
vnb_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))
}
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 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"
);
}
}