use std::fmt;
use std::num::NonZeroU8;
use std::string::ToString;
use std::time::{Duration, Instant, SystemTime};
use crate::http::{selector::Select, state::Color};
use reqwest::{Client as ReqwestClient, Method};
use serde::Serialize;
#[inline]
pub(crate) fn unity() -> NonZeroU8 {
NonZeroU8::new(1).expect("1 == 0")
}
mod effects;
mod scenes;
mod states;
pub use self::effects::*;
pub use self::scenes::*;
pub use self::states::*;
/// Contains useful utilities for working with the LIFX HTTP API.
///
/// Use the prelude to maintain the convenience of glob importing without overly polluting the namespace.
///
/// ## Usage
/// ```
/// use lifxi::http::prelude::*;
/// ```
pub mod prelude {
pub use crate::http::Client;
pub use crate::http::Color;
pub use crate::http::ColorParseError;
pub use crate::http::ColorValidationError;
pub use crate::http::Combine;
pub use crate::http::Randomize;
pub use crate::http::Retry;
pub use crate::http::Selector;
pub use crate::http::SelectorParseError;
pub use crate::http::Send;
pub use crate::http::State;
pub use crate::http::StateChange;
}
/// Trait enabling conversion of non-terminal request builders to requests.
pub trait AsRequest<S: Serialize> {
/// The HTTP verb to be used.
fn method() -> reqwest::Method;
/// A reference to the shared client (so we can reuse it).
fn client(&self) -> &'_ Client;
/// The relative path (to the API root) of the appropriate endpoint.
fn path(&self) -> String;
/// The request body to be used, as configured by the user.
fn body(&self) -> &'_ S;
/// The number of attempts to be made.
fn attempts(&self) -> NonZeroU8;
}
/// The result type for all requests made with the client.
pub type ClientResult = Result<reqwest::Response, Error>;
/// The crux of the HTTP API. Start here.
///
/// The client is the entry point for the web API interface. First construct a client, then use it
/// to perform whatever tasks necessary.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let result = client
/// .select(Selector::All)
/// .set_state()
/// .color(Color::Red)
/// .power(true)
/// .retry()
/// .send();
/// # }
/// ```
pub struct Client {
client: ReqwestClient,
token: String,
}
impl Client {
/// Constructs a new `Client` with the given access token.
///
/// ## Examples
/// ```
/// use lifxi::http::prelude::*;
/// let secret = "foo";
/// let client = Client::new(secret);
/// let secret = "foo".to_string();
/// let client = Client::new(secret);
/// ```
#[allow(clippy::needless_pass_by_value)]
pub fn new<S: ToString>(token: S) -> Self {
Self {
client: ReqwestClient::new(),
token: token.to_string(),
}
}
/// Specifies the lights upon which to act.
///
/// See [the documentation for `Selected<T>`](struct.Selected.html) to understand why this is
/// useful.
pub fn select<T: Select>(&self, selector: T) -> Selected<T> {
Selected {
client: self,
selector,
}
}
/// Creates a request to set multiple states (on multiple lights).
///
/// For a simpler API when working with a single state on one or multiple lights, see
/// [`Selected::set_state`](struct.Selected.html#method.set_state).
pub fn set_states(&self) -> SetStates<'_> {
SetStates::new(self)
}
/// Creates a request to validate the given color.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let secret = "foo";
/// let client = Client::new(secret);
/// let color = Color::Custom("cyan".to_string());
/// let is_valid = client
/// .validate(&color)
/// .send()
/// .is_ok();
/// # }
/// ```
pub fn validate(&self, color: &Color) -> Request<'_, ()> {
Request {
client: self,
path: format!("/color?string={}", color),
body: (),
method: Method::GET,
attempts: unity(),
}
}
/// Entry point for working with scenes.
///
/// See [`Scenes`](struct.Scenes.html).
pub fn scenes(&self) -> Scenes {
Scenes { client: self }
}
}
/// Represents an error encountered when sending a request.
///
/// Errors may come from a variety of sources, but the ones handled most directly by this crate are
/// client errors. If a client error occurs, we map it to a user-friendly error variant; if another
/// error occurs, we just wrap it and return it. This means that errors stemming from your mistakes
/// are easier to diagnose than errors from the middleware stack.
#[derive(Debug)]
pub enum Error {
/// The API is enforcing a rate limit. The associated value is the time at which the rate limit
/// will be lifted, if it was specified.
RateLimited(Option<Instant>),
/// The request was malformed and should not be reattempted (HTTP 400 or 422).
/// If this came from library methods, please
/// [create an issue](https://github.com/Aehmlo/lifxi/issues/new). If you're using a custom
/// color somewhere, please first [validate it](struct.Client.html#method.validate). Otherwise,
/// check for empty strings.
BadRequest,
/// The specified access token was invalid (HTTP 401).
BadAccessToken,
/// The requested OAuth scope was invalid (HTTP 403).
BadOAuthScope,
/// The given selector (or scene UUID) did not match anything associated with this account
/// (HTTP 404). The URL is returned as well, if possible, to help with troubleshooting.
NotFound(Option<String>),
/// The API server encountered an error, but the request was (seemingly) valid (HTTP 5xx).
Server(Option<reqwest::StatusCode>, reqwest::Error),
/// An HTTP stack error was encountered.
Http(reqwest::Error),
/// A serialization error was encountered.
Serialization(reqwest::Error),
/// A bad redirect was encountered.
Redirect(reqwest::Error),
/// A miscellaneous client error occurred (HTTP 4xx).
Client(Option<reqwest::StatusCode>, reqwest::Error),
/// Some other error occured.
Other(reqwest::Error),
}
impl Error {
/// Whether the error is a client error (indicating that the request should not be retried
/// without modification).
fn is_client_error(&self) -> bool {
use self::Error::*;
match self {
RateLimited(_)
| BadRequest
| BadAccessToken
| BadOAuthScope
| NotFound(_)
| Client(_, _) => true,
_ => false,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
use self::Error::*;
use reqwest::StatusCode;
if err.is_client_error() {
match err.status() {
Some(StatusCode::BAD_REQUEST) | Some(StatusCode::UNPROCESSABLE_ENTITY) => {
BadRequest
}
Some(StatusCode::UNAUTHORIZED) => BadAccessToken,
Some(StatusCode::FORBIDDEN) => BadOAuthScope,
Some(StatusCode::NOT_FOUND) => NotFound(err.url().map(|u| u.as_str().to_string())),
s => Client(s, err),
}
} else if err.is_http() {
Http(err)
} else if err.is_serialization() {
Serialization(err)
} else if err.is_redirect() {
Redirect(err)
} else if err.is_server_error() {
Server(err.status(), err)
} else {
Other(err)
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
use self::Error::*;
match self {
RateLimited(t) => {
if let Some(time) = t {
write!(f, "Rate-limited until instant {:?}.", time)
} else {
write!(f, "Rate-limited.")
}
}
BadRequest => write!(f, "Bad request."),
BadAccessToken => write!(f, "Bad access token."),
BadOAuthScope => write!(f, "Bad OAuth scope."),
NotFound(s) => {
if let Some(url) = s {
write!(f, "Bad URL: {}", url)
} else {
write!(f, "Bad URL.")
}
}
Server(_, e) => write!(f, "Server error: {}", e),
Http(e) => write!(f, "HTTP error: {}", e),
Serialization(e) => write!(f, "Serialization error: {}", e),
Redirect(e) => write!(f, "Redirect error: {}", e),
Client(_, e) => write!(f, "Client error: {}", e),
Other(e) => write!(f, "{}", e),
}
}
}
impl ::std::error::Error for Error {}
/// Represents a terminal request.
///
/// The only thing to be done with this request is [send it](#method.send).
pub struct Request<'a, S> {
client: &'a Client,
path: String,
body: S,
method: Method,
attempts: NonZeroU8,
}
impl<'a, S> Request<'a, S>
where
S: Serialize,
{
/// Sends the request, returning the result.
///
/// Requests are synchronous, so this method blocks.
pub fn send(&self) -> ClientResult {
use reqwest::StatusCode;
let header = |name: &'static str| reqwest::header::HeaderName::from_static(name);
let token = self.client.token.as_str();
let client = &self.client.client;
let url = &format!("https://api.lifx.com/v1{}", self.path);
let method = self.method.clone();
let result = client
.request(method, url)
.bearer_auth(token)
.json(&self.body)
.send()?;
let headers = result.headers();
let reset = headers.get(&header("x-ratelimit-reset")).map(|s| {
if let Ok(val) = s.to_str() {
if let Ok(future) = val.parse::<u64>() {
let now = (SystemTime::now(), Instant::now());
if let Ok(timestamp) = now
.0
.duration_since(SystemTime::UNIX_EPOCH)
.map(|t| t.as_secs())
{
return now.1 + Duration::from_secs(future - timestamp);
}
}
}
Instant::now() + Duration::from_secs(60)
});
let mut result = result.error_for_status().map_err(|e| {
if e.status() == Some(StatusCode::TOO_MANY_REQUESTS) {
Error::RateLimited(reset)
} else {
e.into()
}
});
for _ in 1..self.attempts.get() {
match result {
Ok(r) => {
return Ok(r);
}
Err(e) => {
if let Error::RateLimited(Some(t)) = e {
// Wait until we're allowed to try again.
::std::thread::sleep(t - Instant::now());
} else if e.is_client_error() {
return Err(e);
}
result = self.send();
}
}
}
result
}
}
/// Trait for configurable (non-terminal) requests to be sent conveniently.
pub trait Send<S> {
/// Sends the request.
///
/// This method delegates to `Request::send`, so take a look at
/// [that documentation](struct.Request.html#method.send) for more information.
fn send(&self) -> ClientResult;
}
impl<'a, T, S> Send<S> for T
where
T: AsRequest<S> + Retry,
S: Serialize,
{
/// Delegates to [`Request::send`](struct.Request.html#method.send).
fn send(&self) -> ClientResult {
let request = Request {
body: self.body(),
client: self.client(),
method: Self::method(),
path: self.path(),
attempts: self.attempts(),
};
request.send()
}
}
/// Enables automatic implementation of [`Retry`](trait.Retry.html).
#[doc(hidden)]
pub trait Attempts {
/// Updates the number of times to retry the request.
fn set_attempts(&mut self, attempts: NonZeroU8);
}
impl<'a, S: Serialize> Attempts for Request<'a, S> {
fn set_attempts(&mut self, attempts: NonZeroU8) {
self.attempts = attempts;
}
}
/// Trait enabling retrying of failed requests.
pub trait Retry {
/// Retries the corresponding request once.
fn retry(&mut self) -> &'_ mut Self;
/// Retries the corresponding request the given number of times.
fn retries(&mut self, n: NonZeroU8) -> &'_ mut Self;
}
impl<T> Retry for T
where
T: Attempts,
{
fn retry(&mut self) -> &'_ mut Self {
self.retries(unity())
}
fn retries(&mut self, n: NonZeroU8) -> &'_ mut Self {
self.set_attempts(n);
self
}
}
/// A scoped request that can be used to get or set light states.
///
/// Created by [`Client::select`](struct.Client.html#method.select).
pub struct Selected<'a, T: Select> {
client: &'a Client,
selector: T,
}
impl<'a, T> Selected<'a, T>
where
T: Select,
{
/// Creates a request to get information about the selected lights (including their states).
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let lights = client
/// .select(Selector::All)
/// .list()
/// .send();
/// # }
/// ```
pub fn list(&'a self) -> Request<'a, ()> {
Request {
client: self.client,
path: format!("/lights/{}", self.selector),
body: (),
method: Method::GET,
attempts: unity(),
}
}
/// Creates a request to set a uniform state on one or more lights.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let lights = client
/// .select(Selector::All)
/// .set_state()
/// .color(Color::Red)
/// .power(true)
/// .brightness(0.1)
/// .transition(::std::time::Duration::new(7, 0))
/// .infrared(0.8)
/// .send();
/// # }
/// ```
pub fn set_state(&'a self) -> SetState<'a, T> {
SetState::new(self)
}
/// Creates a request to incrementally change state on one or more lights.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let lights = client
/// .select(Selector::All)
/// .change_state()
/// .power(true)
/// .brightness(0.4)
/// .saturation(-0.1)
/// .brightness(0.1)
/// .kelvin(-100)
/// .transition(::std::time::Duration::new(7, 0))
/// .infrared(0.1)
/// .send();
/// # }
/// ```
pub fn change_state(&'a self) -> ChangeState<'a, T> {
ChangeState::new(self)
}
/// Creates a request to begin a "breathe" effect.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let lights = client
/// .select(Selector::All)
/// .breathe(Color::Orange)
/// .from(Color::Purple)
/// .power(true)
/// .cycles(100)
/// .period(::std::time::Duration::new(20, 0))
/// .peak(0.8)
/// .persist(true)
/// .send();
/// # }
/// ```
pub fn breathe(&'a self, color: Color) -> Breathe<'a, T> {
Breathe::new(self, color)
}
/// Creates a request to begin a "pulse" effect.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let lights = client
/// .select(Selector::All)
/// .pulse(Color::Orange)
/// .from(Color::Purple)
/// .power(true)
/// .cycles(100)
/// .period(::std::time::Duration::new(20, 0))
/// .persist(true)
/// .send();
/// # }
/// ```
pub fn pulse(&'a self, color: Color) -> Pulse<'a, T> {
Pulse::new(self, color)
}
/// Begins the process of specifying a cycle.
///
/// Cycles provide a convenient method of moving through a set of changes without client-side
/// logic; the API keeps track of the state of the bulb and will move to the next appropriate
/// state upon repeated requests.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// fn client() -> Client {
/// // TODO: Add lazy-static dependency and use it to make a shared client.
/// unimplemented!()
/// }
/// // Let's make a light show we can advance by pressing a button!
/// // Each press of our internet-connected button calls this function.
/// fn next() {
/// let red = State::builder().color(Color::Red);
/// let green = State::builder().color(Color::Green);
/// let white = State::builder().color(Color::White);
/// let shared = State::builder().color(Color::Brightness(1.0)).power(true);
/// let result = client()
/// .select(Selector::All)
/// .cycle()
/// .add(red)
/// .add(green)
/// .add(white)
/// .rev() // Let's mix it up a little!
/// .default(shared)
/// .send();
/// }
pub fn cycle(&'a self) -> Cycle<'a, T> {
Cycle::new(self)
}
/// Creates a request to toggle power to the selected light(s), with an optional transition
/// time (see [`Toggle::transition`](struct.Toggle.html#method.transition) for details).
///
/// ## Notes
/// All selected lights will have the same power state after this request is processed; if all
/// are off, all will be turned on, but if any are on, all will be turned off.
///
/// ## Example
/// ```
/// use lifxi::http::prelude::*;
/// # fn run() {
/// let client = Client::new("foo");
/// let result = client
/// .select(Selector::All)
/// .toggle()
/// .transition(::std::time::Duration::new(2, 0))
/// .send();
/// # }
/// ```
pub fn toggle(&'a self) -> Toggle<'a, T> {
Toggle::new(self)
}
}