minco-http 0.5.0

Axum and Tower HTTP conventions, principals, request IDs, limits, and RFC 9457 errors for Minco
Documentation
use http::{HeaderMap, HeaderValue, header::IF_MATCH};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
    Ascending,
    Descending,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SortTerm {
    field: String,
    direction: SortDirection,
}

impl SortTerm {
    #[must_use]
    pub fn field(&self) -> &str {
        &self.field
    }

    #[must_use]
    pub const fn direction(&self) -> SortDirection {
        self.direction
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Cursor(String);

impl Cursor {
    pub fn new(value: impl Into<String>) -> Result<Self, ResourceQueryError> {
        Self::parse(value.into())
    }

    fn parse(value: String) -> Result<Self, ResourceQueryError> {
        if value.is_empty()
            || value.len() > 512
            || !value
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
        {
            return Err(ResourceQueryError::InvalidCursor);
        }
        Ok(Self(value))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceDocument<T> {
    pub data: T,
}

impl<T> ResourceDocument<T> {
    #[must_use]
    pub const fn new(data: T) -> Self {
        Self { data }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CursorPageInfo {
    pub has_more: bool,
    pub next_cursor: Option<Cursor>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceCollection<T> {
    pub data: Vec<T>,
    pub page: CursorPageInfo,
}

impl<T> ResourceCollection<T> {
    #[must_use]
    pub const fn new(data: Vec<T>, next_cursor: Option<Cursor>) -> Self {
        Self {
            data,
            page: CursorPageInfo {
                has_more: next_cursor.is_some(),
                next_cursor,
            },
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceListPolicy {
    default_limit: u16,
    max_limit: u16,
    default_sort: Vec<SortTerm>,
    sort_fields: BTreeSet<String>,
    filter_fields: BTreeSet<String>,
}

impl ResourceListPolicy {
    pub fn new<DS, D, SS, S, FS, F>(
        default_limit: u16,
        max_limit: u16,
        default_sort: DS,
        sort_fields: SS,
        filter_fields: FS,
    ) -> Result<Self, ResourceQueryError>
    where
        DS: IntoIterator<Item = D>,
        D: Into<String>,
        SS: IntoIterator<Item = S>,
        S: Into<String>,
        FS: IntoIterator<Item = F>,
        F: Into<String>,
    {
        if default_limit == 0 || max_limit == 0 || default_limit > max_limit {
            return Err(ResourceQueryError::InvalidPolicy);
        }
        let sort_fields = collect_fields(sort_fields)?;
        if sort_fields.is_empty() {
            return Err(ResourceQueryError::InvalidPolicy);
        }
        let filter_fields = collect_fields(filter_fields)?;
        let default_sort = parse_sort(default_sort.into_iter().map(Into::into), &sort_fields)?;
        if default_sort.is_empty() {
            return Err(ResourceQueryError::InvalidPolicy);
        }
        Ok(Self {
            default_limit,
            max_limit,
            default_sort,
            sort_fields,
            filter_fields,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceListQuery {
    limit: u16,
    after: Option<Cursor>,
    sort: Vec<SortTerm>,
    filters: BTreeMap<String, String>,
}

impl ResourceListQuery {
    #[must_use]
    pub const fn limit(&self) -> u16 {
        self.limit
    }

    #[must_use]
    pub const fn after(&self) -> Option<&Cursor> {
        self.after.as_ref()
    }

    #[must_use]
    pub fn sort(&self) -> &[SortTerm] {
        &self.sort
    }

    #[must_use]
    pub const fn filters(&self) -> &BTreeMap<String, String> {
        &self.filters
    }
}

#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ResourceQueryError {
    #[error("resource list policy is invalid")]
    InvalidPolicy,
    #[error("resource list query encoding is invalid")]
    InvalidEncoding,
    #[error("resource list query contains an unsupported parameter")]
    UnsupportedParameter,
    #[error("resource list query repeats a parameter")]
    DuplicateParameter,
    #[error("page limit is outside the configured bounds")]
    InvalidLimit,
    #[error("page cursor is invalid")]
    InvalidCursor,
    #[error("resource sort is invalid or not allowlisted")]
    InvalidSort,
    #[error("resource filter is invalid or not allowlisted")]
    InvalidFilter,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrongEntityTag {
    opaque: String,
    header: HeaderValue,
}

impl StrongEntityTag {
    pub fn for_resource(resource: &str, id: &str, revision: u64) -> Result<Self, EntityTagError> {
        if !valid_tag_component(resource) || !valid_tag_component(id) || revision == 0 {
            return Err(EntityTagError::InvalidTag);
        }
        Self::from_opaque(format!("{resource}:{id}:{revision}"))
    }

    pub fn from_opaque(opaque: impl Into<String>) -> Result<Self, EntityTagError> {
        let opaque = opaque.into();
        if opaque.is_empty()
            || opaque.len() > 200
            || !opaque.bytes().all(|byte| {
                byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')
            })
        {
            return Err(EntityTagError::InvalidTag);
        }
        let header = HeaderValue::from_str(&format!("\"{opaque}\""))
            .map_err(|_| EntityTagError::InvalidTag)?;
        Ok(Self { opaque, header })
    }

    #[must_use]
    pub fn opaque(&self) -> &str {
        &self.opaque
    }

    #[must_use]
    pub fn to_header_value(&self) -> HeaderValue {
        self.header.clone()
    }

    pub fn resource_revision(&self, resource: &str, id: &str) -> Result<u64, EntityTagError> {
        if !valid_tag_component(resource) || !valid_tag_component(id) {
            return Err(EntityTagError::InvalidIfMatch);
        }
        self.opaque
            .strip_prefix(&format!("{resource}:{id}:"))
            .and_then(|revision| revision.parse::<u64>().ok())
            .filter(|revision| *revision > 0)
            .ok_or(EntityTagError::InvalidIfMatch)
    }
}

#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EntityTagError {
    #[error("If-Match is required")]
    PreconditionRequired,
    #[error("If-Match must contain exactly one strong Minco entity tag")]
    InvalidIfMatch,
    #[error("entity tag input is invalid")]
    InvalidTag,
}

pub fn parse_if_match(headers: &HeaderMap) -> Result<StrongEntityTag, EntityTagError> {
    let mut values = headers.get_all(IF_MATCH).iter();
    let value = values.next().ok_or(EntityTagError::PreconditionRequired)?;
    if values.next().is_some() {
        return Err(EntityTagError::InvalidIfMatch);
    }
    let value = value.to_str().map_err(|_| EntityTagError::InvalidIfMatch)?;
    if value.starts_with("W/") || value.contains(',') {
        return Err(EntityTagError::InvalidIfMatch);
    }
    let opaque = value
        .strip_prefix('"')
        .and_then(|value| value.strip_suffix('"'))
        .ok_or(EntityTagError::InvalidIfMatch)?;
    StrongEntityTag::from_opaque(opaque).map_err(|_| EntityTagError::InvalidIfMatch)
}

pub fn parse_resource_list_query(
    raw_query: Option<&str>,
    policy: &ResourceListPolicy,
) -> Result<ResourceListQuery, ResourceQueryError> {
    let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw_query.unwrap_or_default())
        .map_err(|_| ResourceQueryError::InvalidEncoding)?;
    let mut seen = BTreeSet::new();
    let mut limit = None;
    let mut after = None;
    let mut sort = None;
    let mut filters = BTreeMap::new();

    for (name, value) in pairs {
        if !seen.insert(name.clone()) {
            return Err(ResourceQueryError::DuplicateParameter);
        }
        match name.as_str() {
            "page[limit]" => {
                let parsed = value
                    .parse::<u16>()
                    .map_err(|_| ResourceQueryError::InvalidLimit)?;
                if parsed == 0 || parsed > policy.max_limit {
                    return Err(ResourceQueryError::InvalidLimit);
                }
                limit = Some(parsed);
            }
            "page[after]" => after = Some(Cursor::parse(value)?),
            "sort" => {
                sort = Some(parse_sort(
                    value.split(',').map(str::to_owned),
                    &policy.sort_fields,
                )?);
            }
            _ => {
                let Some(field) = name
                    .strip_prefix("filter[")
                    .and_then(|field| field.strip_suffix(']'))
                else {
                    return Err(ResourceQueryError::UnsupportedParameter);
                };
                if !policy.filter_fields.contains(field)
                    || value.is_empty()
                    || value.len() > 256
                    || value.chars().any(char::is_control)
                {
                    return Err(ResourceQueryError::InvalidFilter);
                }
                filters.insert(field.to_owned(), value);
            }
        }
    }

    Ok(ResourceListQuery {
        limit: limit.unwrap_or(policy.default_limit),
        after,
        sort: sort.unwrap_or_else(|| policy.default_sort.clone()),
        filters,
    })
}

fn collect_fields<I, S>(values: I) -> Result<BTreeSet<String>, ResourceQueryError>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    let mut fields = BTreeSet::new();
    for value in values {
        let value = value.into();
        if !valid_field(&value) || !fields.insert(value) {
            return Err(ResourceQueryError::InvalidPolicy);
        }
    }
    Ok(fields)
}

fn parse_sort<I>(values: I, allowed: &BTreeSet<String>) -> Result<Vec<SortTerm>, ResourceQueryError>
where
    I: IntoIterator<Item = String>,
{
    let mut seen = BTreeSet::new();
    let mut terms = Vec::new();
    for value in values {
        let (direction, field) = value
            .strip_prefix('-')
            .map_or((SortDirection::Ascending, value.as_str()), |field| {
                (SortDirection::Descending, field)
            });
        if !valid_field(field) || !allowed.contains(field) || !seen.insert(field.to_owned()) {
            return Err(ResourceQueryError::InvalidSort);
        }
        terms.push(SortTerm {
            field: field.to_owned(),
            direction,
        });
    }
    if terms.is_empty() {
        return Err(ResourceQueryError::InvalidSort);
    }
    Ok(terms)
}

fn valid_field(value: &str) -> bool {
    let mut bytes = value.bytes();
    matches!(bytes.next(), Some(first) if first.is_ascii_lowercase())
        && bytes.all(|byte| byte.is_ascii_alphanumeric())
}

fn valid_tag_component(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 128
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}