This might sound like it could get cumbersome fast, but it's actually really simple in practice, due to clever uses of traits. UnverifiedInvoiceId implements rocket::FromParam, so I can deserialize it from a URL parameter. InvoiceId is only created in the serde::Deserialize implementation of an invoice-returning API response.
So a (significantly simplified) endpoint in the app might look like:
#[get("/invoices/<unverified_invoice_id>")]
pub fn get_invoice_by_id(
unverified_invoice_id: UnverifiedInvoiceId,
api_conn: ApiConnection,
) -> HtmlResponse {
// API "search" methods take unverified user input as parameters
let invoice = api_conn.fetch_invoice_by_id(unverified_invoice_id);
// API "lookup" methods take verified ids
let line_items = api_conn.fetch_invoice_line_items(invoice.id);
render_invoice(invoice, line_items);
}
I can't accidentally switch up things that don't belong together, even though under the hood they're all just unsigned ints.