api-bones 4.0.0

Opinionated REST API types: errors (RFC 9457), pagination, health checks, and more
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# Design: Pagination Types for api-bones

**Issue:** my-service#552  
**ADR:** PLATFORM-006  
**Author:** ArchMind  
**Date:** 2026-03-02  
**Status:** Draft — awaiting review before implementation

---

## Problem

All api-bones list endpoints currently return bare `Vec<T>`. There's no pagination, no total counts, no cursor support. As datasets grow this becomes untenable. We need a standard paginated envelope that:

1. Works for offset-based pagination (admin dashboards, tables)
2. Works for cursor-based pagination (infinite scroll, event streams)
3. Lives in `api-bones` so every service uses the same wire format
4. Stays framework-agnostic in the core, with an optional `axum` feature for extractor support

## Wire Format (target)

### Offset-based response
```json
{
  "data": [{"id": "...", "name": "..."}],
  "pagination": {
    "total": 142,
    "page": 2,
    "per_page": 20,
    "total_pages": 8
  }
}
```

### Cursor-based response
```json
{
  "data": [{"id": "...", "name": "..."}],
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJpZCI6NDJ9"
  }
}
```

---

## New Feature Gates

Add to `Cargo.toml`:

```toml
[features]
default = ["serde", "uuid", "chrono"]
serde = ["dep:serde", "dep:serde_json", "dep:serde_with", "uuid?/serde"]
uuid = ["dep:uuid"]
chrono = ["dep:chrono"]
axum = ["dep:axum", "serde"]          # NEW — optional Axum integration

[dependencies]
# ... existing ...
axum = { version = "0.8", optional = true, default-features = false, features = ["query"] }
```

Services that use Axum (all of them today) enable `api-bones = { features = ["axum"] }`. The core types remain usable without Axum.

---

## Type Definitions

### File: `src/pagination.rs` (new module)

```rust
//! Paginated response envelopes and query parameter extractors.

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Pagination metadata
// ---------------------------------------------------------------------------

/// Offset-based pagination metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OffsetPagination {
    /// Total number of items across all pages.
    pub total: u64,
    /// Current page number (1-indexed).
    pub page: u32,
    /// Items per page.
    pub per_page: u32,
    /// Total number of pages.
    pub total_pages: u32,
}

/// Cursor-based pagination metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CursorPagination {
    /// Whether more items exist after this page.
    pub has_more: bool,
    /// Opaque cursor for the next page. `None` when `has_more` is false.
    #[cfg_attr(
        feature = "serde",
        serde(default, skip_serializing_if = "Option::is_none")
    )]
    pub next_cursor: Option<String>,
}

/// Pagination metadata — either offset-based or cursor-based.
///
/// Serializes *untagged* so the JSON is flat under `"pagination"`:
/// - Offset variant: `{"total": N, "page": N, "per_page": N, "total_pages": N}`
/// - Cursor variant: `{"has_more": bool, "next_cursor": "..."}`
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
pub enum PaginationMeta {
    Offset(OffsetPagination),
    Cursor(CursorPagination),
}

// ---------------------------------------------------------------------------
// Paginated response envelope
// ---------------------------------------------------------------------------

/// Generic paginated response envelope.
///
/// ```json
/// {"data": [...], "pagination": {"total": 142, "page": 2, ...}}
/// ```
///
/// `T` is the item type. The response serializes as a flat object with
/// `"data"` and `"pagination"` keys — no outer `"response"` wrapper.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaginatedResponse<T> {
    /// The page of items.
    pub data: Vec<T>,
    /// Pagination metadata (offset or cursor).
    pub pagination: PaginationMeta,
}

impl<T> PaginatedResponse<T> {
    /// Create an offset-paginated response.
    ///
    /// `total_pages` is computed automatically as `ceil(total / per_page)`.
    pub fn offset(data: Vec<T>, total: u64, page: u32, per_page: u32) -> Self {
        let total_pages = if per_page == 0 {
            0
        } else {
            // integer ceil division
            ((total + u64::from(per_page) - 1) / u64::from(per_page)) as u32
        };
        Self {
            data,
            pagination: PaginationMeta::Offset(OffsetPagination {
                total,
                page,
                per_page,
                total_pages,
            }),
        }
    }

