Using proofs in services
Validate gRPC requests at the boundary, pass proofs into domain logic and queries, map errors to tonic::Status, and test predicates.
Proofs pay off at service boundaries. The pattern is always the same:
- The handler receives raw input and runs
proveonce, with a context registered in theServiceBuilder. - A failure is mapped to a
tonic::Statusand returned. - On success, the
Provenvalue is passed inward. Domain functions and queries takeProven<T, P>, so no path reaches them without the check.
This page builds a Register RPC on top of the Directory context and the NewUsername predicate from Composing predicates:
pub type NewUsername = And<UsernameAvailable, And<NonEmpty, ShortEnough>>;Queries that require a proof
Push the requirement as far down as it goes. A query that inserts a user can only be built from a validated name:
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::sqlx::{self, PgConnection};
use wakuwaku::entity::sqlx::backend::PgSource;
use wakuwaku::entity::{Execute, Query, Write};
use wakuwaku::proof::Proven;
/// Inserts a user. Can only be built from a name that passed `NewUsername`.
pub struct InsertUser {
name: Proven<String, NewUsername>,
}
impl InsertUser {
pub fn new(name: Proven<String, NewUsername>) -> Self {
Self { name }
}
}
impl Query for InsertUser {
type Effect = Write<User>;
}
impl Execute<PgSource> for InsertUser {
type Output = i64;
type Error = sqlx::Error;
async fn execute(self, conn: &mut PgConnection) -> Result<i64, sqlx::Error> {
let (id,) = sqlx::query_as::<_, (i64,)>("INSERT INTO users (name) VALUES ($1) RETURNING id")
.bind(self.name.subject().as_str())
.fetch_one(conn)
.await?;
Ok(id)
}
}The field is private, so outside its module the only way to build an InsertUser is InsertUser::new, which asks for the proof. Keep queries in their own module to get that guarantee.
Domain logic follows the data access convention of staying generic over the handle, and is typed on the proof:
/// Domain logic: generic over the handle, typed on the proof.
pub async fn create_account<D>(db: &D, name: Proven<String, NewUsername>) -> Result<i64, sqlx::Error>
where
D: Processor<InsertUser, Output = i64, Error = sqlx::Error>,
{
db.process(InsertUser::new(name)).await
}Mapping ValidateError to a gRPC status
ValidateError and tonic::Status are both foreign types, so the orphan rules forbid impl From<ValidateError<LookupError>> for Status. Write a function and use it with map_err. Compare reasons against their statics (equality is by identity; see Denied reasons) and keep I/O errors apart from denials. Do not shortcut through ValidateError's Display either (it has one when the I/O error type implements Display): a single Status::invalid_argument(error.to_string()) would report an outage as bad input.
use wakuwaku::deps::tonic::Status;
use wakuwaku::proof::ValidateError;
pub fn validation_status(error: ValidateError<LookupError>) -> Status {
match error {
ValidateError::Deny(reason) if reason == TAKEN => Status::already_exists(reason.0),
ValidateError::Deny(reason) if reason == EMPTY || reason == TOO_LONG => {
Status::invalid_argument(reason.0)
}
ValidateError::Deny(reason) => Status::failed_precondition(reason.0),
// The check could not be decided: nothing is known about the input.
ValidateError::IoError(LookupError::Db(error)) => {
// Keep database details out of the response.
eprintln!("username lookup failed: {error}");
Status::unavailable("could not validate the request, try again")
}
}
}| Error | Status | Why |
|---|---|---|
Deny for a malformed value | INVALID_ARGUMENT | The client sent something that can never be valid. |
Deny for a conflict with existing state | ALREADY_EXISTS / FAILED_PRECONDITION | Valid in form, rejected given the current state. |
IoError | UNAVAILABLE (or INTERNAL) | Nothing is known about the input; the client may retry. |
The reason strings are meant for the client, so write them that way. Do not put internal details in them.
The gRPC handler
The service definition is ordinary protobuf:
syntax = "proto3";
package accounts;
service Accounts {
rpc Register(RegisterRequest) returns (RegisterReply);
}
message RegisterRequest {
string username = 1;
}
message RegisterReply {
int64 id = 1;
}Generate the code as described in gRPC services; the generated code needs direct tonic, tonic-prost and prost dependencies and tonic-prost-build as a build dependency. The service holds the validation context and the write handle. It is assembled from those two registered services when the server is built (see Wiring the context), so it needs no ServiceCreation impl:
pub mod pb {
tonic::include_proto!("accounts");
}
use pb::accounts_server::{Accounts, AccountsServer};
use pb::{RegisterReply, RegisterRequest};
use wakuwaku::deps::tonic::{Request, Response, Status};
use wakuwaku::entity::{Db, ReadWrite};
use wakuwaku::proof::{CheckOrder, prove};
pub struct AccountsService {
directory: Directory,
writer: Db<PgSource, ReadWrite>,
}
#[tonic::async_trait]
impl Accounts for AccountsService {
async fn register(
&self,
request: Request<RegisterRequest>,
) -> Result<Response<RegisterReply>, Status> {
let RegisterRequest { username } = request.into_inner();
// 1. Validate at the boundary.
let username = prove::<_, NewUsername>(username, &self.directory, CheckOrder::Parallel)
.await
.map_err(validation_status)?;
// 2. Hand the proof to the domain.
let id = create_account(&self.writer, username).await.map_err(|error| match error {
// The name was free when checked, but another request took it since.
sqlx::Error::Database(db) if db.is_unique_violation() => {
Status::already_exists(TAKEN.0)
}
_ => Status::internal("could not create the account"),
})?;
Ok(Response::new(RegisterReply { id }))
}
}The subject does not have to be a single field. A predicate can be implemented for the whole request message (impl Predicate<RegisterRequest> for …) when the rules span several fields.
Proofs are snapshots: keep the database constraint
UsernameAvailable was true when it was checked. Two concurrent requests for the same name can both pass it before either inserts. The proof cannot prevent that; a unique index on users.name can. The handler above maps the resulting unique violation to the same ALREADY_EXISTS a denial would produce.
Choosing the check order
CheckOrder::Parallel makes the database lookup and the cheap rules run concurrently, which is a good default when latency matters. With Sequential, NewUsername would run the lookup first (it is the left operand) and skip the cheap rules if the name is taken. To reject bad names without touching the database, use the staged check_new_username from Cheap checks before I/O instead of prove::<_, NewUsername>; it returns the same Proven<String, NewUsername>.
Wiring the context
The context is a dependency like any other. Register it with the service builder next to the write handle, and build the server with compose_grpc_service, which borrows the builder so the service can take a clone of each. And clones the context again on every check, so it must be cheap to clone. A Db over a PgSource wraps a pool handle, so cloning it is cheap.
use wakuwaku::ServiceBuilder;
use wakuwaku::deps::sqlx::postgres::PgPoolOptions;
use wakuwaku::deps::tonic::service::Routes;
use wakuwaku::deps::tonic::transport::Server;
use wakuwaku::entity::{Db, ReadOnly, ReadWrite};
pub async fn serve(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPoolOptions::new().connect(database_url).await?;
let directory = Directory {
// Validation only reads, so it gets a read-only handle.
users: Db::<_, ReadOnly>::new(PgSource::new(pool.clone())),
limits: Limits { max_len: 32 },
};
let (_services, register) = ServiceBuilder::new(directory)
.push(Db::<_, ReadWrite>::new(PgSource::new(pool)))
.compose_grpc_service(|s| {
AccountsServer::new(AccountsService {
directory: s.provide::<Directory, _>().clone(),
writer: s.provide::<Db<PgSource, ReadWrite>, _>().clone(),
})
})
.into_router_fn();
let router = Server::builder().add_routes(Routes::default());
register(router).serve("[::1]:50051".parse()?).await?;
Ok(())
}Giving the predicates a ReadOnly handle means validation code cannot run a write query: that would not compile (see Queries). The two handles share one pool here; the capability only controls which queries each handle accepts.
Testing predicates
Predicates are plain async functions of their subject and context, so pure ones are unit-tested by calling prove directly. Assert the specific reason, not just "it failed":
#[cfg(test)]
mod predicate_tests {
use super::*;
use wakuwaku::proof::{CheckOrder, ValidateError, prove};
fn limits() -> Limits {
Limits { max_len: 8 }
}
#[tokio::test]
async fn rejects_long_names() {
let result = prove::<_, ShortEnough>("a".repeat(9), &limits(), CheckOrder::Sequential).await;
assert!(matches!(result, Err(ValidateError::Deny(reason)) if reason == TOO_LONG));
}
#[tokio::test]
async fn reports_spare_room() {
let Ok(name) = prove::<_, ShortEnough>("haruki".to_owned(), &limits(), CheckOrder::Sequential).await
else {
panic!("`haruki` fits in 8 bytes");
};
assert_eq!(name.evidence().spare, 2);
}
#[tokio::test]
async fn composite_reports_which_rule_failed() {
for order in [CheckOrder::Sequential, CheckOrder::Parallel] {
let result = prove::<_, ValidUsername>(String::new(), &limits(), order).await;
assert!(matches!(result, Err(ValidateError::Deny(reason)) if reason == EMPTY));
}
}
}#[tokio::test] needs a direct tokio dependency with the macros and rt features (rt-multi-thread includes rt).
A few guidelines:
- Test both check orders for composites whose outcome you rely on. They differ in which operands run and which error is reported when several fail (see Check order).
- Test the evidence, not only success. Later code reads it, so a wrong
spareor a wrong row is a bug even when the check passes. - Functions that take a
Provenare tested with real proofs. There is no test-only constructor, by design: get the proof fromprove(or the evidence type'sPureVerifier) in the test. For pure checks this costs nothing, and a verifier needs no runtime: it is a plain#[test]. - I/O predicates read through a concrete context (
Directoryholds aDb<PgSource, ReadOnly>), so test them against a real database in integration tests. Keep the decision itself trivial, as inUsernameAvailable, so the interesting logic stays in pure predicates. See Testing for the overall approach.