languagetool_rust/api/
server.rs

1//! Structure to communicate with some `LanguageTool` server through the API.
2
3#[cfg(feature = "multithreaded")]
4use crate::api::check;
5use crate::{
6    api::{
7        check::{Request, Response},
8        languages, words,
9    },
10    error::{Error, Result},
11};
12#[cfg(feature = "cli")]
13use clap::Args;
14#[cfg(feature = "multithreaded")]
15use lifetime::IntoStatic;
16use reqwest::{
17    header::{HeaderValue, ACCEPT},
18    Client,
19};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::{io, path::PathBuf, time::Instant};
23
24/// Parse `v` if valid port.
25///
26/// A valid port is either
27/// - an empty string
28/// - a 4 chars long string with each char in [0-9]
29///
30/// # Examples
31///
32/// ```
33/// # use languagetool_rust::api::server::parse_port;
34/// assert!(parse_port("8081").is_ok());
35///
36/// assert!(parse_port("").is_ok()); // No port specified, which is accepted
37///
38/// assert!(parse_port("abcd").is_err());
39/// ```
40pub fn parse_port(v: &str) -> Result<String> {
41    if v.is_empty() || (v.len() == 4 && v.chars().all(char::is_numeric)) {
42        return Ok(v.to_string());
43    }
44    Err(Error::InvalidValue(
45        "The value should be a 4 characters long string with digits only".to_string(),
46    ))
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
50#[serde(rename_all = "camelCase")]
51#[non_exhaustive]
52/// A Java property file (one `key = value` entry per line) with values listed
53/// below.
54pub struct ConfigFile {
55    /// Maximum text length, longer texts will cause an error (optional).
56    pub max_text_length: Option<isize>,
57    /// Maximum text length, applies even to users with a special secret 'token'
58    /// parameter (optional).
59    pub max_text_hard_length: Option<isize>,
60    /// Secret JWT token key, if set by user and valid, maxTextLength can be
61    /// increased by the user (optional).
62    pub secret_token_key: Option<isize>,
63    /// Maximum time in milliseconds allowed per check (optional).
64    pub max_check_time_millis: Option<isize>,
65    /// Checking will stop with error if there are more rules matches per word
66    /// (optional).
67    pub max_errors_per_word_rate: Option<isize>,
68    /// Only this many spelling errors will have suggestions for performance
69    /// reasons (optional, affects Hunspell-based languages only).
70    pub max_spelling_suggestions: Option<isize>,
71    /// Maximum number of threads working in parallel (optional).
72    pub max_check_threads: Option<isize>,
73    /// Size of internal cache in number of sentences (optional, default: 0).
74    pub cache_size: Option<isize>,
75    /// How many seconds sentences are kept in cache (optional, default: 300 if
76    /// 'cacheSize' is set).
77    pub cache_ttl_seconds: Option<isize>,
78    /// Maximum number of requests per requestLimitPeriodInSeconds (optional).
79    pub request_limit: Option<isize>,
80    /// Maximum aggregated size of requests per requestLimitPeriodInSeconds
81    /// (optional).
82    pub request_limit_in_bytes: Option<isize>,
83    /// Maximum number of timeout request (optional).
84    pub timeout_request_limit: Option<isize>,
85    /// Time period to which requestLimit and timeoutRequestLimit applies
86    /// (optional).
87    pub request_limit_period_in_seconds: Option<isize>,
88    /// A directory with '1grams', '2grams', '3grams' sub directories which
89    /// contain a Lucene index each with ngram occurrence counts; activates the
90    /// confusion rule if supported (optional).
91    pub language_model: Option<PathBuf>,
92    /// A directory with word2vec data (optional), see <https://github.com/languagetool-org/languagetool/blob/master/languagetool-standalone/CHANGES.md#word2vec>.
93    pub word2vec_model: Option<PathBuf>,
94    /// A model file for better language detection (optional), see
95    /// <https://fasttext.cc/docs/en/language-identification.html>.
96    pub fasttext_model: Option<PathBuf>,
97    /// Compiled fasttext executable for language detection (optional), see
98    /// <https://fasttext.cc/docs/en/support.html>.
99    pub fasttext_binary: Option<PathBuf>,
100    /// Reject request if request queue gets larger than this (optional).
101    pub max_work_queue_size: Option<isize>,
102    /// A file containing rules configuration, such as .langugagetool.cfg
103    /// (optional).
104    pub rules_file: Option<PathBuf>,
105    /// Set to 'true' to warm up server at start, i.e. run a short check with
106    /// all languages (optional).
107    pub warm_up: Option<bool>,
108    /// A comma-separated list of HTTP referrers (and 'Origin' headers) that are
109    /// blocked and will not be served (optional).
110    pub blocked_referrers: Option<Vec<String>>,
111    /// Activate only the premium rules (optional).
112    pub premium_only: Option<bool>,
113    /// A comma-separated list of rule ids that are turned off for this server
114    /// (optional).
115    pub disable_rule_ids: Option<Vec<String>>,
116    /// Set to 'true' to enable caching of internal pipelines to improve
117    /// performance.
118    pub pipeline_caching: Option<bool>,
119    /// Cache size if 'pipelineCaching' is set.
120    pub max_pipeline_pool_size: Option<isize>,
121    /// Time after which pipeline cache items expire.
122    pub pipeline_expire_time_in_seconds: Option<isize>,
123    /// Set to 'true' to fill pipeline cache on start (can slow down start a
124    /// lot).
125    pub pipeline_prewarming: Option<bool>,
126    /// Spellcheck-only languages: You can add simple spellcheck-only support
127    /// for languages that LT doesn't support by defining two optional
128    /// properties:
129    ///
130    /// * 'lang-xx' - set name of the language, use language code instead of
131    ///   'xx', e.g. lang-tr=Turkish;
132    ///
133    /// * 'lang-xx-dictPath' - absolute path to the hunspell .dic file, use
134    ///   language code instead of 'xx', e.g. lang-tr-dictPath=/path/to/tr.dic.
135    ///   Note that the same directory also needs to contain a common_words.txt
136    ///   file with the most common 10,000 words (used for better language
137    ///   detection).
138    pub spellcheck_only: Option<std::collections::HashMap<String, String>>,
139}
140
141impl ConfigFile {
142    /// Write the config file in a `key = value` format.
143    pub fn write_to<T: io::Write>(&self, w: &mut T) -> io::Result<()> {
144        let json = serde_json::to_value(self.clone()).unwrap();
145        let m = json.as_object().unwrap();
146        for (key, value) in m.iter() {
147            match value {
148                Value::Bool(b) => writeln!(w, "{key}={b}")?,
149                Value::Number(n) => writeln!(w, "{key}={n}")?,
150                Value::String(s) => writeln!(w, "{key}=\"{s}\"")?,
151                Value::Array(a) => {
152                    writeln!(
153                        w,
154                        "{}=\"{}\"",
155                        key,
156                        a.iter()
157                            .map(std::string::ToString::to_string)
158                            .collect::<Vec<String>>()
159                            .join(",")
160                    )?
161                },
162                Value::Object(o) => {
163                    for (key, value) in o.iter() {
164                        writeln!(w, "{key}=\"{value}\"")?
165                    }
166                },
167                Value::Null => writeln!(w, "# {key}=")?,
168            }
169        }
170        Ok(())
171    }
172}
173
174impl Default for ConfigFile {
175    fn default() -> Self {
176        Self {
177            max_text_length: None,
178            max_text_hard_length: None,
179            secret_token_key: None,
180            max_check_time_millis: None,
181            max_errors_per_word_rate: None,
182            max_spelling_suggestions: None,
183            max_check_threads: None,
184            cache_size: Some(0),
185            cache_ttl_seconds: Some(300),
186            request_limit: None,
187            request_limit_in_bytes: None,
188            timeout_request_limit: None,
189            request_limit_period_in_seconds: None,
190            language_model: None,
191            word2vec_model: None,
192            fasttext_model: None,
193            fasttext_binary: None,
194            max_work_queue_size: None,
195            rules_file: None,
196            warm_up: None,
197            blocked_referrers: None,
198            premium_only: None,
199            disable_rule_ids: None,
200            pipeline_caching: None,
201            max_pipeline_pool_size: None,
202            pipeline_expire_time_in_seconds: None,
203            pipeline_prewarming: None,
204            spellcheck_only: None,
205        }
206    }
207}
208
209/// Server parameters that are to be used when instantiating a `LanguageTool`
210/// server.
211#[cfg_attr(feature = "cli", derive(Args))]
212#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
213#[non_exhaustive]
214pub struct ServerParameters {
215    /// A Java property file (one `key = value` entry per line) with values
216    /// listed in [`ConfigFile`].
217    #[cfg_attr(feature = "cli", clap(long))]
218    config: Option<PathBuf>,
219    /// Port to bind to, defaults to 8081 if not specified.
220    #[cfg_attr(feature = "cli", clap(short = 'p', long, name = "PRT", default_value = "8081", value_parser = parse_port))]
221    port: String,
222    /// Allow this server process to be connected from anywhere; if not set, it
223    /// can only be connected from the computer it was started on.
224    #[cfg_attr(feature = "cli", clap(long))]
225    public: bool,
226    /// set the Access-Control-Allow-Origin header in the HTTP response, used
227    /// for direct (non-proxy) JavaScript-based access from browsers. Example: --allow-origin "https://my-website.org".
228    /// Don't set a parameter for `*`, i.e. access from all websites.
229    #[cfg_attr(feature = "cli", clap(long, name = "ORIGIN"))]
230    #[allow(rustdoc::bare_urls)]
231    allow_origin: Option<String>,
232    /// In case of exceptions, log the input text (up to 500 characters).
233    #[cfg_attr(feature = "cli", clap(short = 'v', long))]
234    verbose: bool,
235    /// A directory with '1grams', '2grams', '3grams' sub directories (per
236    /// language) which contain a Lucene index (optional, overwrites
237    /// 'languageModel' parameter in properties files).
238    #[cfg_attr(feature = "cli", clap(long))]
239    #[serde(rename = "languageModel")]
240    language_model: Option<PathBuf>,
241    /// A directory with word2vec data (optional), see <https://github.com/languagetool-org/languagetool/blob/master/languagetool-standalone/CHANGES.md#word2vec>.
242    #[cfg_attr(feature = "cli", clap(long))]
243    #[serde(rename = "word2vecModel")]
244    word2vec_model: Option<PathBuf>,
245    /// Activate the premium rules even when user has no username/password -
246    /// useful for API servers.
247    #[cfg_attr(feature = "cli", clap(long))]
248    #[serde(rename = "premiumAlways")]
249    premium_always: bool,
250}
251
252impl Default for ServerParameters {
253    fn default() -> Self {
254        Self {
255            config: None,
256            port: "8081".to_string(),
257            public: false,
258            allow_origin: None,
259            verbose: false,
260            language_model: None,
261            word2vec_model: None,
262            premium_always: false,
263        }
264    }
265}
266
267/// Hostname and (optional) port to connect to a `LanguageTool` server.
268///
269/// To use your local server instead of online api, set:
270/// * `hostname` to "http://localhost"
271/// * `port` to "8081"
272///
273/// if you used the default configuration to start the server.
274#[cfg_attr(feature = "cli", derive(Args))]
275#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
276pub struct ServerCli {
277    /// Server's hostname.
278    #[cfg_attr(
279        feature = "cli",
280        clap(
281            long,
282            default_value = "https://api.languagetoolplus.com",
283            env = "LANGUAGETOOL_HOSTNAME",
284        )
285    )]
286    pub hostname: String,
287    /// Server's port number, with the empty string referring to no specific
288    /// port.
289    #[cfg_attr(feature = "cli", clap(short = 'p', long, name = "PRT", default_value = "", value_parser = parse_port, env = "LANGUAGETOOL_PORT"))]
290    pub port: String,
291}
292
293impl Default for ServerCli {
294    fn default() -> Self {
295        Self {
296            hostname: "https://api.languagetoolplus.com".to_string(),
297            port: "".to_string(),
298        }
299    }
300}
301
302impl ServerCli {
303    /// Create a new [`ServerCli`] instance from environ variables:
304    /// - `LANGUAGETOOL_HOSTNAME`
305    /// - `LANGUAGETOOL_PORT`
306    ///
307    /// If one or both environ variables are empty, an error is returned.
308    pub fn from_env() -> Result<Self> {
309        let hostname = std::env::var("LANGUAGETOOL_HOSTNAME")?;
310        let port = std::env::var("LANGUAGETOOL_PORT")?;
311
312        Ok(Self { hostname, port })
313    }
314
315    /// Create a new [`ServerCli`] instance from environ variables,
316    /// but defaults to [`ServerCli::default`()] if expected environ
317    /// variables are not set.
318    #[must_use]
319    pub fn from_env_or_default() -> Self {
320        ServerCli::from_env().unwrap_or_default()
321    }
322}
323
324/// Client to communicate with the `LanguageTool` server using async requests.
325#[derive(Clone, Debug)]
326pub struct ServerClient {
327    /// API string: hostname and, optionally, port number (see [`ServerCli`]).
328    pub api: String,
329    /// Reqwest client that can send requests to the server.
330    pub client: Client,
331    max_suggestions: isize,
332}
333
334impl From<ServerCli> for ServerClient {
335    #[inline]
336    fn from(cli: ServerCli) -> Self {
337        Self::new(cli.hostname.as_str(), cli.port.as_str())
338    }
339}
340
341impl ServerClient {
342    /// Construct a new server client using hostname and (optional) port
343    ///
344    /// An empty string is accepted as empty port.
345    /// For port validation, please use [`parse_port`] as this constructor does
346    /// not check anything.
347    #[must_use]
348    pub fn new(hostname: &str, port: &str) -> Self {
349        let api = if port.is_empty() {
350            format!("{hostname}/v2")
351        } else {
352            format!("{hostname}:{port}/v2")
353        };
354        let client = Client::new();
355        Self {
356            api,
357            client,
358            max_suggestions: -1,
359        }
360    }
361
362    /// Set the maximum number of suggestions (defaults to -1), a negative
363    /// number will keep all replacement suggestions.
364    #[must_use]
365    pub fn with_max_suggestions(mut self, max_suggestions: isize) -> Self {
366        self.max_suggestions = max_suggestions;
367        self
368    }
369
370    /// Convert a [`ServerCli`] into a proper (usable) client.
371    #[must_use]
372    pub fn from_cli(cli: ServerCli) -> Self {
373        cli.into()
374    }
375
376    /// Send a check request to the server and await for the response.
377    pub async fn check(&self, request: &Request<'_>) -> Result<Response> {
378        let resp = self
379            .client
380            .post(format!("{0}/check", self.api))
381            .header(ACCEPT, HeaderValue::from_static("application/json"))
382            .form(request)
383            .send()
384            .await
385            .map_err(Error::Reqwest)?;
386
387        match resp.error_for_status_ref() {
388            Ok(_) => {
389                resp.json::<Response>()
390                    .await
391                    .map_err(Into::into)
392                    .map(|mut resp| {
393                        if self.max_suggestions > 0 {
394                            let max = self.max_suggestions as usize;
395                            resp.matches.iter_mut().for_each(|m| {
396                                let len = m.replacements.len();
397                                if max < len {
398                                    m.replacements[max] =
399                                        format!("... ({} not shown)", len - max).into();
400                                    m.replacements.truncate(max + 1);
401                                }
402                            });
403                        }
404                        resp
405                    })
406            },
407            Err(_) => Err(Error::InvalidRequest(resp.text().await?)),
408        }
409    }
410
411    /// Send multiple check requests and join them into a single response.
412    ///
413    /// # Error
414    ///
415    /// If any of the requests has `self.text` field which is none, or
416    /// if zero request is provided.
417    #[cfg(feature = "multithreaded")]
418    pub async fn check_multiple_and_join<'source>(
419        &self,
420        requests: Vec<Request<'source>>,
421    ) -> Result<check::ResponseWithContext<'source>> {
422        use std::borrow::Cow;
423
424        if requests.is_empty() {
425            return Err(Error::InvalidRequest(
426                "no request; cannot join zero request".to_string(),
427            ));
428        }
429
430        let tasks = requests
431            .into_iter()
432            .map(|r| r.into_static())
433            .map(|request| {
434                let server_client = self.clone();
435
436                tokio::spawn(async move {
437                    let response = server_client.check(&request).await?;
438                    let text = request.text.ok_or_else(|| {
439                        Error::InvalidRequest(
440                            "missing text field; cannot join requests with data annotations"
441                                .to_string(),
442                        )
443                    })?;
444                    Result::<(Cow<'static, str>, Response)>::Ok((text, response))
445                })
446            });
447
448        let mut response_with_context: Option<check::ResponseWithContext> = None;
449
450        for task in tasks {
451            let (text, response) = task.await.unwrap()?;
452
453            response_with_context = Some(match response_with_context {
454                Some(resp) => resp.append(check::ResponseWithContext::new(text, response)),
455                None => check::ResponseWithContext::new(text, response),
456            })
457        }
458
459        Ok(response_with_context.unwrap())
460    }
461
462    /// Send multiple check requests and join them into a single response,
463    /// without any context.
464    ///
465    /// # Error
466    ///
467    /// If any of the requests has `self.text` or `self.data` field which is
468    /// [`None`].
469    #[cfg(feature = "multithreaded")]
470    pub async fn check_multiple_and_join_without_context(
471        &self,
472        requests: Vec<Request<'_>>,
473    ) -> Result<check::Response> {
474        let mut response: Option<check::Response> = None;
475
476        let tasks = requests
477            .into_iter()
478            .map(|r| r.into_static())
479            .map(|request| {
480                let server_client = self.clone();
481
482                tokio::spawn(async move {
483                    let response = server_client.check(&request).await?;
484                    Result::<Response>::Ok(response)
485                })
486            });
487
488        // Make requests in sequence
489        for task in tasks {
490            let resp = task.await.unwrap()?;
491
492            response = Some(match response {
493                Some(r) => r.append(resp),
494                None => resp,
495            })
496        }
497
498        Ok(response.unwrap())
499    }
500
501    /// Send a check request to the server, await for the response and annotate
502    /// it.
503    #[cfg(feature = "annotate")]
504    pub async fn annotate_check(
505        &self,
506        request: &Request<'_>,
507        origin: Option<&str>,
508        color: bool,
509    ) -> Result<String> {
510        let text = request.get_text();
511        let resp = self.check(request).await?;
512
513        Ok(resp.annotate(text.as_ref(), origin, color))
514    }
515
516    /// Send a languages request to the server and await for the response.
517    pub async fn languages(&self) -> Result<languages::Response> {
518        let resp = self
519            .client
520            .get(format!("{}/languages", self.api))
521            .send()
522            .await
523            .map_err(Error::Reqwest)?;
524
525        match resp.error_for_status_ref() {
526            Ok(_) => resp.json::<languages::Response>().await.map_err(Into::into),
527            Err(_) => Err(Error::InvalidRequest(resp.text().await?)),
528        }
529    }
530
531    /// Send a words request to the server and await for the response.
532    pub async fn words(&self, request: &words::Request) -> Result<words::Response> {
533        let resp = self
534            .client
535            .get(format!("{}/words", self.api))
536            .header(ACCEPT, HeaderValue::from_static("application/json"))
537            .query(request)
538            .send()
539            .await
540            .map_err(Error::Reqwest)?;
541
542        match resp.error_for_status_ref() {
543            Ok(_) => resp.json::<words::Response>().await.map_err(Error::Reqwest),
544            Err(_) => Err(Error::InvalidRequest(resp.text().await?)),
545        }
546    }
547
548    /// Send a words/add request to the server and await for the response.
549    pub async fn words_add(&self, request: &words::add::Request) -> Result<words::add::Response> {
550        let resp = self
551            .client
552            .post(format!("{}/words/add", self.api))
553            .header(ACCEPT, HeaderValue::from_static("application/json"))
554            .form(request)
555            .send()
556            .await
557            .map_err(Error::Reqwest)?;
558
559        match resp.error_for_status_ref() {
560            Ok(_) => {
561                resp.json::<words::add::Response>()
562                    .await
563                    .map_err(Error::Reqwest)
564            },
565            Err(_) => Err(Error::InvalidRequest(resp.text().await?)),
566        }
567    }
568
569    /// Send a words/delete request to the server and await for the response.
570    pub async fn words_delete(
571        &self,
572        request: &words::delete::Request,
573    ) -> Result<words::delete::Response> {
574        let resp = self
575            .client
576            .post(format!("{}/words/delete", self.api))
577            .header(ACCEPT, HeaderValue::from_static("application/json"))
578            .form(request)
579            .send()
580            .await
581            .map_err(Error::Reqwest)?;
582
583        match resp.error_for_status_ref() {
584            Ok(_) => {
585                resp.json::<words::delete::Response>()
586                    .await
587                    .map_err(Error::Reqwest)
588            },
589            Err(_) => Err(Error::InvalidRequest(resp.text().await?)),
590        }
591    }
592
593    /// Ping the server and return the elapsed time in milliseconds if the
594    /// server responded.
595    pub async fn ping(&self) -> Result<u128> {
596        let start = Instant::now();
597        self.client.get(&self.api).send().await?;
598        Ok((Instant::now() - start).as_millis())
599    }
600}
601
602impl Default for ServerClient {
603    fn default() -> Self {
604        Self::from_cli(ServerCli::default())
605    }
606}
607
608impl ServerClient {
609    /// Create a new [`ServerClient`] instance from environ variables.
610    ///
611    /// See [`ServerCli::from_env`] for more details.
612    pub fn from_env() -> Result<Self> {
613        Ok(Self::from_cli(ServerCli::from_env()?))
614    }
615
616    /// Create a new [`ServerClient`] instance from environ variables,
617    /// but defaults to [`ServerClient::default`] if expected environ
618    /// variables are not set.
619    #[must_use]
620    pub fn from_env_or_default() -> Self {
621        Self::from_cli(ServerCli::from_env_or_default())
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use std::borrow::Cow;
628
629    use assert_matches::assert_matches;
630
631    use super::ServerClient;
632    use crate::{api::check::Request, error::Error};
633
634    fn get_testing_server_client() -> ServerClient {
635        ServerClient::new("http://localhost", "8010")
636    }
637
638    #[tokio::test]
639    async fn test_server_ping() {
640        let client = get_testing_server_client();
641        assert!(
642            client.ping().await.is_ok(),
643            "\n----------------------------------------------------------------------------------------------\n\
644            IMPORTANT: Please ensure that there is a local LanguageTool service running on port 8010.\n\
645            ----------------------------------------------------------------------------------------------\n"
646        );
647    }
648
649    #[tokio::test]
650    async fn test_server_check_text() {
651        let client = get_testing_server_client();
652
653        let req = Request::default().with_text("je suis une poupee");
654        assert!(client.check(&req).await.is_ok());
655
656        // Too long
657        let req = Request::default().with_text("Repeat ".repeat(1500));
658        assert_matches!(client.check(&req).await, Err(Error::InvalidRequest(_)));
659    }
660
661    #[tokio::test]
662    async fn test_server_check_data() {
663        let client = get_testing_server_client();
664        let req = Request::default()
665            .with_data_str("{\"annotation\":[{\"text\": \"je suis une poupee\"}]}")
666            .unwrap();
667        assert!(client.check(&req).await.is_ok());
668
669        // Too long
670        let req = Request::default()
671            .with_data_str(&format!(
672                "{{\"annotation\":[{{\"text\": \"{}\"}}]}}",
673                "repeat".repeat(5000)
674            ))
675            .unwrap();
676        assert_matches!(client.check(&req).await, Err(Error::InvalidRequest(_)));
677    }
678
679    #[tokio::test]
680    async fn test_server_check_multiple_and_join() {
681        const TEXT: &str = "I am a doll.\nBut what are you?";
682        let client = get_testing_server_client();
683
684        let requests = Request::default()
685            .with_language("en-US".into())
686            .with_text(TEXT)
687            .split(20, "\n");
688        let resp = client.check_multiple_and_join(requests).await.unwrap();
689
690        assert_eq!(resp.text, Cow::from(TEXT));
691        assert_eq!(resp.text_length, TEXT.len());
692        #[cfg(feature = "unstable")]
693        assert!(!resp.response.warnings.as_ref().unwrap().incomplete_results);
694        assert_eq!(resp.response.iter_matches().next(), None);
695        assert_eq!(resp.response.language.name, "English (US)");
696
697        // Fails when trying to use it without text
698        let requests = vec![Request::default().with_language("en-US".into())];
699        assert!(client.check_multiple_and_join(requests).await.is_err());
700        let requests = vec![Request::default()
701            .with_language("en-US".into())
702            .with_data_str("{\"annotation\":[{\"text\": \"je suis une poupee\"}]}")
703            .unwrap()];
704        assert!(client.check_multiple_and_join(requests).await.is_err());
705    }
706
707    #[tokio::test]
708    async fn test_server_check_multiple_and_join_without_context() {
709        let client = get_testing_server_client();
710
711        let requests = vec![Request::default()
712            .with_language("en-US".into())
713            .with_data_str("{\"annotation\":[{\"text\": \"I am a doll\"}]}")
714            .unwrap()];
715        let resp = client
716            .check_multiple_and_join_without_context(requests)
717            .await
718            .unwrap();
719
720        #[cfg(feature = "unstable")]
721        assert!(!resp.warnings.as_ref().unwrap().incomplete_results);
722        assert_eq!(resp.iter_matches().next(), None);
723        assert_eq!(resp.language.name, "English (US)");
724
725        let requests = vec![Request::default()
726            .with_language("en-US".into())
727            .with_text("I am a doll.")];
728        let resp = client
729            .check_multiple_and_join_without_context(requests)
730            .await
731            .unwrap();
732        assert_eq!(resp.iter_matches().next(), None);
733
734        // Fails when trying to use it without text or data
735        let requests = vec![Request::default().with_language("en-US".into())];
736        assert!(client.check_multiple_and_join(requests).await.is_err());
737    }
738
739    #[cfg(feature = "annotate")]
740    #[tokio::test]
741    async fn test_server_annotate() {
742        let client = get_testing_server_client();
743
744        let req = Request::default()
745            .with_text("Who are you?")
746            .with_language("en-US".into());
747        let annotated = client
748            .annotate_check(&req, Some("origin"), false)
749            .await
750            .unwrap();
751        assert_eq!(
752            annotated,
753            "No errors were found in provided text".to_string()
754        );
755
756        let req = Request::default()
757            .with_text("Who ar you?")
758            .with_language("en-US".into());
759        let annotated = client
760            .annotate_check(&req, Some("origin"), false)
761            .await
762            .unwrap();
763        assert!(
764            annotated.starts_with("error[MORFOLOGIK_RULE_EN_US]: Possible spelling mistake found.")
765        );
766        assert!(annotated.contains("^^ Possible spelling mistake"));
767    }
768
769    #[tokio::test]
770    async fn test_server_languages() {
771        let client = get_testing_server_client();
772        assert!(client.languages().await.is_ok());
773    }
774}