1use lino_objects_codec::LinoValue;
7
8use crate::codec::{decode_from, encode_for};
9use crate::headers::Headers;
10use crate::media_type::{
11 LINO_CONTENT_TYPE, is_decodable_media_type, parse_content_type, with_charset,
12};
13use crate::query::QueryParams;
14use crate::value::{get, to_query_text};
15
16pub const DEFAULT_ACCEPT: &str = "text/lino, application/json;q=0.5";
22
23pub const DESCRIPTION_PATH: &str = "/.well-known/lino-api";
25
26#[derive(Debug)]
28pub enum LinoClientError {
29 Transport(reqwest::Error),
31 Status {
33 status: u16,
35 problem: Option<LinoValue>,
37 message: String,
39 },
40 Encoding(String),
42}
43
44impl LinoClientError {
45 pub fn status(&self) -> Option<u16> {
47 match self {
48 Self::Status { status, .. } => Some(*status),
49 _ => None,
50 }
51 }
52
53 pub fn problem(&self) -> Option<&LinoValue> {
55 match self {
56 Self::Status { problem, .. } => problem.as_ref(),
57 _ => None,
58 }
59 }
60
61 fn of_status(status: u16, problem: Option<LinoValue>) -> Self {
63 let message = problem
64 .as_ref()
65 .and_then(|problem| {
66 get(problem, "detail")
67 .or_else(|| get(problem, "title"))
68 .map(to_query_text)
69 })
70 .filter(|message| !message.is_empty())
71 .unwrap_or_else(|| format!("Request failed with status {status}"));
72 Self::Status {
73 status,
74 problem,
75 message,
76 }
77 }
78}
79
80impl std::fmt::Display for LinoClientError {
81 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 Self::Transport(error) => write!(formatter, "{error}"),
84 Self::Status { message, .. } => write!(formatter, "{message}"),
85 Self::Encoding(message) => write!(formatter, "{message}"),
86 }
87 }
88}
89
90impl std::error::Error for LinoClientError {}
91
92impl From<reqwest::Error> for LinoClientError {
93 fn from(error: reqwest::Error) -> Self {
94 Self::Transport(error)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub struct LinoResponse {
101 pub status: u16,
103 pub headers: Headers,
105 pub data: Option<LinoValue>,
107 pub text: String,
109 pub etag: Option<String>,
111 pub location: Option<String>,
113}
114
115impl LinoResponse {
116 pub fn value(&self) -> LinoValue {
118 self.data.clone().unwrap_or(LinoValue::Null)
119 }
120
121 pub fn allowed_methods(&self) -> Vec<String> {
123 self.headers
124 .get("allow")
125 .unwrap_or("")
126 .split(',')
127 .map(str::trim)
128 .filter(|method| !method.is_empty())
129 .map(str::to_string)
130 .collect()
131 }
132}
133
134#[derive(Debug, Clone, Default)]
136pub struct RequestOptions {
137 pub query: QueryParams,
139 pub headers: Headers,
141 pub accept: Option<String>,
143 pub if_match: Option<String>,
145 pub if_none_match: Option<String>,
147}
148
149impl RequestOptions {
150 pub fn new() -> Self {
152 Self::default()
153 }
154
155 pub fn with_query<N: Into<String>>(mut self, name: N, value: &LinoValue) -> Self {
167 self.query.append(name, to_query_text(value));
168 self
169 }
170
171 pub fn with_query_text<N: Into<String>, V: Into<String>>(mut self, name: N, value: V) -> Self {
173 self.query.append(name, value);
174 self
175 }
176
177 pub fn with_header<N: Into<String>, V: Into<String>>(mut self, name: N, value: V) -> Self {
179 self.headers.insert(name, value);
180 self
181 }
182
183 pub fn with_accept<S: Into<String>>(mut self, accept: S) -> Self {
185 self.accept = Some(accept.into());
186 self
187 }
188
189 pub fn if_match<S: Into<String>>(mut self, etag: S) -> Self {
191 self.if_match = Some(etag.into());
192 self
193 }
194
195 pub fn if_none_match<S: Into<String>>(mut self, etag: S) -> Self {
197 self.if_none_match = Some(etag.into());
198 self
199 }
200}
201
202#[derive(Debug, Clone)]
204pub struct LinoClient {
205 base_url: String,
206 accept: String,
207 content_type: String,
208 headers: Headers,
209 http: reqwest::Client,
210}
211
212impl LinoClient {
213 pub fn new<S: Into<String>>(base_url: S) -> Self {
215 Self {
216 base_url: base_url.into().trim_end_matches('/').to_string(),
217 accept: DEFAULT_ACCEPT.to_string(),
218 content_type: LINO_CONTENT_TYPE.to_string(),
219 headers: Headers::new(),
220 http: reqwest::Client::new(),
221 }
222 }
223
224 pub fn with_accept<S: Into<String>>(mut self, accept: S) -> Self {
226 self.accept = accept.into();
227 self
228 }
229
230 pub fn with_content_type<S: Into<String>>(mut self, content_type: S) -> Self {
232 self.content_type = content_type.into();
233 self
234 }
235
236 pub fn with_header<N: Into<String>, V: Into<String>>(mut self, name: N, value: V) -> Self {
238 self.headers.insert(name, value);
239 self
240 }
241
242 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
244 self.http = http;
245 self
246 }
247
248 pub fn base_url(&self) -> &str {
250 &self.base_url
251 }
252
253 pub async fn request(
261 &self,
262 method: &str,
263 path: &str,
264 body: Option<&LinoValue>,
265 options: &RequestOptions,
266 ) -> Result<LinoResponse, LinoClientError> {
267 let query = options.query.to_query_string();
268 let url = if query.is_empty() {
269 format!("{}{path}", self.base_url)
270 } else {
271 format!("{}{path}?{query}", self.base_url)
272 };
273
274 let mut request_headers = Headers::new();
275 request_headers.insert(
276 "Accept",
277 options
278 .accept
279 .clone()
280 .unwrap_or_else(|| self.accept.clone()),
281 );
282 request_headers.extend_missing(&self.headers);
283 for (name, value) in options.headers.iter() {
284 request_headers.insert(name.to_string(), value.to_string());
285 }
286 if let Some(etag) = &options.if_match {
287 request_headers.insert("If-Match", etag.clone());
288 }
289 if let Some(etag) = &options.if_none_match {
290 request_headers.insert("If-None-Match", etag.clone());
291 }
292
293 let method_name = method.to_uppercase();
294 let http_method = reqwest::Method::from_bytes(method_name.as_bytes())
295 .map_err(|error| LinoClientError::Encoding(error.to_string()))?;
296 let mut builder = self.http.request(http_method, url);
297 if let Some(value) = body {
298 let encoded = encode_for(value, &self.content_type)
299 .map_err(|error| LinoClientError::Encoding(error.to_string()))?;
300 request_headers.insert("Content-Type", with_charset(&self.content_type));
301 builder = builder.body(encoded);
302 }
303 for (name, value) in request_headers.iter() {
304 builder = builder.header(name, value);
305 }
306
307 let response = builder.send().await?;
308 let status = response.status().as_u16();
309 let mut headers = Headers::new();
310 for (name, value) in response.headers() {
311 headers.insert(name.as_str(), value.to_str().unwrap_or_default());
312 }
313 let text = response.text().await?;
314 let data = decode_response(status, &method_name, &headers, &text);
315
316 if status >= 400 {
317 return Err(LinoClientError::of_status(status, data));
318 }
319 Ok(LinoResponse {
320 status,
321 etag: headers.get("etag").map(str::to_string),
322 location: headers.get("location").map(str::to_string),
323 headers,
324 data,
325 text,
326 })
327 }
328
329 pub async fn get(
331 &self,
332 path: &str,
333 options: &RequestOptions,
334 ) -> Result<LinoResponse, LinoClientError> {
335 self.request("GET", path, None, options).await
336 }
337
338 pub async fn post(
340 &self,
341 path: &str,
342 body: &LinoValue,
343 options: &RequestOptions,
344 ) -> Result<LinoResponse, LinoClientError> {
345 self.request("POST", path, Some(body), options).await
346 }
347
348 pub async fn put(
350 &self,
351 path: &str,
352 body: &LinoValue,
353 options: &RequestOptions,
354 ) -> Result<LinoResponse, LinoClientError> {
355 self.request("PUT", path, Some(body), options).await
356 }
357
358 pub async fn patch(
360 &self,
361 path: &str,
362 body: &LinoValue,
363 options: &RequestOptions,
364 ) -> Result<LinoResponse, LinoClientError> {
365 self.request("PATCH", path, Some(body), options).await
366 }
367
368 pub async fn delete(
370 &self,
371 path: &str,
372 options: &RequestOptions,
373 ) -> Result<LinoResponse, LinoClientError> {
374 self.request("DELETE", path, None, options).await
375 }
376
377 pub async fn head(
379 &self,
380 path: &str,
381 options: &RequestOptions,
382 ) -> Result<LinoResponse, LinoClientError> {
383 self.request("HEAD", path, None, options).await
384 }
385
386 pub async fn options(
388 &self,
389 path: &str,
390 options: &RequestOptions,
391 ) -> Result<Vec<String>, LinoClientError> {
392 Ok(self
393 .request("OPTIONS", path, None, options)
394 .await?
395 .allowed_methods())
396 }
397
398 pub async fn list(
400 &self,
401 path: &str,
402 options: &RequestOptions,
403 ) -> Result<LinoValue, LinoClientError> {
404 Ok(self.get(path, options).await?.value())
405 }
406
407 pub async fn describe(&self) -> Result<LinoValue, LinoClientError> {
409 Ok(self
410 .get(DESCRIPTION_PATH, &RequestOptions::new())
411 .await?
412 .value())
413 }
414}
415
416pub fn decode_response(
422 status: u16,
423 method: &str,
424 headers: &Headers,
425 text: &str,
426) -> Option<LinoValue> {
427 if status == 204 || status == 304 || method == "HEAD" || text.is_empty() {
428 return None;
429 }
430 let media_type = parse_content_type(headers.get("content-type"));
431 if !is_decodable_media_type(&media_type) {
432 return None;
433 }
434 decode_from(text, &media_type).ok()
435}
436
437pub fn create_lino_client<S: Into<String>>(base_url: S) -> LinoClient {
439 LinoClient::new(base_url)
440}