Validate inputs
You already wrote the validation rules on the entity in
Declare the entity. The #[crud] macro’s generated
POST /posts handler takes Valid<Json<CreatePost>>, so every request
body is checked against those rules before PostsService::create runs.
By the end of this page, curl returns a structured 400 for an empty
title, and a well-formed body persists a post through the database you
wired on the previous page.
No handler edit required
Section titled “No handler edit required”On the previous page you left impl PostsController {} empty. That is
not a shortcut — for a bare CRUD feature, the macro is the handler.
#[crud] emits create and update methods that already wrap the
body in Valid<...>:
async fn create( &self, __body: ::nest_rs_http::Valid<::poem::web::Json<CreatePost>>,) -> ::poem::Result<::poem::web::Json<Post>> { // delegates to CrudService::create}When you later override create on a richer feature — org id from a JWT,
an explicit authz check — you keep the same extractor:
Valid<Json<CreatePost>>. The
users controller in api is the
reference override.
The crate driving this page is
nest-rs-pipes —
it provides the transport-agnostic Pipe trait, the bundled
ValidationPipe<T>, and the value-form Valid<T> / Piped<P, T>
carriers that GraphQL, WS, and queue handlers bind per argument. The
HTTP binding lives in nest-rs-http as the Valid<E> extractor.
Where the rules live
Section titled “Where the rules live”You already wrote the rules — on the entity, in Declare the entity:
#[expose(input(create, update), validate(length(min = 1)))]pub title: String,
#[expose(input(create, update), validate(length(min = 1)))]pub body: String,#[expose] carries the validate(...) attribute through to
CreatePost and UpdatePost. No second declaration on the
DTO; the entity is the single source.
The bad-body response
Section titled “The bad-body response”A malformed body comes back as an RFC 9457 application/problem+json
400 with the structured field errors validator produces riding as an
errors extension member.
AuthnGuard runs before the validation pipe, so both calls below carry the
$TOKEN you signed on Persist through Postgres — drop
the header and the answer is a 401 that never reaches the pipe this page is
about.
$ curl -i -X POST http://localhost:3005/posts -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"","body":"World"}'HTTP/1.1 400 Bad Requestcontent-type: application/problem+json
{ "type": "https://www.rfc-editor.org/rfc/rfc9110#status.400", "title": "Bad Request", "status": 400, "detail": "validation failed", "errors": { "title": [{ "code": "length", "message": null, "params": { "min": 1 } }] }}The failing field surfaces in errors. The client can render per-field
messages from errors.<field>[].code without parsing the human message.
message is null unless the rule sets one (validate(length(min = 1, message = "…"))), and params carries the rule’s own arguments — not the rejected
value, which never echoes back.
The good-body path is unchanged
Section titled “The good-body path is unchanged”A well-formed body flows straight through — the pipe is a no-op on successful validation — and, with the database wired on the previous page, the handler persists the post and returns it.
$ curl -s -X POST http://localhost:3005/posts -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"Hello","body":"World"}'{"id":"018f…","title":"Hello","body":"World"}A 2xx with the created row — the body cleared validation and the
service committed it in a transaction. The pipe only ever intercepts the
malformed case.
Two failure modes worth the distinction
Section titled “Two failure modes worth the distinction”| Failure | Status | Trigger |
|---|---|---|
| The body isn’t valid JSON | 400 | poem rejects before any pipe runs |
| The body parses but a field is wrong | 400 with errors | ValidationPipe rejects after extraction |
Both are 400; the second carries the structured errors member a
client form binds to. The handler never sees either.
A custom rule with #[validate(custom)]
Section titled “A custom rule with #[validate(custom)]”When validator’s built-in rules aren’t enough, point the entity
field’s validate(...) at a function — no hand-written DTO, the rule
rides through to CreatePost / UpdatePost exactly like the built-in
ones:
#[expose( input(create, update), validate(length(min = 1), custom(function = "not_a_placeholder_title")))]pub title: String,fn not_a_placeholder_title(title: &str) -> Result<(), validator::ValidationError> { if title.eq_ignore_ascii_case("todo") { return Err(validator::ValidationError::new("placeholder_title")); } Ok(())}The error code ("placeholder_title") surfaces in errors.title[].code
— same shape, same client contract.
What you have now
Section titled “What you have now”- A
POST /postsroute that rejects malformed bodies before they reach the service — wired by#[crud], not by hand. - A structured
400body with one entry per failing field, codes stable enough to assert on. - A pattern you carry forward: entity rules → generated or overridden
handlers that take
Valid<Json<E>>.
Going further
Section titled “Going further”- Test it end to end — the next step: lock the round-trip down with an e2e suite.
- Pipes — the reference page for
Valid<E>and the pipe layer.
Built by YV17labs