Skip to main content

twitch_api/
lib.rs

1#![deny(missing_docs, rustdoc::broken_intra_doc_links)]
2#![allow(clippy::needless_raw_string_hashes)]
3#![cfg_attr(test, allow(deprecated))] // for pubsub tests
4#![cfg_attr(nightly, feature(doc_cfg))]
5#![doc(html_root_url = "https://docs.rs/twitch_api/0.7.2")]
6//! [![github]](https://github.com/twitch-rs/twitch_api) [![crates-io]](https://crates.io/crates/twitch_api) [![docs-rs-big]](https://docs.rs/twitch_api/0.7.2/twitch_api)
7//!
8//! [github]: https://img.shields.io/badge/github-twitch--rs/twitch__api-8da0cb?style=for-the-badge&labelColor=555555&logo=github"
9//! [crates-io]: https://img.shields.io/crates/v/twitch_api.svg?style=for-the-badge&color=fc8d62&logo=rust"
10//! [docs-rs-big]: https://img.shields.io/badge/docs.rs-twitch__api-66c2a5?style=for-the-badge&labelColor=555555&logoColor=white&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K"
11//!
12//! <br>
13//!
14//! <h5>Rust library for talking with the new Twitch API aka. "Helix", EventSub and more! Use Twitch endpoints fearlessly!</h5>
15//!
16//! # Examples
17//!
18//! Get a channel
19//!
20//! ```rust,no_run
21//! use twitch_api::helix::HelixClient;
22//! use twitch_api::twitch_oauth2::{AccessToken, UserToken};
23//!
24//! #[tokio::main]
25//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
26//!     // Create the HelixClient, which is used to make requests to the Twitch API
27//!     let client: HelixClient<reqwest::Client> = HelixClient::default();
28//!     // Create a UserToken, which is used to authenticate requests.
29//!     let token =
30//!         UserToken::from_token(&client, AccessToken::from("mytoken"))
31//!             .await?;
32//!
33//!     println!(
34//!         "Channel: {:?}",
35//!         client.get_channel_from_login("twitchdev", &token).await?
36//!     );
37//!
38//!     Ok(())
39//! }
40//! ```
41//!
42//! Get information about a channel with the [`Get Channel Information`](crate::helix::channels::get_channel_information) helix endpoint.
43//!
44//! ```rust,no_run
45//! use twitch_api::twitch_oauth2::{
46//!     tokens::errors::AppAccessTokenError, AppAccessToken, TwitchToken,
47//! };
48//! use twitch_api::{helix::channels::GetChannelInformationRequest, TwitchClient};
49//!
50//! #[tokio::main]
51//! async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
52//!     let client: TwitchClient<reqwest::Client> = TwitchClient::default();
53//!     let token = AppAccessToken::get_app_access_token(
54//!         &client,
55//!         "validclientid".into(),
56//!         "validclientsecret".into(),
57//!         vec![/* scopes */],
58//!     )
59//!     .await?;
60//!     let req = GetChannelInformationRequest::broadcaster_ids(&["27620241"]);
61//!     println!(
62//!         "{:?}",
63//!         &client.helix.req_get(req, &token).await?.data[0].title
64//!     );
65//!
66//!     Ok(())
67//! }
68//! ```
69//!
70//! There is also convenience functions, like accessing channel information with a specified login name
71//! ```rust,no_run
72//! # use twitch_api::{TwitchClient, helix::channels::GetChannelInformationRequest};
73//! # use twitch_api::twitch_oauth2::{AppAccessToken, Scope, TwitchToken, tokens::errors::AppAccessTokenError};
74//! # #[tokio::main]
75//! # async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
76//! let client = TwitchClient::default();
77//! # let _:&TwitchClient<twitch_api::DummyHttpClient> = &client;
78//! # let client_id = twitch_oauth2::ClientId::new("validclientid".to_string());
79//! # let client_secret = twitch_oauth2::ClientSecret::new("validclientsecret".to_string());
80//! # let token =
81//! #   match AppAccessToken::get_app_access_token(&client, client_id, client_secret, Scope::all()).await {
82//! #       Ok(t) => t,
83//! #       Err(AppAccessTokenError::Request(e)) => panic!("got error: {:?}", e),
84//! #       Err(e) => panic!(e),
85//! #   };
86//!
87//! println!("{:?}", &client.helix.get_channel_from_login("twitch", &token).await?.unwrap().title);
88//! # Ok(())
89//! # }
90//! ```
91//!
92//! # Features
93//!
94//! This crate provides almost no functionality by default, only exposing [`types`]. To enable more features, refer to below table.
95//!
96//! | Feature |         |
97//! | -------: | :------- |
98//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>twitch_oauth2</code></span> | Gives [scopes](twitch_oauth2::Scope) for endpoints and topics that are needed to call them. |
99//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>client</code></span> | Gives a [client abstraction](HttpClient) for endpoints. See [`HelixClient`] |
100//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>helix</code></span> | Enables [Helix](helix) endpoints |
101//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>eventsub</code></span> | Enables deserializable structs for [EventSub](eventsub) |
102//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>hmac</code></span> | Enable [message authentication](eventsub::Event::verify_payload) using HMAC on [EventSub](eventsub) |
103//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>time</code></span> | Enable time utilities on [Timestamp](types::Timestamp) |
104//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>all</code></span> | Enables all above features. Do not use this in production, it's better if you specify exactly what you need |
105//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>ureq</code></span> | Enables ureq for [`HttpClient`]. |
106//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>reqwest</code></span> | Enables reqwest for [`HttpClient`]. Note that this does not enable any default TLS backend, if you get `invalid URL, scheme is not http`, specify `reqwest` in your Cargo.toml. By default, `reqwest` uses feature `default-tls` |
107//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>tower</code></span> | Enables using [tower services](client::TowerService) for [`HttpClient`]. |
108//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>beta</code></span> | Enables beta endpoints, topics or features. Breakage may occur, semver compatibility not guaranteed. |
109//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>unsupported</code></span> | Enables undocumented or experimental endpoints, including beta endpoints, topics or features. Breakage may occur, semver compatibility not guaranteed. |
110//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>trace_unknown_fields</code></span> | Logs ignored fields as `WARN` log messages where  applicable. Please consider using this and filing an issue or PR when a new field has been added to the endpoint but not added to this library. |
111//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>deny_unknown_fields</code></span> | Adds `#[serde(deny_unknown_fields)]` on all applicable structs/enums. Please consider using this and filing an issue or PR when a new field has been added to the endpoint but not added to this library. |
112//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>deser_borrow</code></span> | Makes fields on [`Deserialize`](serde::Deserialize)-able structs borrow if they can be borrowed, this feature is enabled by default, but exists to enable using [`serde::de::DeserializeOwned`] or [`for<'de> serde::Deserialize<'de>`](serde::Deserialize) by disabling this feature. |
113
114/// Doc test for README
115#[doc = include_str!("../README.md")]
116#[doc(hidden)]
117pub struct ReadmeDoctests;
118
119pub use twitch_types as types;
120
121#[cfg(feature = "helix")]
122pub mod helix;
123
124#[cfg(feature = "pubsub")]
125#[deprecated(
126    since = "0.7.0",
127    note = "use `EventSub` instead, see https://discuss.dev.twitch.com/t/legacy-pubsub-deprecation-and-shutdown-timeline/58043"
128)]
129pub mod pubsub;
130
131#[cfg(feature = "eventsub")]
132pub mod eventsub;
133
134#[cfg(all(feature = "helix", feature = "client"))]
135#[doc(inline)]
136pub use crate::helix::HelixClient;
137
138/// Extra types not defined in [`twitch_types`]
139pub mod extra;
140
141#[cfg(any(feature = "twitch_oauth2", all(feature = "helix", feature = "client")))]
142#[doc(no_inline)]
143pub use twitch_oauth2;
144
145#[cfg(feature = "client")]
146pub mod client;
147#[cfg(feature = "client")]
148pub use client::Client as HttpClient;
149
150#[doc(hidden)]
151#[cfg(feature = "client")]
152pub use client::DummyHttpClient;
153
154#[cfg(any(feature = "helix", feature = "pubsub", feature = "eventsub"))]
155/// Generate a url with a default if `mock_api` feature is disabled, or env var is not defined or is invalid utf8
156macro_rules! mock_env_url {
157    ($var:literal, $default:expr $(,)?) => {
158        once_cell::sync::Lazy::new(move || {
159            #[cfg(feature = "mock_api")]
160            if let Ok(url) = std::env::var($var) {
161                return url::Url::parse(&url).expect(concat!(
162                    "URL could not be made from `env:",
163                    $var,
164                    "`."
165                ));
166            };
167            url::Url::parse(&$default).unwrap()
168        })
169    };
170}
171
172/// Location of Twitch Helix
173///
174/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_HELIX_URL`.
175///
176/// # Examples
177///
178/// Set the environment variable `TWITCH_HELIX_URL` to `http://localhost:8080/mock/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
179#[cfg(feature = "helix")]
180pub static TWITCH_HELIX_URL: once_cell::sync::Lazy<url::Url> =
181    mock_env_url!("TWITCH_HELIX_URL", "https://api.twitch.tv/helix/");
182/// Location to twitch PubSub
183///
184/// Can be overriden when feature `mock_api` is enabled with environment variable `TWITCH_PUBSUB_URL`.
185#[cfg(feature = "pubsub")]
186pub static TWITCH_PUBSUB_URL: once_cell::sync::Lazy<url::Url> =
187    mock_env_url!("TWITCH_PUBSUB_URL", "wss://pubsub-edge.twitch.tv");
188
189/// Location to twitch Eventsub WebSocket
190///
191/// Can be overriden when feature `mock_api` is enabled with environment variable `TWITCH_EVENTSUB_WEBSOCKET_URL`.
192#[cfg(feature = "eventsub")]
193pub static TWITCH_EVENTSUB_WEBSOCKET_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!(
194    "TWITCH_EVENTSUB_WEBSOCKET_URL",
195    "wss://eventsub.wss.twitch.tv/ws"
196);
197
198/// Client for Twitch APIs.
199///
200/// Most [http clients][crate::HttpClient] will be able to use the `'static` lifetime
201///
202/// ```rust,no_run
203/// # use twitch_api::{TwitchClient}; pub mod reqwest {pub type Client = twitch_api::client::DummyHttpClient;}
204/// pub struct MyStruct {
205///     twitch: TwitchClient<'static, reqwest::Client>,
206///     token: twitch_oauth2::AppAccessToken,
207/// }
208/// // etc
209/// ```
210///
211/// See [`client`] for implemented clients, you can also define your own if needed.
212#[cfg(all(feature = "client", feature = "helix"))]
213#[derive(Clone)]
214#[non_exhaustive]
215pub struct TwitchClient<'a, C>
216where C: HttpClient + 'a {
217    /// Helix endpoint. See [`helix`]
218    #[cfg(feature = "helix")]
219    pub helix: HelixClient<'a, C>,
220}
221
222#[cfg(all(feature = "client", feature = "helix"))]
223impl<C: HttpClient + 'static> TwitchClient<'static, C> {
224    /// Create a new [`TwitchClient`]
225    #[cfg(feature = "helix")]
226    pub fn new() -> Self
227    where C: Clone + client::ClientDefault<'static> {
228        let client = C::default_client();
229        Self::with_client(client)
230    }
231}
232
233#[cfg(all(feature = "client", feature = "helix"))]
234impl<C: HttpClient + client::ClientDefault<'static> + 'static> Default
235    for TwitchClient<'static, C>
236{
237    fn default() -> Self { Self::new() }
238}
239
240#[cfg(all(feature = "client", feature = "helix"))]
241impl<'a, C: HttpClient + 'a> TwitchClient<'a, C> {
242    /// Create a new [`TwitchClient`] with an existing [`HttpClient`]
243    #[cfg_attr(nightly, doc(cfg(all(feature = "client", feature = "helix"))))]
244    #[cfg(feature = "helix")]
245    pub const fn with_client(client: C) -> Self
246    where C: Clone + 'a {
247        TwitchClient {
248            #[cfg(feature = "helix")]
249            helix: HelixClient::with_client(client),
250        }
251    }
252
253    /// Retrieve a reference of the [`HttpClient`] inside this [`TwitchClient`]
254    pub const fn get_client(&self) -> &C { self.helix.get_client() }
255}
256
257/// A deserialization error
258#[cfg(feature = "serde_json")]
259#[derive(Debug, thiserror::Error, displaydoc::Display)]
260#[non_exhaustive]
261pub enum DeserError {
262    /// could not deserialize, error on [{path}]. {error}
263    PathError {
264        /// Path to where the erroring key/value is
265        path: String,
266        /// Error for the key/value
267        #[source]
268        error: serde_json::Error,
269    },
270}
271
272/// Parse a string as `T`, logging ignored fields and giving a more detailed error message on parse errors
273///
274/// The log_ignored argument decides if a trace of ignored value should be emitted
275#[cfg(feature = "serde_json")]
276pub fn parse_json<'a, T: serde::Deserialize<'a>>(
277    s: &'a str,
278    #[allow(unused_variables)] log_ignored: bool,
279) -> Result<T, DeserError> {
280    #[cfg(feature = "trace_unknown_fields")]
281    {
282        let jd = &mut serde_json::Deserializer::from_str(s);
283        let mut track = serde_path_to_error::Track::new();
284        let pathd = serde_path_to_error::Deserializer::new(jd, &mut track);
285        if log_ignored {
286            let mut fun = |path: serde_ignored::Path| {
287                tracing::warn!(key=%path,"Found ignored key");
288            };
289            serde_ignored::deserialize(pathd, &mut fun).map_err(|e| DeserError::PathError {
290                path: track.path().to_string(),
291                error: e,
292            })
293        } else {
294            T::deserialize(pathd).map_err(|e| DeserError::PathError {
295                path: track.path().to_string(),
296                error: e,
297            })
298        }
299    }
300    #[cfg(not(feature = "trace_unknown_fields"))]
301    {
302        let jd = &mut serde_json::Deserializer::from_str(s);
303        serde_path_to_error::deserialize(jd).map_err(|e| DeserError::PathError {
304            path: e.path().to_string(),
305            error: e.into_inner(),
306        })
307    }
308}
309
310/// Parse a json Value as `T`, logging ignored fields and giving a more detailed error message on parse errors
311#[cfg(feature = "serde_json")]
312pub fn parse_json_value<'a, T: serde::Deserialize<'a>>(
313    value: serde_json::Value,
314    #[allow(unused_variables)] log_ignored: bool,
315) -> Result<T, DeserError> {
316    #[cfg(feature = "trace_unknown_fields")]
317    {
318        let de = serde::de::IntoDeserializer::into_deserializer(value);
319        let mut track = serde_path_to_error::Track::new();
320        let pathd = serde_path_to_error::Deserializer::new(de, &mut track);
321        if log_ignored {
322            let mut fun = |path: serde_ignored::Path| {
323                tracing::warn!(key=%path,"Found ignored key");
324            };
325            serde_ignored::deserialize(pathd, &mut fun).map_err(|e| DeserError::PathError {
326                path: track.path().to_string(),
327                error: e,
328            })
329        } else {
330            T::deserialize(pathd).map_err(|e| DeserError::PathError {
331                path: track.path().to_string(),
332                error: e,
333            })
334        }
335    }
336    #[cfg(not(feature = "trace_unknown_fields"))]
337    {
338        let de = serde::de::IntoDeserializer::into_deserializer(value);
339        serde_path_to_error::deserialize(de).map_err(|e| DeserError::PathError {
340            path: e.path().to_string(),
341            error: e.into_inner(),
342        })
343    }
344}
345
346#[cfg(any(feature = "helix", feature = "pubsub", feature = "eventsub"))]
347#[allow(dead_code)]
348/// Deserialize 'null' as <T as Default>::Default
349fn deserialize_default_from_null<'de, D, T>(deserializer: D) -> Result<T, D::Error>
350where
351    D: serde::Deserializer<'de>,
352    T: serde::Deserialize<'de> + Default, {
353    use serde::Deserialize;
354    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
355}
356
357#[cfg(any(feature = "helix", feature = "eventsub"))]
358#[allow(dead_code)]
359/// Deserialize "" as <T as Default>::Default
360fn deserialize_none_from_empty_string<'de, D, S>(deserializer: D) -> Result<Option<S>, D::Error>
361where
362    D: serde::Deserializer<'de>,
363    S: serde::Deserialize<'de>, {
364    use serde::de::IntoDeserializer;
365    struct Inner<S>(std::marker::PhantomData<S>);
366    impl<'de, S> serde::de::Visitor<'de> for Inner<S>
367    where S: serde::Deserialize<'de>
368    {
369        type Value = Option<S>;
370
371        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372            formatter.write_str("any string")
373        }
374
375        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
376        where E: serde::de::Error {
377            match value {
378                "" => Ok(None),
379                v => S::deserialize(v.into_deserializer()).map(Some),
380            }
381        }
382
383        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
384        where E: serde::de::Error {
385            match &*value {
386                "" => Ok(None),
387                v => S::deserialize(v.into_deserializer()).map(Some),
388            }
389        }
390
391        fn visit_unit<E>(self) -> Result<Self::Value, E>
392        where E: serde::de::Error {
393            Ok(None)
394        }
395    }
396
397    deserializer.deserialize_any(Inner(std::marker::PhantomData))
398}
399
400/// Helper functions for tests
401#[cfg(test)]
402pub mod tests {
403    /// Checks that `val` can be serialized and deserialized to `T` with JSON and CBOR.
404    ///
405    /// In pseudocode, this tests `deserialize(serialize(val))`.
406    #[track_caller]
407    pub fn roundtrip<T: serde::de::DeserializeOwned + serde::Serialize>(val: &T) {
408        serde_json::from_slice::<T>(&serde_json::to_vec(val).expect("could not make into json"))
409            .expect("could not convert back from json");
410        serde_cbor::from_slice::<T>(
411            &serde_cbor::to_vec(val).expect("could not make into cbor bytes"),
412        )
413        .expect("could not convert back from cbor");
414    }
415}