ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.