Skip to main content

cerno_sdk/
lib.rs

1//! Rust client for the cerno service.
2//!
3//! ```no_run
4//! # async fn example() -> Result<(), cerno_sdk::Error> {
5//! use cerno_sdk::Client;
6//!
7//! let client = Client::new("http://localhost:3000")?;
8//! let answers = client
9//!     .systemone("Ticket: server room at 31C, rising.")
10//!     .noul("urgent", "Is this urgent?")
11//!     .choice("team", "Which team?", ["IT", "Facility", "HR"])
12//!     .score("sev", "How severe?", 5)
13//!     .send()
14//!     .await?;
15//!
16//! assert!(answers.noul("urgent")? > 0.8);
17//! assert_eq!(answers.choice("team")?, "Facility");
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! The wire types come from `cerno-types`, the same crate the server serialises from, so the
23//! client cannot drift from the service it talks to.
24//!
25//! The same client exists for [Python] and [TypeScript], written by hand against the same
26//! OpenAPI document and tested against the same [conformance cases], so all three build identical
27//! requests.
28//!
29//! [Python]: https://github.com/cebor/cerno/blob/main/sdks/python/README.md
30//! [TypeScript]: https://github.com/cebor/cerno/blob/main/sdks/typescript/README.md
31//! [conformance cases]: https://github.com/cebor/cerno/blob/main/spec/conformance/cases.json
32
33mod builder;
34mod response;
35
36pub use builder::{Levels, SystemOne};
37pub use response::Answers;
38
39pub use cerno_types::{
40    Answer, Calibration, ErrorCode, ErrorResponse, ModelInfo, ModelsResponse, SystemOneRequest,
41    SystemOneResponse,
42};
43
44use std::time::Duration;
45
46const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
47
48#[derive(Debug, thiserror::Error)]
49pub enum Error {
50    #[error("could not reach cerno: {0}")]
51    Transport(#[from] reqwest::Error),
52
53    /// The service answered with a structured failure. Branch on `code`, not on the message.
54    #[error("cerno returned {status} ({code:?}): {}", .response.message)]
55    Api {
56        status: u16,
57        code: ErrorCode,
58        response: ErrorResponse,
59    },
60
61    /// A structured failure with a code this client does not know, from a newer service.
62    ///
63    /// Kept apart from [`Error::Unexpected`] because it is not a proxy getting in the way: the
64    /// service answered, in its own shape, and the code and message are worth reading. Updating
65    /// the client turns it into an [`Error::Api`].
66    #[error("cerno returned {status} ({code}): {message}")]
67    UnknownCode {
68        status: u16,
69        code: String,
70        message: String,
71        question_id: Option<String>,
72    },
73
74    /// A response that was not shaped like anything cerno sends — a proxy or a gateway between
75    /// client and service, most likely. `body` is kept whole; the message shows its start, as
76    /// the Python and TypeScript clients do, so a gateway's HTML page does not fill a log line.
77    #[error("cerno returned {status}: {}", excerpt(.body))]
78    Unexpected { status: u16, body: String },
79
80    #[error("no answer for question {0:?}")]
81    MissingAnswer(String),
82
83    #[error("question {id:?} answered with a {actual}, not a {expected}")]
84    WrongType {
85        id: String,
86        expected: &'static str,
87        actual: &'static str,
88    },
89}
90
91/// How much of an unexpected body an error message shows.
92const MAX_EXCERPT: usize = 200;
93
94fn excerpt(body: &str) -> String {
95    match body.char_indices().nth(MAX_EXCERPT) {
96        Some((cut, _)) => format!("{}…", &body[..cut]),
97        None => body.to_string(),
98    }
99}
100
101#[derive(Clone)]
102pub struct Client {
103    http: reqwest::Client,
104    base_url: String,
105}
106
107impl Client {
108    pub fn new(base_url: impl Into<String>) -> Result<Self, Error> {
109        Self::with_timeout(base_url, DEFAULT_TIMEOUT)
110    }
111
112    pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Result<Self, Error> {
113        Ok(Self {
114            http: reqwest::Client::builder().timeout(timeout).build()?,
115            base_url: base_url.into().trim_end_matches('/').to_string(),
116        })
117    }
118
119    /// Start a request. Questions are added to the returned builder.
120    pub fn systemone(&self, state: impl Into<String>) -> SystemOne<'_> {
121        SystemOne::new(self, state.into())
122    }
123
124    /// The models this service will answer for.
125    pub async fn models(&self) -> Result<ModelsResponse, Error> {
126        let response = self
127            .http
128            .get(format!("{}/v1/models", self.base_url))
129            .send()
130            .await?;
131        decode(response).await
132    }
133
134    /// Whether the service is up. An unreachable or unresponsive service is simply not up, the
135    /// same answer the Python and TypeScript clients give.
136    pub async fn health(&self) -> bool {
137        match self
138            .http
139            .get(format!("{}/health", self.base_url))
140            .send()
141            .await
142        {
143            Ok(response) => response.status().is_success(),
144            Err(_) => false,
145        }
146    }
147
148    pub(crate) async fn post_systemone(
149        &self,
150        request: &SystemOneRequest,
151    ) -> Result<SystemOneResponse, Error> {
152        let response = self
153            .http
154            .post(format!("{}/v1/systemone", self.base_url))
155            .json(request)
156            .send()
157            .await?;
158        decode(response).await
159    }
160}
161
162/// Turn a response into a value, or into the most specific error we can justify.
163async fn decode<T: serde::de::DeserializeOwned>(response: reqwest::Response) -> Result<T, Error> {
164    let status = response.status();
165    let body = response.text().await?;
166
167    if status.is_success() {
168        return serde_json::from_str(&body).map_err(|_| Error::Unexpected {
169            status: status.as_u16(),
170            body,
171        });
172    }
173
174    if let Ok(response) = serde_json::from_str::<ErrorResponse>(&body) {
175        return Err(Error::Api {
176            status: status.as_u16(),
177            code: response.code,
178            response,
179        });
180    }
181
182    // Our shape, but a code added after this client was built.
183    #[derive(serde::Deserialize)]
184    struct Unrecognised {
185        code: String,
186        message: String,
187        #[serde(default)]
188        question_id: Option<String>,
189    }
190    match serde_json::from_str::<Unrecognised>(&body) {
191        Ok(error) => Err(Error::UnknownCode {
192            status: status.as_u16(),
193            code: error.code,
194            message: error.message,
195            question_id: error.question_id,
196        }),
197        // Not our error shape, so do not pretend to know what went wrong.
198        Err(_) => Err(Error::Unexpected {
199            status: status.as_u16(),
200            body,
201        }),
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn an_unexpected_body_is_shortened_in_the_message_and_kept_whole() {
211        let body = format!("<html>{}</html>", "x".repeat(5000));
212        let error = Error::Unexpected {
213            status: 502,
214            body: body.clone(),
215        };
216
217        let message = error.to_string();
218        assert!(message.chars().count() < 250, "{message}");
219        assert!(message.ends_with('…'), "{message}");
220        let Error::Unexpected { body: kept, .. } = error else {
221            unreachable!()
222        };
223        assert_eq!(kept, body);
224    }
225
226    #[test]
227    fn a_short_unexpected_body_is_shown_whole() {
228        let error = Error::Unexpected {
229            status: 503,
230            body: "<html>service unavailable</html>".into(),
231        };
232
233        assert_eq!(
234            error.to_string(),
235            "cerno returned 503: <html>service unavailable</html>"
236        );
237    }
238}