Skip to main content

lino_rest_api/
resource.rs

1//! CRUD resource registration.
2//!
3//! Turns a [`Store`] into the six routes the specification describes for a
4//! collection and its items, including pagination `Link` headers (section 6.1),
5//! entity tags and conditional requests (section 7).
6
7use std::sync::Arc;
8
9use lino_objects_codec::LinoValue;
10
11use crate::app::LinoApp;
12use crate::codec::encode_for;
13use crate::collection::pagination_link_header;
14use crate::etag::{compute_etag, evaluate_preconditions};
15use crate::problem::LinoHttpError;
16use crate::request::{LinoRequest, percent_encode};
17use crate::response::{Body, LinoResult, created, no_content, ok};
18use crate::store::Store;
19use crate::value::{get, to_query_text, with};
20
21/// Operations a resource can expose, in registration order.
22pub const RESOURCE_OPERATIONS: [&str; 6] = ["list", "create", "get", "update", "patch", "remove"];
23
24/// How a resource is registered.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ResourceOptions {
27    /// Path parameter holding the identifier.
28    pub id_param: String,
29    /// Field of an item holding the identifier; [`None`] asks the store.
30    pub id_field: Option<String>,
31    /// Human readable name used in problem details.
32    pub name: Option<String>,
33    /// Subset of [`RESOURCE_OPERATIONS`] to expose.
34    pub operations: Vec<String>,
35    /// Demand `If-Match` on `PUT`, `PATCH` and `DELETE`.
36    pub require_precondition: bool,
37    /// Let `PUT` create a missing item.
38    pub upsert: bool,
39}
40
41impl Default for ResourceOptions {
42    fn default() -> Self {
43        Self {
44            id_param: "id".to_string(),
45            id_field: None,
46            name: None,
47            operations: RESOURCE_OPERATIONS.iter().map(|s| s.to_string()).collect(),
48            require_precondition: false,
49            upsert: false,
50        }
51    }
52}
53
54impl ResourceOptions {
55    /// The default options.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// The same options with a human readable resource name.
61    pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
62        self.name = Some(name.into());
63        self
64    }
65
66    /// The same options with another path parameter for the identifier.
67    pub fn with_id_param<S: Into<String>>(mut self, id_param: S) -> Self {
68        self.id_param = id_param.into();
69        self
70    }
71
72    /// The same options with another identifier field.
73    pub fn with_id_field<S: Into<String>>(mut self, id_field: S) -> Self {
74        self.id_field = Some(id_field.into());
75        self
76    }
77
78    /// The same options exposing only these operations.
79    pub fn with_operations<I, S>(mut self, operations: I) -> Self
80    where
81        I: IntoIterator<Item = S>,
82        S: Into<String>,
83    {
84        self.operations = operations.into_iter().map(Into::into).collect();
85        self
86    }
87
88    /// The same options demanding `If-Match` on every unsafe method.
89    pub fn requiring_precondition(mut self, require: bool) -> Self {
90        self.require_precondition = require;
91        self
92    }
93
94    /// The same options letting `PUT` create a missing item.
95    pub fn with_upsert(mut self, upsert: bool) -> Self {
96        self.upsert = upsert;
97        self
98    }
99
100    /// Whether an operation is exposed.
101    fn exposes(&self, operation: &str) -> bool {
102        self.operations.iter().any(|name| name == operation)
103    }
104}
105
106/// Entity tag of a value in the representation the client asked for.
107///
108/// Entity tags are per representation, which is why the negotiated media type is
109/// part of the computation and why every response carries `Vary: Accept`.
110///
111/// # Errors
112///
113/// A `500` when the value cannot be encoded as the negotiated representation.
114pub fn representation_etag(
115    request: &LinoRequest,
116    value: &LinoValue,
117) -> Result<String, LinoHttpError> {
118    let encoded = encode_for(value, &request.media_type)
119        .map_err(|error| LinoHttpError::new(500, Some(&error.to_string())))?;
120    Ok(compute_etag(&encoded))
121}
122
123/// The `404` raised when an item does not exist.
124fn not_found(name: &str, identifier: &str) -> LinoHttpError {
125    LinoHttpError::new(404, Some(&format!("{name} {identifier} does not exist")))
126}
127
128/// Read a page member of a collection envelope.
129fn page_number(envelope: &LinoValue, key: &str) -> usize {
130    match get(envelope, "page").and_then(|page| get(page, key)) {
131        Some(LinoValue::Int(number)) if *number >= 0 => *number as usize,
132        _ => 0,
133    }
134}
135
136/// The URL of one item of a collection.
137fn item_location(base_path: &str, identifier: &LinoValue) -> String {
138    format!("{base_path}/{}", percent_encode(&to_query_text(identifier)))
139}
140
141/// Read the current item and evaluate `If-Match` against it before mutating.
142///
143/// Conditional requests on unsafe methods are about the representation that is
144/// *being replaced*, so the check happens here rather than on the response.
145///
146/// # Errors
147///
148/// A `412` or a `428` when the precondition fails, and whatever the store reports.
149async fn load_for_write(
150    store: &dyn Store,
151    request: &LinoRequest,
152    identifier: &str,
153    require_precondition: bool,
154) -> Result<Option<LinoValue>, LinoHttpError> {
155    let Some(existing) = store.get(identifier).await? else {
156        return Ok(None);
157    };
158    evaluate_preconditions(
159        &request.headers,
160        &request.method,
161        &representation_etag(request, &existing)?,
162        require_precondition,
163    )?;
164    Ok(Some(existing))
165}
166
167/// Register the routes of a CRUD resource on an application.
168///
169/// # Examples
170///
171/// ```
172/// use std::sync::Arc;
173///
174/// use lino_rest_api::app::{LinoApp, RawRequest};
175/// use lino_rest_api::resource::ResourceOptions;
176/// use lino_rest_api::store::MemoryStore;
177/// use lino_rest_api::value::{object, string};
178///
179/// # tokio_test::block_on(async {
180/// let store = Arc::new(MemoryStore::seeded([object([("title", string("Ship it"))])]));
181/// let mut app = LinoApp::new();
182/// app.resource("/tasks", store, ResourceOptions::new().with_name("task"));
183///
184/// let response = app.respond(RawRequest::new("GET", "/tasks/1")).await;
185/// assert_eq!(response.status, 200);
186/// assert!(response.body.contains("Ship it"));
187/// # });
188/// ```
189pub fn register_resource<S: Store + 'static>(
190    app: &mut LinoApp,
191    base_path: &str,
192    store: Arc<S>,
193    options: ResourceOptions,
194) {
195    let field = options
196        .id_field
197        .clone()
198        .unwrap_or_else(|| store.id_field().to_string());
199    let name = options
200        .name
201        .clone()
202        .unwrap_or_else(|| base_path.trim_matches('/').to_string());
203    let item_path = format!("{base_path}/:{}", options.id_param);
204    let base_path = base_path.to_string();
205
206    if options.exposes("list") {
207        let store = store.clone();
208        app.get(
209            &base_path,
210            &format!("List {name}"),
211            move |request: LinoRequest| {
212                let store = store.clone();
213                async move {
214                    let query = request.collection_query()?;
215                    let envelope = store.list(&query).await?;
216                    let link = pagination_link_header(
217                        &request.path,
218                        &request.query(),
219                        page_number(&envelope, "limit"),
220                        page_number(&envelope, "offset"),
221                        page_number(&envelope, "total"),
222                    );
223                    let result = ok(envelope);
224                    Ok(if link.is_empty() {
225                        result
226                    } else {
227                        result.with_header("Link", link)
228                    })
229                }
230            },
231        );
232    }
233
234    if options.exposes("create") {
235        let store = store.clone();
236        let field = field.clone();
237        let base = base_path.clone();
238        app.post(
239            &base_path,
240            &format!("Create {name}"),
241            move |request: LinoRequest| {
242                let store = store.clone();
243                let field = field.clone();
244                let base = base.clone();
245                async move {
246                    let body = request.require_body()?.clone();
247                    let item = store.create(&body).await?;
248                    let location = match get(&item, &field) {
249                        Some(LinoValue::Null) | None => None,
250                        Some(identifier) => Some(item_location(&base, identifier)),
251                    };
252                    Ok(match location {
253                        Some(location) => created(item, &location),
254                        None => LinoResult::new(201, Body::Value(item)),
255                    })
256                }
257            },
258        );
259    }
260
261    if options.exposes("get") {
262        let store = store.clone();
263        let name = name.clone();
264        let id_param = options.id_param.clone();
265        app.get(
266            &item_path,
267            &format!("Read one {name}"),
268            move |request: LinoRequest| {
269                let store = store.clone();
270                let name = name.clone();
271                let id_param = id_param.clone();
272                async move {
273                    let identifier = request.param(&id_param).unwrap_or_default();
274                    store
275                        .get(&identifier)
276                        .await?
277                        .ok_or_else(|| not_found(&name, &identifier))
278                }
279            },
280        );
281    }
282
283    if options.exposes("update") {
284        let store = store.clone();
285        let name = name.clone();
286        let field = field.clone();
287        let base = base_path.clone();
288        let id_param = options.id_param.clone();
289        let require_precondition = options.require_precondition;
290        let upsert = options.upsert;
291        app.put(
292            &item_path,
293            &format!("Replace one {name}"),
294            move |request: LinoRequest| {
295                let store = store.clone();
296                let name = name.clone();
297                let field = field.clone();
298                let base = base.clone();
299                let id_param = id_param.clone();
300                async move {
301                    let body = request.require_body()?.clone();
302                    let identifier = request.param(&id_param).unwrap_or_default();
303                    let existing =
304                        load_for_write(&*store, &request, &identifier, require_precondition)
305                            .await?;
306                    if existing.is_none() {
307                        if !upsert {
308                            return Err(not_found(&name, &identifier));
309                        }
310                        let seeded = with(
311                            &body,
312                            field.clone(),
313                            crate::store::normalize_id(&identifier),
314                        );
315                        let item = store.create(&seeded).await?;
316                        let location = match get(&item, &field) {
317                            Some(LinoValue::Null) | None => {
318                                item_location(&base, &crate::value::string(identifier))
319                            }
320                            Some(value) => item_location(&base, value),
321                        };
322                        return Ok(created(item, &location));
323                    }
324                    store
325                        .update(&identifier, &body)
326                        .await?
327                        .map(ok)
328                        .ok_or_else(|| not_found(&name, &identifier))
329                }
330            },
331        );
332    }
333
334    if options.exposes("patch") {
335        let store = store.clone();
336        let name = name.clone();
337        let id_param = options.id_param.clone();
338        let require_precondition = options.require_precondition;
339        app.patch(
340            &item_path,
341            &format!("Merge changes into one {name}"),
342            move |request: LinoRequest| {
343                let store = store.clone();
344                let name = name.clone();
345                let id_param = id_param.clone();
346                async move {
347                    let body = request.require_body()?.clone();
348                    let identifier = request.param(&id_param).unwrap_or_default();
349                    if load_for_write(&*store, &request, &identifier, require_precondition)
350                        .await?
351                        .is_none()
352                    {
353                        return Err(not_found(&name, &identifier));
354                    }
355                    store
356                        .patch(&identifier, &body)
357                        .await?
358                        .map(ok)
359                        .ok_or_else(|| not_found(&name, &identifier))
360                }
361            },
362        );
363    }
364
365    if options.exposes("remove") {
366        let store = store.clone();
367        let name = name.clone();
368        let id_param = options.id_param.clone();
369        let require_precondition = options.require_precondition;
370        app.delete(
371            &item_path,
372            &format!("Delete one {name}"),
373            move |request: LinoRequest| {
374                let store = store.clone();
375                let name = name.clone();
376                let id_param = id_param.clone();
377                async move {
378                    let identifier = request.param(&id_param).unwrap_or_default();
379                    if load_for_write(&*store, &request, &identifier, require_precondition)
380                        .await?
381                        .is_none()
382                    {
383                        return Err(not_found(&name, &identifier));
384                    }
385                    store.remove(&identifier).await?;
386                    Ok(no_content())
387                }
388            },
389        );
390    }
391}