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
9pub 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}