Files
Oxicloud/src/application/dtos/cursor.rs
T
Edouard Vanbelle c65f2b5385 feat(api): cursor listing contract — PageCursor trait + resource field
- Add src/application/dtos/cursor.rs with three shared types:
  · PageCursor trait  — default base64url+JSON encode/decode; one bare
    impl line per cursor struct
  · CursorQuery struct — standard limit/cursor/sort_by query params with
    limit_clamped() and decode_cursor<C>() helpers; compose via flatten
  · CursorListResponse<T> — standard {items, next_cursor?} envelope with
    from_oversized() and with_cursor() builders

- Migrate GrantCursor to impl PageCursor (remove duplicate encode/decode)

- Update GET /api/grants/incoming/resources:
  · SharedWithMeQuery now embeds CursorQuery via #[serde(flatten)]
  · Replace file/folder nullable pair with ResourceContentDto (untagged
    enum) under a single always-present 'resource' field
  · SharedWithMeDto is now a type alias for CursorListResponse<SharedWithMeItemDto>
  · Handler uses q.paging.limit_clamped() and decode_cursor<GrantCursor>()

- Add docs/architecture/resource-listing.md — authoritative contract for
  all listing endpoints (cursor design, SQL keyset WHERE, sort_by naming,
  Rust + JS skeletons, compliance table, migration guide)

- Register doc in VitePress sidebar and architecture index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:15:04 +02:00

162 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Standard types for cursor-based, sortable listing endpoints.
//!
//! All `GET` endpoints that return a collection **must** use these types so
//! that every listing is consistent for API consumers.
//!
//! # Quick start
//!
//! ```rust,ignore
//! // 1. Define a cursor for your endpoint
//! #[derive(Serialize, Deserialize)]
//! pub struct MyCursor { pub created_at: DateTime<Utc>, pub id: Uuid }
//! impl PageCursor for MyCursor {} // encode/decode for free
//!
//! // 2. Compose the standard query params
//! #[derive(Deserialize, IntoParams)]
//! pub struct MyQuery {
//! #[serde(flatten)]
//! pub paging: CursorQuery,
//! pub my_filter: Option<String>, // endpoint-specific extras
//! }
//!
//! // 3. Return the standard envelope
//! async fn list_things(Query(q): Query<MyQuery>, …) -> Json<CursorListResponse<ThingDto>> {
//! let limit = q.paging.limit_clamped();
//! let cursor = q.paging.decode_cursor::<MyCursor>();
//! // fetch limit+1 rows …
//! Json(CursorListResponse::from_oversized(rows, limit, |r| MyCursor { … }))
//! }
//! ```
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
// ToSchema is used on CursorQuery so it can be flattened into IntoParams structs
// ════════════════════════════════════════════════════════════════════════════
// PageCursor trait
// ════════════════════════════════════════════════════════════════════════════
/// Marker trait for opaque keyset-pagination cursors.
///
/// The default `encode` / `decode` implementations use
/// URL-safe base64url (no padding) over a JSON serialisation of `Self`.
/// Any struct that derives `Serialize + Deserialize` can implement this
/// with a bare `impl PageCursor for MyCursor {}`.
///
/// The encoding is intentionally opaque to API callers. Treat an
/// undecodable cursor as "start from the top" — never return an error.
pub trait PageCursor: Sized + Serialize + for<'de> Deserialize<'de> {
/// Encode `self` as a URL-safe, no-padding base64url string.
fn encode(&self) -> String {
URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).unwrap_or_default())
}
/// Decode from a base64url string. Returns `None` on any parse failure.
fn decode(s: &str) -> Option<Self> {
let bytes = URL_SAFE_NO_PAD.decode(s).ok()?;
serde_json::from_slice(&bytes).ok()
}
}
// ════════════════════════════════════════════════════════════════════════════
// CursorQuery — standard query params
// ════════════════════════════════════════════════════════════════════════════
/// Standard query parameters for cursor-based listing endpoints.
///
/// Use `CursorQuery` directly as the `Query<CursorQuery>` extractor when an
/// endpoint has no extra filter params. When extra params are needed, declare
/// them in an endpoint-specific struct and **repeat** the three fields — Axum's
/// query extractor uses `serde_urlencoded` which does not support
/// `#[serde(flatten)]`. Use `CursorQuery::default_limit()` for the default
/// and the helpers `limit_clamped()` / `decode_cursor()` by either calling
/// them on `CursorQuery` directly or re-implementing them inline:
///
/// ```rust,ignore
/// #[derive(Deserialize, IntoParams)]
/// pub struct MyQuery {
/// #[serde(default = "CursorQuery::default_limit")]
/// pub limit: u32,
/// pub cursor: Option<String>,
/// pub sort_by: Option<String>,
/// pub status: Option<String>, // endpoint-specific
/// }
/// ```
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
pub struct CursorQuery {
/// Maximum items per page (1–200, default 50).
#[serde(default = "CursorQuery::default_limit")]
pub limit: u32,
/// Opaque cursor from a previous response. Absent on the first page.
pub cursor: Option<String>,
/// Sort dimension. Valid values are endpoint-defined (e.g. `"granted_at"`,
/// `"name"`, `"granted_by"`). Unknown values should return HTTP 400.
pub sort_by: Option<String>,
}
impl CursorQuery {
/// Default value for the `limit` field — exposed `pub` so endpoint-specific
/// query structs can reference it in `#[serde(default = "CursorQuery::default_limit")]`.
pub fn default_limit() -> u32 {
50
}
/// Returns `limit` clamped to `[1, 200]`.
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
/// Decode the optional cursor string into type `C`.
/// Returns `None` when no cursor is present or when decoding fails
/// (invalid cursor → start from the top).
pub fn decode_cursor<C: PageCursor>(&self) -> Option<C> {
self.cursor.as_deref().and_then(C::decode)
}
}
// ════════════════════════════════════════════════════════════════════════════
// CursorListResponse — standard response envelope
// ════════════════════════════════════════════════════════════════════════════
/// Standard response envelope for cursor-paginated listing endpoints.
///
/// `next_cursor` is omitted from the JSON when `None` (i.e. last page).
/// Callers must treat a missing `next_cursor` as end-of-results — never
/// include a `total` count (that would require an expensive `COUNT(*)`).
#[derive(Debug, Serialize, ToSchema)]
pub struct CursorListResponse<T: Serialize> {
pub items: Vec<T>,
/// Opaque cursor for the next page. Absent when this is the last page.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
impl<T: Serialize> CursorListResponse<T> {
/// Build a response from an over-fetched slice (fetch `limit + 1` rows).
///
/// If `items.len() > limit` a next page exists: `items` is truncated to
/// `limit` and `cursor_fn` is called on the **last kept item** to produce
/// the next cursor. Otherwise `next_cursor` is `None`.
pub fn from_oversized<C: PageCursor>(
mut items: Vec<T>,
limit: usize,
cursor_fn: impl FnOnce(&T) -> C,
) -> Self {
let next_cursor = if items.len() > limit {
let c = cursor_fn(&items[limit - 1]);
items.truncate(limit);
Some(c.encode())
} else {
None
};
Self { items, next_cursor }
}
/// Build a response when the next cursor is already known (e.g. returned
/// by a service layer that handles the `limit+1` logic internally).
pub fn with_cursor(items: Vec<T>, next_cursor: Option<String>) -> Self {
Self { items, next_cursor }
}
}