Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ferra-core

ferra-core is the zero-I/O contract layer that every other Ferra crate depends on. It exports the following items across five modules:

ModuleItems
metaFieldType, FieldMeta, ModelMeta
modelFerraModel
idId
errorIdParseError
timeDateTime, Date, plus the date! macro

Nothing in this crate allocates, performs I/O, or talks to a database. Its external runtime dependencies are serde, uuid, and jiff (activated in 0.5.0 Rolling for the typed time vocabulary). A fourth dep (utoipa) is feature-gated behind openapi and stays off in the default build — ferra-openapi flips the feature on transitively when it depends on this crate.


Model metadata (FieldMeta / FieldType / ModelMeta)

The three types in ferra_core::meta describe a resource precisely enough for downstream crates to derive HTTP routes, SQL, OpenAPI, and hypermedia links from a single source of truth.

FieldType

A tag identifying the Rust type of a field.

#![allow(unused)]
fn main() {
pub enum FieldType {
    String, I32, I64, F64, Bool, Uuid,
    OptionString, OptionI32, OptionI64, OptionF64, OptionBool,
}
}

Option variants are spelled out so every field’s nullability is visible in the IR without indirection. OptionUuid is deliberately absent in 0.1.0 — it is deferred to a later phase.

FieldType is #[non_exhaustive]. Pattern matches on it MUST include a _ => arm to stay forward-compatible:

#![allow(unused)]
fn main() {
use ferra_core::meta::FieldType;

fn is_optional(ty: FieldType) -> bool {
    matches!(
        ty,
        FieldType::OptionString
            | FieldType::OptionI32
            | FieldType::OptionI64
            | FieldType::OptionF64
            | FieldType::OptionBool,
    )
}
}

FieldMeta

Description of a single field on a model. Seven named pub fields — no boolean-collapse tuples — so every flag is individually grep-able and readable in IDE hover output.

#![allow(unused)]
fn main() {
pub struct FieldMeta {
    pub name:     &'static str,
    pub ty:       FieldType,
    pub required: bool,
    pub readable: bool,
    pub writable: bool,
    pub skip_api: bool,
    pub is_id:    bool,
}
}

FieldMeta is #[non_exhaustive]. Construction goes through the const fn new constructor:

#![allow(unused)]
fn main() {
use ferra_core::meta::{FieldMeta, FieldType};

const TITLE_FIELD: FieldMeta = FieldMeta::new(
    "title",
    FieldType::String,
    /*required*/ true,
    /*readable*/ true,
    /*writable*/ true,
    /*skip_api*/ false,
    /*is_id*/   false,
);
}

ModelMeta

Description of a single resource:

#![allow(unused)]
fn main() {
pub struct ModelMeta {
    pub resource_name: &'static str,   // external name, e.g. "films"
    pub table_name:    &'static str,   // SQL table name
    pub id_field:      &'static str,   // must match one field's name
    pub fields:        &'static [FieldMeta],
}
}

Invariants (hand-authoring discipline in 0.1.0; enforced by compile_error! from 0.2.0 Smelting onward):

  • fields is non-empty.
  • Exactly one entry in fields has is_id = true and its name matches id_field.
  • All fields[i].name values are distinct.

Hand-authoring in 0.1.0

In 0.1.0 every ModelMeta is hand-written. Starting in 0.2.0, the #[model] proc-macro (shipping in ferra-forge) emits the same types automatically.

#![allow(unused)]
fn main() {
use ferra_core::meta::{FieldMeta, FieldType, ModelMeta};

const FILM_FIELDS: &[FieldMeta] = &[
    FieldMeta::new("id",    FieldType::Uuid,   true, true, false, false, true),
    FieldMeta::new("title", FieldType::String, true, true, true,  false, false),
];

const FILM_META: ModelMeta =
    ModelMeta::new("films", "films", "id", FILM_FIELDS);
}

The FerraModel trait

FerraModel binds a Rust struct to its static ModelMeta description. It is the single piece of user code required to make a Rust struct a Ferra resource.

#![allow(unused)]
fn main() {
pub trait FerraModel:
    serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
{
    fn meta() -> &'static ModelMeta;
}
}

The supertrait set is part of the 0.1.0 contract. Every bound is load-bearing:

