concord 2.6.0

A terminal user interface client for Discord
use serde::{Deserialize, Serialize};

use super::{
    TranslationError, TranslationFuture, TranslationProvider, TranslationRequest,
    TranslationResult, parse_endpoint,
};

const PROVIDER: &str = "DeepL";

pub(super) struct DeepLProvider {
    http: reqwest::Client,
    endpoint: reqwest::Url,
    api_key: String,
}

impl DeepLProvider {
    pub(super) fn new(
        http: reqwest::Client,
        endpoint: &str,
        api_key: String,
    ) -> Result<Self, TranslationError> {
        Ok(Self {
            http,
            endpoint: parse_endpoint(PROVIDER, endpoint)?,
            api_key,
        })
    }

    fn request(&self, request: TranslationRequest<'_>) -> reqwest::RequestBuilder {
        self.http
            .post(self.endpoint.clone())
            .header(
                reqwest::header::AUTHORIZATION,
                format!("DeepL-Auth-Key {}", self.api_key),
            )
            .json(&DeepLRequest {
                text: [request.text],
                target_lang: request.target_language.to_ascii_uppercase(),
            })
    }
}

impl TranslationProvider for DeepLProvider {
    fn translate<'a>(&'a self, request: TranslationRequest<'a>) -> TranslationFuture<'a> {
        Box::pin(async move {
            let response =
                self.request(request)
                    .send()
                    .await
                    .map_err(|source| TranslationError::Request {
                        provider: PROVIDER,
                        source,
                    })?;
            if !response.status().is_success() {
                return Err(TranslationError::Http {
                    provider: PROVIDER,
                    status: response.status(),
                });
            }
            let response = response.json::<DeepLResponse>().await.map_err(|source| {
                TranslationError::InvalidResponse {
                    provider: PROVIDER,
                    source,
                }
            })?;
            let translation = response
                .translations
                .into_iter()
                .next()
                .ok_or(TranslationError::EmptyResponse { provider: PROVIDER })?;
            if translation.text.trim().is_empty() {
                return Err(TranslationError::EmptyResponse { provider: PROVIDER });
            }
            Ok(TranslationResult {
                text: translation.text,
            })
        })
    }
}

#[derive(Serialize)]
struct DeepLRequest<'a> {
    text: [&'a str; 1],
    target_lang: String,
}

#[derive(Deserialize)]
struct DeepLResponse {
    translations: Vec<DeepLTranslation>,
}

#[derive(Deserialize)]
struct DeepLTranslation {
    text: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn request_and_response_follow_deepl_contract() {
        let _ = rustls::crypto::ring::default_provider().install_default();
        let provider = DeepLProvider::new(
            reqwest::Client::new(),
            "https://api-free.deepl.com/v2/translate",
            "secret".to_owned(),
        )
        .expect("test endpoint is valid");

        let request = provider
            .request(TranslationRequest {
                text: "hello",
                target_language: "ko",
            })
            .build()
            .expect("request should build");

        assert_eq!(
            request.headers()[reqwest::header::AUTHORIZATION],
            "DeepL-Auth-Key secret"
        );
        let body: serde_json::Value = serde_json::from_slice(
            request
                .body()
                .and_then(reqwest::Body::as_bytes)
                .expect("JSON body"),
        )
        .expect("body should be JSON");
        assert_eq!(
            body,
            serde_json::json!({ "text": ["hello"], "target_lang": "KO" })
        );

        let response: DeepLResponse = serde_json::from_value(serde_json::json!({
            "translations": [{ "detected_source_language": "EN", "text": "안녕하세요" }]
        }))
        .expect("response should parse");

        assert_eq!(response.translations[0].text, "안녕하세요");
    }
}