Skip to content

Validation Flows

Validation keeps handlers simple by enforcing contracts at the edge.

Basic validation

Define schemas to validate incoming data with strongly-typed structures. Let the framework validate before your handler runs.


id: python_validation_basic language: python title: Validation Basic tags: - python


from msgspec import Struct

class Payment(Struct):
    id: str
    amount: float

@app.post("/payments")
async def create_payment(payment: Payment) -> Payment:
    return payment

id: typescript_validation_basic language: typescript title: Validation Basic tags: - typescript


import { Spikard, type Request } from "spikard";
import { z } from "zod";

const PaymentSchema = z.object({
  id: z.string().uuid(),
  amount: z.number().positive(),
});
type Payment = z.infer<typeof PaymentSchema>;

const app = new Spikard();

const createPayment = async (req: Request): Promise<Payment> => {
  return PaymentSchema.parse(req.json());
};

app.addRoute(
  {
    method: "POST",
    path: "/payments",
    handler_name: "createPayment",
    request_schema: PaymentSchema,
    response_schema: PaymentSchema,
    is_async: true,
  },
  createPayment,
);

id: ruby_validation_basic language: ruby title: Validation Basic tags: - ruby


require "spikard"

PaymentSchema = Dry::Schema.Params do
  required(:id).filled(:string)
  required(:amount).filled(:float)
end

app = Spikard::App.new

app.post("/payments") do |_params, _query, body|
  PaymentSchema.call(body)
end

id: php_validation_basic language: php title: Validation Basic tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Post;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;

final class PaymentsController
{
    #[Post('/payments')]
    public function create(Request $request): Response
    {
        $payment = $request->body;

        // Manual validation
        $errors = [];
        if (!isset($payment['id']) || !is_string($payment['id'])) {
            $errors[] = 'id is required and must be a string';
        }
        if (!isset($payment['amount']) || !is_numeric($payment['amount'])) {
            $errors[] = 'amount is required and must be numeric';
        }
        if (isset($payment['amount']) && $payment['amount'] <= 0) {
            $errors[] = 'amount must be positive';
        }

        if (!empty($errors)) {
            return Response::json(['errors' => $errors], 400);
        }

        return Response::json([
            'id' => $payment['id'],
            'amount' => (float) $payment['amount'],
            'status' => 'pending'
        ], 201);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new PaymentsController());

id: rust_validation_basic language: rust title: Validation Basic tags: - rust


use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct Payment {
    id: String,
    amount: f64,
}

app.route(
    post("/payments").request_body::<Payment>().response_body::<Payment>(),
    |ctx: Context| async move {
        let payment: Payment = ctx.json()?;
        Ok(Json(payment))
    },
)?;

Request body validation

Define schemas to automatically validate incoming JSON payloads. Invalid requests are rejected before reaching your handler.


id: python_validation_request_body language: python title: Validation Request Body tags: - python


from msgspec import Struct, ValidationError
from typing import Annotated
from spikard import Body

class CreateUserRequest(Struct):
    email: Annotated[str, "Email address"]
    age: Annotated[int, "User age must be 18+"]
    username: Annotated[str, "Alphanumeric username"]

@app.post("/users")
async def create_user(request: Body[CreateUserRequest]) -> dict:
    # Validation happens automatically before this handler runs
    # If validation fails, returns 400 with error details
    return {
        "id": "usr_123",
        "email": request.email,
        "age": request.age,
        "username": request.username
    }

id: typescript_validation_request_body language: typescript title: Validation Request Body tags: - typescript


import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(18),
  username: z.string().regex(/^[a-zA-Z0-9_]+$/),
});
type CreateUserRequest = z.infer<typeof CreateUserSchema>;

app.addRoute(
  {
    method: "POST",
    path: "/users",
    handler_name: "createUser",
    request_schema: CreateUserSchema,
    is_async: true,
  },
  async (req) => {
    const user = CreateUserSchema.parse(req.json());
    return {
      id: "usr_123",
      email: user.email,
      age: user.age,
      username: user.username,
    };
  },
);

id: ruby_validation_request_body language: ruby title: Validation Request Body tags: - ruby


