Skip to main content

hammer/
server.rs

1//! Requests to a service on the user's own Hammer sync server, such as `languagetool`, through
2//! `hammer_server_request`. Hammer sends them as the signed-in user, so the plugin never holds their
3//! credentials, and only to the services the manifest names under `permissions.hammerServer`.
4//! [languagetool] has typed functions for the `languagetool` service.
5
6pub mod languagetool;
7
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt;
11
12#[link(wasm_import_module = "extism:host/user")]
13unsafe extern "C" {
14    fn hammer_server_request(request: u64, body: u64) -> u64;
15}
16
17pub struct Response {
18    pub status: u16,
19    pub body: String,
20}
21
22impl Response {
23    pub fn is_success(&self) -> bool {
24        (200..300).contains(&self.status)
25    }
26
27    /// What a status other than success means, with the message the sync server gave for the user
28    /// when it gave one; None on success.
29    pub fn error(&self) -> Option<ServerError> {
30        if self.is_success() {
31            return None;
32        }
33        let kind = match self.status {
34            429 => ErrorKind::RateLimited,
35            400..500 => ErrorKind::Rejected,
36            _ => ErrorKind::ServiceFailed,
37        };
38        let message = display_message(&self.body).unwrap_or_else(|| {
39            let detail: String = self.body.chars().take(200).collect();
40            match detail.trim().is_empty() {
41                true => format!("The service replied {}", self.status),
42                false => format!("The service replied {}: {}", self.status, detail.trim()),
43            }
44        });
45        Some(ServerError { kind, message })
46    }
47}
48
49/// The sync server's own refusals carry a message meant for the user.
50fn display_message(body: &str) -> Option<String> {
51    let reply: serde_json::Value = serde_json::from_str(body).ok()?;
52    reply.get("displayMessage")?.as_str().filter(|it| !it.is_empty()).map(str::to_owned)
53}
54
55/// Why a request failed: the first five are from Hammer, before any reply; the rest are what
56/// [Response::error] makes of a reply's status.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum ErrorKind {
60    /// No sync account in this workspace, or its session has ended.
61    NotSignedIn,
62    /// The sync server doesn't offer the service.
63    Unavailable,
64    /// The sync server couldn't be reached.
65    Network,
66    /// The manifest doesn't name the service.
67    NotPermitted,
68    /// A refused method or path, or a reply that isn't text, or isn't what the service should send.
69    Invalid,
70    /// Too many requests: 429. The sync server allows more after a minute.
71    RateLimited,
72    /// The service refused the request, such as one too large or in a language it doesn't check: 4xx.
73    Rejected,
74    /// The service failed, or the sync server couldn't reach it: 5xx.
75    ServiceFailed,
76}
77
78#[derive(Debug)]
79pub struct ServerError {
80    pub kind: ErrorKind,
81    pub message: String,
82}
83
84impl fmt::Display for ServerError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(&self.message)
87    }
88}
89
90impl std::error::Error for ServerError {}
91
92/// Sends a request to `service`, at `path` under it (such as `/v2/check`), and waits for its response.
93/// `method` is `GET` or `POST`; only `Content-Type` and `Accept` headers are sent. A response of any
94/// status is Ok.
95pub fn request(
96    service: &str,
97    method: &str,
98    path: &str,
99    headers: &[(&str, &str)],
100    body: Option<&[u8]>,
101) -> Result<Response, ServerError> {
102    let request = crate::to_kernel(&encode_request(service, method, path, headers));
103    let body = body.filter(|it| !it.is_empty()).map_or(0, crate::to_kernel);
104    let reply = unsafe { hammer_server_request(request, body) };
105    unsafe {
106        crate::free(request);
107        if body != 0 {
108            crate::free(body);
109        }
110    }
111    if reply == 0 {
112        return Err(ServerError { kind: ErrorKind::Invalid, message: "The sync server gave no reply".into() });
113    }
114    let bytes = crate::from_kernel(reply);
115    unsafe { crate::free(reply) };
116    parse_reply(&bytes)
117}
118
119/// The JSON `hammer_server_request` reads: `headers` is an object.
120fn encode_request(service: &str, method: &str, path: &str, headers: &[(&str, &str)]) -> Vec<u8> {
121    #[derive(Serialize)]
122    struct Request<'a> {
123        service: &'a str,
124        method: &'a str,
125        path: &'a str,
126        headers: BTreeMap<&'a str, &'a str>,
127    }
128    let request = Request { service, method, path, headers: headers.iter().copied().collect() };
129    serde_json::to_vec(&request).expect("strings always serialize")
130}
131
132fn parse_reply(bytes: &[u8]) -> Result<Response, ServerError> {
133    #[derive(Deserialize)]
134    struct Reply {
135        status: Option<u16>,
136        body: Option<String>,
137        error: Option<String>,
138        message: Option<String>,
139    }
140    let reply: Reply = serde_json::from_slice(bytes).map_err(|e| ServerError { kind: ErrorKind::Invalid, message: e.to_string() })?;
141    if let Some(status) = reply.status {
142        return Ok(Response { status, body: reply.body.unwrap_or_default() });
143    }
144    let kind = match reply.error.as_deref() {
145        Some("not_signed_in") => ErrorKind::NotSignedIn,
146        Some("unavailable") => ErrorKind::Unavailable,
147        Some("network") => ErrorKind::Network,
148        Some("not_permitted") => ErrorKind::NotPermitted,
149        _ => ErrorKind::Invalid,
150    };
151    Err(ServerError { kind, message: reply.message.unwrap_or_else(|| "The sync server gave no reply".into()) })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn a_request_names_its_headers_in_an_object() {
160        let json = encode_request("languagetool", "POST", "/v2/check", &[("Content-Type", "application/x-www-form-urlencoded")]);
161        assert_eq!(
162            String::from_utf8(json).unwrap(),
163            r#"{"service":"languagetool","method":"POST","path":"/v2/check","headers":{"Content-Type":"application/x-www-form-urlencoded"}}"#
164        );
165    }
166
167    #[test]
168    fn a_status_is_a_response() {
169        let response = parse_reply(br#"{"status":200,"body":"{\"matches\":[]}"}"#).unwrap();
170        assert_eq!(response.status, 200);
171        assert_eq!(response.body, r#"{"matches":[]}"#);
172        assert!(response.is_success());
173    }
174
175    #[test]
176    fn an_error_keeps_its_kind_and_message() {
177        let error = parse_reply(br#"{"error":"not_signed_in","message":"Sign in"}"#).err().unwrap();
178        assert_eq!(error.kind, ErrorKind::NotSignedIn);
179        assert_eq!(error.to_string(), "Sign in");
180    }
181
182    #[test]
183    fn a_status_names_its_error_with_the_servers_message() {
184        let response = |status, body: &str| Response { status, body: body.into() };
185        assert!(response(200, "").error().is_none());
186        assert_eq!(response(429, "").error().unwrap().kind, ErrorKind::RateLimited);
187        let refused = response(413, r#"{"error":"api_services_error_toolarge","displayMessage":"Too large"}"#).error().unwrap();
188        assert_eq!((refused.kind, refused.message.as_str()), (ErrorKind::Rejected, "Too large"));
189        let failed = response(502, "").error().unwrap();
190        assert_eq!((failed.kind, failed.message.as_str()), (ErrorKind::ServiceFailed, "The service replied 502"));
191        assert_eq!(response(400, "Bad language").error().unwrap().message, "The service replied 400: Bad language");
192    }
193
194    #[test]
195    fn an_unknown_error_is_invalid() {
196        let error = parse_reply(br#"{"error":"something_new"}"#).err().unwrap();
197        assert_eq!(error.kind, ErrorKind::Invalid);
198    }
199}