Skip to main content

actionqueue_daemon/http/
pagination.rs

1//! Shared pagination types and parsing for list endpoints.
2//!
3//! This module provides the common pagination types (`Pagination`, `PaginationError`,
4//! `PaginationErrorResponse`, `PaginationErrorDetails`) and parsing functions
5//! (`parse_pagination`, `parse_limit`, `parse_offset`, `parse_non_negative`,
6//! `pagination_error`) used by both the tasks list and runs list handlers.
7
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::Json;
11use serde::Serialize;
12
13/// Default limit for list pagination.
14pub const DEFAULT_LIMIT: usize = 100;
15/// Maximum allowed limit for list pagination.
16pub const MAX_LIMIT: usize = 1000;
17
18/// Parsed pagination parameters.
19#[derive(Debug, Clone, Copy)]
20pub struct Pagination {
21    /// Maximum number of items to return.
22    pub limit: usize,
23    /// Number of items to skip before returning results.
24    pub offset: usize,
25}
26
27/// Pagination parsing error.
28#[derive(Debug, Clone)]
29pub struct PaginationError {
30    /// The field that caused the error.
31    pub field: &'static str,
32    /// Human-readable error message.
33    pub message: String,
34}
35
36impl PaginationError {
37    /// Creates a new pagination error.
38    pub fn new(field: &'static str, message: impl Into<String>) -> Self {
39        Self { field, message: message.into() }
40    }
41}
42
43/// Pagination error response payload.
44#[derive(Debug, Clone, Serialize)]
45pub struct PaginationErrorResponse {
46    /// Error type identifier.
47    pub error: &'static str,
48    /// Human-readable error message.
49    pub message: String,
50    /// Structured error details.
51    pub details: PaginationErrorDetails,
52}
53
54/// Structured pagination error details.
55#[derive(Debug, Clone, Serialize)]
56pub struct PaginationErrorDetails {
57    /// The field that caused the error.
58    pub field: &'static str,
59}
60
61/// Parses raw query string into validated pagination parameters.
62pub fn parse_pagination(raw_query: Option<&str>) -> Result<Pagination, PaginationError> {
63    let mut limit = None;
64    let mut offset = None;
65
66    if let Some(query) = raw_query {
67        for segment in query.split('&') {
68            if segment.is_empty() {
69                continue;
70            }
71
72            let mut parts = segment.splitn(2, '=');
73            let key = parts.next().unwrap_or("");
74            let value = parts.next().unwrap_or("");
75
76            match key {
77                "limit" => {
78                    limit = Some(parse_limit(value)?);
79                }
80                "offset" => {
81                    offset = Some(parse_offset(value)?);
82                }
83                _ => {}
84            }
85        }
86    }
87
88    Ok(Pagination { limit: limit.unwrap_or(DEFAULT_LIMIT), offset: offset.unwrap_or(0) })
89}
90
91/// Parses and validates the limit parameter.
92pub fn parse_limit(value: &str) -> Result<usize, PaginationError> {
93    let parsed = parse_non_negative(value, "limit")?;
94    if parsed == 0 || parsed > MAX_LIMIT {
95        return Err(PaginationError::new(
96            "limit",
97            format!("limit must be between 1 and {MAX_LIMIT}"),
98        ));
99    }
100    Ok(parsed)
101}
102
103/// Parses and validates the offset parameter.
104pub fn parse_offset(value: &str) -> Result<usize, PaginationError> {
105    parse_non_negative(value, "offset")
106}
107
108/// Parses a non-negative integer value from a query parameter string.
109pub fn parse_non_negative(value: &str, field: &'static str) -> Result<usize, PaginationError> {
110    if value.is_empty() {
111        return Err(PaginationError::new(field, format!("{field} must be a non-negative integer")));
112    }
113
114    value
115        .parse::<usize>()
116        .map_err(|_| PaginationError::new(field, format!("{field} must be a non-negative integer")))
117}
118
119/// Converts a pagination error into an HTTP 422 Unprocessable Entity response.
120pub fn pagination_error(error: PaginationError) -> impl IntoResponse {
121    let payload = PaginationErrorResponse {
122        error: "invalid_pagination",
123        message: error.message,
124        details: PaginationErrorDetails { field: error.field },
125    };
126
127    (StatusCode::UNPROCESSABLE_ENTITY, Json(payload))
128}