Skip to main content

lino_rest_api/
client.rs

1//! Links Notation REST client (specification section 10).
2//!
3//! Built on `reqwest`, so it talks to any service that speaks the wire protocol,
4//! whichever language implements it.
5
6use 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
16/// Default `Accept` sent by the client: Links Notation first, JSON as a fallback.
17///
18/// Spelled out rather than built from [`crate::media_type::LINO_CONTENT_TYPE`],
19/// because a constant cannot be formatted at compile time; the assertion in
20/// `tests/client.rs` keeps the two in step.
21pub const DEFAULT_ACCEPT: &str = "text/lino, application/json;q=0.5";
22
23/// Path of the native service description (specification section 9).
24pub const DESCRIPTION_PATH: &str = "/.well-known/lino-api";
25
26/// Anything that can go wrong while talking to a service.
27#[derive(Debug)]
28pub enum LinoClientError {
29    /// The request never produced a response.
30    Transport(reqwest::Error),
31    /// The service answered with a `4xx` or a `5xx`.
32    Status {
33        /// HTTP status code.
34        status: u16,
35        /// Decoded problem details, when the body could be decoded.
36        problem: Option<LinoValue>,
37        /// Human readable message, taken from the problem when there is one.
38        message: String,
39    },
40    /// A value could not be encoded as the configured request representation.
41    Encoding(String),
42}
43
44impl LinoClientError {
45    /// The status code of a `4xx` or `5xx` answer.
46    pub fn status(&self) -> Option<u16> {
47        match self {
48            Self::Status { status, .. } => Some(*status),
49            _ => None,
50        }
51    }
52
53    /// The problem details carried by a `4xx` or `5xx` answer.
54    pub fn problem(&self) -> Option<&LinoValue> {
55        match self {
56            Self::Status { problem, .. } => problem.as_ref(),
57            _ => None,
58        }
59    }
60
61    /// Build the error of a failed response.
62    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/// A decoded response.
99#[derive(Debug, Clone, PartialEq)]
100pub struct LinoResponse {
101    /// HTTP status code.
102    pub status: u16,
103    /// Response headers.
104    pub headers: Headers,
105    /// Decoded body, or [`None`] when the response carried none.
106    pub data: Option<LinoValue>,
107    /// The body as it arrived, for representations this client cannot decode.
108    pub text: String,
109    /// The `ETag` of the representation, when it carries one.
110    pub etag: Option<String>,
111    /// The `Location` of a created resource, when the response carries one.
112    pub location: Option<String>,
113}
114
115impl LinoResponse {
116    /// The decoded body, or a null value when there is none.
117    pub fn value(&self) -> LinoValue {
118        self.data.clone().unwrap_or(LinoValue::Null)
119    }
120
121    /// The methods advertised in an `Allow` header.
122    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/// Everything a single call may add to a request.
135#[derive(Debug, Clone, Default)]
136pub struct RequestOptions {
137    /// Query parameters, repeated parameters kept in order.
138    pub query: QueryParams,
139    /// Extra request headers.
140    pub headers: Headers,
141    /// Override the `Accept` header of the client.
142    pub accept: Option<String>,
143    /// `If-Match` precondition.
144    pub if_match: Option<String>,
145    /// `If-None-Match` precondition.
146    pub if_none_match: Option<String>,
147}
148
149impl RequestOptions {
150    /// Options that add nothing to a request.
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// The same options with one more query parameter.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use lino_rest_api::client::RequestOptions;
161    /// use lino_rest_api::value::boolean;
162    ///
163    /// let options = RequestOptions::new().with_query("done", &boolean(false));
164    /// assert_eq!(options.query.to_query_string(), "done=false");
165    /// ```
166    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    /// The same options with one more textual query parameter.
172    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    /// The same options with one more request header.
178    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    /// The same options asking for another representation.
184    pub fn with_accept<S: Into<String>>(mut self, accept: S) -> Self {
185        self.accept = Some(accept.into());
186        self
187    }
188
189    /// The same options carrying an `If-Match` precondition.
190    pub fn if_match<S: Into<String>>(mut self, etag: S) -> Self {
191        self.if_match = Some(etag.into());
192        self
193    }
194
195    /// The same options carrying an `If-None-Match` precondition.
196    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/// A client for a service that speaks Links Notation.
203#[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    /// A client for a service at a base URL.
214    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    /// The same client asking for another representation.
225    pub fn with_accept<S: Into<String>>(mut self, accept: S) -> Self {
226        self.accept = accept.into();
227        self
228    }
229
230    /// The same client sending request bodies as another representation.
231    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    /// The same client sending one more header with every request.
237    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    /// The same client built on a configured `reqwest` client.
243    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
244        self.http = http;
245        self
246    }
247
248    /// The base URL every path is appended to.
249    pub fn base_url(&self) -> &str {
250        &self.base_url
251    }
252
253    /// Perform a request and decode the response.
254    ///
255    /// # Errors
256    ///
257    /// A [`LinoClientError::Status`] for any `4xx` or `5xx`, carrying the
258    /// decoded problem details, and a [`LinoClientError::Transport`] when the
259    /// request never reached the service.
260    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    /// `GET` a resource.
330    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    /// `POST` to a collection.
339    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    /// `PUT` a representation.
349    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    /// `PATCH` a representation.
359    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    /// `DELETE` a resource.
369    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    /// `HEAD` a resource.
378    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    /// `OPTIONS` a path, returning the advertised methods.
387    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    /// List a collection, returning the decoded envelope.
399    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    /// Fetch the service description of specification section 9.
408    pub async fn describe(&self) -> Result<LinoValue, LinoClientError> {
409        Ok(self
410            .get(DESCRIPTION_PATH, &RequestOptions::new())
411            .await?
412            .value())
413    }
414}
415
416/// Decode a response body according to its own `Content-Type` (section 10).
417///
418/// A body this client cannot decode is left to the caller as text rather than
419/// reported as an error, because a service may legitimately answer with a
420/// representation the client never asked for.
421pub 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
437/// Create a client, for symmetry with the other packages.
438pub fn create_lino_client<S: Into<String>>(base_url: S) -> LinoClient {
439    LinoClient::new(base_url)
440}