Skip to main content

psyche_subtitle_toolkit/translation/
deepl.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{Result, SubtitleToolkitError};
4
5use super::{TranslationRequest, Translator};
6
7/// Translator backend that calls the [DeepL](https://www.deepl.com) `/v2/translate` endpoint.
8///
9/// Supports both the free tier (`https://api-free.deepl.com`) and the pro tier
10/// (`https://api.deepl.com`). The default base URL targets the free tier.
11///
12/// # Example
13///
14/// ```no_run
15/// # async fn example() -> psyche_subtitle_toolkit::Result<()> {
16/// use psyche_subtitle_toolkit::DeepLTranslator;
17///
18/// let translator = DeepLTranslator::new("your-api-key")?;
19/// // let result = translator.translate(request).await?;
20/// # Ok(())
21/// # }
22/// ```
23#[derive(Debug, Clone)]
24pub struct DeepLTranslator {
25    client: reqwest::Client,
26    base_url: String,
27    api_key: String,
28}
29
30impl DeepLTranslator {
31    /// Create a new translator targeting the DeepL free API (`https://api-free.deepl.com`).
32    pub fn new(api_key: impl Into<String>) -> Result<Self> {
33        Self::with_base_url("https://api-free.deepl.com", api_key)
34    }
35
36    /// Create a new translator with a custom base URL.
37    ///
38    /// Use `"https://api.deepl.com"` for the pro tier, or
39    /// `"https://api-free.deepl.com"` for the free tier (default).
40    pub fn with_base_url(
41        base_url: impl Into<String>,
42        api_key: impl Into<String>,
43    ) -> Result<Self> {
44        let client = reqwest::Client::builder()
45            .timeout(std::time::Duration::from_secs(120))
46            .build()
47            .map_err(SubtitleToolkitError::Http)?;
48        Ok(Self {
49            client,
50            base_url: base_url.into().trim_end_matches('/').to_string(),
51            api_key: api_key.into(),
52        })
53    }
54}
55
56#[async_trait::async_trait]
57impl Translator for DeepLTranslator {
58    async fn translate(&self, request: TranslationRequest<'_>) -> Result<String> {
59        let response = self
60            .client
61            .post(format!("{}/v2/translate", self.base_url))
62            .header(
63                "Authorization",
64                format!("DeepL-Auth-Key {}", self.api_key),
65            )
66            .json(&DeepLTranslateRequest {
67                text: request.source_text.lines().collect(),
68                target_lang: &request.target_language.to_uppercase(),
69                split_sentences: "0",
70                preserve_formatting: true,
71            })
72            .send()
73            .await?;
74
75        if !response.status().is_success() {
76            return Err(SubtitleToolkitError::Translation {
77                provider: "deepl",
78                message: response
79                    .text()
80                    .await
81                    .unwrap_or_else(|_| "request failed".into()),
82            });
83        }
84
85        let body = response.json::<DeepLTranslateResponse>().await?;
86        if body.translations.is_empty() {
87            return Err(SubtitleToolkitError::Translation {
88                provider: "deepl",
89                message: "response contained no translations".into(),
90            });
91        }
92        let translated = body
93            .translations
94            .into_iter()
95            .map(|t| t.text)
96            .collect::<Vec<_>>()
97            .join("\n");
98
99        Ok(translated)
100    }
101}
102
103#[derive(Debug, Serialize)]
104struct DeepLTranslateRequest<'a> {
105    text: Vec<&'a str>,
106    target_lang: &'a str,
107    split_sentences: &'static str,
108    preserve_formatting: bool,
109}
110
111#[derive(Debug, Deserialize)]
112struct DeepLTranslateResponse {
113    translations: Vec<DeepLTranslation>,
114}
115
116#[derive(Debug, Deserialize)]
117struct DeepLTranslation {
118    text: String,
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use wiremock::matchers::{header, method, path};
125    use wiremock::{Mock, MockServer, ResponseTemplate};
126
127    #[tokio::test]
128    async fn translates_numbered_text() {
129        let server = MockServer::start().await;
130
131        Mock::given(method("POST"))
132            .and(path("/v2/translate"))
133            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
134                "translations": [{ "text": "<1> Olá" }, { "text": "<2> mundo" }]
135            })))
136            .mount(&server)
137            .await;
138
139        let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
140        let result = translator
141            .translate(TranslationRequest {
142                source_text: "<1> hello\n<2> world",
143                target_language: "pt-BR",
144            })
145            .await
146            .unwrap();
147
148        assert_eq!(result, "<1> Olá\n<2> mundo");
149    }
150
151    #[tokio::test]
152    async fn sends_deepl_auth_header() {
153        let server = MockServer::start().await;
154
155        Mock::given(method("POST"))
156            .and(header("Authorization", "DeepL-Auth-Key my-secret-key"))
157            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
158                "translations": [{ "text": "<1> ok" }]
159            })))
160            .mount(&server)
161            .await;
162
163        let translator = DeepLTranslator::with_base_url(server.uri(), "my-secret-key").unwrap();
164        translator
165            .translate(TranslationRequest {
166                source_text: "<1> test",
167                target_language: "de",
168            })
169            .await
170            .unwrap();
171    }
172
173    #[tokio::test]
174    async fn uppercases_target_language() {
175        let server = MockServer::start().await;
176
177        Mock::given(method("POST"))
178            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
179                "translations": [{ "text": "<1> ok" }]
180            })))
181            .mount(&server)
182            .await;
183
184        let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
185        // "pt-BR" should be sent as "PT-BR" in the request body
186        translator
187            .translate(TranslationRequest {
188                source_text: "<1> test",
189                target_language: "pt-BR",
190            })
191            .await
192            .unwrap();
193    }
194
195    #[tokio::test]
196    async fn error_on_non_200() {
197        let server = MockServer::start().await;
198
199        Mock::given(method("POST"))
200            .respond_with(
201                ResponseTemplate::new(403).set_body_string("Quota exceeded"),
202            )
203            .mount(&server)
204            .await;
205
206        let translator = DeepLTranslator::with_base_url(server.uri(), "bad-key").unwrap();
207        let err = translator
208            .translate(TranslationRequest {
209                source_text: "<1> hello",
210                target_language: "de",
211            })
212            .await
213            .unwrap_err();
214
215        assert!(err.to_string().contains("deepl"));
216        assert!(err.to_string().contains("Quota exceeded"));
217    }
218
219    #[tokio::test]
220    async fn error_on_empty_translations() {
221        let server = MockServer::start().await;
222
223        Mock::given(method("POST"))
224            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
225                "translations": []
226            })))
227            .mount(&server)
228            .await;
229
230        let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
231        let err = translator
232            .translate(TranslationRequest {
233                source_text: "<1> hello",
234                target_language: "de",
235            })
236            .await
237            .unwrap_err();
238
239        assert!(err.to_string().contains("no translations"));
240    }
241
242    #[tokio::test]
243    async fn sends_each_line_as_separate_array_element() {
244        let server = MockServer::start().await;
245
246        Mock::given(method("POST"))
247            .and(path("/v2/translate"))
248            .and(wiremock::matchers::body_string_contains(r#""<1> hello""#))
249            .and(wiremock::matchers::body_string_contains(r#""<2> world""#))
250            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
251                "translations": [{ "text": "<1> Olá" }, { "text": "<2> mundo" }]
252            })))
253            .expect(1)
254            .mount(&server)
255            .await;
256
257        let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
258        translator
259            .translate(TranslationRequest {
260                source_text: "<1> hello\n<2> world",
261                target_language: "pt-BR",
262            })
263            .await
264            .unwrap();
265    }
266
267    #[tokio::test]
268    async fn joins_translated_lines_back() {
269        let server = MockServer::start().await;
270
271        Mock::given(method("POST"))
272            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
273                "translations": [
274                    { "text": "<1> Zeile eins" },
275                    { "text": "<2> Zeile zwei" },
276                    { "text": "<3> Zeile drei" }
277                ]
278            })))
279            .mount(&server)
280            .await;
281
282        let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
283        let result = translator
284            .translate(TranslationRequest {
285                source_text: "<1> Line one\n<2> Line two\n<3> Line three",
286                target_language: "de",
287            })
288            .await
289            .unwrap();
290
291        assert_eq!(result, "<1> Zeile eins\n<2> Zeile zwei\n<3> Zeile drei");
292    }
293}