Skip to content

Validate inputs

You already wrote the validation rules on the entity — page 2. The #[crud] macro’s generated POST /posts handler takes Valid<Json<CreatePostDto>>, 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 the service never sees the bad input.

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<CreatePostDto>>,
) -> ::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<CreatePostDto>>. 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 and the bundled ValidationPipe<T>. The HTTP binding lives in nest-rs-http as the Valid<E> extractor.

You already wrote the rules — on the entity, page 2:

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 CreatePostDto and UpdatePostDto. No second declaration on the DTO; the entity is the single source.

A malformed body comes back as a JSON 400 with the structured field errors validator produces:

Terminal window
$ curl -i -X POST http://localhost:3005/posts \
-H 'Content-Type: application/json' \
-d '{"title":"","body":"World"}'
HTTP/1.1 400 Bad Request
content-type: application/json
{
"statusCode": 400,
"error": "Bad Request",
"message": "validation failed",
"details": {
"title": [{ "code": "length", "params": { "min": 1, "value": "" } }]
}
}

The failing field surfaces in details. The client can render per-field messages from details.<field>[].code without parsing the human message.

A well-formed body still reaches the handler exactly as before — the pipe is a no-op on successful validation.

Terminal window
$ curl -X POST http://localhost:3005/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","body":"World"}'
HTTP/1.1 500 Internal Server Error

Still 500 — the DB isn’t wired yet — but the body cleared validation and the handler ran. Page 6 closes that gap.

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

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

When validator’s built-in rules aren’t enough, point at a function:

#[derive(Deserialize, Validate)]
pub struct CreatePostDto {
#[validate(length(min = 1), custom(function = "not_a_placeholder_title"))]
pub title: String,
pub body: 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 details.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>>.

Next: persist through Postgres →