1mod 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 #[error("cerno returned {status} ({code:?}): {}", .response.message)]
55 Api {
56 status: u16,
57 code: ErrorCode,
58 response: ErrorResponse,
59 },
60
61 #[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 #[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
91const 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 pub fn systemone(&self, state: impl Into<String>) -> SystemOne<'_> {
121 SystemOne::new(self, state.into())
122 }
123
124 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 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
162async 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 #[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 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}