Skip to main content

minco_http/
resource.rs

1use http::{HeaderMap, HeaderValue, header::IF_MATCH};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use thiserror::Error;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SortDirection {
8    Ascending,
9    Descending,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SortTerm {
14    field: String,
15    direction: SortDirection,
16}
17
18impl SortTerm {
19    #[must_use]
20    pub fn field(&self) -> &str {
21        &self.field
22    }
23
24    #[must_use]
25    pub const fn direction(&self) -> SortDirection {
26        self.direction
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct Cursor(String);
33
34impl Cursor {
35    pub fn new(value: impl Into<String>) -> Result<Self, ResourceQueryError> {
36        Self::parse(value.into())
37    }
38
39    fn parse(value: String) -> Result<Self, ResourceQueryError> {
40        if value.is_empty()
41            || value.len() > 512
42            || !value
43                .bytes()
44                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
45        {
46            return Err(ResourceQueryError::InvalidCursor);
47        }
48        Ok(Self(value))
49    }
50
51    #[must_use]
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ResourceDocument<T> {
59    pub data: T,
60}
61
62impl<T> ResourceDocument<T> {
63    #[must_use]
64    pub const fn new(data: T) -> Self {
65        Self { data }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct CursorPageInfo {
72    pub has_more: bool,
73    pub next_cursor: Option<Cursor>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ResourceCollection<T> {
78    pub data: Vec<T>,
79    pub page: CursorPageInfo,
80}
81
82impl<T> ResourceCollection<T> {
83    #[must_use]
84    pub const fn new(data: Vec<T>, next_cursor: Option<Cursor>) -> Self {
85        Self {
86            data,
87            page: CursorPageInfo {
88                has_more: next_cursor.is_some(),
89                next_cursor,
90            },
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ResourceListPolicy {
97    default_limit: u16,
98    max_limit: u16,
99    default_sort: Vec<SortTerm>,
100    sort_fields: BTreeSet<String>,
101    filter_fields: BTreeSet<String>,
102}
103
104impl ResourceListPolicy {
105    pub fn new<DS, D, SS, S, FS, F>(
106        default_limit: u16,
107        max_limit: u16,
108        default_sort: DS,
109        sort_fields: SS,
110        filter_fields: FS,
111    ) -> Result<Self, ResourceQueryError>
112    where
113        DS: IntoIterator<Item = D>,
114        D: Into<String>,
115        SS: IntoIterator<Item = S>,
116        S: Into<String>,
117        FS: IntoIterator<Item = F>,
118        F: Into<String>,
119    {
120        if default_limit == 0 || max_limit == 0 || default_limit > max_limit {
121            return Err(ResourceQueryError::InvalidPolicy);
122        }
123        let sort_fields = collect_fields(sort_fields)?;
124        if sort_fields.is_empty() {
125            return Err(ResourceQueryError::InvalidPolicy);
126        }
127        let filter_fields = collect_fields(filter_fields)?;
128        let default_sort = parse_sort(default_sort.into_iter().map(Into::into), &sort_fields)?;
129        if default_sort.is_empty() {
130            return Err(ResourceQueryError::InvalidPolicy);
131        }
132        Ok(Self {
133            default_limit,
134            max_limit,
135            default_sort,
136            sort_fields,
137            filter_fields,
138        })
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ResourceListQuery {
144    limit: u16,
145    after: Option<Cursor>,
146    sort: Vec<SortTerm>,
147    filters: BTreeMap<String, String>,
148}
149
150impl ResourceListQuery {
151    #[must_use]
152    pub const fn limit(&self) -> u16 {
153        self.limit
154    }
155
156    #[must_use]
157    pub const fn after(&self) -> Option<&Cursor> {
158        self.after.as_ref()
159    }
160
161    #[must_use]
162    pub fn sort(&self) -> &[SortTerm] {
163        &self.sort
164    }
165
166    #[must_use]
167    pub const fn filters(&self) -> &BTreeMap<String, String> {
168        &self.filters
169    }
170}
171
172#[derive(Debug, Error, Clone, PartialEq, Eq)]
173pub enum ResourceQueryError {
174    #[error("resource list policy is invalid")]
175    InvalidPolicy,
176    #[error("resource list query encoding is invalid")]
177    InvalidEncoding,
178    #[error("resource list query contains an unsupported parameter")]
179    UnsupportedParameter,
180    #[error("resource list query repeats a parameter")]
181    DuplicateParameter,
182    #[error("page limit is outside the configured bounds")]
183    InvalidLimit,
184    #[error("page cursor is invalid")]
185    InvalidCursor,
186    #[error("resource sort is invalid or not allowlisted")]
187    InvalidSort,
188    #[error("resource filter is invalid or not allowlisted")]
189    InvalidFilter,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StrongEntityTag {
194    opaque: String,
195    header: HeaderValue,
196}
197
198impl StrongEntityTag {
199    pub fn for_resource(resource: &str, id: &str, revision: u64) -> Result<Self, EntityTagError> {
200        if !valid_tag_component(resource) || !valid_tag_component(id) || revision == 0 {
201            return Err(EntityTagError::InvalidTag);
202        }
203        Self::from_opaque(format!("{resource}:{id}:{revision}"))
204    }
205
206    pub fn from_opaque(opaque: impl Into<String>) -> Result<Self, EntityTagError> {
207        let opaque = opaque.into();
208        if opaque.is_empty()
209            || opaque.len() > 200
210            || !opaque.bytes().all(|byte| {
211                byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')
212            })
213        {
214            return Err(EntityTagError::InvalidTag);
215        }
216        let header = HeaderValue::from_str(&format!("\"{opaque}\""))
217            .map_err(|_| EntityTagError::InvalidTag)?;
218        Ok(Self { opaque, header })
219    }
220
221    #[must_use]
222    pub fn opaque(&self) -> &str {
223        &self.opaque
224    }
225
226    #[must_use]
227    pub fn to_header_value(&self) -> HeaderValue {
228        self.header.clone()
229    }
230
231    pub fn resource_revision(&self, resource: &str, id: &str) -> Result<u64, EntityTagError> {
232        if !valid_tag_component(resource) || !valid_tag_component(id) {
233            return Err(EntityTagError::InvalidIfMatch);
234        }
235        self.opaque
236            .strip_prefix(&format!("{resource}:{id}:"))
237            .and_then(|revision| revision.parse::<u64>().ok())
238            .filter(|revision| *revision > 0)
239            .ok_or(EntityTagError::InvalidIfMatch)
240    }
241}
242
243#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
244pub enum EntityTagError {
245    #[error("If-Match is required")]
246    PreconditionRequired,
247    #[error("If-Match must contain exactly one strong Minco entity tag")]
248    InvalidIfMatch,
249    #[error("entity tag input is invalid")]
250    InvalidTag,
251}
252
253pub fn parse_if_match(headers: &HeaderMap) -> Result<StrongEntityTag, EntityTagError> {
254    let mut values = headers.get_all(IF_MATCH).iter();
255    let value = values.next().ok_or(EntityTagError::PreconditionRequired)?;
256    if values.next().is_some() {
257        return Err(EntityTagError::InvalidIfMatch);
258    }
259    let value = value.to_str().map_err(|_| EntityTagError::InvalidIfMatch)?;
260    if value.starts_with("W/") || value.contains(',') {
261        return Err(EntityTagError::InvalidIfMatch);
262    }
263    let opaque = value
264        .strip_prefix('"')
265        .and_then(|value| value.strip_suffix('"'))
266        .ok_or(EntityTagError::InvalidIfMatch)?;
267    StrongEntityTag::from_opaque(opaque).map_err(|_| EntityTagError::InvalidIfMatch)
268}
269
270pub fn parse_resource_list_query(
271    raw_query: Option<&str>,
272    policy: &ResourceListPolicy,
273) -> Result<ResourceListQuery, ResourceQueryError> {
274    let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw_query.unwrap_or_default())
275        .map_err(|_| ResourceQueryError::InvalidEncoding)?;
276    let mut seen = BTreeSet::new();
277    let mut limit = None;
278    let mut after = None;
279    let mut sort = None;
280    let mut filters = BTreeMap::new();
281
282    for (name, value) in pairs {
283        if !seen.insert(name.clone()) {
284            return Err(ResourceQueryError::DuplicateParameter);
285        }
286        match name.as_str() {
287            "page[limit]" => {
288                let parsed = value
289                    .parse::<u16>()
290                    .map_err(|_| ResourceQueryError::InvalidLimit)?;
291                if parsed == 0 || parsed > policy.max_limit {
292                    return Err(ResourceQueryError::InvalidLimit);
293                }
294                limit = Some(parsed);
295            }
296            "page[after]" => after = Some(Cursor::parse(value)?),
297            "sort" => {
298                sort = Some(parse_sort(
299                    value.split(',').map(str::to_owned),
300                    &policy.sort_fields,
301                )?);
302            }
303            _ => {
304                let Some(field) = name
305                    .strip_prefix("filter[")
306                    .and_then(|field| field.strip_suffix(']'))
307                else {
308                    return Err(ResourceQueryError::UnsupportedParameter);
309                };
310                if !policy.filter_fields.contains(field)
311                    || value.is_empty()
312                    || value.len() > 256
313                    || value.chars().any(char::is_control)
314                {
315                    return Err(ResourceQueryError::InvalidFilter);
316                }
317                filters.insert(field.to_owned(), value);
318            }
319        }
320    }
321
322    Ok(ResourceListQuery {
323        limit: limit.unwrap_or(policy.default_limit),
324        after,
325        sort: sort.unwrap_or_else(|| policy.default_sort.clone()),
326        filters,
327    })
328}
329
330fn collect_fields<I, S>(values: I) -> Result<BTreeSet<String>, ResourceQueryError>
331where
332    I: IntoIterator<Item = S>,
333    S: Into<String>,
334{
335    let mut fields = BTreeSet::new();
336    for value in values {
337        let value = value.into();
338        if !valid_field(&value) || !fields.insert(value) {
339            return Err(ResourceQueryError::InvalidPolicy);
340        }
341    }
342    Ok(fields)
343}
344
345fn parse_sort<I>(values: I, allowed: &BTreeSet<String>) -> Result<Vec<SortTerm>, ResourceQueryError>
346where
347    I: IntoIterator<Item = String>,
348{
349    let mut seen = BTreeSet::new();
350    let mut terms = Vec::new();
351    for value in values {
352        let (direction, field) = value
353            .strip_prefix('-')
354            .map_or((SortDirection::Ascending, value.as_str()), |field| {
355                (SortDirection::Descending, field)
356            });
357        if !valid_field(field) || !allowed.contains(field) || !seen.insert(field.to_owned()) {
358            return Err(ResourceQueryError::InvalidSort);
359        }
360        terms.push(SortTerm {
361            field: field.to_owned(),
362            direction,
363        });
364    }
365    if terms.is_empty() {
366        return Err(ResourceQueryError::InvalidSort);
367    }
368    Ok(terms)
369}
370
371fn valid_field(value: &str) -> bool {
372    let mut bytes = value.bytes();
373    matches!(bytes.next(), Some(first) if first.is_ascii_lowercase())
374        && bytes.all(|byte| byte.is_ascii_alphanumeric())
375}
376
377fn valid_tag_component(value: &str) -> bool {
378    !value.is_empty()
379        && value.len() <= 128
380        && value
381            .bytes()
382            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
383}