    /// Create a cursor-paginated response.
    pub fn cursor(data: Vec<T>, has_more: bool, next_cursor: Option<String>) -> Self {
        Self {
            data,
            pagination: PaginationMeta::Cursor(CursorPagination {
                has_more,
                next_cursor,
            }),
        }
    }

    /// Map the items in this response to a different type.
    pub fn map<U>(self, f: impl FnMut(T) -> U) -> PaginatedResponse<U> {
        PaginatedResponse {
            data: self.data.into_iter().map(f).collect(),
            pagination: self.pagination,
        }
    }
}

// ---------------------------------------------------------------------------
// Query parameter extractors
// ---------------------------------------------------------------------------

/// Default items per page.
const DEFAULT_PER_PAGE: u32 = 20;
/// Maximum items per page (prevents abuse).
const MAX_PER_PAGE: u32 = 100;
/// Default page number.
const DEFAULT_PAGE: u32 = 1;

/// Offset-based pagination query parameters.
///
/// Extracts `?page=2&per_page=20` from the query string.
/// Missing values get sensible defaults.
///
/// ```
/// use api_bones::OffsetParams;
///
/// let params = OffsetParams::default();
/// assert_eq!(params.page(), 1);
/// assert_eq!(params.per_page(), 20);
/// assert_eq!(params.offset(), 0);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OffsetParams {
    /// Page number (1-indexed). Defaults to 1.
    #[cfg_attr(feature = "serde", serde(default = "default_page"))]
    page: u32,
    /// Items per page. Defaults to 20, clamped to [1, 100].
    #[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
    per_page: u32,
}

#[cfg(feature = "serde")]
const fn default_page() -> u32 { DEFAULT_PAGE }
#[cfg(feature = "serde")]
const fn default_per_page() -> u32 { DEFAULT_PER_PAGE }

impl Default for OffsetParams {
    fn default() -> Self {
        Self {
            page: DEFAULT_PAGE,
            per_page: DEFAULT_PER_PAGE,
        }
    }
}

impl OffsetParams {
    /// Create with explicit page and per_page (clamped).
    #[must_use]
    pub fn new(page: u32, per_page: u32) -> Self {
        Self {
            page: page.max(1),
            per_page: per_page.clamp(1, MAX_PER_PAGE),
        }
    }

    /// Current page (1-indexed, minimum 1).
    #[must_use]
    pub fn page(&self) -> u32 {
        self.page.max(1)
    }

    /// Items per page (clamped to [1, 100]).
    #[must_use]
    pub fn per_page(&self) -> u32 {
        self.per_page.clamp(1, MAX_PER_PAGE)
    }

    /// SQL-style offset: `(page - 1) * per_page`.
    #[must_use]
    pub fn offset(&self) -> u64 {
        u64::from(self.page().saturating_sub(1)) * u64::from(self.per_page())
    }

    /// SQL-style limit (same as `per_page()`).
    #[must_use]
    pub fn limit(&self) -> u64 {
        u64::from(self.per_page())
    }
}

/// Cursor-based pagination query parameters.
///
/// Extracts `?cursor=eyJpZCI6NDJ9&limit=20` from the query string.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CursorParams {
    /// Opaque cursor from a previous response. `None` for the first page.
    #[cfg_attr(
        feature = "serde",
        serde(default, skip_serializing_if = "Option::is_none")
    )]
    pub cursor: Option<String>,
    /// Items to return. Defaults to 20, clamped to [1, 100].
    #[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
    limit: u32,
}

impl Default for CursorParams {
    fn default() -> Self {
        Self {
            cursor: None,
            limit: DEFAULT_PER_PAGE,
        }
    }
}

impl CursorParams {
    /// Create with explicit cursor and limit.
    #[must_use]
    pub fn new(cursor: Option<String>, limit: u32) -> Self {
        Self {
            cursor,
            limit: limit.clamp(1, MAX_PER_PAGE),
        }
    }

    /// Items to return (clamped to [1, 100]).
    #[must_use]
    pub fn limit(&self) -> u32 {
        self.limit.clamp(1, MAX_PER_PAGE)
    }
}