CreateUserSchema = Dry::Schema.Params do
  required(:email).filled(:string, format?: /@/)
  required(:age).filled(:integer, gteq?: 18)
  required(:username).filled(:string, format?: /^[a-zA-Z0-9_]+$/)
end

app.post("/users") do |_params, _query, body|
  result = CreateUserSchema.call(body)

  if result.failure?
    halt 400, result.errors.to_h
  end

  {
    id: "usr_123",
    email: result[:email],
    age: result[:age],
    username: result[:username]
  }
end

id: php_validation_request_body language: php title: Validation Request Body tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Post;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Validation\Validator;

final class CreateUserRequest
{
    public function __construct(
        public readonly string $email,
        public readonly int $age,
        public readonly string $username,
    ) {}

    public static function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'age' => ['required', 'integer', 'min:18'],
            'username' => ['required', 'string', 'regex:/^[a-zA-Z0-9_]+$/'],
        ];
    }
}

final class UsersController
{
    #[Post('/users')]
    public function create(Request $request): Response
    {
        $validator = new Validator($request->body, CreateUserRequest::rules());

        if ($validator->fails()) {
            return Response::json(['errors' => $validator->errors()], 400);
        }

        return Response::json([
            'id' => 'usr_123',
            'email' => $request->body['email'],
            'age' => $request->body['age'],
            'username' => $request->body['username'],
        ], 201);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new UsersController());

id: rust_validation_request_body language: rust title: Validation Request Body tags: - rust


use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, JsonSchema)]
struct CreateUserRequest {
    email: String,
    #[schemars(range(min = 18))]
    age: i32,
    username: String,
}

app.route(
    post("/users").request_body::<CreateUserRequest>(),
    |ctx: Context| async move {
        let user: CreateUserRequest = ctx.json()?;
        Ok(Json(json!({
            "id": "usr_123",
            "email": user.email,
            "age": user.age,
            "username": user.username
        })))
    }
)?;

Query parameter validation

Validate URL query strings with type constraints and custom rules.


id: python_validation_query language: python title: Validation Query tags: - python


from msgspec import Struct

class ListUsersQuery(Struct):
    page: int = 1  # Default value
    limit: int = 10
    sort_by: str | None = None
    min_age: int | None = None

@app.get("/users")
async def list_users(query: ListUsersQuery) -> dict:
    # Validate constraints in handler or use custom validators
    if query.limit > 100:
        raise ValueError("limit cannot exceed 100")
    if query.page < 1:
        raise ValueError("page must be positive")

    return {
        "page": query.page,
        "limit": query.limit,
        "users": []
    }

id: typescript_validation_query language: typescript title: Validation Query tags: - typescript


import { z } from "zod";

const ListUsersQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  sort_by: z.enum(["name", "email", "created_at"]).optional(),
  min_age: z.coerce.number().int().min(0).max(120).optional(),
});

app.addRoute(
  {
    method: "GET",
    path: "/users",
    handler_name: "listUsers",
    is_async: true,
  },
  async (req) => {
    const query = ListUsersQuery.parse(Object.fromEntries(new URL(req.url).searchParams));

    return {
      page: query.page,
      limit: query.limit,
      users: [],
    };
  },
);

id: ruby_validation_query language: ruby title: Validation Query tags: - ruby


ListUsersQuery = Dry::Schema.Params do
  optional(:page).filled(:integer, gteq?: 1)
  optional(:limit).filled(:integer, gteq?: 1, lteq?: 100)
  optional(:sort_by).filled(:string, included_in?: %w[name email created_at])
  optional(:min_age).filled(:integer, gteq?: 0, lteq?: 120)
end

app.get("/users") do |_params, query, _body|
  result = ListUsersQuery.call(query)

  halt 400, result.errors.to_h if result.failure?

  {
    page: result[:page] || 1,
    limit: result[:limit] || 10,
    users: []
  }
end

id: php_validation_query language: php title: Validation Query tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Get;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Validation\Validator;

