Serialization
What a record's JSON holds and in what order, hiding fields and appending computed ones, per-record changes, date formats, API resources with a __type, OpenAPI schemas from models, and records in queued jobs.
On this page
Records serialize to JSON with serde, so a handler returns Json(record), Json(collection) or a page as it is. A record's JSON is its model's, plus what the ORM knows about it: extra columns, appended accessors and loaded relations. The model decides what clients see: fields it hides never leave the server.
#[derive(Model, Clone, Debug, Default, Serialize)]
#[model(table = "users", timestamps, hidden(password_hash, two_factor_secret), appends(initials))]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
#[model(cast = "hashed")]
pub password_hash: String,
#[model(cast = "encrypted")]
pub two_factor_secret: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[accessors]
impl User {
#[accessor]
pub fn initials(&self) -> String {
self.name.split_whitespace().filter_map(|word| word.chars().next()).collect()
}
}{
"id": "0199a7a0-6b1c-7d3e-9f40-5c2a1b3c4d5e",
"name": "Ada Lovelace",
"email": "[email protected]",
"created_at": "2026-10-27T08:30:00.000000Z",
"updated_at": "2026-10-27T08:30:00.000000Z",
"initials": "AL"
}What a record's JSON holds
In this order:
- the model's fields, as the model's own
Serializewrites them, so serde attributes keep working (rename,skip_serializing_if,serialize_with); - where a cast decides a field's output, the cast's form (a formatted date, a decimal as a string);
- date-times in one format, RFC 3339 in UTC with microseconds (
2026-10-27T08:30:00.000000Z); - the extra columns the query selected, such as
activities_count; - the appended accessors;
- the loaded relations, under their names, each related record applying its own rules (
nullfor an empty to-one relation,[]for an empty to-many one; a many-to-many record carries itspivot).
Then only the names the model makes visible are kept, when it lists any, and the hidden ones are left out. record.to_json(), to_json_pretty() and to_value() give the same JSON as text or as a serde_json::Value; a collection serializes as an array of them, and a page as {data, meta}.
Hiding fields
hidden(..) lists what never appears in a record's JSON; visible(..) lists the only things that do, for models where a field added later should stay private until someone shows it:
#[derive(Model, Clone, Debug, Default, Serialize)]
#[model(table = "api_keys", visible(id, name, last_used_at))]
pub struct ApiKey {
pub id: Uuid,
pub name: String,
pub token_hash: String,
pub last_used_at: Option<DateTime<Utc>>,
}Names are fields or columns (matched to serde's keys), or output keys: accessors, extras, relations. #[model(hidden)] on a field adds it to the list. Hide every secret: encrypted fields hold their plain value in the record, so their JSON would too. #[serde(skip)] on a field also keeps it out, for every serializer, while hidden is the ORM's rule and can be lifted per record.
Appending computed values
appends(..) adds accessors to every record's JSON, after the fields and extras. Each name must be an accessor of the model, or the model doesn't compile.
Per-record changes
A record, or a whole collection, can change the rules before it is serialized:
user.make_visible(["two_factor_secret"]); // off the hidden list
user.make_hidden(["email"]);
user.make_hidden_if(!is_admin, ["created_at"]);
user.append(["initials"])?; // an error for a name that isn't an accessor
user.without_appends();set_hidden, merge_hidden, set_visible, merge_visible, set_appends and merge_appends replace or extend the lists, and the _if forms take a bool or a function of the record. hidden(), visible(), appends() and has_appended(name) read them. A collection has the same methods and applies them to every record.
record.only(["id", "email"]) and record.except(["password_hash"]) return a JSON map of some values, whatever the rules: for building an audit entry or a message, not a response.
Dates
Every date-time serializes as RFC 3339 in UTC with microseconds, whatever the database returned, so responses don't change with the database. A field with a datetime_format cast keeps its own format. To format a model's dates another way, name a function:
/// Dates as Unix seconds.
fn unix_seconds(at: &DateTime<FixedOffset>) -> serde_json::Value {
serde_json::json!(at.timestamp())
}
#[derive(Model, Clone, Debug, Default, Serialize)]
#[model(table = "events", timestamps, serialize_dates_with = unix_seconds)]
pub struct Event {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}{"id":"0199a7a0-6b1c-7d3e-9f40-5c2a1b3c4d5e","name":"signed_in","created_at":1793089800,"updated_at":1793089800}API resources
When the JSON a client gets differs from the model, or front-end permission checks need the subject's type, return a resource. to_resource_json() is the record's JSON plus navree-api's "__type":
{"__type":"Deal","amount_cents":120000,"id":"0199a7a0-6b1c-7d3e-9f40-5c2a1b3c4d5e","title":"Renewal"}A dedicated shape is a type implementing Resource<M> (an ApiResource built from a record), which a model can name as its own:
use navree::api::ApiResource;
use navree::orm::attributes::Resource;
/// What the front end receives for a deal.
#[derive(Serialize, utoipa::ToSchema)]
pub struct DealResource {
pub id: Uuid,
pub title: String,
pub amount: String,
}
impl ApiResource for DealResource {
const TYPE: &'static str = "Deal";
}
impl Resource<Deal> for DealResource {
fn from_record(deal: &Record<Deal>) -> Self {
DealResource {
id: deal.id,
title: deal.title.clone(),
amount: format!("{:.2}", deal.amount_cents as f64 / 100.0),
}
}
}
#[derive(Model, Clone, Debug, Default, Serialize)]
#[model(table = "deals", resource = DealResource)]
pub struct Deal { /* .. */ }deal.resource() and deals.resources() build the model's resource, to_resource::<R>() any other, and to_resource_json_as::<R>() its JSON with __type. to_value_masked(Some(&fields)) keeps only the fields an ability permits, for field-level access. In a handler, Ok(Json(deal.resource())), or page.map(|deal| deal.resource()) for a page.
OpenAPI schemas
Record<M> implements utoipa's ToSchema, named after the model, so routes document their responses from the model itself:
#[get("/{user}")]
#[api(summary = "Show a user", tag = "Users", response = Record<User>)]
async fn show(&self, user: Bound<User>) -> Json<Record<User>> {
Json(user.into_inner())
}
#[get("/feed")]
#[api(summary = "The event feed", tag = "Users", response = CursorPage<Record<Event>>)]
async fn feed(&self, uow: Uow, cursor: CursorRequest) -> Result<CursorPage<Record<Event>>> {
Ok(Event::query().latest().cursor_paginate(&uow, cursor).await?)
}The schema has one property per serialized field under its serde key, typed from the field or its cast, with hidden fields left out, the visible list respected, Option fields nullable, skip_serializing_if fields not required, and the appended accessors. #[model(schema = Type)] on a field and #[accessor(schema = Type)] give an exact schema, such as an enum's values. SimplePage<T> and CursorPage<T> have schemas too; for navree-db's Page, navree::orm::attributes::page_schema::<Record<Deal>> is a SchemaFn for routes registered by hand, and OpenAPI shows the named page type #[api] takes.
Records in queued jobs
A job carries a record's identity rather than its values, and reads the record again when it runs, so it sees the record as it is then:
use navree::orm::attributes::ModelRef;
/// Emails a deal's owner (serialized into the queue).
#[derive(Serialize, Deserialize)]
pub struct NotifyOwner {
pub deal: ModelRef<Deal>,
}
let job = NotifyOwner { deal: deal.to_ref()? };
// ... when the job runs:
let deal = job.deal.restore(&uow).await?; // ModelNotFound if it was deleted sinceA ModelRef serializes as {"model": "Deal", "key": ..}, with the names of the record's loaded relations and its connection. restore reads from the writer, without global scopes; restore_or_skip returns None for a deleted record. collection.to_refs() and ModelRefs::restore do the same for many records, in order, leaving out deleted ones.