hapi server with joi validation: working versions (tested 2026-10-16)
Build a @hapi/hapi server with joi-validated params, payload and query, a custom failAction and Boom errors, tested via server.inject offline.
passed6 passed · 0 failed2026-10-16 20:33 UTC- Status
- passed
- Last passed
- 2026-10-16 20:33 UTC
- Tests
- 6 / 6
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions5
@hapi/hapi@21.4.10@hapi/boom@10.0.1joi@18.3.0@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38962988948
The code
Exactly the files that passed. Your agent can pull them with get_recipe("hapi-server-inject-joi") (install), or see them on GitHub.
package.json
{
"name": "hapi-server-inject-joi",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@hapi/boom": "10.0.1",
"@hapi/hapi": "21.4.10",
"joi": "18.3.0"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
src/server.ts
import Hapi from "@hapi/hapi";
import Joi from "joi";
import Boom from "@hapi/boom";
const failAction: Hapi.Lifecycle.FailAction = (_request, _h, err) => {
throw Boom.badRequest(err?.message ?? "Invalid request");
};
export function createServer(): Hapi.Server {
const server = Hapi.server({ port: 0 });
server.route({
method: "GET",
path: "/users/{id}",
options: {
validate: {
params: Joi.object({
id: Joi.string()
.pattern(/^u_[a-z0-9]+$/)
.required(),
}),
failAction,
},
},
handler: (request) => {
const { id } = request.params as { id: string };
if (id === "u_missing") {
throw Boom.notFound(`User ${id} was not found`);
}
return { id, name: "Ada" };
},
});
server.route({
method: "POST",
path: "/users",
options: {
validate: {
payload: Joi.object({
name: Joi.string().min(1).max(80).required(),
email: Joi.string().email().required(),
}),
failAction,
},
},
handler: (request, h) => {
const { name, email } = request.payload as {
name: string;
email: string;
};
return h.response({ id: `u_${name.toLowerCase()}`, name, email }).code(201);
},
});
server.route({
method: "GET",
path: "/search",
options: {
validate: {
query: Joi.object({
q: Joi.string().min(1).max(50).required(),
limit: Joi.number().integer().min(1).max(50).default(10),
}),
failAction,
},
},
handler: (request) => {
const { q, limit } = request.query as { q: string; limit: number };
return { q, limit };
},
});
return server;
}
test/server.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import Hapi from "@hapi/hapi";
import { createServer } from "../src/server.ts";
let server: Hapi.Server;
before(async () => {
server = createServer();
await server.initialize();
});
after(async () => {
await server.stop();
});
test("GET /users/{id} returns the user for a valid id", async () => {
const res = await server.inject({ method: "GET", url: "/users/u_ada" });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.result, { id: "u_ada", name: "Ada" });
});
test("GET /users/{id} rejects an invalid id with 400", async () => {
const res = await server.inject({ method: "GET", url: "/users/123" });
assert.equal(res.statusCode, 400);
const payload = res.result as { error: string };
assert.equal(payload.error, "Bad Request");
});
test("GET /users/{id} returns a Boom 404 for a missing user", async () => {
const res = await server.inject({ method: "GET", url: "/users/u_missing" });
assert.equal(res.statusCode, 404);
const payload = res.result as { error: string; message: string };
assert.equal(payload.error, "Not Found");
assert.equal(payload.message, "User u_missing was not found");
});
test("POST /users accepts a valid payload with 201", async () => {
const res = await server.inject({
method: "POST",
url: "/users",
payload: { name: "Ada", email: "[email protected]" },
});
assert.equal(res.statusCode, 201);
assert.deepEqual(res.result, {
id: "u_ada",
name: "Ada",
email: "[email protected]",
});
});
test("POST /users rejects an invalid payload with 400", async () => {
const res = await server.inject({
method: "POST",
url: "/users",
payload: { name: "Ada", email: "not-an-email" },
});
assert.equal(res.statusCode, 400);
const payload = res.result as { error: string };
assert.equal(payload.error, "Bad Request");
});
test("GET /search validates and coerces the query string", async () => {
const ok = await server.inject({
method: "GET",
url: "/search?q=hapi&limit=5",
});
assert.equal(ok.statusCode, 200);
assert.deepEqual(ok.result, { q: "hapi", limit: 5 });
const fallback = await server.inject({ method: "GET", url: "/search?q=hapi" });
assert.equal(fallback.statusCode, 200);
assert.deepEqual(fallback.result, { q: "hapi", limit: 10 });
const bad = await server.inject({ method: "GET", url: "/search" });
assert.equal(bad.statusCode, 400);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.