noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};

use crate::validation::{ValidationError, require_in_range};

pub const DEFAULT_PAGE: u32 = 1;
pub const DEFAULT_PAGE_SIZE: u32 = 20;
pub const MAX_PAGE_SIZE: u32 = 100;

fn default_page() -> u32 {
    DEFAULT_PAGE
}

fn default_page_size() -> u32 {
    DEFAULT_PAGE_SIZE
}

/// Query pagination. Defaults: `page=1`, `page_size=20` (max 100).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct PageRequest {
    #[serde(default = "default_page")]
    pub page: u32,
    #[serde(default = "default_page_size")]
    pub page_size: u32,
}

impl Default for PageRequest {
    fn default() -> Self {
        Self {
            page: DEFAULT_PAGE,
            page_size: DEFAULT_PAGE_SIZE,
        }
    }
}

impl PageRequest {
    pub fn validate(&self) -> Result<(), ValidationError> {
        let mut err = ValidationError::new();
        if let Err(msg) = require_in_range(self.page, 1, u32::MAX, "page") {
            err.add("page", msg);
        }
        if let Err(msg) = require_in_range(self.page_size, 1, MAX_PAGE_SIZE, "page_size") {
            err.add("page_size", msg);
        }
        err.finish()
    }

    pub fn offset(&self) -> u64 {
        (self.page.saturating_sub(1) as u64) * u64::from(self.page_size)
    }

    pub fn limit(&self) -> u32 {
        self.page_size
    }
}

/// Page of items plus totals. `T` should implement utoipa `ToSchema` in OpenAPI usage.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PageResult<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub page_size: u32,
    pub total: u64,
}

impl<T> PageResult<T> {
    pub fn new(items: Vec<T>, request: &PageRequest, total: u64) -> Self {
        Self {
            items,
            page: request.page,
            page_size: request.page_size,
            total,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_from_empty_json() {
        let req: PageRequest = serde_json::from_str("{}").unwrap();
        assert_eq!(req.page, 1);
        assert_eq!(req.page_size, 20);
        req.validate().unwrap();
    }

    #[test]
    fn zero_page_fails() {
        let req = PageRequest {
            page: 0,
            page_size: 20,
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn page_size_cap() {
        let req = PageRequest {
            page: 1,
            page_size: 101,
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn offset_from_page() {
        let req = PageRequest {
            page: 3,
            page_size: 20,
        };
        assert_eq!(req.offset(), 40);
    }
}