final class UsersController
{
    #[Get('/users')]
    public function list(Request $request): Response
    {
        $rules = [
            'page' => ['nullable', 'integer', 'min:1'],
            'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
            'sort_by' => ['nullable', 'string', 'in:name,email,created_at'],
            'min_age' => ['nullable', 'integer', 'min:0', 'max:120'],
        ];

        $validator = new Validator($request->query, $rules);

        if ($validator->fails()) {
            return Response::json(['errors' => $validator->errors()], 400);
        }

        $page = (int) ($request->query['page'] ?? 1);
        $limit = (int) ($request->query['limit'] ?? 10);

        return Response::json([
            'page' => $page,
            'limit' => $limit,
            'users' => [],
        ]);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new UsersController());

id: rust_validation_query language: rust title: Validation Query tags: - rust


use serde::Deserialize;

#[derive(Deserialize)]
struct ListUsersQuery {
    #[serde(default = "default_page")]
    page: i32,
    #[serde(default = "default_limit")]
    limit: i32,
    sort_by: Option<String>,
    min_age: Option<i32>,
}

fn default_page() -> i32 { 1 }
fn default_limit() -> i32 { 10 }

app.route(
    get("/users"),
    |ctx: Context| async move {
        let query: ListUsersQuery = ctx.query()?;

        if query.limit > 100 {
            return Err(Error::BadRequest("limit cannot exceed 100"));
        }

        Ok(Json(json!({
            "page": query.page,
            "limit": query.limit,
            "users": []
        })))
    }
)?;

Path parameter validation

Validate URL path segments with type checking and format constraints.


id: python_validation_path language: python title: Validation Path tags: - python


from uuid import UUID

@app.get("/users/{user_id}/posts/{post_id}")
async def get_user_post(user_id: UUID, post_id: int) -> dict:
    # Type validation happens automatically
    # user_id must be valid UUID format
    # post_id must be valid integer
    return {
        "user_id": str(user_id),
        "post_id": post_id,
        "title": "Sample Post"
    }

id: typescript_validation_path language: typescript title: Validation Path tags: - typescript


import { z } from "zod";

const PathParams = z.object({
  user_id: z.string().uuid(),
  post_id: z.coerce.number().int().positive(),
});

app.addRoute(
  {
    method: "GET",
    path: "/users/:user_id/posts/:post_id",
    handler_name: "getUserPost",
    is_async: true,
  },
  async (req) => {
    const params = PathParams.parse(req.params);

    return {
      user_id: params.user_id,
      post_id: params.post_id,
      title: "Sample Post",
    };
  },
);

id: ruby_validation_path language: ruby title: Validation Path tags: - ruby


PathParamsSchema = Dry::Schema.Params do
  required(:user_id).filled(:string, format?: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
  required(:post_id).filled(:integer, gt?: 0)
end

app.get("/users/:user_id/posts/:post_id") do |params, _query, _body|
  result = PathParamsSchema.call(params)

  halt 400, result.errors.to_h if result.failure?

  {
    user_id: result[:user_id],
    post_id: result[:post_id],
    title: "Sample Post"
  }
end

id: php_validation_path language: php title: Validation Path tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Get;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Validation\Validator;

final class PostsController
{
    #[Get('/users/{user_id}/posts/{post_id}')]
    public function show(Request $request, string $user_id, int $post_id): Response
    {
        $rules = [
            'user_id' => ['required', 'string', 'uuid'],
            'post_id' => ['required', 'integer', 'min:1'],
        ];

        $params = [
            'user_id' => $user_id,
            'post_id' => $post_id,
        ];

        $validator = new Validator($params, $rules);

        if ($validator->fails()) {
            return Response::json(['errors' => $validator->errors()], 400);
        }

        return Response::json([
            'user_id' => $user_id,
            'post_id' => $post_id,
            'title' => 'Sample Post',
        ]);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new PostsController());

id: rust_validation_path language: rust title: Validation Path tags: - rust


use uuid::Uuid;

app.route(
    get("/users/:user_id/posts/:post_id"),
    |ctx: Context| async move {
        let user_id: Uuid = ctx.path_param("user_id")?.parse()?;
        let post_id: i32 = ctx.path_param("post_id")?.parse()?;

        Ok(Json(json!({
            "user_id": user_id.to_string(),
            "post_id": post_id,
            "title": "Sample Post"
        })))
    }
)?;

Response validation

Validate outgoing responses to ensure API contracts are maintained. This catches serialization errors and schema violations before sending data to clients.


id: python_validation_response language: python title: Validation Response tags: - python


from msgspec import Struct

class User(Struct):
    id: str
    email: str
    age: int

class UserListResponse(Struct):
    users: list[User]
    total: int
    page: int

@app.get("/users", response_schema=UserListResponse)
async def list_users() -> UserListResponse:
    # Response will be validated against UserListResponse schema
    # Any field mismatch or type error returns 500 with details
    users = [
        User(id="usr_1", email="alice@example.com", age=30),
        User(id="usr_2", email="bob@example.com", age=25)
    ]

    response = UserListResponse(
        users=users,
        total=len(users),
        page=1
    )

    # Validation happens here before sending response
    return response

# Example error: missing required field
@app.get("/invalid", response_schema=User)
async def invalid_response() -> dict:
    # This will fail validation - missing 'age' field
    # Returns 500: {"error": "Response validation failed: missing field 'age'"}
    return {"id": "usr_1", "email": "test@example.com"}

id: typescript_validation_response language: typescript title: Validation Response tags: - typescript


import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number().int(),
});

const UserListResponse = z.object({
  users: z.array(UserSchema),
  total: z.number().int(),
  page: z.number().int(),
});

app.addRoute(
  {
    method: "GET",
    path: "/users",
    handler_name: "listUsers",
    response_schema: UserListResponse,
  },
  async (_req) => {
    const users = [
      { id: "usr_1", email: "alice@example.com", age: 30 },
      { id: "usr_2", email: "bob@example.com", age: 25 },
    ];

    const response = {
      users: users,
      total: users.length,
      page: 1,
    };

    // Validate before returning
    return UserListResponse.parse(response);
  },
);

// Example: validation catches errors
app.addRoute(
  {
    method: "GET",
    path: "/invalid",
    handler_name: "invalidResponse",
    response_schema: UserSchema,
  },
  async (_req) => {
    // This will throw ZodError - missing 'age'
    // Framework catches it and returns 500
    return UserSchema.parse({
      id: "usr_1",
      email: "test@example.com",
      // Missing: age
    });
  },
);

id: ruby_validation_response language: ruby title: Validation Response tags: - ruby


UserSchema = Dry::Schema.JSON do
  required(:id).filled(:string)
  required(:email).filled(:string, format?: /@/)
  required(:age).filled(:integer)
end

UserListResponseSchema = Dry::Schema.JSON do
  required(:users).array(:hash) do
    required(:id).filled(:string)
    required(:email).filled(:string, format?: /@/)
    required(:age).filled(:integer)
  end
  required(:total).filled(:integer)
  required(:page).filled(:integer)
end

app.get("/users") do |_params, _query, _body|
  users = [
    { id: "usr_1", email: "alice@example.com", age: 30 },
    { id: "usr_2", email: "bob@example.com", age: 25 }
  ]

