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}