pvoutput_client/client/
mod.rs

1//! Asynchronous client and associated methods.
2
3mod get;
4mod post;
5
6use std::{
7	fmt::{Debug, Display},
8	io::Cursor,
9	sync::Arc,
10	time::Duration,
11};
12
13use const_format::formatcp;
14use serde::{Serialize, de::DeserializeOwned};
15
16use crate::{
17	API_BASE_URL, parser,
18	parser::parse_none,
19	types::{
20		error::{NoEndpointSpecificError, Result},
21		params::SearchParams,
22		response::{RateLimit, Response, SearchResult},
23	},
24};
25
26/// An asynchronous client for the [PVOutput](https://pvoutput.org) API.
27///
28/// Can safely be (cheaply) cloned or shared across threads.
29///
30/// # Usage
31///
32/// ```no_run
33/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
34/// # tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async {
35/// use pvoutput_client::{Client, types::params::GetStatusParams};
36/// let client = Client::new("your-api-key", 12345, None, None)?;
37/// let status = client.get_status(GetStatusParams::default()).await?;
38/// println!("Generated {}Wh at {}", status.data.energy_generation, status.data.time);
39/// # Ok(())
40/// # })}
41/// ```
42#[derive(Clone)]
43pub struct Client {
44	reqwest: reqwest::Client,
45	api_key: Arc<String>,
46	system_id: u32,
47}
48
49impl Client {
50	/// Creates a new [`Client`].
51	///
52	/// If you want to provide your own [`reqwest::Client`], see [`Client::new_with_reqwest`].
53	///
54	/// # Parameters
55	/// - `api_key`: Your [PVOutput API key](https://pvoutput.org/help/api_specification.html#getting-started).
56	/// - `system_id`: A [PVOutput system ID](https://pvoutput.org/help/api_specification.html#getting-started). The
57	///   system must be owned by the API key provided.
58	/// - `user_agent`: A [user agent](https://developer.mozilla.org/en-US/docs/Glossary/User_agent) to use for all
59	///   requests.
60	///   If not provided, [`Client::default_user_agent`] will be used.
61	/// - `timeout`: An overall timeout, applied from when the request starts connecting to when the response body has
62	///   finished. If not provided, a default value of twenty seconds will be used.
63	///
64	/// # Example
65	/// ```
66	/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
67	/// let client = pvoutput_client::Client::new("your-api-key", 12345, None, None)?;
68	/// # Ok(())
69	/// # }
70	/// ```
71	pub fn new(
72		api_key: &str,
73		system_id: u32,
74		user_agent: Option<&str>,
75		timeout: Option<Duration>,
76	) -> Result<Self, NoEndpointSpecificError> {
77		Ok(Self {
78			reqwest: reqwest::ClientBuilder::new()
79				.user_agent(user_agent.unwrap_or(Self::default_user_agent()))
80				.timeout(timeout.unwrap_or(Duration::from_secs(20)))
81				.build()?,
82			api_key: Arc::new(api_key.to_string()),
83			system_id,
84		})
85	}
86
87	/// Creates a new [`Client`] backed by the provided [`reqwest::Client`].
88	///
89	/// If you don't want to provide your own reqwest `Client`, see [`Client::new`].
90	///
91	/// The provided reqwest `Client` should have a user agent and timeout.
92	/// [`Client::default_user_agent`] provides a sensible default user agent string.
93	///
94	/// # Example
95	/// ```
96	/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
97	/// let reqwest = reqwest::ClientBuilder::new()
98	///   .user_agent(pvoutput_client::Client::default_user_agent())
99	///   .timeout(std::time::Duration::from_secs(20))
100	///   .build()?;
101	/// let client = pvoutput_client::Client::new_with_reqwest("your-api-key", 12345, reqwest)?;
102	/// # Ok(())
103	/// # }
104	/// ```
105	pub fn new_with_reqwest(
106		api_key: &str,
107		system_id: u32,
108		reqwest: reqwest::Client,
109	) -> Result<Self, NoEndpointSpecificError> {
110		Ok(Self {
111			reqwest,
112			api_key: Arc::new(api_key.to_string()),
113			system_id,
114		})
115	}
116
117	/// Provides a default [user agent](https://developer.mozilla.org/en-US/docs/Glossary/User_agent).
118	/// The agent will look something like `pvoutput-client/v0.1.0 +https://gitlab.com/Lynnesbian/pvoutput-client`
119	#[must_use]
120	pub const fn default_user_agent() -> &'static str {
121		const NAME: &str = env!("CARGO_PKG_NAME");
122		const VERSION: &str = env!("CARGO_PKG_VERSION");
123		const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
124
125		formatcp!("{NAME}/v{VERSION} +{REPOSITORY}")
126	}
127
128	/// Creates a [`csv::Reader`] for a `&str` with the requisite options set (`has_headers`: `false`, `flexible`:
129	/// `true`).
130	pub(crate) fn reader(response: &str) -> csv::Reader<Cursor<&str>> {
131		csv::ReaderBuilder::new().has_headers(false).flexible(true).from_reader(Cursor::new(response))
132	}
133
134	/// Like [`Self::reader`], but for semicolon-delimited responses.
135	pub(crate) fn semicolon_reader(response: &str) -> csv::Reader<Cursor<&str>> {
136		csv::ReaderBuilder::new()
137			.has_headers(false)
138			.flexible(true)
139			.delimiter(b';')
140			.from_reader(Cursor::new(response))
141	}
142
143	/// Creates a [`csv::Writer`] backed by a [`Vec<u8>`] with headers disabled and flexible parsing enabled.
144	pub(crate) fn writer() -> csv::Writer<Vec<u8>> {
145		csv::WriterBuilder::new().flexible(true).has_headers(false).from_writer(Vec::new())
146	}
147
148	/// Creates a [`reqwest::header::HeaderMap`] with the API key, system ID, and rate limit headers.
149	fn headers(&self) -> reqwest::header::HeaderMap {
150		let mut headers = reqwest::header::HeaderMap::new();
151		headers.insert("X-Pvoutput-Apikey", self.api_key.parse().unwrap());
152		headers.insert("X-Pvoutput-SystemId", self.system_id.to_string().parse().unwrap());
153		headers.insert("X-Rate-Limit", reqwest::header::HeaderValue::from_static("1"));
154		headers
155	}
156
157	/// Creates a [`RateLimit`] and stringified response body from a successful [`reqwest::Response`], or a
158	/// `ClientError` from an unsuccessful one.
159	async fn handle_response<Endpoint, F>(response: reqwest::Response, parser: F) -> Result<(RateLimit, String), Endpoint>
160	where
161		F: FnOnce(&str) -> Option<Endpoint>,
162		Endpoint: Debug + Display,
163	{
164		if response.status().is_success() {
165			// TODO: allow rate limit header parsing to fail?
166			Ok((RateLimit::try_from(response.headers())?, response.text().await?))
167		} else {
168			Err(parser::error(response, parser).await)
169		}
170	}
171
172	/// Sends a `GET` request with the requisite headers, then parses the response into a `RateLimit` and a stringified
173	/// response body.
174	async fn get<Endpoint, F>(
175		&self,
176		url: &str,
177		params: impl Serialize,
178		parser: F,
179	) -> Result<(RateLimit, String), Endpoint>
180	where
181		F: FnOnce(&str) -> Option<Endpoint>,
182		Endpoint: Debug + Display,
183	{
184		let response = self.reqwest.get(url).headers(self.headers()).query(&params).send().await?;
185
186		Self::handle_response(response, parser).await
187	}
188
189	/// Sends a `POST` request with the requisite headers, then parses the response into a `RateLimit` and a stringified
190	/// response body.
191	async fn post<Endpoint, F>(&self, url: &str, body: impl Serialize, parser: F) -> Result<(RateLimit, String), Endpoint>
192	where
193		F: FnOnce(&str) -> Option<Endpoint>,
194		Endpoint: Debug + Display,
195	{
196		let response = self.reqwest.post(url).headers(self.headers()).form(&body).send().await?;
197
198		Self::handle_response(response, parser).await
199	}
200
201	/// Convenience method for posting a set of semicolon-delimited CSVs.
202	async fn post_csv<Endpoint, F>(
203		&self,
204		url: &str,
205		body: Vec<impl Serialize>,
206		parser: F,
207	) -> Result<(RateLimit, String), Endpoint>
208	where
209		F: FnOnce(&str) -> Option<Endpoint>,
210		Endpoint: Debug + Display,
211	{
212		let mut writer = Self::writer();
213		for entry in body {
214			writer.serialize(entry)?;
215		}
216		let body = String::from_utf8_lossy(&writer.into_inner()?).replace('\n', ";");
217
218		self.post(url, &[("data", body)], parser).await
219	}
220
221	/// Convenience method for retrieving multiple response objects separated by semicolons.
222	async fn get_multiple<T: DeserializeOwned, Endpoint, F>(
223		&self,
224		url: &str,
225		params: impl Serialize,
226		parser: F,
227	) -> Result<Response<Vec<T>>, Endpoint>
228	where
229		F: FnOnce(&str) -> Option<Endpoint>,
230		Endpoint: Debug + Display,
231	{
232		let (rate_limit, response) = self.get(url, params, parser).await?;
233		let response = response.replace(';', "\n");
234		let mut reader = Self::reader(&response);
235
236		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?, rate_limit))
237	}
238
239	/// Convenience method for retrieving a single response object.
240	async fn get_single<T: DeserializeOwned, Endpoint, F>(
241		&self,
242		url: &str,
243		params: impl Serialize,
244		parser: F,
245	) -> Result<Response<T>, Endpoint>
246	where
247		F: FnOnce(&str) -> Option<Endpoint>,
248		Endpoint: Debug + Display,
249	{
250		let (rate_limit, response) = self.get(url, params, parser).await?;
251		let mut reader = Self::reader(&response);
252
253		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?.remove(0), rate_limit))
254	}
255
256	/// Convenience method for posting a form body and retrieving multiple response objects separated by semicolons.
257	async fn post_multiple<T: DeserializeOwned, Endpoint, F>(
258		&self,
259		url: &str,
260		body: impl Serialize,
261		parser: F,
262	) -> Result<Response<Vec<T>>, Endpoint>
263	where
264		F: FnOnce(&str) -> Option<Endpoint>,
265		Endpoint: Debug + Display,
266	{
267		let (rate_limit, response) = self.post(url, body, parser).await?;
268		let response = response.replace(';', "\n");
269		let mut reader = Self::reader(&response);
270
271		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?, rate_limit))
272	}
273
274	/// Convenience method for posting a CSV body and retrieving an empty response.
275	async fn post_empty_csv<Endpoint, F>(
276		&self,
277		url: &str,
278		body: Vec<impl Serialize>,
279		parser: F,
280	) -> Result<Response<()>, Endpoint>
281	where
282		F: FnOnce(&str) -> Option<Endpoint>,
283		Endpoint: Debug + Display,
284	{
285		Ok(Response::new((), self.post_csv(url, body, parser).await?.0))
286	}
287
288	/// Convenience method for posting a body and retrieving an empty response.
289	async fn post_empty<Endpoint, F>(&self, url: &str, body: impl Serialize, parser: F) -> Result<Response<()>, Endpoint>
290	where
291		F: FnOnce(&str) -> Option<Endpoint>,
292		Endpoint: Debug + Display,
293	{
294		Ok(Response::new((), self.post(url, body, parser).await?.0))
295	}
296
297	/// Searches systems.
298	/// Limited to a maximum of 30 results.
299	///
300	/// [PVOutput docs](https://pvoutput.org/help/api_specification.html#search-service)
301	pub async fn search(&self, params: SearchParams) -> Result<Response<Vec<SearchResult>>, NoEndpointSpecificError> {
302		self.get_multiple(formatcp!("{API_BASE_URL}/search.jsp"), params, parse_none).await
303	}
304}