Skip to main content

lino_rest_api/
collection.rs

1//! Collection envelope, in-memory query execution and RFC 8288 link headers
2//! (specification section 6).
3
4use lino_objects_codec::LinoValue;
5
6use crate::query::{CollectionQuery, Filter, QueryParams, SortKey, form_encode};
7use crate::value::{array, compare, get, int, object};
8
9/// Test whether an item satisfies every filter.
10///
11/// A filter carrying more than one value matches when the item field equals any
12/// of them, which is what repeated query parameters (`?tag=a&tag=b`) mean.
13pub fn matches_filters(item: &LinoValue, filters: &[Filter]) -> bool {
14    filters.iter().all(|filter| {
15        let actual = get(item, &filter.field).cloned().unwrap_or(LinoValue::Null);
16        filter.values.contains(&actual)
17    })
18}
19
20/// Sort items by the sort keys of a collection query.
21///
22/// The original list is left untouched, and the sort is stable, so items that
23/// compare equal keep the order the store returned them in.
24pub fn sort_items(items: &[LinoValue], sort: &[SortKey]) -> Vec<LinoValue> {
25    let mut sorted = items.to_vec();
26    if sort.is_empty() {
27        return sorted;
28    }
29    sorted.sort_by(|left, right| {
30        for key in sort {
31            let left_value = get(left, &key.field).cloned().unwrap_or(LinoValue::Null);
32            let right_value = get(right, &key.field).cloned().unwrap_or(LinoValue::Null);
33            let comparison = compare(&left_value, &right_value);
34            if comparison != std::cmp::Ordering::Equal {
35                return if key.descending {
36                    comparison.reverse()
37                } else {
38                    comparison
39                };
40            }
41        }
42        std::cmp::Ordering::Equal
43    });
44    sorted
45}
46
47/// Reduce an item to a sparse fieldset.
48pub fn project_fields(item: &LinoValue, fields: Option<&Vec<String>>) -> LinoValue {
49    let Some(fields) = fields else {
50        return item.clone();
51    };
52    let Some(members) = crate::value::as_object(item) else {
53        return item.clone();
54    };
55    LinoValue::Object(
56        fields
57            .iter()
58            .filter_map(|field| {
59                members
60                    .iter()
61                    .find(|(name, _)| name == field)
62                    .map(|(name, value)| (name.clone(), value.clone()))
63            })
64            .collect(),
65    )
66}
67
68/// Wrap items in the collection envelope of the specification.
69///
70/// # Examples
71///
72/// ```
73/// use lino_rest_api::collection::collection_envelope;
74/// use lino_rest_api::value::{get, int};
75///
76/// let envelope = collection_envelope(vec![int(1)], 20, 0, 1);
77/// assert_eq!(get(get(&envelope, "page").unwrap(), "count"), Some(&int(1)));
78/// ```
79pub fn collection_envelope(
80    items: Vec<LinoValue>,
81    limit: usize,
82    offset: usize,
83    total: usize,
84) -> LinoValue {
85    let count = items.len();
86    object([
87        ("items", array(items)),
88        (
89            "page",
90            object([
91                ("limit", int(limit as i64)),
92                ("offset", int(offset as i64)),
93                ("total", int(total as i64)),
94                ("count", int(count as i64)),
95            ]),
96        ),
97    ])
98}
99
100/// Run a parsed collection query against an in-memory list.
101pub fn apply_collection_query(items: &[LinoValue], query: &CollectionQuery) -> LinoValue {
102    let filtered: Vec<LinoValue> = items
103        .iter()
104        .filter(|item| matches_filters(item, &query.filters))
105        .cloned()
106        .collect();
107    let ordered = sort_items(&filtered, &query.sort);
108    let page: Vec<LinoValue> = ordered
109        .into_iter()
110        .skip(query.offset)
111        .take(query.limit)
112        .map(|item| project_fields(&item, query.fields.as_ref()))
113        .collect();
114
115    collection_envelope(page, query.limit, query.offset, filtered.len())
116}
117
118/// Build the RFC 8288 `Link` header value for a paginated collection.
119///
120/// The value is empty when the page size is zero, because there is then nothing
121/// to page through.
122pub fn pagination_link_header(
123    path: &str,
124    query: &QueryParams,
125    limit: usize,
126    offset: usize,
127    total: usize,
128) -> String {
129    if limit == 0 {
130        return String::new();
131    }
132
133    let build = |target_offset: usize| {
134        let mut parameters: Vec<String> = query
135            .iter()
136            .filter(|(name, _)| *name != "limit" && *name != "offset")
137            .map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value)))
138            .collect();
139        parameters.push(format!("limit={limit}"));
140        parameters.push(format!("offset={target_offset}"));
141        format!("{path}?{}", parameters.join("&"))
142    };
143
144    let last_offset = if total == 0 {
145        0
146    } else {
147        ((total - 1) / limit) * limit
148    };
149    let mut links = vec![format!("<{}>; rel=\"first\"", build(0))];
150    if offset > 0 {
151        links.push(format!(
152            "<{}>; rel=\"prev\"",
153            build(offset.saturating_sub(limit))
154        ));
155    }
156    if offset + limit < total {
157        links.push(format!("<{}>; rel=\"next\"", build(offset + limit)));
158    }
159    links.push(format!("<{}>; rel=\"last\"", build(last_offset)));
160
161    links.join(", ")
162}