# π‘ Backbone Core β API Reference
**Status:** β
Current Β· **Last Updated:** 2026-06-06
The HTTP contract produced by `BackboneCrudHandler::routes(service, base_path)`. Paths
below are relative to `{base}` (e.g. `/api/v1/products`). All request bodies accept
`application/json` or `application/x-www-form-urlencoded` (JSON is the lenient default).
## π§ Endpoint catalogue
### Read endpoints (`read_routes`)
| GET | `{base}` | List active (paginate / filter / sort / search / `?fields=` / `?include=`) | 200 | `PaginatedApiResponse<R>` |
| GET | `{base}/:id` | Get one active by id (`?fields=` / `?include=`) | 200 | `ApiResponse<R>` |
| GET | `{base}/trash` | List soft-deleted (paginate / filter / `?fields=`) | 200 | `PaginatedApiResponse<R>` |
| GET | `{base}/:id/deleted` | Get one soft-deleted by id (`?fields=`) | 200 | `ApiResponse<R>` |
| GET | `{base}/count` | Count active entities | 200 | `ApiResponse<u64>` |
| GET | `{base}/trash/count` | Count soft-deleted entities | 200 | `ApiResponse<u64>` |
### Write endpoints (`write_routes`)
| POST | `{base}` | Create one | 201 | `C` (create DTO) |
| PUT | `{base}/:id` | Full update one | 200 | `U` (update DTO) |
| PATCH | `{base}/:id` | Partial update one | 200 | object of fields |
| DELETE | `{base}/:id` | Soft-delete one | 200 | β |
| POST | `{base}/:id/restore` | Restore one | 200 | β |
| POST | `{base}/upsert` | Create or update one | 201 | `C` (create DTO) |
| POST | `{base}/bulk` | Create many | 201 | `{ "items": [C, β¦] }` |
| PUT | `{base}/bulk` | Full-update many (atomic) | 200 | `[{ "id", β¦U }]` |
| PATCH | `{base}/bulk` | Partial-update many (atomic) | 200 | shared or per-id (below) |
| POST | `{base}/delete/bulk` | Soft-delete many (atomic) | 200 | `{ "ids": [...] }` |
| POST | `{base}/restore/bulk` | Restore many (atomic) | 200 | `{ "ids": [...] }` |
| POST | `{base}/restore/all` | Restore all soft-deleted (atomic) | 200 | β |
| DELETE | `{base}/trash/bulk` | Permanently delete many trashed (atomic) | 200 | `{ "ids": [...] }` |
| DELETE | `{base}/trash/:id` | Permanently delete one trashed | 204 | β |
| DELETE | `{base}/empty` | Empty the trash (permanent) | 200 | β |
> **Route precedence:** static segments are registered before `:id` captures, so
> `/trash/bulk`, `/restore/all`, `/delete/bulk` never collide with `/:id`.
## π Query parameters (`ListQueryParams`)
Applies to `GET {base}`, `GET {base}/trash`, and the `?fields=` projection on the
single-get endpoints.
| `page` | `u32` | 1 | 1-indexed |
| `limit` | `u32` | 20 | Clamped to `MAX_PER_PAGE` = 100 |
| `sort_by` | `string` | β | Field/column name |
| `sort_order` | `string` | β | `asc` / `desc` |
| `search` | `string` | β | Free-text search term |
| `status` | `string` | β | Common status filter |
| `fields` | `string` | β | Sparse fieldset (reserved, see below) |
| `include` | `string` | β | Relation expansion (reserved; alias `with`, see below) |
| *(any other key)* | `string` | β | Becomes a filter passed to the repository |
### Sparse fieldsets (`?fields=`)
`?fields=a,b,c` trims each response object to the requested top-level keys **plus the
always-on `id`**. Comma-separated, whitespace-trimmed; unknown keys are ignored; an
absent/empty value returns every field. `fields`, `include`, and `with` are **reserved**
response-shaping keys β stripped before filters reach the repository, so they never leak
into the `WHERE` clause.
### Relation expansion (`?include=`)
`?include=<rel>` (alias `?with=`) hydrates declared **to-one** relations, injecting
the related row into each response object as a sibling keyed by the relation name.
Comma-separated and whitespace-trimmed; only relations the entity declares are
honored β unknown names are silently ignored. Applies to `GET {base}` and
`GET {base}/:id` (not the trash/deleted reads).
```bash
curl "http://localhost:8080/api/v1/products?include=provider,category"
```
```jsonc
{
"id": "p_1",
"name": "Widget",
"providerId": "pr_9",
"provider": { "id": "pr_9", "businessName": "Acme" } // β injected
}
```
An entity opts in by overriding the `backbone_orm::EntityRepoMeta::relations()`
hook (defaults to none, so existing entities are unaffected):
```rust
fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
// (relation name in the response, target DB table, local FK response key)
&[("provider", "providers", "providerId")]
}
```
Expansion is **batched**: for each requested relation, the handler collects the
FK values across every row on the page and issues one `WHERE id = ANY(...)` β
no N+1. The target table comes from the (generator-emitted) `relations()`
metadata, **never** client input, so it is not an injection vector. The expanded
object's top-level keys are camelCased to match the response; a row whose FK has
no match (or is null) gets `null` for that relation.
Ordering: relation expansion runs **after** field-level security and **before**
sparse projection β so `?fields=` can include or omit an expanded relation key
like any other field.
> **v1 limitation:** the expanded object is the raw related row, **not** run
> through the target entity's response DTO or its `@private` field-security. Do
> not enable `?include=` for a target that has private fields until this is
> revisited.
### Field-level security (`@private` / `@owner`)
Read endpoints strip an entity's `@private` fields from the serialized response
unless the caller is allowed to see them. Visibility is decided by an
`AccessScope` (`backbone_core::AccessScope`) that the application's auth
middleware injects as an axum `Extension`:
| `Platform` | Always (superadmin / root) |
| `Tenant(id)` | Only when the row's `@owner` field equals `id` |
| *(no extension)* | Never β **fails closed** |
An entity opts in by overriding two `backbone_orm::EntityRepoMeta` hooks (both
default to no-op, so existing entities are unaffected):
```rust
fn private_fields() -> &'static [&'static str] { &["hppPerUnit"] } // response keys (camelCase)
fn owner_field() -> Option<&'static str> { Some("providerId") } // response key holding the owner id
```
Security runs **before** sparse projection, so the visibility ceiling always
beats a `?fields=` request β a client cannot recover a stripped `@private` field
by naming it in `?fields=`. Names are matched against the **response JSON keys**
(camelCase), not DB columns. A `Tenant` scope against a row whose `@owner` is
`null` is treated as non-owner (only `Platform` sees private fields).
### Pagination depth
The effective offset is `max(page-1, 0) * clamp(limit, 1, 100)`. If it exceeds
`MAX_PAGINATION_OFFSET` (10,000), the request is rejected with `400` rather than running
an expensive deep `OFFSET` scan:
```
Result set too deep: offset 10100 exceeds the maximum of 10000. Please add filters to narrow your search.
```
## π¦ Request body shapes
### Bulk create β `POST {base}/bulk`
```json
{ "items": [ { "name": "A" }, { "name": "B" } ] }
```
### Bulk full update β `PUT {base}/bulk`
Array of objects, each an `id` plus the flattened update DTO:
```json
[ { "id": "p_1", "name": "A", "price_cents": 1 },
{ "id": "p_2", "name": "B", "price_cents": 2 } ]
```
### Bulk partial update β `PATCH {base}/bulk` (two auto-detected shapes)
```jsonc
// Shared: one patch applied to many ids
{ "ids": ["p_1", "p_2"], "patch": { "price_cents": 0 } }
// Per-item: a distinct patch per id
{ "items": [ { "id": "p_1", "patch": { "price_cents": 0 } },
{ "id": "p_2", "patch": { "name": "B2" } } ] }
```
### Id-list bodies β `BatchIdsRequest`
Used by `delete/bulk`, `restore/bulk`, `trash/bulk`:
```json
{ "ids": ["p_1", "p_2", "p_3"] }
```
Batches larger than `MAX_BATCH_SIZE` (1,000) β `400`. Update/patch batches that repeat an
id, or id-list operations referencing a missing id, roll back the whole batch β `400`.
## π¨ Response envelopes
### `ApiResponse<T>` β single resource
```jsonc
{ "success": true, "data": { /* T */ }, "message": "β¦optional" }
// error:
{ "success": false, "error": "β¦" }
```
### `PaginatedApiResponse<T>` β list endpoints
```jsonc
{
"success": true,
"data": [ /* T, β¦ */ ],
"meta": { "total": 150, "page": 1, "limit": 20, "total_pages": 8 }
}
// error: same shape with "success": false, "data": [], and "error": "β¦"
```
### `BulkResponse<T>`
```json
{ "items": [ /* T, β¦ */ ], "total": 2, "failed": 0, "errors": [] }
```
## π¦ Status codes
| 200 OK | List, get, update, soft-delete, restore, counts, empty-trash, bulk (200) |
| 201 Created | `POST {base}`, `POST {base}/bulk`, `POST {base}/upsert` |
| 204 No Content | `DELETE {base}/trash/:id` (permanent delete, no body) |
| 400 Bad Request | Bad filter/sort key (`42703` / invalid syntax), pagination too deep, batch too large, duplicate/missing id in a batch, malformed body |
| 404 Not Found | Entity (or trashed entity) not found by id |
| 409 Conflict | Create conflicts with an existing unique entity |
| 500 Internal Server Error | Genuine database/server failure |
β For OpenAPI/Swagger generation of this surface for your concrete entity, see
[openapi.md](openapi.md).