pvoutput-client 0.0.1

Client for the PVOutput.org API
Documentation
//! Asynchronous client and associated methods.

mod get;
mod post;

use std::{io::Cursor, sync::Arc, time::Duration};

use const_format::formatcp;
use serde::{Serialize, de::DeserializeOwned};

use crate::{
	API_BASE_URL, parser,
	types::{
		error::Result,
		params::SearchParams,
		response::{RateLimit, Response, SearchResult},
	},
};

/// An asynchronous client for the [PVOutput](https://pvoutput.org) API.
///
/// Can safely be (cheaply) cloned or shared across threads.
///
/// # Usage
///
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async {
/// use pvoutput_client::{Client, types::params::GetStatusParams};
/// let client = Client::new("your-api-key", 12345, None, None)?;
/// let status = client.get_status(GetStatusParams::default()).await?;
/// println!("Generated {}Wh at {}", status.data.energy_generation, status.data.time);
/// # Ok(())
/// # })}
/// ```
#[derive(Clone)]
pub struct Client {
	reqwest: reqwest::Client,
	api_key: Arc<String>,
	system_id: u32,
}

impl Client {
	/// Creates a new [`Client`].
	///
	/// # Parameters
	/// - `api_key`: Your [PVOutput API key](https://pvoutput.org/help/api_specification.html#getting-started).
	/// - `system_id`: A [PVOutput system ID](https://pvoutput.org/help/api_specification.html#getting-started). The
	///   system must be owned by the API key provided.
	/// - `user_agent`: A [user agent](https://developer.mozilla.org/en-US/docs/Glossary/User_agent) to use for all
	///   requests. If not provided, a default will be used; this default looks something like
	///   `pvoutput-client/v0.1.0 +https://gitlab.com/Lynnesbian/pvoutput-client`
	/// - `timeout`: An overall timeout, applied from when the request starts connecting to when the response body has
	///   finished. If not provided, a default value of twenty seconds will be used.
	///
	/// # Example
	/// ```
	/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
	/// let client = pvoutput_client::Client::new("your-api-key", 12345, None, None)?;
	/// # Ok(())
	/// # }
	/// ```
	pub fn new(api_key: &str, system_id: u32, user_agent: Option<&str>, timeout: Option<Duration>) -> Result<Self> {
		const NAME: &str = env!("CARGO_PKG_NAME");
		const VERSION: &str = env!("CARGO_PKG_VERSION");
		const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");

		Ok(Self {
			reqwest: reqwest::ClientBuilder::new()
				.user_agent(user_agent.unwrap_or(formatcp!("{NAME}/v{VERSION} +{REPOSITORY}")))
				.timeout(timeout.unwrap_or(Duration::from_secs(20)))
				.build()?,
			api_key: Arc::new(api_key.to_string()),
			system_id,
		})
	}

	/// Creates a [`csv::Reader`] for a `&str` with the requisite options set (`has_headers`: `false`, `flexible`:
	/// `true`).
	pub(crate) fn reader(response: &str) -> csv::Reader<Cursor<&str>> {
		csv::ReaderBuilder::new().has_headers(false).flexible(true).from_reader(Cursor::new(response))
	}

	/// Like [`Self::reader`], but for semicolon-delimited responses.
	pub(crate) fn semicolon_reader(response: &str) -> csv::Reader<Cursor<&str>> {
		csv::ReaderBuilder::new()
			.has_headers(false)
			.flexible(true)
			.delimiter(b';')
			.from_reader(Cursor::new(response))
	}

	/// Creates a [`csv::Writer`] backed by a [`Vec<u8>`] with headers disabled and flexible parsing enabled.
	pub(crate) fn writer() -> csv::Writer<Vec<u8>> {
		csv::WriterBuilder::new().flexible(true).has_headers(false).from_writer(Vec::new())
	}

	/// Creates a [`reqwest::header::HeaderMap`] with the API key, system ID, and rate limit headers.
	fn headers(&self) -> reqwest::header::HeaderMap {
		let mut headers = reqwest::header::HeaderMap::new();
		headers.insert("X-Pvoutput-Apikey", self.api_key.parse().unwrap());
		headers.insert("X-Pvoutput-SystemId", self.system_id.to_string().parse().unwrap());
		headers.insert("X-Rate-Limit", reqwest::header::HeaderValue::from_static("1"));
		headers
	}

	/// Creates a [`RateLimit`] and stringified response body from a successful [`reqwest::Response`], or a
	/// `ClientError` from an unsuccessful one.
	async fn handle_response(response: reqwest::Response) -> Result<(RateLimit, String)> {
		if response.status().is_success() {
			// TODO: allow rate limit header parsing to fail?
			Ok((RateLimit::try_from(response.headers())?, response.text().await?))
		} else {
			Err(parser::error(response).await)
		}
	}

	/// Sends a `GET` request with the requisite headers, then parses the response into a `RateLimit` and a stringified
	/// response body.
	async fn get(&self, url: &str, params: impl Serialize) -> Result<(RateLimit, String)> {
		let response = self.reqwest.get(url).headers(self.headers()).query(&params).send().await?;

		Self::handle_response(response).await
	}

	/// Sends a `POST` request with the requisite headers, then parses the response into a `RateLimit` and a stringified
	/// response body.
	async fn post(&self, url: &str, body: impl Serialize) -> Result<(RateLimit, String)> {
		let response = self.reqwest.post(url).headers(self.headers()).form(&body).send().await?;

		Self::handle_response(response).await
	}

	/// Convenience method for posting a set of semicolon-delimited CSVs.
	async fn post_csv(&self, url: &str, body: Vec<impl Serialize>) -> Result<(RateLimit, String)> {
		let mut writer = Self::writer();
		for entry in body {
			writer.serialize(entry)?;
		}
		let body = String::from_utf8_lossy(&writer.into_inner()?).replace('\n', ";");

		self.post(url, &[("data", body)]).await
	}

	/// Convenience method for retrieving multiple response objects separated by semicolons.
	async fn get_multiple<T: DeserializeOwned>(&self, url: &str, params: impl Serialize) -> Result<Response<Vec<T>>> {
		let (rate_limit, response) = self.get(url, params).await?;
		let response = response.replace(';', "\n");
		let mut reader = Self::reader(&response);

		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?, rate_limit))
	}

	/// Convenience method for retrieving a single response object.
	async fn get_single<T: DeserializeOwned>(&self, url: &str, params: impl Serialize) -> Result<Response<T>> {
		let (rate_limit, response) = self.get(url, params).await?;
		let mut reader = Self::reader(&response);

		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?.remove(0), rate_limit))
	}

	/// Convenience method for posting a form body and retrieving multiple response objects separated by semicolons.
	async fn post_multiple<T: DeserializeOwned>(&self, url: &str, body: impl Serialize) -> Result<Response<Vec<T>>> {
		let (rate_limit, response) = self.post(url, body).await?;
		let response = response.replace(';', "\n");
		let mut reader = Self::reader(&response);

		Ok(Response::new(reader.deserialize().collect::<csv::Result<Vec<T>>>()?, rate_limit))
	}

	/// Convenience method for posting a CSV body and retrieving an empty response.
	async fn post_empty_csv(&self, url: &str, body: Vec<impl Serialize>) -> Result<Response<()>> {
		Ok(Response::new((), self.post_csv(url, body).await?.0))
	}

	/// Convenience method for posting a body and retrieving an empty response.
	async fn post_empty(&self, url: &str, body: impl Serialize) -> Result<Response<()>> {
		Ok(Response::new((), self.post(url, body).await?.0))
	}

	/// Searches systems.
	/// Limited to a maximum of 30 results.
	///
	/// [PVOutput docs](https://pvoutput.org/help/api_specification.html#search-service)
	pub async fn search(&self, params: SearchParams) -> Result<Response<Vec<SearchResult>>> {
		self.get_multiple(formatcp!("{API_BASE_URL}/search.jsp"), params).await
	}
}