BoundWhy it’s required
SerializeLater phases render responses.
DeserializeOwnedLater phases deserialise request bodies (no borrowed 'de).
Send + SyncModels participate in async handlers on a multi-threaded Tokio runtime.
'staticFerra models are owned values — never borrowed from request state.

A hand-written implementation is four lines:

#![allow(unused)]
fn main() {
use ferra_core::{id::Id, meta::{FieldMeta, FieldType, ModelMeta}, model::FerraModel};
use serde::{Deserialize, Serialize};
const FILM_FIELDS: &[FieldMeta] = &[
    FieldMeta::new("id",    FieldType::Uuid,   true, true, false, false, true),
    FieldMeta::new("title", FieldType::String, true, true, true,  false, false),
];
const FILM_META: ModelMeta = ModelMeta::new("films", "films", "id", FILM_FIELDS);
#[derive(Serialize, Deserialize)]
struct Film { id: Id, title: String }

impl FerraModel for Film {
    fn meta() -> &'static ModelMeta { &FILM_META }
}
}

In 0.1.0 this trait has no downstream consumer yet — ferra-http (0.3.0 Casting) is its first real reader. The trait ships early because narrowing supertraits later would be a breaking change.


Id — the identifier primitive

Id is the identifier type every Ferra model uses. It is a 16-byte newtype wrapping uuid::Uuid.

Construction

#![allow(unused)]
fn main() {
use ferra_core::id::Id;

let a = Id::new();        // fresh v4 UUID (collision-resistant)
let b = Id::default();    // same as Id::new()
let n = Id::nil();        // all-zero sentinel — TEST FIXTURE ONLY
}

Id::nil() is explicitly a test fixture: it is distinguishable from any Id::new() output, which makes it useful as a sentinel in unit tests. Do not store or return Id::nil() from production code.

Wire format

Display emits the RFC 4122 hyphenated lowercase form (for example, "67e55044-10b1-426f-9247-bb680e5fe0c8"). FromStr accepts any of the four canonical UUID forms: hyphenated, simple (no hyphens), URN (urn:uuid:...), and braced ({...}). The serde impls delegate to Display / FromStr, so the wire representation is consistent across JSON, MessagePack, and every serde-backed format.

Full round-trip example

This example mirrors the doctest on Id in crates/ferra-core/src/id.rs, which is what cargo test actually runs; the markdown fence below is a readable transcription, not the compiled source. The doctest is the ground-truth contract for how Id behaves.

#![allow(unused)]
fn main() {
use std::str::FromStr;
use ferra_core::id::Id;

// Construction.
let id = Id::new();

// Text round-trip.
let text = id.to_string();
let parsed = Id::from_str(&text).expect("fresh ids always parse");
assert_eq!(id, parsed);

// JSON round-trip.
let json = serde_json::to_string(&id).unwrap();
let back: Id = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);

// Equality and the nil sentinel.
let nil = Id::nil();
assert_eq!(nil, Id::nil());
assert_ne!(nil, id);

// Parsing failure is typed, never a panic.
let err = Id::from_str("not a uuid").unwrap_err();
assert_eq!(err.to_string(), "failed to parse Id as a UUID");

// Id is Copy — passing by value is free.
fn takes_id(_x: Id) {}
takes_id(id);
takes_id(id);       // still valid, the previous call didn't move it.
}

Why a newtype

Wrapping uuid::Uuid rather than exposing it directly lets Ferra swap the inner representation later (for example, to UUID v7 time-ordered identifiers) without churning every caller. The 16-byte payload is Copy; passing Id through the HTTP pipeline costs nothing at runtime.

Id is not PartialOrd / Ord — v4 UUIDs carry no meaningful ordering semantics, and adding those traits later is non-breaking.

IdParseError

<Id as FromStr>::Err is IdParseError, a hand-written unit struct with a Display impl and a std::error::Error impl. Its inner is pub(crate), so you can only obtain an IdParseError by a parse failure — never by direct construction.

In later phases, IdParseError may grow variants distinguishing “wrong length” from “invalid character”. Because external code only ever matches it as an opaque error, adding variants will be a non-breaking change.


Time vocabulary (DateTime / Date / date!)

Ferra exposes one typed time vocabulary across the framework. Two #[repr(transparent)] newtypes — ferra::DateTime (timestamp) and ferra::Date (calendar date) — wrap the underlying time library so the framework can swap that library in a future release without a consumer-facing signature change. Reach the types through ferra::DateTime / ferra::Date (or via ferra::prelude::*); never import the underlying time library directly.

DateTime — timestamp

DateTime represents a precise instant anchored to UTC. Wire format: RFC 3339 timestamp text ("2023-11-14T22:13:20Z").

