diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 30f2171e..a3e6a9fb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -96,10 +96,12 @@ export default defineConfig({ items: [ { text: "Internal Architecture", link: "/architecture/" }, { text: "Caching", link: "/architecture/caching" }, + { text: "Resource Listing API", link: "/architecture/resource-listing" }, { text: "Storage Safety", link: "/architecture/file-system-safety" }, { text: "Database Transactions", link: "/architecture/database-transactions" }, { text: "Share Integration", link: "/architecture/share-integration" }, { text: "Storage Quotas", link: "/architecture/storage-quotas" }, + { text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" }, ], }, { text: "FAQ", link: "/faq" }, diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 26bc07ae..b5cd0c6f 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -71,4 +71,5 @@ src/ ## Further Reading - [Caching Architecture →](/architecture/caching) +- [Resource Listing API →](/architecture/resource-listing) - [Storage Quotas →](/architecture/storage-quotas) diff --git a/docs/architecture/resource-listing.md b/docs/architecture/resource-listing.md new file mode 100644 index 00000000..7aaf7497 --- /dev/null +++ b/docs/architecture/resource-listing.md @@ -0,0 +1,331 @@ +# Resource Listing API Contract + +Every OxiCloud endpoint that returns a **collection** of items must follow the conventions in +this document. Consistency makes the REST API predictable for clients and keeps server-side +code easy to audit and extend. + +## TL;DR checklist + +- [ ] Response is `CursorListResponse` → `{ items: T[], next_cursor?: string }` +- [ ] Query embeds `CursorQuery` via `#[serde(flatten)]` +- [ ] `limit` is clamped with `q.paging.limit_clamped()` — never trust the raw value +- [ ] Cursor is decoded with `q.paging.decode_cursor::()` — invalid cursor → first page +- [ ] Cursor struct implements `PageCursor` (one bare `impl` line) +- [ ] Cursor includes **every column** in `ORDER BY` plus a unique tiebreaker (`id`) +- [ ] `sort_by` param is present even if only one sort value is meaningful today +- [ ] SQL fetches `limit + 1` rows to detect whether a next page exists + +--- + +## Response envelope — `CursorListResponse` + +All listing endpoints return the same wrapper (defined in +`src/application/dtos/cursor.rs`): + +```json +{ + "items": [ … ], + "next_cursor": "eyJncmFudGVkX2F0IjoiMjAyNi…" +} +``` + +| Field | Type | Rules | +|---|---|---| +| `items` | `T[]` | The page of results. Length ≤ `limit`. | +| `next_cursor` | `string` | **Omitted** (not `null`) when this is the last page. | + +**Never** include `total`, `page`, or `offset` — computing a total requires a `COUNT(*)` that +does not scale. + +--- + +## Resource content field + +When an item can be a **file or a folder** (or any future resource type), use a single +`resource` field rather than nullable `file`/`folder` siblings. The existing +`resource_type` discriminator tells the client which shape to expect. + +```json +{ + "resource_type": "file", + "resource": { "id": "…", "name": "photo.jpg", "size": 204800, … }, + … +} +``` + +Adding a third resource type in the future only requires a new `resource_type` variant — +the wrapper shape stays the same, so older clients that don't know the new variant simply +skip the item. + +### Rust — `ResourceContentDto` + +```rust +#[derive(Debug, Serialize, ToSchema)] +#[serde(untagged)] // ← serialises as the inner object; no wrapper key +pub enum ResourceContentDto { + File(FileDto), + Folder(FolderDto), + // Playlist(PlaylistDto), ← add future variants here +} +``` + +### JavaScript / JSDoc + +```js +/** + * @typedef {Object} MyListItem + * @property {'file'|'folder'} resource_type + * @property {FileItem|FolderItem} resource // always present; shape follows resource_type + */ + +const f = item.resource; +if (item.resource_type === 'folder') { /* FolderItem fields */ } +else { /* FileItem fields */ } +``` + +--- + +## Standard query parameters — `CursorQuery` + +`CursorQuery` (in `src/application/dtos/cursor.rs`) carries the three fields every listing +endpoint needs. Use it directly as `Query` when there are no extra filters. +When extra params are needed, **repeat the three fields** in your endpoint-specific struct +— Axum's query extractor uses `serde_urlencoded` which does not support +`#[serde(flatten)]`: + +```rust +#[derive(Debug, Deserialize, IntoParams)] +pub struct MyQuery { + // Standard cursor fields — repeated (not flattened) due to serde_urlencoded limitation + #[serde(default = "CursorQuery::default_limit")] + pub limit: u32, + pub cursor: Option, + pub sort_by: Option, + // Endpoint-specific extra + pub status: Option, +} +``` + +| Parameter | Type | Default | Constraints | +|---|---|---|---| +| `limit` | integer | 50 | 1–200; use `q.paging.limit_clamped()` | +| `cursor` | string | — | Opaque; absent on first page | +| `sort_by` | string | endpoint-defined | See §Sort values below | + +### Naming conventions + +- Use `sort_by`, **not** `order`, `orderBy`, or `sort`. +- Values are **snake_case**: `granted_at`, `name`, `size`, `granted_by`. +- Append `_desc` for descending: `name_desc`, `size_desc`. No separate `direction` param. +- Default sort is the most natural recency order (usually `created_at DESC`). +- Return **HTTP 400** for unknown `sort_by` values. + +--- + +## Cursor design — `PageCursor` trait + +Use **keyset (seek) pagination** — never offset-based pagination. + +### Why not offset? + +`OFFSET N` forces the database to scan and discard N rows on every page load, which becomes +unacceptably slow for large collections. Keyset pagination skips directly to the right row +via an index seek, regardless of page depth. + +### Implementing a cursor + +`PageCursor` (in `src/application/dtos/cursor.rs`) provides `encode`/`decode` as default +methods. A cursor struct needs only a bare `impl` line: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyCursor { + pub created_at: DateTime, + pub id: Uuid, // tiebreaker — must be unique +} + +impl PageCursor for MyCursor {} // encode/decode for free +``` + +**The cursor must include every column in `ORDER BY`** plus a unique tiebreaker so that two +rows with identical sort values never cause items to be skipped or repeated. + +| Sort | Cursor fields | +|---|---| +| `created_at DESC` (default) | `created_at`, `id` | +| `granted_by ASC` | `granted_by`, `created_at`, `id` | +| `name ASC` | `name`, `id` | + +Encoding is URL-safe base64url (no padding) over a JSON payload — opaque to API callers. +An undecodable cursor is treated as "start from the top" (never an error). + +--- + +## SQL implementation + +Fetch **`limit + 1`** rows. If more than `limit` rows are returned, a next page exists: +truncate to `limit` and encode the last kept item as the next cursor. + +```sql +-- Default: ORDER BY created_at DESC, id DESC +WHERE ( + $cursor_created_at IS NULL -- first page + OR created_at < $cursor_created_at + OR (created_at = $cursor_created_at AND id < $cursor_id::uuid) +) +ORDER BY created_at DESC, id DESC +LIMIT $limit + 1 +``` + +For an additional sort dimension (e.g. `sort_by = "granted_by"`): + +```sql +-- ORDER BY granted_by ASC, created_at DESC, id DESC +WHERE ( + $cursor_granted_by IS NULL + OR granted_by > $cursor_granted_by + OR (granted_by = $cursor_granted_by AND created_at < $cursor_created_at) + OR (granted_by = $cursor_granted_by AND created_at = $cursor_created_at + AND id < $cursor_id::uuid) +) +ORDER BY granted_by ASC, created_at DESC, id DESC +LIMIT $limit + 1 +``` + +--- + +## Rust implementation skeleton + +```rust +// ── DTO layer ──────────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +pub struct ThingCursor { pub created_at: DateTime, pub id: Uuid } +impl PageCursor for ThingCursor {} + +#[derive(Deserialize, IntoParams)] +pub struct ThingQuery { + #[serde(flatten)] + pub paging: CursorQuery, + pub status: Option, +} + +// ── Handler ────────────────────────────────────────────────────────────────── + +pub async fn list_things( + Query(q): Query, + State(state): State, + auth_user: AuthUser, +) -> impl IntoResponse { + let limit = q.paging.limit_clamped(); + let cursor = q.paging.decode_cursor::(); + let sort = q.paging.sort_by.as_deref().unwrap_or("created_at"); + + // Service returns limit+1 rows; the cursor comes from the service layer + // (it knows which columns to include based on the sort). + let (rows, next_cursor) = state.service + .list_things(auth_user.id, limit + 1, cursor, sort) + .await?; + + Json(CursorListResponse::with_cursor( + rows.into_iter().take(limit).map(ThingDto::from).collect(), + next_cursor.map(|c| c.encode()), + )) +} +``` + +--- + +## JavaScript consumption pattern + +```js +// static/js/core/types.js +/** + * @template T + * @typedef {Object} CursorListResponse + * @property {T[]} items + * @property {string} [next_cursor] // absent on last page + */ + +// View module (e.g. sharedWithMeView.js) +let _cursor = null; +let _loading = false; + +async function loadPage() { + if (_loading) return; + _loading = true; + try { + const params = new URLSearchParams({ limit: '50' }); + if (_sortBy) params.set('sort_by', _sortBy); + if (_cursor) params.set('cursor', _cursor); + + /** @type {CursorListResponse} */ + const data = await fetch(`/api/things?${params}`).then(r => r.json()); + renderItems(data.items); + _cursor = data.next_cursor ?? null; + loadMoreBtn.hidden = _cursor === null; + } finally { + _loading = false; + } +} + +// Reset on section entry or sort change: +function reset() { _cursor = null; clearList(); loadPage(); } +``` + +--- + +## Sort values reference + +| Value | SQL ORDER BY | Typical use | +|---|---|---| +| `created_at` (default) | `created_at DESC, id DESC` | Newest first | +| `created_at_asc` | `created_at ASC, id ASC` | Oldest first | +| `granted_by` | `granted_by ASC, created_at DESC, id DESC` | Swimlane grouping | +| `name` | `lower(name) ASC, id ASC` | Case-insensitive alpha | +| `name_desc` | `lower(name) DESC, id DESC` | Reverse alpha | +| `size` | `size_bytes ASC, id ASC` | Smallest first | +| `size_desc` | `size_bytes DESC, id DESC` | Largest first | + +Only expose sort values that are meaningful for the resource type. The `sort_by` param +must always exist in the query struct, even if only one value is supported today — this +avoids a breaking API change when a second sort is added later. + +--- + +## Endpoint compliance + +| Endpoint | Cursor | `sort_by` | Status | +|---|---|---|---| +| `GET /api/grants/incoming/resources` | ✅ | 🔜 planned | **Reference implementation** | +| `GET /api/photos` | ⚠️ `before` header | ❌ | Non-standard — migrate to body cursor | +| `GET /api/search` | ❌ offset | ✅ | Migrate cursor | +| `GET /api/folders/paginated` | ❌ page | ❌ | Migrate cursor | +| `GET /api/folders/{id}/contents/paginated` | ❌ page | ❌ | Migrate cursor | +| `GET /api/admin/users` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/address-books/{id}/contacts` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/shares` | ❌ page | ❌ | Migrate cursor | +| `GET /api/playlists` | ❌ offset | ❌ | Migrate cursor | +| `GET /api/recent` | ❌ limit only | ❌ | Migrate cursor | +| `GET /api/files` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/folders` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/folders/{id}/listing` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/favorites` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/trash` | ❌ **none** | ❌ | Unbounded — **urgent** | +| `GET /api/grants/incoming` | ❌ **none** | ❌ | Unbounded | +| `GET /api/grants/outgoing` | ❌ **none** | ❌ | Unbounded | + +--- + +## Migration guide — offset/none → cursor + +1. **Add `CursorQuery`** via `#[serde(flatten)]` to the query struct; remove `page`, + `offset`, `per_page`. +2. **Define a cursor struct** with the `ORDER BY` columns + `id`; add `impl PageCursor`. +3. **Adjust SQL** to the keyset `WHERE` pattern; fetch `limit + 1`. +4. **Return `CursorListResponse`** built with `from_oversized` or `with_cursor`. +5. **Remove** `total`, `total_pages`, `has_next`, `has_prev` from the response. +6. **Update the JS caller**: remove page tracking, add `_cursor` state, pass it on + "Load more", reset to `null` on section entry. +7. **Update `types.js`**: remove old pagination typedef fields, add + `next_cursor?: string` to the response typedef. diff --git a/src/application/dtos/cursor.rs b/src/application/dtos/cursor.rs new file mode 100644 index 00000000..74f2a79e --- /dev/null +++ b/src/application/dtos/cursor.rs @@ -0,0 +1,161 @@ +//! 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, 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, // endpoint-specific extras +//! } +//! +//! // 3. Return the standard envelope +//! async fn list_things(Query(q): Query, …) -> Json> { +//! let limit = q.paging.limit_clamped(); +//! let cursor = q.paging.decode_cursor::(); +//! // 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 { + 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` 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, +/// pub sort_by: Option, +/// pub status: Option, // 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, + /// Sort dimension. Valid values are endpoint-defined (e.g. `"granted_at"`, + /// `"name"`, `"granted_by"`). Unknown values should return HTTP 400. + pub sort_by: Option, +} + +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(&self) -> Option { + 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 { + pub items: Vec, + /// Opaque cursor for the next page. Absent when this is the last page. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl CursorListResponse { + /// 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( + mut items: Vec, + 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, next_cursor: Option) -> Self { + Self { items, next_cursor } + } +} diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 23553a0c..d6b1030d 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; @@ -232,26 +233,58 @@ impl From for GrantDto { // ════════════════════════════════════════════════════════════════════════════ /// Query parameters for `GET /api/grants/incoming/resources`. +/// +/// `limit`, `cursor`, and `sort_by` follow the standard [`CursorQuery`] +/// contract. They are declared directly here rather than via +/// `#[serde(flatten)]` because `serde_urlencoded` (Axum's query extractor) +/// does not support flattening. #[derive(Debug, Deserialize, IntoParams)] pub struct SharedWithMeQuery { /// Maximum number of items to return (1–200, default 50). - #[serde(default = "shared_with_me_default_limit")] + #[serde(default = "CursorQuery::default_limit")] pub limit: u32, + /// Opaque cursor from a previous response. Omit to start from the + /// most-recently-granted item. + pub cursor: Option, + /// Sort dimension. Supported values: `"granted_at"` (default), + /// `"granted_by"` (for swimlane grouping). + pub sort_by: Option, /// Comma-separated resource types to include, e.g. `file,folder`. /// Omit to return all known types. pub resource_types: Option, - /// Opaque cursor returned by a previous call. Omit to start from the - /// most-recently-granted item. - pub cursor: Option, } -fn shared_with_me_default_limit() -> u32 { - 50 +impl SharedWithMeQuery { + /// Returns `limit` clamped to `[1, 200]`. + pub fn limit_clamped(&self) -> usize { + self.limit.clamp(1, 200) as usize + } + + /// Decode the optional cursor string. Invalid cursor → start from top. + pub fn decode_cursor(&self) -> Option { + self.cursor.as_deref().and_then(C::decode) + } } -/// One item in the shared-with-me list. Exactly one of `file` / `folder` is -/// populated, indicated by `resource_type`. Additional optional fields for -/// future resource types (playlist, addressbook, …) will be added here. +/// The resource payload for one item in the shared-with-me list. +/// +/// The variant is discriminated by `resource_type` on the parent +/// [`SharedWithMeItemDto`]. Serialised as the inner object (no wrapper key) +/// via `#[serde(untagged)]`, so consumers see the file/folder fields directly +/// under the `resource` key. +#[derive(Debug, Serialize, ToSchema)] +#[serde(untagged)] +pub enum ResourceContentDto { + File(FileDto), + Folder(FolderDto), +} + +/// One item in the shared-with-me list. +/// +/// `resource_type` indicates whether `resource` contains a file or a folder. +/// Using a single `resource` field (instead of nullable `file`/`folder` pairs) +/// makes adding new resource types backward-compatible — only `resource_type` +/// gains a new variant; the wrapper shape stays the same. #[derive(Debug, Serialize, ToSchema)] pub struct SharedWithMeItemDto { pub resource_type: ResourceTypeDto, @@ -261,17 +294,9 @@ pub struct SharedWithMeItemDto { pub granted_at: chrono::DateTime, /// UUID of the user who created the (earliest) grant. pub granted_by: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub folder: Option, + /// Full resource details. Shape is determined by `resource_type`. + pub resource: ResourceContentDto, } /// Response for `GET /api/grants/incoming/resources`. -#[derive(Debug, Serialize, ToSchema)] -pub struct SharedWithMeDto { - pub items: Vec, - /// Opaque cursor for the next page. Absent when the last page is reached. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, -} +pub type SharedWithMeDto = CursorListResponse; diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 52cb3135..68400c05 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,4 +1,6 @@ pub mod address_book_dto; +pub mod cursor; +pub use cursor::{CursorListResponse, CursorQuery, PageCursor}; pub mod app_password_dto; pub mod calendar_dto; pub mod contact_dto; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 939dfdeb..bdfcbb22 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,7 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use crate::application::dtos::cursor::PageCursor; use std::fmt; use uuid::Uuid; @@ -265,20 +265,8 @@ pub struct GrantCursor { pub resource_id: Uuid, } -impl GrantCursor { - /// Encode as a URL-safe base64 JSON string (no padding). - pub fn encode(&self) -> String { - let json = serde_json::to_vec(self).unwrap_or_default(); - URL_SAFE_NO_PAD.encode(&json) - } - - /// Decode from a URL-safe base64 JSON string. Returns `None` on any - /// parse failure — callers treat a bad cursor as "start from the top". - pub fn decode(s: &str) -> Option { - let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; - serde_json::from_slice(&bytes).ok() - } -} +/// Delegate encode/decode to the shared [`PageCursor`] trait. +impl PageCursor for GrantCursor {} #[cfg(test)] mod tests { diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index eb813ccb..12c34128 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -18,9 +18,10 @@ use tracing::{error, info, warn}; use utoipa::IntoParams; use uuid::Uuid; +use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, - SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, + CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, + SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -320,10 +321,10 @@ pub async fn list_shared_with_me( .unwrap_or_default(); // Clamp limit to 1–200. - let limit = q.limit.clamp(1, 200); + let limit = q.limit_clamped() as u32; // Decode cursor (treat invalid cursor as "start from top"). - let cursor = q.cursor.as_deref().and_then(GrantCursor::decode); + let cursor = q.decode_cursor::(); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state @@ -384,8 +385,9 @@ pub async fn list_shared_with_me( permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, - file: Some(file_dto.clone().without_hierarchy_info()), - folder: None, + resource: ResourceContentDto::File( + file_dto.clone().without_hierarchy_info(), + ), }); } Err(e) if e.kind == ErrorKind::NotFound => { @@ -414,8 +416,9 @@ pub async fn list_shared_with_me( permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, - file: None, - folder: Some(folder_dto.clone().without_hierarchy_info()), + resource: ResourceContentDto::Folder( + folder_dto.clone().without_hierarchy_info(), + ), }); } Err(e) if e.kind == ErrorKind::NotFound => { @@ -438,10 +441,10 @@ pub async fn list_shared_with_me( ( StatusCode::OK, - Json(SharedWithMeDto { + Json(SharedWithMeDto::with_cursor( items, - next_cursor: next_cursor.map(|c| c.encode()), - }), + next_cursor.map(|c| c.encode()), + )), ) .into_response() } diff --git a/static/js/core/types.js b/static/js/core/types.js index 2aafe956..51a561b8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -297,14 +297,13 @@ /** * One item returned by `GET /api/grants/incoming/resources`. - * Exactly one of `file` / `folder` is populated (indicated by `resource_type`). + * `resource_type` discriminates the shape of `resource`. * @typedef {Object} SharedWithMeItem * @property {ResourceTypeEnum} resource_type * @property {PermissionTypeEnum[]} permissions - All permissions the caller holds on this resource. * @property {string} granted_at - ISO-8601 timestamp of the earliest grant. * @property {string} granted_by - UUID of the user who created the grant. - * @property {FileItem|undefined} [file] - Populated when resource_type === 'file'. - * @property {FolderItem|undefined} [folder] - Populated when resource_type === 'folder'. + * @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type. */ /** diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 6cc68ad6..4e3f8f9e 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -150,8 +150,8 @@ const sharedWithMeView = { const ownerMap = new Map(); for (const item of items) { - if (item.resource_type === 'folder' && item.folder) { - const f = item.folder; + if (item.resource_type === 'folder') { + const f = /** @type {FolderItem} */ (item.resource); folders.push( /** @type {FolderItem} */ ({ id: f.id, @@ -168,8 +168,8 @@ const sharedWithMeView = { }) ); ownerMap.set(f.id, item.granted_by); - } else if (item.resource_type === 'file' && item.file) { - const f = item.file; + } else if (item.resource_type === 'file') { + const f = /** @type {FileItem} */ (item.resource); files.push( /** @type {FileItem} */ ({ id: f.id,