use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Endpoints {
pub api: String,
pub id: String,
pub website: String,
pub realtime: String,
}
impl Default for Endpoints {
fn default() -> Self {
Self {
api: "https://api.kick.com/public/v1".into(),
id: "https://id.kick.com".into(),
website: "https://kick.com".into(),
realtime: "https://web.kick.com/api/v1/realtime".into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Method {
Get,
Post,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub method: Method,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
}
impl Request {
pub(crate) fn new(method: Method, url: String) -> Self {
Self { method, url, headers: vec![("accept".into(), "application/json".into())], body: None }
}
pub(crate) fn header(mut self, name: &str, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub(crate) fn bearer(self, token: &str) -> Self {
self.header("authorization", format!("Bearer {token}"))
}
pub(crate) fn json(mut self, body: &serde_json::Value) -> Self {
self.headers.push(("content-type".into(), "application/json".into()));
self.body = Some(body.to_string());
self
}
pub(crate) fn form(mut self, pairs: &[(&str, &str)]) -> Self {
self.headers.push(("content-type".into(), "application/x-www-form-urlencoded".into()));
self.body =
Some(pairs.iter().map(|(k, v)| format!("{}={}", encode(k), encode(v))).collect::<Vec<_>>().join("&"));
self
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("Kick answered {status}")]
Status {
status: u16,
excerpt: String,
},
#[error("Kick's answer was not JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("Kick's answer had an unexpected shape: {0}")]
Shape(String),
#[error("invalid request: {0}")]
Invalid(String),
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Error::Status { status, .. } => Some(*status),
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
self.status() == Some(429)
}
pub(crate) fn shape(what: impl Into<String>) -> Self {
Error::Shape(what.into())
}
}
pub(crate) fn json(status: u16, body: &str) -> Result<serde_json::Value, Error> {
if !(200..300).contains(&status) {
return Err(Error::Status { status, excerpt: body.chars().take(200).collect() });
}
Ok(serde_json::from_str(body)?)
}
pub(crate) fn data(status: u16, body: &str) -> Result<serde_json::Value, Error> {
match json(status, body)? {
serde_json::Value::Object(mut map) => map.remove("data").ok_or_else(|| Error::shape("no `data` in the answer")),
_ => Err(Error::shape("the answer is not an object")),
}
}
pub(crate) fn encode(s: &str) -> String {
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
const SET: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
utf8_percent_encode(s, SET).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_say_what_went_wrong() {
let e = json(429, "slow down").unwrap_err();
assert!(e.is_rate_limited());
assert_eq!(e.status(), Some(429));
assert!(matches!(data(200, "[]"), Err(Error::Shape(_))));
assert!(matches!(data(200, "{\"message\":\"OK\"}"), Err(Error::Shape(_))));
assert!(matches!(json(200, "<html>"), Err(Error::Json(_))));
}
#[test]
fn segments_are_encoded() {
assert_eq!(encode("a b/c?d"), "a%20b%2Fc%3Fd");
assert_eq!(encode("user_01.x-y~"), "user_01.x-y~");
}
}