Skip to content

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.

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<...>:

(expanded by #[crud] — you don't write this)
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.

You already wrote the rules — on the entity, in Declare the entity:

crates/features/src/posts/entity.rs
#[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.

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.

Terminal window
$ 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 Request
content-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.

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.

Terminal window
$ 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.

FailureStatusTrigger
The body isn’t valid JSON400poem rejects before any pipe runs
The body parses but a field is wrong400 with errorsValidationPipe rejects after extraction

Both are 400; the second carries the structured errors member a client form binds to. The handler never sees either.

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:

crates/features/src/posts/entity.rs
#[expose(
input(create, update),
validate(length(min = 1), custom(function = "not_a_placeholder_title"))
)]
pub title: String,
crates/features/src/posts/entity.rs
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.

  • A POST /posts route that rejects malformed bodies before they reach the service — wired by #[crud], not by hand.
  • A structured 400 body 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>>.
  • 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