The route from chapter 7 generates real, unique short codes, but it will happily shorten "not a url", an empty string, or even javascript:alert(1). In this chapter we close that hole by validating the incoming URL before it reaches the store, and we do it by writing the rejection tests first.

http or https, since the schema alone can't.{ error, message } body with a 400 status.Three ideas carry this chapter, and they're easy to conflate until you name them.
The unhappy path. Every way a request can go wrong: missing field, wrong type, malformed or dangerous value. The happy path is the mirror image, where a well-formed request succeeds. We test the unhappy path first because ignored garbage flows straight into storage.
format: "uri", and why it's not enough. A JSON Schema keyword that checks the string is a syntactically valid URI. The catch is that ftp://... and are both valid URIs, so it waves them through, which is why we need a second layer of validation in the handler.
javascript:alert(1)Protocol allow-list. The protocol is the scheme before the :// (http, https, ftp, javascript, and so on). Instead of naming every bad protocol, we name the only two we accept, http: and https:, and reject the rest. Otherwise javascript: turns a short link into a stored-XSS vector. The engine behind the check is Node's built-in WHATWG URL parser: new URL(value) exposes parsed.protocol, or throws on a string that isn't a URL, which is how a try/catch catches "not a url".
Validation here is split in two because each layer catches what the other can't.
The schema handles the cheap structural problems declaratively: missing, non-string, empty, non-URI, too long. Fastify checks those before the handler runs, so most bad input never reaches our code. But the schema is blind to meaning. Because ftp:// and javascript: are valid URIs, it lets them through. That gap is covered by the handler's isValidHttpUrl guard, which parses the URL with WHATWG and enforces the http(s) allow-list the schema couldn't express. One syntactic check and one semantic check together cover what neither does alone.
That leaves one problem: a 400 can now come from either layer, and left alone they return different shapes. Fastify's default body for a schema failure carries extra statusCode and code fields on top of error and message, while our handler returns a plain { error, message }. A client would see two different error contracts depending on which rule it tripped, which is a bad API. So we register one setErrorHandler that maps schema-validation failures down to the same { error, message } envelope. After that, every 400 from this endpoint is byte-for-byte the same shape. This one-off handler is the seed for chapter 18, where it grows into a full error strategy with custom error classes.
We write the rejection tests first for the same reason TDD always pays off here: it forces us to decide the contract (what counts as invalid, what status and body a client gets) before typing the handler, rather than discovering the answers ad hoc. The bad-input cases become the specification instead of an afterthought retrofitted after a production incident, which is why this chapter sits before the database section rather than after.
Start from the red branch:
bashgit checkout 08-url-validation-startnpm install
The branch ships one new test file: the unhappy path, written first. It uses app.inject() for in-process HTTP testing, with a typed post() helper and a shared expectBadRequest() assertion that carries the "one consistent error shape" point:
typescript// __tests__/shorten.validation.test.tsimport { FastifyInstance } from "fastify";import type { Response as InjectResponse } from "light-my-request";import { buildApp } from "../src/app";describe("POST /shorten — input validation", () => {let app: FastifyInstance;beforeAll(async () => {app = await buildApp({ logger: false });await app.ready();});afterAll(async () => {await app.close();});const post = (payload: object): Promise<InjectResponse> =>app.inject({ method: "POST", url: "/shorten", payload });const expectBadRequest = (response: InjectResponse): void => {expect(response.statusCode).toBe(400);const body = response.json();expect(typeof body.error).toBe("string");expect(typeof body.message).toBe("string");expect(body.error).toBe("Bad Request");};it("rejects a request with no url field", async () => {const response = await post({});expectBadRequest(response);});it("rejects a url that is not a string", async () => {const response = await post({ url: 12345 });expectBadRequest(response);});it("rejects an empty url string", async () => {const response = await post({ url: "" });expectBadRequest(response);});it("rejects a url using the ftp:// protocol", async () => {const response = await post({ url: "ftp://files.example.com/report.pdf" });expectBadRequest(response);});it("rejects a url using the javascript: protocol", async () => {const response = await post({ url: "javascript:alert(1)" });expectBadRequest(response);});it("rejects a string that is not a URL at all", async () => {const response = await post({ url: "not a url" });expectBadRequest(response);});it("rejects a url that exceeds the maximum length", async () => {const tooLong = "https://example.com/" + "a".repeat(2048);const response = await post({ url: tooLong });expectBadRequest(response);});it("still accepts a valid https url", async () => {const response = await post({ url: "https://dalabs.academy" });expect(response.statusCode).toBe(201);expect(response.json().url).toBe("https://dalabs.academy");});it("still accepts a valid http url", async () => {const response = await post({ url: "http://example.com/path?query=1" });expect(response.statusCode).toBe(201);expect(response.json().url).toBe("http://example.com/path?query=1");});});
Read this file as the specification. Every invalid input has a name and an expected outcome: a 400 carrying { error: "Bad Request", message: string }. The expectBadRequest helper is shared across all seven rejection cases, so if any one of them returned a differently-shaped body that test would fail. That's how the "one consistent shape" rule becomes enforceable rather than aspirational. The two happy-path cases at the bottom pin the other side: a valid http/https URL still returns 201 with url echoed back.
One subtlety: the "missing url" test already passes on the start branch. The chapter-7 schema declared required: ["url"], so a {} body was already rejected. That one validation rule existed; this chapter adds all the rest. The six failing tests on start are the others.
Run it:
bashnpm test

