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