  response = {
    users: users,
    total: users.length,
    page: 1
  }

  # Validate response before returning
  result = UserListResponseSchema.call(response)

  if result.failure?
    halt 500, { error: "Response validation failed", details: result.errors.to_h }
  end

  result.to_h
end

# Example: catch validation errors
app.get("/invalid") do |_params, _query, _body|
  response = { id: "usr_1", email: "test@example.com" }
  # Missing 'age' field

  result = UserSchema.call(response)

  if result.failure?
    halt 500, { error: "Response validation failed", details: result.errors.to_h }
  end

  result.to_h
end

id: php_validation_response language: php title: Validation Response tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Get;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Validation\Validator;

final class UsersController
{
    private function userResponseRules(): array
    {
        return [
            'id' => ['required', 'string'],
            'email' => ['required', 'email'],
            'age' => ['required', 'integer'],
        ];
    }

    private function userListResponseRules(): array
    {
        return [
            'users' => ['required', 'array'],
            'users.*.id' => ['required', 'string'],
            'users.*.email' => ['required', 'email'],
            'users.*.age' => ['required', 'integer'],
            'total' => ['required', 'integer'],
            'page' => ['required', 'integer'],
        ];
    }

    #[Get('/users')]
    public function list(Request $request): Response
    {
        $users = [
            ['id' => 'usr_1', 'email' => 'alice@example.com', 'age' => 30],
            ['id' => 'usr_2', 'email' => 'bob@example.com', 'age' => 25],
        ];

        $response = [
            'users' => $users,
            'total' => count($users),
            'page' => 1,
        ];

        // Validate response before returning
        $validator = new Validator($response, $this->userListResponseRules());

        if ($validator->fails()) {
            return Response::json([
                'error' => 'Response validation failed',
                'details' => $validator->errors(),
            ], 500);
        }

        return Response::json($response);
    }