Red. Six validation cases (non-string, empty, ftp://, javascript:, not-a-url, too-long) still return 201 instead of 400, because the old schema let almost everything through. The "missing url" case and both happy-path cases already pass, and npm run typecheck is clean. Only the runtime behaviour is missing.
Switch to the finish branch to see the implementation:
bashgit checkout 08-url-validation-finish
The first move is to make the schema do real work. The body's url property goes from a bare { type: "string" } to a constrained one:
typescript// src/routes/shorten.tsimport { FastifyPluginAsync } from "fastify";import { UrlStore } from "../services/url.service";import { generateUniqueShortCode, RandomSource } from "../utils/short-code";import { MAX_URL_LENGTH, isValidHttpUrl } from "../utils/validate-url";interface ShortenRequestBody {url: string;}interface ShortenRouteOptions {urlStore: UrlStore;random?: RandomSource;}export const shortenRoute: FastifyPluginAsync<ShortenRouteOptions> = async (app,opts) => {const { urlStore, random } = opts;app.post<{ Body: ShortenRequestBody }>("/shorten", {schema: {description: "Create a shortened URL",tags: ["URLs"],body: {type: "object",required: ["url"],additionalProperties: false,properties: {url: {type: "string",format: "uri",minLength: 1,maxLength: MAX_URL_LENGTH,},},},response: {201: {type: "object",required: ["shortCode", "url", "shortUrl"],properties: {shortCode: { type: "string" },url: { type: "string" },shortUrl: { type: "string" },},},},},handler: async (request, reply) => {const { url } = request.body;if (!isValidHttpUrl(url)) {reply.code(400);return {error: "Bad Request",message: "url must be a valid http or https URL",};}const shortCode = generateUniqueShortCode(urlStore, random);urlStore.save(shortCode, url);reply.code(201);return {shortCode,url,shortUrl: `http://localhost:3000/${shortCode}`,};},});};
The schema now rejects four of the six reds before the handler even runs: a non-string fails format: "uri", an empty string fails minLength: 1, "not a url" fails format: "uri", and the 2068-character payload fails maxLength: 2048. Those are the cheap structural checks, and Fastify enforces them declaratively for free.
The remaining two, ftp:// and javascript:, are syntactically valid URIs, so the schema waves them through. They're caught by the explicit guard at the top of the handler: if (!isValidHttpUrl(url)) returns a 400 with our { error, message } body before any code is generated or anything is stored. That single if is the protocol allow-list the schema couldn't express.
Notice the message text differs by source. The handler's rejection carries our own literal "url must be a valid http or https URL", while the schema's rejections carry Ajv's wording. That inconsistency is exactly what the error handler exists to tame.
The second change lives in app.ts. A setErrorHandler normalises schema-validation failures into the same envelope the handler uses:
typescript// src/app.tsimport Fastify, { FastifyError, FastifyInstance } from "fastify";import swagger from "@fastify/swagger";import swaggerUi from "@fastify/swagger-ui";import { healthRoute } from "./routes/health";import { shortenRoute } from "./routes/shorten";import { UrlService } from "./services/url.service";import { RandomSource } from "./utils/short-code";interface BuildAppOptions {logger?: boolean;random?: RandomSource;}export const buildApp = async (opts: BuildAppOptions = {}): Promise<FastifyInstance> => {const app = Fastify({ logger: opts.logger ?? true });app.setErrorHandler((error: FastifyError, _request, reply) => {if (error.validation) {reply.code(400).send({error: "Bad Request",message: error.message,});return;}reply.send(error);});const urlStore = new UrlService();await app.register(swagger, {openapi: {info: {title: "URL Shortener API",description: "A URL shortener service built with Fastify and TDD",version: "1.0.0",},},});await app.register(swaggerUi, {routePrefix: "/documentation",});await app.register(healthRoute);await app.register(shortenRoute, { urlStore, random: opts.random });return app;};
When a schema rule fails, Fastify throws a FastifyError with error.validation set. The handler intercepts those, re-sends a clean { error: "Bad Request", message: error.message }, and lets everything else fall through to Fastify's default behaviour. After this, the schema-rejected 400s match the handler-rejected 400s exactly: { error, message }, every time, regardless of which layer said no.
Run the suite. The validation tests go green, the happy path stays green:
bashnpm test
Green: 6 suites, 32 tests pass.
The protocol/length/parse logic is currently inline at the top of the handler. It works, but it mixes two concerns: HTTP wiring and the rules for "is this a URL we'll shorten?" The refactor pulls those rules into a pure function so they can be tested directly, with a table of edge cases, without spinning up Fastify.
typescript// src/utils/validate-url.tsexport const ALLOWED_PROTOCOLS = ["http:", "https:"] as const;export const MAX_URL_LENGTH = 2048;export const isValidHttpUrl = (value: unknown): value is string => {if (typeof value !== "string") {return false;}if (value.length === 0 || value.length > MAX_URL_LENGTH) {return false;}let parsed: URL;try {parsed = new URL(value);} catch {return false;}return (ALLOWED_PROTOCOLS as readonly string[]).includes(parsed.protocol);};
The function reads top to bottom as the three rules from the doc comment. First a type guard: anything that isn't a string is out. (This is why the signature is value is string, a TypeScript type predicate, so callers that pass the check get a narrowed string.) Then the length bounds, so a non-empty string within MAX_URL_LENGTH survives. Then the parse: new URL(value) is the global WHATWG parser built into Node, and it throws a TypeError on a string that isn't an absolute URL, which is how "not a url" is rejected, caught in the try/catch. Finally the protocol allow-list against ["http:", "https:"], which filters out ftp:, mailto:, file:, and javascript:.
The length and type checks are deliberately redundant with the schema. The pure validator stays correct on its own, so it doesn't silently rely on the schema having run first. Belt and braces.
With the logic extracted, it earns its own unit suite of eleven cases, no Fastify in sight:
typescript// __tests__/validate-url.test.tsimport {ALLOWED_PROTOCOLS,MAX_URL_LENGTH,isValidHttpUrl,} from "../src/utils/validate-url";describe("isValidHttpUrl", () => {it("accepts a plain https url", () => {expect(isValidHttpUrl("https://dalabs.academy")).toBe(true);});it("accepts an http url with a path and query string", () => {expect(isValidHttpUrl("http://example.com/path?query=1")).toBe(true);});it("rejects a value that is not a string", () => {expect(isValidHttpUrl(12345)).toBe(false);expect(isValidHttpUrl(null)).toBe(false);expect(isValidHttpUrl(undefined)).toBe(false);});it("rejects an empty string", () => {expect(isValidHttpUrl("")).toBe(false);});it("rejects the ftp protocol", () => {expect(isValidHttpUrl("ftp://files.example.com/report.pdf")).toBe(false);});it("rejects the javascript protocol", () => {expect(isValidHttpUrl("javascript:alert(1)")).toBe(false);});it("rejects the mailto and file protocols", () => {expect(isValidHttpUrl("mailto:hi@example.com")).toBe(false);expect(isValidHttpUrl("file:///etc/passwd")).toBe(false);});it("rejects a string that does not parse as a URL", () => {expect(isValidHttpUrl("not a url")).toBe(false);});it("accepts a url exactly at the maximum length", () => {const base = "https://example.com/";const atLimit = base + "a".repeat(MAX_URL_LENGTH - base.length);expect(atLimit).toHaveLength(MAX_URL_LENGTH);expect(isValidHttpUrl(atLimit)).toBe(true);});it("rejects a url one character over the maximum length", () => {const overLimit = "https://example.com/" + "a".repeat(MAX_URL_LENGTH);expect(overLimit.length).toBeGreaterThan(MAX_URL_LENGTH);expect(isValidHttpUrl(overLimit)).toBe(false);});it("only allows the http and https protocols", () => {expect(ALLOWED_PROTOCOLS).toEqual(["http:", "https:"]);});});
This is why we extracted the function: each case is one input and one boolean, with no app to build, no request to inject, and no async. The two boundary tests are the most useful: a URL of exactly MAX_URL_LENGTH characters is accepted, and one character over is rejected. Testing both sides of a boundary is how you catch the off-by-one that an "is it roughly too long?" assertion would miss. The mailto/file case proves the allow-list isn't just a special-cased pair of ftp/javascript checks, since anything outside http(s) is gone.
Run the full suite one more time:
bashnpm test

Green: 6 suites, 32 tests pass. The base from chapter 7 was four suites and twelve tests; this chapter adds two, the nine validation tests against the HTTP layer and the eleven unit tests against the pure validator. npm run typecheck stays clean. Every malformed input now bounces off the edge with a consistent 400, and the store only ever sees a valid http(s) URL.
We stopped at validation and left normalization out. No trimming whitespace, no lowercasing the host, no de-duplicating identical URLs that differ only in trailing slash or casing. Those are real concerns, but each one is a design decision with consequences. De-dupe in particular interacts with the unique-constraint and collision work coming later, so it's better handled once a real database is in play. (The WHATWG URL parser already normalises a few things for us on access, like default ports and percent-encoding.) For now, the contract is simple and honest: a syntactically valid, length-bounded, http(s) URL gets shortened, and everything else gets a 400.
With validation at the edge, every later chapter operates on already-valid data, which is the right moment for a real database. Next we stand up PostgreSQL with Docker and Docker Compose, pinned and with a separate test database.