ConstructorReturnsNotes
DateTime::from_second(seconds: i64)Result<DateTime, jiff::Error>seconds since the Unix epoch
DateTime::from_millisecond(milliseconds: i64)Result<DateTime, jiff::Error>milliseconds since the Unix epoch
DateTime::from_microsecond(microseconds: i64)Result<DateTime, jiff::Error>microseconds since the Unix epoch
DateTime::from_nanosecond(nanoseconds: i128)Result<DateTime, jiff::Error>nanoseconds since the Unix epoch
DateTime::from_duration(duration: jiff::SignedDuration)Result<DateTime, jiff::Error>epoch-relative duration
DateTime::now()DateTimereads the system clock; not const

The constructor names mirror the underlying time library verbatim; they are not invented. Names that do not exist on DateTime and will not be added: from_unix_timestamp, from_unix_nanos.

Each from_* constructor returns a Result and propagates an out-of-range argument as the underlying time library’s error type. The methods are not const fn — call sites use the standard ? operator, not const-context evaluation.

#![allow(unused)]
fn main() {
use ferra::DateTime;

fn epoch_seconds(seconds: i64) -> Result<DateTime, jiff::Error> {
    DateTime::from_second(seconds)
}

let now = DateTime::now();
let later = DateTime::from_second(1_700_000_000)?;
}

Date — calendar date

Date represents a time-zone-free calendar date. Wire format: RFC 3339 calendar date text ("YYYY-MM-DD").

ConstructorReturnsNotes
Date::new(year: i16, month: i8, day: i8)Dateconst fn; out-of-range triple is a const-eval error

Date::new is const fn — an out-of-range (year, month, day) triple is rejected at cargo build time, not at runtime:

#![allow(unused)]
fn main() {
use ferra::Date;

const RELEASED: Date = Date::new(2027, 1, 1);
// const INVALID: Date = Date::new(2027, 13, 1); // would not compile
}

date! macro — hyphen-literal calendar dates

ferra::date!(YYYY-MM-DD) re-tokenises a hyphen-literal into Date::new(year, month, day). Same compile-time validation as Date::new:

#![allow(unused)]
fn main() {
use ferra::{Date, date};

const SUNSET: Date = date!(2027-01-01);
// const INVALID: Date = date!(2027-13-01); // const-eval error
}

The macro is the recommended call site for fixed dates (release sunsets, scheduled migrations) because the literal form is most readable. Use Date::new(...) directly when the inputs are computed or come from configuration.

Use as model fields

Both newtypes are accepted as field types on #[derive(FerraModel)] structs and emit the right OpenAPI schema (format: date-time and format: date):

#![allow(unused)]
fn main() {
use ferra::{Date, DateTime, FerraModel};

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    released: Date,
    indexed_at: DateTime,
}
}

Use as Sunset: header values

Foundry::deprecated(sunset) accepts a Date. The framework serialises the value as the Sunset: HTTP header on every response under the deprecated version (RFC 8594):

#![allow(unused)]
fn main() {
use ferra::{Foundry, date};

let app = Foundry::new(conn)
    .api_version("v1")?
    .mount::<Film>()
    .deprecated(date!(2027-01-01))
    .build();
}

Banned imports

The framework forbids direct use of legacy time libraries. Two gates enforce this:

  1. #[derive(FerraModel)] rejects field declarations whose type path starts with chrono:: or time::. The diagnostic names ferra::DateTime / ferra::Date as the replacement and is spanned to the offending field.
  2. cargo deny check bans the chrono and time crates from the dependency graph except as transitive deps of the Sea-ORM ecosystem (sea-orm, sea-query, sqlx-core, sqlx-postgres). A Ferra crate or downstream consumer that adds either crate directly fails CI.

Sample diagnostic for a chrono-typed field:

error: field type uses the legacy `chrono` time crate; Ferra exposes
       typed time values through `ferra::DateTime` for timestamps
       and `ferra::Date` for calendar dates
       help: replace with `ferra::DateTime` (or `ferra::Date` for
             calendar-only values); the wire format is RFC 3339 in
             both cases
       note: docs/user-guide/ferra-core.md §"Time vocabulary"

Why newtypes

The two newtypes are #[repr(transparent)] over the underlying library’s value types. The layout is identical to the inner type, so a future release that replaces the underlying library does so at the ferra-core::time module boundary alone — consumer-facing signatures stay stable. The inner field is pub(crate); consumers reach the value only through ferra::DateTime / ferra::Date.

The OpenAPI schema emission is encapsulated in the same module: <DateTime as utoipa::ToSchema> and <Date as utoipa::ToSchema> emit {type: "string", format: "date-time"} and {type: "string", format: "date"} respectively. Consumers and downstream crates never write the schema literal — the typed-newtype contract preserves the framework’s “single source of truth” rule.