koprogo_api/infrastructure/external/
linky_api_client_impl.rs

1use crate::application::ports::{
2    ConsumptionDataPoint, LinkyApiClient, LinkyApiError, OAuth2TokenResponse, PowerDataPoint,
3};
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9/// Production Linky API client implementation
10///
11/// Implements OAuth2 authorization code flow and data fetching
12/// from Enedis Linky API and ORES (Belgium) API
13pub struct LinkyApiClientImpl {
14    client: reqwest::Client,
15    base_url: String,
16    client_id: String,
17    client_secret: String,
18}
19
20impl LinkyApiClientImpl {
21    pub fn new(base_url: String, client_id: String, client_secret: String) -> Self {
22        let client = reqwest::Client::builder()
23            .timeout(Duration::from_secs(30))
24            .build()
25            .expect("Failed to build HTTP client");
26
27        Self {
28            client,
29            base_url,
30            client_id,
31            client_secret,
32        }
33    }
34}
35
36#[derive(Debug, Serialize)]
37struct TokenRequest {
38    grant_type: String,
39    code: Option<String>,
40    refresh_token: Option<String>,
41    redirect_uri: Option<String>,
42    client_id: String,
43    client_secret: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct TokenResponseBody {
48    access_token: String,
49    refresh_token: String,
50    expires_in: u64,
51    token_type: String,
52}
53
54#[derive(Debug, Deserialize)]
55struct ConsumptionResponse {
56    meter_reading: MeterReading,
57}
58
59#[derive(Debug, Deserialize)]
60#[allow(dead_code)]
61struct MeterReading {
62    usage_point_id: String,
63    start: String,
64    end: String,
65    quality: String,
66    reading_type: ReadingType,
67    interval_reading: Vec<IntervalReading>,
68}
69
70#[derive(Debug, Deserialize)]
71#[allow(dead_code)]
72struct ReadingType {
73    unit: String,
74    aggregate: String,
75    measuring_period: String,
76}
77
78#[derive(Debug, Deserialize)]
79#[allow(dead_code)]
80struct IntervalReading {
81    value: String,
82    date: String,
83    interval_length: Option<String>,
84    measure_type: Option<String>,
85}
86
87#[derive(Debug, Deserialize)]
88struct PowerResponse {
89    meter_reading: PowerMeterReading,
90}
91
92#[derive(Debug, Deserialize)]
93#[allow(dead_code)]
94struct PowerMeterReading {
95    usage_point_id: String,
96    start: String,
97    end: String,
98    quality: String,
99    reading_type: ReadingType,
100    interval_reading: Vec<PowerIntervalReading>,
101}
102
103#[derive(Debug, Deserialize)]
104struct PowerIntervalReading {
105    value: String,
106    date: String,
107    direction: Option<String>,
108}
109
110#[async_trait]
111impl LinkyApiClient for LinkyApiClientImpl {
112    async fn exchange_authorization_code(
113        &self,
114        authorization_code: &str,
115        redirect_uri: &str,
116    ) -> Result<OAuth2TokenResponse, LinkyApiError> {
117        let token_url = format!("{}/oauth2/v3/token", self.base_url);
118
119        let request_body = TokenRequest {
120            grant_type: "authorization_code".to_string(),
121            code: Some(authorization_code.to_string()),
122            refresh_token: None,
123            redirect_uri: Some(redirect_uri.to_string()),
124            client_id: self.client_id.clone(),
125            client_secret: self.client_secret.clone(),
126        };
127
128        let response = self
129            .client
130            .post(&token_url)
131            .form(&request_body)
132            .send()
133            .await
134            .map_err(|e| {
135                if e.is_timeout() {
136                    LinkyApiError::Timeout("Request timed out".to_string())
137                } else {
138                    LinkyApiError::HttpError(e.to_string())
139                }
140            })?;
141
142        if response.status() == 401 {
143            return Err(LinkyApiError::InvalidAuthorizationCode(
144                "Invalid or expired authorization code".to_string(),
145            ));
146        }
147
148        if !response.status().is_success() {
149            return Err(LinkyApiError::HttpError(format!(
150                "HTTP {} from token endpoint",
151                response.status()
152            )));
153        }
154
155        let token_response: TokenResponseBody = response.json().await.map_err(|e| {
156            LinkyApiError::DeserializationError(format!("Failed to parse token response: {}", e))
157        })?;
158
159        Ok(OAuth2TokenResponse {
160            access_token: token_response.access_token,
161            refresh_token: Some(token_response.refresh_token),
162            expires_in: token_response.expires_in as i64,
163            token_type: token_response.token_type,
164        })
165    }
166
167    async fn refresh_access_token(
168        &self,
169        refresh_token: &str,
170    ) -> Result<OAuth2TokenResponse, LinkyApiError> {
171        let token_url = format!("{}/oauth2/v3/token", self.base_url);
172
173        let request_body = TokenRequest {
174            grant_type: "refresh_token".to_string(),
175            code: None,
176            refresh_token: Some(refresh_token.to_string()),
177            redirect_uri: None,
178            client_id: self.client_id.clone(),
179            client_secret: self.client_secret.clone(),
180        };
181
182        let response = self
183            .client
184            .post(&token_url)
185            .form(&request_body)
186            .send()
187            .await
188            .map_err(|e| {
189                if e.is_timeout() {
190                    LinkyApiError::Timeout("Request timed out".to_string())
191                } else {
192                    LinkyApiError::HttpError(e.to_string())
193                }
194            })?;
195
196        if response.status() == 401 {
197            return Err(LinkyApiError::TokenExpired(
198                "Refresh token expired or invalid".to_string(),
199            ));
200        }
201
202        if !response.status().is_success() {
203            return Err(LinkyApiError::HttpError(format!(
204                "HTTP {} from token endpoint",
205                response.status()
206            )));
207        }
208
209        let token_response: TokenResponseBody = response.json().await.map_err(|e| {
210            LinkyApiError::DeserializationError(format!("Failed to parse token response: {}", e))
211        })?;
212
213        Ok(OAuth2TokenResponse {
214            access_token: token_response.access_token,
215            refresh_token: Some(token_response.refresh_token),
216            expires_in: token_response.expires_in as i64,
217            token_type: token_response.token_type,
218        })
219    }
220
221    async fn get_daily_consumption(
222        &self,
223        prm: &str,
224        access_token: &str,
225        start_date: DateTime<Utc>,
226        end_date: DateTime<Utc>,
227    ) -> Result<Vec<ConsumptionDataPoint>, LinkyApiError> {
228        if start_date >= end_date {
229            return Err(LinkyApiError::InvalidDateRange(
230                "start_date must be before end_date".to_string(),
231            ));
232        }
233
234        let url = format!("{}/metering_data_dc/v5/daily_consumption", self.base_url);
235
236        let response = self
237            .client
238            .get(&url)
239            .bearer_auth(access_token)
240            .query(&[
241                ("usage_point_id", prm),
242                ("start", &start_date.format("%Y-%m-%d").to_string()),
243                ("end", &end_date.format("%Y-%m-%d").to_string()),
244            ])
245            .send()
246            .await
247            .map_err(|e| {
248                if e.is_timeout() {
249                    LinkyApiError::Timeout("Request timed out".to_string())
250                } else {
251                    LinkyApiError::HttpError(e.to_string())
252                }
253            })?;
254
255        if response.status() == 401 {
256            return Err(LinkyApiError::TokenExpired(
257                "Access token expired".to_string(),
258            ));
259        }
260
261        if response.status() == 403 {
262            return Err(LinkyApiError::AuthenticationFailed(
263                "Access denied".to_string(),
264            ));
265        }
266
267        if response.status() == 404 {
268            return Err(LinkyApiError::InvalidPRM(prm.to_string()));
269        }
270
271        if response.status() == 429 {
272            return Err(LinkyApiError::RateLimitExceeded(
273                "API rate limit exceeded".to_string(),
274            ));
275        }
276
277        if !response.status().is_success() {
278            return Err(LinkyApiError::HttpError(format!(
279                "HTTP {} from consumption endpoint",
280                response.status()
281            )));
282        }
283
284        let consumption_response: ConsumptionResponse = response.json().await.map_err(|e| {
285            LinkyApiError::DeserializationError(format!(
286                "Failed to parse consumption response: {}",
287                e
288            ))
289        })?;
290
291        if consumption_response
292            .meter_reading
293            .interval_reading
294            .is_empty()
295        {
296            return Err(LinkyApiError::NoDataAvailable(
297                "No data available for the specified period".to_string(),
298            ));
299        }
300
301        let data_points: Result<Vec<ConsumptionDataPoint>, LinkyApiError> = consumption_response
302            .meter_reading
303            .interval_reading
304            .into_iter()
305            .map(|interval| {
306                let value = interval
307                    .value
308                    .parse::<f64>()
309                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
310
311                let timestamp = DateTime::parse_from_rfc3339(&interval.date)
312                    .map(|dt| dt.with_timezone(&Utc))
313                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
314
315                Ok(ConsumptionDataPoint {
316                    timestamp,
317                    value,
318                    quality: Some(consumption_response.meter_reading.quality.clone()),
319                })
320            })
321            .collect();
322
323        data_points
324    }
325
326    async fn get_monthly_consumption(
327        &self,
328        prm: &str,
329        access_token: &str,
330        start_date: DateTime<Utc>,
331        end_date: DateTime<Utc>,
332    ) -> Result<Vec<ConsumptionDataPoint>, LinkyApiError> {
333        if start_date >= end_date {
334            return Err(LinkyApiError::InvalidDateRange(
335                "start_date must be before end_date".to_string(),
336            ));
337        }
338
339        let url = format!("{}/metering_data_dc/v5/monthly_consumption", self.base_url);
340
341        let response = self
342            .client
343            .get(&url)
344            .bearer_auth(access_token)
345            .query(&[
346                ("usage_point_id", prm),
347                ("start", &start_date.format("%Y-%m-%d").to_string()),
348                ("end", &end_date.format("%Y-%m-%d").to_string()),
349            ])
350            .send()
351            .await
352            .map_err(|e| {
353                if e.is_timeout() {
354                    LinkyApiError::Timeout("Request timed out".to_string())
355                } else {
356                    LinkyApiError::HttpError(e.to_string())
357                }
358            })?;
359
360        if response.status() == 401 {
361            return Err(LinkyApiError::TokenExpired(
362                "Access token expired".to_string(),
363            ));
364        }
365
366        if response.status() == 403 {
367            return Err(LinkyApiError::AuthenticationFailed(
368                "Access denied".to_string(),
369            ));
370        }
371
372        if response.status() == 404 {
373            return Err(LinkyApiError::InvalidPRM(prm.to_string()));
374        }
375
376        if response.status() == 429 {
377            return Err(LinkyApiError::RateLimitExceeded(
378                "API rate limit exceeded".to_string(),
379            ));
380        }
381
382        if !response.status().is_success() {
383            return Err(LinkyApiError::HttpError(format!(
384                "HTTP {} from consumption endpoint",
385                response.status()
386            )));
387        }
388
389        let consumption_response: ConsumptionResponse = response.json().await.map_err(|e| {
390            LinkyApiError::DeserializationError(format!(
391                "Failed to parse consumption response: {}",
392                e
393            ))
394        })?;
395
396        if consumption_response
397            .meter_reading
398            .interval_reading
399            .is_empty()
400        {
401            return Err(LinkyApiError::NoDataAvailable(
402                "No data available for the specified period".to_string(),
403            ));
404        }
405
406        let data_points: Result<Vec<ConsumptionDataPoint>, LinkyApiError> = consumption_response
407            .meter_reading
408            .interval_reading
409            .into_iter()
410            .map(|interval| {
411                let value = interval
412                    .value
413                    .parse::<f64>()
414                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
415
416                let timestamp = DateTime::parse_from_rfc3339(&interval.date)
417                    .map(|dt| dt.with_timezone(&Utc))
418                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
419
420                Ok(ConsumptionDataPoint {
421                    timestamp,
422                    value,
423                    quality: Some(consumption_response.meter_reading.quality.clone()),
424                })
425            })
426            .collect();
427
428        data_points
429    }
430
431    async fn get_consumption_load_curve(
432        &self,
433        prm: &str,
434        access_token: &str,
435        start_date: DateTime<Utc>,
436        end_date: DateTime<Utc>,
437    ) -> Result<Vec<ConsumptionDataPoint>, LinkyApiError> {
438        if start_date >= end_date {
439            return Err(LinkyApiError::InvalidDateRange(
440                "start_date must be before end_date".to_string(),
441            ));
442        }
443
444        let url = format!(
445            "{}/metering_data_clc/v5/consumption_load_curve",
446            self.base_url
447        );
448
449        let response = self
450            .client
451            .get(&url)
452            .bearer_auth(access_token)
453            .query(&[
454                ("usage_point_id", prm),
455                ("start", &start_date.format("%Y-%m-%dT%H:%M:%S").to_string()),
456                ("end", &end_date.format("%Y-%m-%dT%H:%M:%S").to_string()),
457            ])
458            .send()
459            .await
460            .map_err(|e| {
461                if e.is_timeout() {
462                    LinkyApiError::Timeout("Request timed out".to_string())
463                } else {
464                    LinkyApiError::HttpError(e.to_string())
465                }
466            })?;
467
468        if response.status() == 401 {
469            return Err(LinkyApiError::TokenExpired(
470                "Access token expired".to_string(),
471            ));
472        }
473
474        if response.status() == 403 {
475            return Err(LinkyApiError::AuthenticationFailed(
476                "Access denied".to_string(),
477            ));
478        }
479
480        if response.status() == 404 {
481            return Err(LinkyApiError::InvalidPRM(prm.to_string()));
482        }
483
484        if response.status() == 429 {
485            return Err(LinkyApiError::RateLimitExceeded(
486                "API rate limit exceeded".to_string(),
487            ));
488        }
489
490        if !response.status().is_success() {
491            return Err(LinkyApiError::HttpError(format!(
492                "HTTP {} from load curve endpoint",
493                response.status()
494            )));
495        }
496
497        let consumption_response: ConsumptionResponse = response.json().await.map_err(|e| {
498            LinkyApiError::DeserializationError(format!(
499                "Failed to parse load curve response: {}",
500                e
501            ))
502        })?;
503
504        if consumption_response
505            .meter_reading
506            .interval_reading
507            .is_empty()
508        {
509            return Err(LinkyApiError::NoDataAvailable(
510                "No data available for the specified period".to_string(),
511            ));
512        }
513
514        let data_points: Result<Vec<ConsumptionDataPoint>, LinkyApiError> = consumption_response
515            .meter_reading
516            .interval_reading
517            .into_iter()
518            .map(|interval| {
519                let value = interval
520                    .value
521                    .parse::<f64>()
522                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
523
524                let timestamp = DateTime::parse_from_rfc3339(&interval.date)
525                    .map(|dt| dt.with_timezone(&Utc))
526                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
527
528                Ok(ConsumptionDataPoint {
529                    timestamp,
530                    value,
531                    quality: Some(consumption_response.meter_reading.quality.clone()),
532                })
533            })
534            .collect();
535
536        data_points
537    }
538
539    async fn get_max_power(
540        &self,
541        prm: &str,
542        access_token: &str,
543        start_date: DateTime<Utc>,
544        end_date: DateTime<Utc>,
545    ) -> Result<Vec<PowerDataPoint>, LinkyApiError> {
546        if start_date >= end_date {
547            return Err(LinkyApiError::InvalidDateRange(
548                "start_date must be before end_date".to_string(),
549            ));
550        }
551
552        let url = format!("{}/metering_data_mp/v5/max_power", self.base_url);
553
554        let response = self
555            .client
556            .get(&url)
557            .bearer_auth(access_token)
558            .query(&[
559                ("usage_point_id", prm),
560                ("start", &start_date.format("%Y-%m-%d").to_string()),
561                ("end", &end_date.format("%Y-%m-%d").to_string()),
562            ])
563            .send()
564            .await
565            .map_err(|e| {
566                if e.is_timeout() {
567                    LinkyApiError::Timeout("Request timed out".to_string())
568                } else {
569                    LinkyApiError::HttpError(e.to_string())
570                }
571            })?;
572
573        if response.status() == 401 {
574            return Err(LinkyApiError::TokenExpired(
575                "Access token expired".to_string(),
576            ));
577        }
578
579        if response.status() == 403 {
580            return Err(LinkyApiError::AuthenticationFailed(
581                "Access denied".to_string(),
582            ));
583        }
584
585        if response.status() == 404 {
586            return Err(LinkyApiError::InvalidPRM(prm.to_string()));
587        }
588
589        if response.status() == 429 {
590            return Err(LinkyApiError::RateLimitExceeded(
591                "API rate limit exceeded".to_string(),
592            ));
593        }
594
595        if !response.status().is_success() {
596            return Err(LinkyApiError::HttpError(format!(
597                "HTTP {} from max power endpoint",
598                response.status()
599            )));
600        }
601
602        let power_response: PowerResponse = response.json().await.map_err(|e| {
603            LinkyApiError::DeserializationError(format!(
604                "Failed to parse max power response: {}",
605                e
606            ))
607        })?;
608
609        if power_response.meter_reading.interval_reading.is_empty() {
610            return Err(LinkyApiError::NoDataAvailable(
611                "No data available for the specified period".to_string(),
612            ));
613        }
614
615        let data_points: Result<Vec<PowerDataPoint>, LinkyApiError> = power_response
616            .meter_reading
617            .interval_reading
618            .into_iter()
619            .map(|interval| {
620                let value = interval
621                    .value
622                    .parse::<f64>()
623                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
624
625                let timestamp = DateTime::parse_from_rfc3339(&interval.date)
626                    .map(|dt| dt.with_timezone(&Utc))
627                    .map_err(|e| LinkyApiError::DeserializationError(e.to_string()))?;
628
629                Ok(PowerDataPoint {
630                    timestamp,
631                    value,
632                    direction: interval.direction,
633                })
634            })
635            .collect();
636
637        data_points
638    }
639}