use crate::protocol::{sort::SortDirection, time_range::TimeRangeNs};
use anyhow::{anyhow, bail, Error, Result};
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr, PickFirst};
use std::fmt;
pub const DEFAULT_PAGE_SIZE: u32 = 100;
#[serde_as]
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
pub struct LimitOffsetPagination {
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub limit: Option<u32>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub offset: Option<u32>,
}
impl LimitOffsetPagination {
pub fn resolve(&self) -> (u32, u32) {
(
self.limit.unwrap_or(DEFAULT_PAGE_SIZE),
self.offset.unwrap_or(0),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct LimitOffsetPage {
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", schema(as = String))]
pub struct TimestampIdCursor {
pub timestamp_ns: u64,
pub id: String,
}
impl TimestampIdCursor {
pub fn into_parts(self) -> (u64, String) {
(self.timestamp_ns, self.id)
}
}
impl fmt::Display for TimestampIdCursor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.timestamp_ns, self.id)
}
}
impl std::str::FromStr for TimestampIdCursor {
type Err = Error;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let (ts, id) = raw
.split_once(':')
.ok_or_else(|| anyhow!("invalid cursor (expected \"{{timestamp_ns}}:{{id}}\")"))?;
let timestamp_ns: u64 = ts
.parse()
.map_err(|_| anyhow!("invalid cursor timestamp (expected integer nanoseconds)"))?;
let id = id.trim();
if id.is_empty() {
bail!("invalid cursor id (must be non-empty)");
}
Ok(Self {
timestamp_ns,
id: id.to_string(),
})
}
}
#[serde_as]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
pub struct CursorPagination {
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub limit: Option<u32>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct CursorPage {
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_count: Option<u64>,
}
pub type TimeseriesPage = CursorPage;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))]
#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
pub struct TimeseriesPagination {
#[serde(flatten)]
pub range: TimeRangeNs,
pub sort_ts: Option<SortDirection>,
#[serde(flatten)]
pub pagination: CursorPagination,
}
impl TimeseriesPagination {
pub fn validate(&self) -> Result<()> {
self.range.validate()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Deserialize)]
struct LimitOffsetQuery {
#[serde(flatten)]
pagination: LimitOffsetPagination,
}
#[test]
fn limit_offset_query_params_decode_as_numbers() {
let parsed: LimitOffsetQuery = serde_urlencoded::from_str("limit=10&offset=20").unwrap();
assert_eq!(parsed.pagination.limit, Some(10));
assert_eq!(parsed.pagination.offset, Some(20));
}
#[test]
fn limit_offset_query_params_empty_is_none() {
let parsed: LimitOffsetQuery = serde_urlencoded::from_str("").unwrap();
assert_eq!(parsed.pagination.limit, None);
assert_eq!(parsed.pagination.offset, None);
}
#[test]
fn limit_offset_query_params_invalid_number_errors() {
let err = serde_urlencoded::from_str::<LimitOffsetQuery>("limit=abc").unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn limit_offset_json_accepts_number_or_string() {
let from_number: LimitOffsetPagination =
serde_json::from_str(r#"{ "limit": 10 }"#).unwrap();
let from_string: LimitOffsetPagination =
serde_json::from_str(r#"{ "limit": "10" }"#).unwrap();
assert_eq!(from_number.limit, Some(10));
assert_eq!(from_string.limit, Some(10));
}
#[test]
fn limit_offset_json_invalid_string_errors() {
let err =
serde_json::from_str::<LimitOffsetPagination>(r#"{ "limit": "abc" }"#).unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn resolve_defaults() {
let p = LimitOffsetPagination::default();
assert_eq!(p.resolve(), (DEFAULT_PAGE_SIZE, 0));
}
#[test]
fn resolve_explicit_values() {
let p = LimitOffsetPagination {
limit: Some(25),
offset: Some(50),
};
assert_eq!(p.resolve(), (25, 50));
}
#[derive(Debug, Deserialize)]
struct CursorQuery {
#[serde(flatten)]
pagination: CursorPagination,
}
#[test]
fn cursor_query_params_decode_limit_and_cursor() {
let parsed: CursorQuery = serde_urlencoded::from_str("limit=10&cursor=abc").unwrap();
assert_eq!(parsed.pagination.limit, Some(10));
assert_eq!(parsed.pagination.cursor.as_deref(), Some("abc"));
}
#[test]
fn cursor_query_params_empty_is_none() {
let parsed: CursorQuery = serde_urlencoded::from_str("").unwrap();
assert_eq!(parsed.pagination.limit, None);
assert_eq!(parsed.pagination.cursor, None);
}
#[test]
fn cursor_json_accepts_number_or_string() {
let from_number: CursorPagination = serde_json::from_str(r#"{ "limit": 10 }"#).unwrap();
let from_string: CursorPagination = serde_json::from_str(r#"{ "limit": "10" }"#).unwrap();
assert_eq!(from_number.limit, Some(10));
assert_eq!(from_string.limit, Some(10));
}
#[test]
fn timestamp_id_cursor_round_trip() {
let c: TimestampIdCursor = "123:abc".parse().unwrap();
assert_eq!(c.timestamp_ns, 123);
assert_eq!(c.id, "abc");
assert_eq!(c.to_string(), "123:abc");
}
#[test]
fn timestamp_id_cursor_rejects_missing_colon() {
let err = "123".parse::<TimestampIdCursor>().unwrap_err();
assert_eq!(
err.to_string(),
"invalid cursor (expected \"{timestamp_ns}:{id}\")"
);
}
#[test]
fn timestamp_id_cursor_rejects_empty_id() {
let err = "123: ".parse::<TimestampIdCursor>().unwrap_err();
assert_eq!(err.to_string(), "invalid cursor id (must be non-empty)");
}
}