/// Unified pagination query parameters.
///
/// If `cursor` is present, cursor-based mode is used.
/// Otherwise, falls back to offset-based with `page`/`per_page`.
///
/// This allows a single query extractor for endpoints that support both modes.
///
/// ```
/// // Offset: GET /api/v1/users?page=2&per_page=10
/// // Cursor: GET /api/v1/events?cursor=eyJpZCI6NDJ9&limit=50
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaginationParams {
    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
    pub cursor: Option<String>,
    #[cfg_attr(feature = "serde", serde(default = "default_page"))]
    pub page: u32,
    #[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
    pub per_page: u32,
    /// Alias for `per_page` in cursor mode.
    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
    pub limit: Option<u32>,
}

impl Default for PaginationParams {
    fn default() -> Self {
        Self {
            cursor: None,
            page: DEFAULT_PAGE,
            per_page: DEFAULT_PER_PAGE,
            limit: None,
        }
    }
}

impl PaginationParams {
    /// Returns `true` if cursor-based pagination was requested.
    #[must_use]
    pub fn is_cursor(&self) -> bool {
        self.cursor.is_some()
    }

    /// Convert to offset params (ignoring cursor).
    #[must_use]
    pub fn as_offset(&self) -> OffsetParams {
        OffsetParams::new(self.page, self.per_page)
    }

    /// Convert to cursor params.
    #[must_use]
    pub fn as_cursor(&self) -> CursorParams {
        CursorParams::new(
            self.cursor.clone(),
            self.limit.unwrap_or(self.per_page),
        )
    }
}
```

### Axum Integration (`src/pagination.rs`, gated)

```rust
// ---------------------------------------------------------------------------
// Axum extractor support (feature = "axum")
// ---------------------------------------------------------------------------

#[cfg(feature = "axum")]
mod axum_support {
    use super::*;

    // axum::extract::Query<T> already implements FromRequestParts
    // for any T: DeserializeOwned. Since OffsetParams, CursorParams,
    // and PaginationParams all derive Deserialize, they work out of
    // the box as:
    //
    //   async fn list_users(
    //       Query(params): Query<OffsetParams>,
    //   ) -> impl IntoResponse { ... }
    //
    // No custom FromRequestParts impl needed.

    // IntoResponse for PaginatedResponse<T> so handlers can return it directly.

    impl<T: serde::Serialize> axum::response::IntoResponse for PaginatedResponse<T> {
        fn into_response(self) -> axum::response::Response {
            axum::Json(self).into_response()
        }
    }
}
```

### Module Registration (`src/lib.rs`)

```rust
pub mod pagination;

pub use pagination::{
    CursorPagination, CursorParams, OffsetPagination, OffsetParams,
    PaginatedResponse, PaginationMeta, PaginationParams,
};
```

---

## Design Decisions

### 1. Untagged enum for `PaginationMeta`

`#[serde(untagged)]` gives us clean JSON without a `"type"` discriminator. The two variants have disjoint field sets (`total`/`page` vs `has_more`/`next_cursor`), so deserialization is unambiguous.

### 2. Separate `OffsetParams` / `CursorParams` + unified `PaginationParams`

Most endpoints will use one pagination style exclusively:
- **CRUD list endpoints**`OffsetParams` (tables with page numbers)
- **Event/audit streams**`CursorParams` (forward-only traversal)

The unified `PaginationParams` exists for rare endpoints that support both, and for future-proofing. Endpoints pick the extractor that fits.

### 3. Framework-agnostic core, optional `axum` feature

The params types derive `Deserialize`, so `axum::extract::Query<OffsetParams>` works automatically — no custom `FromRequestParts` needed. The `axum` feature only adds `IntoResponse` for `PaginatedResponse<T>`, enabling handlers to return it directly.

### 4. Clamped values with accessors

Raw fields + accessor methods that clamp (`per_page` to [1, 100], `page` minimum 1). This prevents accidental `per_page=999999` abuse at the type level, not the handler level.

### 5. `map()` on `PaginatedResponse`

Allows converting domain types to response DTOs without reconstructing the pagination metadata:
```rust
let db_result: PaginatedResponse<UserRow> = repo.list_users(params).await?;
let response = db_result.map(UserResponse::from);
```

### 6. Constants are private, not configurable

`DEFAULT_PER_PAGE` (20) and `MAX_PER_PAGE` (100) are hardcoded. If a service needs different limits, it can clamp after extraction. Keeping these as shared defaults enforces consistency across the platform.

---

## Trait Implementations Required

