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:
| Module | Items |
|---|---|
meta | FieldType, FieldMeta, ModelMeta |
model | FerraModel |
id | Id |
error | IdParseError |
time | DateTime, 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):
fieldsis non-empty.- Exactly one entry in
fieldshasis_id = trueand itsnamematchesid_field. - All
fields[i].namevalues 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:
| Bound | Why it’s required |
|---|---|
Serialize | Later phases render responses. |
DeserializeOwned | Later phases deserialise request bodies (no borrowed 'de). |
Send + Sync | Models participate in async handlers on a multi-threaded Tokio runtime. |
'static | Ferra 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").
| Constructor | Returns | Notes |
|---|---|---|
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() | DateTime | reads 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").
| Constructor | Returns | Notes |
|---|---|---|
Date::new(year: i16, month: i8, day: i8) | Date | const 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:
#[derive(FerraModel)]rejects field declarations whose type path starts withchrono::ortime::. The diagnostic namesferra::DateTime/ferra::Dateas the replacement and is spanned to the offending field.cargo deny checkbans thechronoandtimecrates 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.