    #[Get('/invalid')]
    public function invalid(Request $request): Response
    {
        $response = ['id' => 'usr_1', 'email' => 'test@example.com'];
        // Missing 'age' field

        $validator = new Validator($response, $this->userResponseRules());

        if ($validator->fails()) {
            return Response::json([
                'error' => 'Response validation failed',
                'details' => $validator->errors(),
            ], 500);
        }

        return Response::json($response);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new UsersController());

id: rust_validation_response language: rust title: Validation Response tags: - rust


use serde::{Deserialize, Serialize};
use schemars::JsonSchema;

#[derive(Serialize, Deserialize, JsonSchema)]
struct User {
    id: String,
    email: String,
    age: i32,
}

#[derive(Serialize, JsonSchema)]
struct UserListResponse {
    users: Vec<User>,
    total: i32,
    page: i32,
}

app.route(
    get("/users").response_body::<UserListResponse>(),
    |_ctx: Context| async move {
        let users = vec![
            User {
                id: "usr_1".to_string(),
                email: "alice@example.com".to_string(),
                age: 30,
            },
            User {
                id: "usr_2".to_string(),
                email: "bob@example.com".to_string(),
                age: 25,
            },
        ];

        let response = UserListResponse {
            total: users.len() as i32,
            page: 1,
            users,
        };

        // Serialization validates against schema
        Ok(Json(response))
    }
)?;

Custom error formatting

Customize validation error responses to match your API style and provide clear feedback to clients.


id: python_validation_error_format language: python title: Validation Error Format tags: - python


from msgspec import ValidationError
from spikard import Response

@app.exception_handler(ValidationError)
async def validation_exception_handler(
    request,
    exc: ValidationError
) -> Response:
    return Response.json(
        {
            "error": "validation_failed",
            "message": "Request validation failed",
            "details": [
                {
                    "field": err.get("loc", ["unknown"])[0],
                    "message": err.get("msg", "Invalid value"),
                    "type": err.get("type", "unknown")
                }
                for err in exc.errors()
            ]
        },
        status_code=422
    )

id: typescript_validation_error_format language: typescript title: Validation Error Format tags: - typescript


import { ZodError } from "zod";

app.setErrorHandler((error, req, res) => {
  if (error instanceof ZodError) {
    return res.status(422).json({
      error: "validation_failed",
      message: "Request validation failed",
      details: error.errors.map((err) => ({
        field: err.path.join("."),
        message: err.message,
        type: err.code,
      })),
    });
  }

  // Handle other errors
  return res.status(500).json({
    error: "internal_error",
    message: error.message,
  });
});

id: ruby_validation_error_format language: ruby title: Validation Error Format tags: - ruby


def format_validation_errors(result)
  {
    error: "validation_failed",
    message: "Request validation failed",
    details: result.errors.messages.map do |msg|
      {
        field: msg.path.join("."),
        message: msg.text,
        type: msg.predicate.to_s
      }
    end
  }
end

app.post("/users") do |_params, _query, body|
  result = CreateUserSchema.call(body)

  if result.failure?
    halt 422, format_validation_errors(result)
  end

