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.

from msgspec import Struct

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

@app.post("/payments")
async def create_payment(payment: Payment) -> Payment:
    return payment
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,
);
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
<?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());
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.

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
    }
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
  };
});
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
<?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());
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.

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": []
    }
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: []
  };
});
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
<?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());
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.

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"
    }
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"
  };
});
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
<?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());
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.

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"}
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
  });
});
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
<?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());
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.

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
    )
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
  });
});
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
<?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());
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.

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
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);
  });
});
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
<?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());
    }
}
#[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