The One Trait

Firestone knows every route, model and rule in your API. It cannot know your storage, and should not guess, so that is the whole of what you write:

#[async_trait]
pub trait Backend: Send + Sync + 'static {
    async fn list(&self, resource: &str) -> Result<Vec<Value>, Error>;
    async fn get(&self, resource: &str, key: &str) -> Result<Option<Value>, Error>;
    async fn create(&self, resource: &str, body: Value) -> Result<(String, Value), Error>;
    async fn replace(&self, resource: &str, key: &str, body: Value) -> Result<Value, Error>;
    async fn patch(&self, resource: &str, key: &str, body: Value) -> Result<Value, Error>;
    async fn delete(&self, resource: &str, key: &str) -> Result<(), Error>;

    /// Find the first resource whose dotted `path` holds `value`.
    async fn find(&self, resource: &str, path: &str, value: &Value)
        -> Result<Option<Value>, Error>;
}

It is deliberately resource-agnostic: resource is the kind from the schema, so one implementation serves every resource rather than one per kind. Bodies have already been through the generated models by the time they arrive, so a malformed body was refused before your code ran.

InMemory is generated alongside the trait and wired into main.rs, so the server runs as it is. Swap it out and nothing else in the crate changes:

let api = Api::new(Arc::new(MyPostgres::new(pool)));

find Is What the Rules Use

find is the one method that is not plain CRUD. It is how a validation rule reaches your storage:

person:
  references:
    kind: persons
    key: first_name
    value: person.first_name

becomes find("persons", "first_name", "Ann") when an address naming Ann is created. path is a path into the resource, not a column name — person.first_name is a perfectly ordinary rule — so translating it into a query is your job and belongs in one mapping table:

const LOOKUPS: &[((&str, &str), &str)] = &[
    (("persons", "first_name"), "first_name = $1"),
    (("addressbook", "person.first_name"), "person->>'first_name' = $1"),
];

Raise on a pair you have not mapped rather than returning None, otherwise a typo in a schema becomes a rule that quietly rejects everything.

Transactions

The generated handler validates and then writes, and those are two separate calls. A referenced resource can disappear in between, so for anything that matters:

  • Build the backend per request around the transaction that performs the write, so the lookups and the mutation see one snapshot.
  • Take the locks the check implies, or run SERIALIZABLE and retry.
  • Keep the foreign keys. Validation turns a constraint violation into a clear 422 naming the rule; it is not the guarantee itself.

Errors

Return Error and the response shape is handled for you:

VariantStatus
NotFound404
Validationwhatever the rule asked for, usually 422
NotImplemented501
Internal500

A 401 is not in that list: the generated server refuses an unauthenticated request to a secured operation before a handler runs, from the security block in the schema, so it never reaches your backend.

All of them leave as an RFC 9457 problem document served as application/problem+json, which is what the generated OpenAPI document declares:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 422,
  "detail": "No persons found with first_name 'Ann'.",
  "violations": [
    {
      "rule": "person_must_exist",
      "resource": "addressbook",
      "field": "person.first_name",
      "message": "No persons found with first_name 'Ann'."
    }
  ]
}

Next Steps