  # Process valid request
  { id: "usr_123", email: result[:email] }
end

id: php_validation_error_format language: php title: Validation Error Format tags: - php


<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Attributes\Post;
use Spikard\Config\ServerConfig;
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Validation\Validator;
use Spikard\Validation\ValidationError;

function formatValidationErrors(Validator $validator): array
{
    $details = [];

    foreach ($validator->errors() as $field => $messages) {
        foreach ($messages as $message) {
            $details[] = [
                'field' => $field,
                'message' => $message,
                'type' => 'validation_error',
            ];
        }
    }

    return [
        'error' => 'validation_failed',
        'message' => 'Request validation failed',
        'details' => $details,
    ];
}

final class UsersController
{
    #[Post('/users')]
    public function create(Request $request): Response
    {
        $rules = [
            'email' => ['required', 'email'],
            'age' => ['required', 'integer', 'min:18'],
            'username' => ['required', 'string', 'regex:/^[a-zA-Z0-9_]+$/'],
        ];

        $validator = new Validator($request->body, $rules);

        if ($validator->fails()) {
            return Response::json(formatValidationErrors($validator), 422);
        }

        // Process valid request
        return Response::json([
            'id' => 'usr_123',
            'email' => $request->body['email'],
        ], 201);
    }
}

$app = (new App(new ServerConfig(port: 8000)))
    ->registerController(new UsersController());

id: rust_validation_error_format language: rust title: Validation Error Format tags: - rust


use serde_json::json;

#[derive(Debug)]
struct ValidationErrorResponse {
    error: String,
    message: String,
    details: Vec<ValidationDetail>,
}

#[derive(Debug)]
struct ValidationDetail {
    field: String,
    message: String,
}

impl From<ValidationErrorResponse> for Response {
    fn from(err: ValidationErrorResponse) -> Response {
        Response::builder()
            .status(422)
            .json(json!({
                "error": err.error,
                "message": err.message,
                "details": err.details
            }))
    }
}

app.route(
    post("/users").request_body::<CreateUserRequest>(),
    |ctx: Context| async move {
        let user: CreateUserRequest = ctx.json()
            .map_err(|e| ValidationErrorResponse {
                error: "validation_failed".to_string(),
                message: "Request validation failed".to_string(),
                details: vec![ValidationDetail {
                    field: "body".to_string(),
                    message: e.to_string(),
                }]
            })?;

        Ok(Json(json!({"id": "usr_123", "email": user.email})))
    }
)?;

Testing validation

Verify that schemas correctly validate inputs and reject invalid data.


id: python_validation_testing language: python title: Validation Testing tags: - python


import pytest
from spikard.testing import TestClient

@pytest.mark.asyncio
async def test_user_creation_validation(client: TestClient):
    # Valid request succeeds
    response = await client.post("/users", json={
        "email": "test@example.com",
        "age": 25,
        "username": "testuser"
    })
    assert response.status_code == 200

    # Invalid email rejected
    response = await client.post("/users", json={
        "email": "not-an-email",
        "age": 25,
        "username": "testuser"
    })
    assert response.status_code == 422
    assert "email" in response.json()["details"][0]["field"]

    # Age below minimum rejected
    response = await client.post("/users", json={
        "email": "test@example.com",
        "age": 16,
        "username": "testuser"
    })
    assert response.status_code == 422

    # Missing required field rejected
    response = await client.post("/users", json={
        "email": "test@example.com",
        "age": 25
    })
    assert response.status_code == 422

id: typescript_validation_testing language: typescript title: Validation Testing tags: - typescript


import { describe, it, expect } from "vitest";
import request from "supertest";

describe("User creation validation", () => {
  it("accepts valid requests", async () => {
    const response = await request(app).post("/users").send({
      email: "test@example.com",
      age: 25,
      username: "testuser",
    });

    expect(response.status).toBe(200);
  });

  it("rejects invalid email", async () => {
    const response = await request(app).post("/users").send({
      email: "not-an-email",
      age: 25,
      username: "testuser",
    });

    expect(response.status).toBe(422);
    expect(response.body.details[0].field).toContain("email");
  });

  it("rejects age below minimum", async () => {
    const response = await request(app).post("/users").send({
      email: "test@example.com",
      age: 16,
      username: "testuser",
    });

    expect(response.status).toBe(422);
  });

  it("rejects missing required fields", async () => {
    const response = await request(app).post("/users").send({
      email: "test@example.com",
      age: 25,
    });

    expect(response.status).toBe(422);
  });
});

id: ruby_validation_testing language: ruby title: Validation Testing tags: - ruby


require "spikard"
require "rspec"
require "rack/test"

RSpec.describe "User creation validation" do
  include Rack::Test::Methods

  def app
    @app
  end

