pvoutput-client 0.0.3

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

mod get;
mod post;

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

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

use crate::{
	API_BASE_URL, parser,
	parser::parse_none,
	types::{
		error::{NoEndpointSpecificError, 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`].
	///
	/// If you want to provide your own [`reqwest::Client`], see [`Client::new_with_reqwest`].
	///
	/// # 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, [`Client::default_user_agent`] will be used.
	/// - `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, NoEndpointSpecificError> {
		Ok(Self {
			reqwest: reqwest::ClientBuilder::new()
				.user_agent(user_agent.unwrap_or(Self::default_user_agent()))
				.timeout(timeout.unwrap_or(Duration::from_secs(20)))
				.build()?,
			api_key: Arc::new(api_key.to_string()),
			system_id,
		})
	}

	/// Creates a new [`Client`] backed by the provided [`reqwest::Client`].
	///
	/// If you don't want to provide your own reqwest `Client`, see [`Client::new`].
	///
	/// The provided reqwest `Client` should have a user agent and timeout.
	/// [`Client::default_user_agent`] provides a sensible default user agent string.
	///
	/// # Example
	/// ```
	/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
	/// let reqwest = reqwest::ClientBuilder::new()
	///   .user_agent(pvoutput_client::Client::default_user_agent())
	///   .timeout(std::time::Duration::from_secs(20))
	///   .build()?;
	/// let client = pvoutput_client::Client::new_with_reqwest("your-api-key", 12345, reqwest)?;
	/// # Ok(())
	/// # }
	/// ```
	pub fn new_with_reqwest(
		api_key: &str,
		system_id: u32,
		reqwest: reqwest::Client,
	) -> Result<Self, NoEndpointSpecificError> {
		Ok(Self {
			reqwest,
			api_key: Arc::new(api_key.to_string()),
			system_id,
		})
	}

	/// Provides a default [user agent](https://developer.mozilla.org/en-US/docs/Glossary/User_agent).
	/// The agent will look something like `pvoutput-client/v0.1.0 +https://gitlab.com/Lynnesbian/pvoutput-client`
	#[must_use]
	pub const fn default_user_agent() -> &'static str {
		const NAME: &str = env!("CARGO_PKG_NAME");
		const VERSION: &str = env!("CARGO_PKG_VERSION");
		const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");

		formatcp!("{NAME}/v{VERSION} +{REPOSITORY}")
	}

	/// 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<Endpoint, F>(response: reqwest::Response, parser: F) -> Result<(RateLimit, String), Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		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, parser).await)
		}
	}

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

		Self::handle_response(response, parser).await
	}

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

		Self::handle_response(response, parser).await
	}

	/// Convenience method for posting a set of semicolon-delimited CSVs.
	async fn post_csv<Endpoint, F>(
		&self,
		url: &str,
		body: Vec<impl Serialize>,
		parser: F,
	) -> Result<(RateLimit, String), Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		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)], parser).await
	}

	/// Convenience method for retrieving multiple response objects separated by semicolons.
	async fn get_multiple<T: DeserializeOwned, Endpoint, F>(
		&self,
		url: &str,
		params: impl Serialize,
		parser: F,
	) -> Result<Response<Vec<T>>, Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		let (rate_limit, response) = self.get(url, params, parser).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, Endpoint, F>(
		&self,
		url: &str,
		params: impl Serialize,
		parser: F,
	) -> Result<Response<T>, Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		let (rate_limit, response) = self.get(url, params, parser).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, Endpoint, F>(
		&self,
		url: &str,
		body: impl Serialize,
		parser: F,
	) -> Result<Response<Vec<T>>, Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		let (rate_limit, response) = self.post(url, body, parser).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<Endpoint, F>(
		&self,
		url: &str,
		body: Vec<impl Serialize>,
		parser: F,
	) -> Result<Response<()>, Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		Ok(Response::new((), self.post_csv(url, body, parser).await?.0))
	}

	/// Convenience method for posting a body and retrieving an empty response.
	async fn post_empty<Endpoint, F>(&self, url: &str, body: impl Serialize, parser: F) -> Result<Response<()>, Endpoint>
	where
		F: FnOnce(&str) -> Option<Endpoint>,
		Endpoint: Debug + Display,
	{
		Ok(Response::new((), self.post(url, body, parser).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>>, NoEndpointSpecificError> {
		self.get_multiple(formatcp!("{API_BASE_URL}/search.jsp"), params, parse_none).await
	}
}