| Type | Trait | Notes |
|---|---|---|
| `PaginatedResponse<T>` | `Serialize` (when `T: Serialize`) | `#[cfg_attr(feature = "serde", ...)]` |
| `PaginatedResponse<T>` | `Deserialize` (when `T: Deserialize`) | For SDK/client usage |
| `PaginatedResponse<T>` | `axum::response::IntoResponse` (when `T: Serialize`) | `#[cfg(feature = "axum")]` |
| `OffsetParams`, `CursorParams`, `PaginationParams` | `Serialize + Deserialize` | Query string extraction |
| `OffsetPagination`, `CursorPagination` | `Serialize + Deserialize` | Part of response |
| `PaginationMeta` | `Serialize + Deserialize` (untagged) | Enum dispatch |
| All types | `Debug, Clone, PartialEq, Eq` | Match existing conventions (`Eq` where possible) |

Note: `PaginatedResponse<T>` gets `PartialEq` only (not `Eq`) to match `ApiError` convention — `T` may not be `Eq`.

---

## Example Usage

### Handler returning offset-paginated users

```rust
use axum::extract::{Query, State};
use api_bones::{OffsetParams, PaginatedResponse};

async fn list_users(
    State(state): State<AppState>,
    Query(params): Query<OffsetParams>,
) -> Result<PaginatedResponse<UserResponse>, ApiErrorResponse> {
    let (users, total) = UserService::list_paginated(
        &state.db,
        params.offset(),
        params.limit(),
    ).await?;

    Ok(PaginatedResponse::offset(
        users.into_iter().map(UserResponse::from).collect(),
        total,
        params.page(),
        params.per_page(),
    ))
}
```

### Handler returning cursor-paginated events

```rust
use api_bones::{CursorParams, PaginatedResponse};

async fn list_events(
    State(state): State<AppState>,
    Query(params): Query<CursorParams>,
) -> Result<PaginatedResponse<EventResponse>, ApiErrorResponse> {
    let (events, next_cursor) = EventService::list_after_cursor(
        &state.db,
        params.cursor.as_deref(),
        params.limit(),
    ).await?;

    let has_more = next_cursor.is_some();
    Ok(PaginatedResponse::cursor(events, has_more, next_cursor))
}
```

### Using `map()` for DTO conversion

```rust
let page: PaginatedResponse<OrgRow> = org_repo.list(params).await?;
let response: PaginatedResponse<OrganizationResponse> = page.map(Into::into);
```

---

## Migration Path for my-service

Current list endpoints return `Json<Vec<T>>`. Migration per endpoint:

1. Add `Query(params): Query<OffsetParams>` parameter
2. Change service layer to accept `offset`/`limit` and return `(Vec<T>, u64)` (items + total count)
3. Return `PaginatedResponse::offset(...)` instead of `Json(vec)`
4. Update OpenAPI annotations (`body = PaginatedResponse<T>`)

This is backward-**incompatible** (response shape changes from `[...]` to `{"data": [...], "pagination": {...}}`). Coordinate with frontend/SDK consumers. Consider versioning or a migration period if needed.

---

## Test Plan

1. **Serde round-trip** for offset and cursor variants
2. **Wire format** assertions (exact JSON shape, field names)
3. **OffsetParams clamping**: `per_page=0` → 1, `per_page=999` → 100, `page=0` → 1
4. **OffsetParams::offset()**: page 1 → 0, page 3 with per_page 20 → 40
5. **Untagged deserialization**: offset JSON → `PaginationMeta::Offset`, cursor JSON → `PaginationMeta::Cursor`
6. **`map()`**: verify pagination metadata preserved after mapping
7. **PaginatedResponse::offset()** `total_pages` calculation: edge cases (0 items, 1 item, exact multiple)
8. **Feature gate compilation**: build with `--no-default-features`, with `serde` only, with `axum`

---

## Open Questions

1. **Should `IntoResponse` set any pagination headers?** (e.g., `X-Total-Count`, `Link` headers for RFC 8288). Current design: no — keep it simple, metadata is in the body. Revisit if needed.
2. **Should `PaginatedResponse` implement `utoipa::ToSchema`?** The my-service uses utoipa for OpenAPI. This may need a `utoipa` feature gate or manual schema impl since generic types are tricky with utoipa's derive macro. Defer to implementation phase.
3. **Cursor encoding convention?** The spec doesn't prescribe cursor format — that's intentionally left to each service (base64-encoded JSON, opaque UUID, etc.). The type is just `String`.