tiktokapi-v2 0.5.0

TikTok API v2 library.
Documentation
use crate::{
    error::{Error, OAuthError},
    options::{apply_options, make_url, TiktokOptions},
};
use base64::prelude::{Engine as _, BASE64_URL_SAFE_NO_PAD};
use itertools::Itertools;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use rand::Rng;
use reqwest::header::CACHE_CONTROL;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

const AUTH_URL: &str = "https://www.tiktok.com/v2/auth/authorize/";
const TOKEN_URL: &str = "https://open.tiktokapis.com/v2/oauth/token/";
const REVOKE_URL: &str = "https://open.tiktokapis.com/v2/oauth/revoke/";

pub enum TiktokScope {
    ResearchAdlibBasic,
    ResearchDataBasic,
    UserInfoBasic,
    UserInfoProfile,
    UserInfoStats,
    VideoList,
    VideoPublish,
    VideoUpload,
}

impl TiktokScope {
    pub fn all() -> Vec<Self> {
        vec![
            Self::ResearchAdlibBasic,
            Self::ResearchDataBasic,
            Self::UserInfoBasic,
            Self::UserInfoProfile,
            Self::UserInfoStats,
            Self::VideoList,
            Self::VideoPublish,
            Self::VideoUpload,
        ]
    }
}

impl std::fmt::Display for TiktokScope {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::ResearchAdlibBasic => write!(f, "research.adlib.basic"),
            Self::ResearchDataBasic => write!(f, "research.data.basic"),
            Self::UserInfoBasic => write!(f, "user.info.basic"),
            Self::UserInfoProfile => write!(f, "user.info.profile"),
            Self::UserInfoStats => write!(f, "user.info.stats"),
            Self::VideoList => write!(f, "video.list"),
            Self::VideoPublish => write!(f, "video.publish"),
            Self::VideoUpload => write!(f, "video.upload"),
        }
    }
}

#[derive(Debug, Clone)]
pub struct OAuthUrlResult {
    pub oauth_url: String,
    pub csrf_token: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResult {
    pub open_id: String,
    pub scope: String,
    pub access_token: String,
    pub expires_in: u64,
    pub refresh_token: String,
    pub refresh_expires_in: u64,
    pub token_type: String,
}

pub struct TiktokOauth {
    scopes: Vec<TiktokScope>,
    client_key: String,
    client_secret: String,
    callback_url: String,
    options: Option<TiktokOptions>,
}

impl TiktokOauth {
    pub fn new(
        client_key: &str,
        client_secret: &str,
        callback_url: &str,
        scopes: Vec<TiktokScope>,
    ) -> Self {
        Self::new_with_options(client_key, client_secret, callback_url, scopes, None)
    }

    pub fn new_with_options(
        client_key: &str,
        client_secret: &str,
        callback_url: &str,
        scopes: Vec<TiktokScope>,
        options: Option<TiktokOptions>,
    ) -> Self {
        Self {
            callback_url: callback_url.to_owned(),
            scopes,
            client_key: client_key.to_owned(),
            client_secret: client_secret.to_owned(),
            options,
        }
    }

    pub fn oauth_url(&self, state: Option<String>) -> OAuthUrlResult {
        let csrf_token = state.unwrap_or(csrf_token());
        let scope = self.scopes.iter().map(|it| it.to_string()).join(",");
        let redirect_uri = utf8_percent_encode(&self.callback_url, NON_ALPHANUMERIC);
        let oauth_url = format!(
            "{}?client_key={}&response_type=code&scope={}&redirect_uri={}&state={}",
            AUTH_URL, self.client_key, scope, redirect_uri, csrf_token
        );
        OAuthUrlResult {
            oauth_url,
            csrf_token,
        }
    }

    pub async fn token(&self, code: &str) -> Result<TokenResult, Error> {
        let mut form = HashMap::new();
        form.insert("client_key", self.client_key.as_str());
        form.insert("client_secret", self.client_secret.as_str());
        form.insert("grant_type", "authorization_code");
        form.insert("code", code);
        form.insert("redirect_uri", self.callback_url.as_str());
        execute_token(form, &self.options).await
    }

    pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResult, Error> {
        let mut form = HashMap::new();
        form.insert("client_key", self.client_key.as_str());
        form.insert("client_secret", self.client_secret.as_str());
        form.insert("grant_type", "refresh_token");
        form.insert("refresh_token", refresh_token);
        execute_token(form, &self.options).await
    }

    pub async fn revoke(&self, access_token: &str) -> Result<(), Error> {
        let mut form = HashMap::new();
        form.insert("client_key", self.client_key.as_str());
        form.insert("client_secret", self.client_secret.as_str());
        form.insert("token", access_token);
        let response = execute_send(REVOKE_URL, &form, &self.options).await?;
        let status_code = response.status();
        if status_code.is_success() {
            Ok(())
        } else {
            let json = response.json().await?;
            let token_error: OAuthError = serde_json::from_value(json)?;
            Err(Error::OAuth(token_error, status_code))
        }
    }
}

async fn execute_send(
    url: &str,
    form: &HashMap<&str, &str>,
    options: &Option<TiktokOptions>,
) -> Result<reqwest::Response, reqwest::Error> {
    let builder = reqwest::Client::new()
        .post(make_url(url, options))
        .header(CACHE_CONTROL, "no-cache")
        .form(form);
    apply_options(builder, options).send().await
}

async fn execute_token(
    form: HashMap<&str, &str>,
    options: &Option<TiktokOptions>,
) -> Result<TokenResult, Error> {
    let response = execute_send(TOKEN_URL, &form, options).await?;
    let status_code = response.status();
    let json = response.json().await?;
    if status_code.is_success() {
        let token_result: TokenResult = serde_json::from_value(json)?;
        Ok(token_result)
    } else {
        let token_error: OAuthError = serde_json::from_value(json)?;
        Err(Error::OAuth(token_error, status_code))
    }
}

fn csrf_token() -> String {
    let random_bytes: Vec<u8> = (0..16).map(|_| rand::thread_rng().gen::<u8>()).collect();
    BASE64_URL_SAFE_NO_PAD.encode(random_bytes)
}