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