  it "accepts valid requests" do
    post "/users", {
      email: "test@example.com",
      age: 25,
      username: "testuser"
    }.to_json, { "CONTENT_TYPE" => "application/json" }

    expect(last_response.status).to eq(200)
  end

  it "rejects invalid email" do
    post "/users", {
      email: "not-an-email",
      age: 25,
      username: "testuser"
    }.to_json, { "CONTENT_TYPE" => "application/json" }

    expect(last_response.status).to eq(422)
    body = JSON.parse(last_response.body)
    expect(body["details"].first["field"]).to include("email")
  end

  it "rejects age below minimum" do
    post "/users", {
      email: "test@example.com",
      age: 16,
      username: "testuser"
    }.to_json, { "CONTENT_TYPE" => "application/json" }

    expect(last_response.status).to eq(422)
  end

  it "rejects missing required fields" do
    post "/users", {
      email: "test@example.com",
      age: 25
    }.to_json, { "CONTENT_TYPE" => "application/json" }

    expect(last_response.status).to eq(422)
  end
end

id: php_validation_testing language: php title: Validation Testing tags: - php


<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use Spikard\Testing\TestClient;

final class UserValidationTest extends TestCase
{
    private TestClient $client;

    protected function setUp(): void
    {
        $this->client = new TestClient($this->createApp());
    }

    public function testAcceptsValidRequests(): void
    {
        $response = $this->client->post('/users', [
            'email' => 'test@example.com',
            'age' => 25,
            'username' => 'testuser',
        ]);

        $this->assertEquals(201, $response->getStatusCode());
    }

    public function testRejectsInvalidEmail(): void
    {
        $response = $this->client->post('/users', [
            'email' => 'not-an-email',
            'age' => 25,
            'username' => 'testuser',
        ]);

        $this->assertEquals(422, $response->getStatusCode());

        $body = json_decode($response->getBody(), true);
        $this->assertArrayHasKey('details', $body);
        $this->assertStringContainsString('email', $body['details'][0]['field']);
    }

    public function testRejectsAgeBelowMinimum(): void
    {
        $response = $this->client->post('/users', [
            'email' => 'test@example.com',
            'age' => 16,
            'username' => 'testuser',
        ]);

        $this->assertEquals(422, $response->getStatusCode());
    }

    public function testRejectsMissingRequiredFields(): void
    {
        $response = $this->client->post('/users', [
            'email' => 'test@example.com',
            'age' => 25,
            // missing username
        ]);

        $this->assertEquals(422, $response->getStatusCode());
    }
}

id: rust_validation_testing language: rust title: Validation Testing tags: - rust


#[cfg(test)]
mod tests {
    use super::*;
    use axum_test::TestServer;

    #[tokio::test]
    async fn test_user_creation_validation() {
        let server = TestServer::new(app).unwrap();

        // Valid request succeeds
        let response = server
            .post("/users")
            .json(&serde_json::json!({
                "email": "test@example.com",
                "age": 25,
                "username": "testuser"
            }))
            .await;
        assert_eq!(response.status_code(), 200);

        // Invalid email rejected
        let response = server
            .post("/users")
            .json(&serde_json::json!({
                "email": "not-an-email",
                "age": 25,
                "username": "testuser"
            }))
            .await;
        assert_eq!(response.status_code(), 422);

        // Age below minimum rejected
        let response = server
            .post("/users")
            .json(&serde_json::json!({
                "email": "test@example.com",
                "age": 16,
                "username": "testuser"
            }))
            .await;
        assert_eq!(response.status_code(), 422);

        // Missing required field rejected
        let response = server
            .post("/users")
            .json(&serde_json::json!({
                "email": "test@example.com",
                "age": 25
            }))
            .await;
        assert_eq!(response.status_code(), 422);
    }
}

Best practices

  • Keep schemas in version control - Track schema changes alongside code changes to maintain API contract history
  • Generate OpenAPI/AsyncAPI specs - Use CLI generators to create fixtures and tests from schemas
  • Test validation thoroughly - Add tests for both valid inputs and all rejection cases
  • Document validation rules - Add comments or descriptions to schema fields explaining constraints
  • Use semantic error codes - Return structured error responses that clients can handle programmatically

Edit this page on GitHub