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

http or https.{ error, message } body with a 400 status.Three ideas carry this chapter. They are easy to mix up until you define them.
The unhappy path. This covers every way a request can go wrong, such as a missing field, a wrong type, or a dangerous value. The happy path is the mirror image, where a well-formed request succeeds. We test the unhappy path first because ignored bad data flows straight into storage.
format: "uri", and why it is not enough. This is a JSON Schema keyword that checks if a string is a syntactically valid URI. The catch is that ftp://... and are both valid URIs. The schema waves them through. This 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 :// (like http, https, ftp, or javascript). Instead of trying to name every bad protocol, we name the only two we accept: http: and https:. We reject the rest. Otherwise, javascript: could turn a short link into a stored cross-site scripting (XSS) vector. The engine behind this check is Node's built-in WHATWG URL parser. Calling new URL(value) exposes parsed.protocol. It also throws an error on a string that isn't a URL at all. This is how a try/catch block catches "not a url".
Validation here is split in two because each layer catches what the other cannot.
The schema handles basic structural problems declaratively. It checks for missing fields, non-strings, empty strings, non-URIs, and strings that are too long. Fastify checks these rules before the handler runs, so most bad input never reaches our code. However, the schema is blind to meaning. Because ftp:// and javascript: are valid URIs, the schema lets them through. That gap is covered by the handler's isValidHttpUrl guard. This guard parses the URL and enforces the http(s) allow-list that the schema could not express. One syntactic check and one semantic check work together to cover what neither can do alone.
That leaves one problem. A 400 Bad Request can now come from either layer. 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. Meanwhile, our handler returns a plain { error, message }. A client would see two different error contracts depending on which rule it tripped. That makes for a frustrating API. To fix this, we register a single setErrorHandler that maps schema-validation failures down to the same { error, message } envelope. After that, every 400 from this endpoint has the exact same shape. This simple 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 because it forces us to decide the contract upfront. We have to define what counts as invalid, what status code to return, and what body a client gets before typing the handler. The bad-input cases become the specification. They are not an afterthought retrofitted after a production incident. This is why validation sits here, right before we introduce a real database.
Start from the red branch:
bashgit checkout 08-url-validation-startnpm install
This branch ships one new test file for the unhappy path. It uses app.inject() for in-process HTTP testing. We include a typed post() helper and a shared expectBadRequest() assertion to enforce our consistent error shape:
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 status carrying { error: "Bad Request", message: string }. The expectBadRequest helper is shared across all seven rejection cases. If any one of them returned a differently-shaped body, the test would fail. This makes the consistent shape rule enforceable. The two happy-path cases at the bottom pin the other side. A valid http or https URL still returns 201 with the url echoed back.
There is one subtlety here. The "missing url" test already passes on the start branch. The chapter 7 schema declared required: ["url"], so an empty {} body was already rejected. That single validation rule existed; this chapter adds the rest. The six failing tests on the start branch are the new rules.
Run the tests:
bashnpm test

Red. Six validation cases (non-string, empty, ftp://, javascript:, not-a-url, and too-long) still return 201 instead of 400. 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 behavior 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 strictly 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 failing tests 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. These are the structural checks, and Fastify enforces them declaratively.
The remaining two inputs, ftp:// and javascript:, are syntactically valid URIs. The schema waves them through. They are caught by the explicit guard at the top of the handler. The if (!isValidHttpUrl(url)) check returns a 400 with our { error, message } body before any code is generated or stored. That single if statement is the protocol allow-list the schema could not express.
Notice that the message text differs depending on the source. The handler's rejection carries our custom string: "url must be a valid http or https URL". The schema's rejections carry Ajv's default wording. This inconsistency is exactly what the error handler exists to tame.
The second change lives in app.ts. We add a setErrorHandler to normalize 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 the error.validation property set. Our custom handler intercepts those errors and sends a clean { error: "Bad Request", message: error.message } response. It lets everything else fall through to Fastify's default behavior. Now, the schema-rejected 400s match the handler-rejected 400s exactly. The client receives { error, message } regardless of which layer rejected the request.
Run the suite. The validation tests go green, and the happy path stays green:
bashnpm test
Green: 6 suites, 32 tests pass.
The protocol, length, and parsing logic is currently inline at the top of the handler. It works, but it mixes two concerns: HTTP wiring and the rules for what makes a valid URL. This refactor pulls those rules into a pure function. That way, 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. First is a type guard: anything that isn't a string is rejected. The signature uses value is string, which is a TypeScript type predicate. This ensures callers that pass the check get a narrowed string type. Next are the length bounds, ensuring a non-empty string within MAX_URL_LENGTH survives. Then comes the parse. Calling new URL(value) uses the global WHATWG parser built into Node. It throws a TypeError on a string that isn't an absolute URL. This is how "not a url" is rejected and caught in the try/catch block. Finally, the protocol allow-list checks against ["http:", "https:"]. This 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 does not silently rely on the schema running first. It is a belt-and-braces approach.
With the logic extracted, it earns its own unit test suite of eleven cases, with 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 simply one input and one boolean. There is no app to build, no request to inject, and no async code. 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 off-by-one errors. The mailto and file case proves the allow-list isn't just a special-cased pair of ftp and javascript checks. Anything outside http(s) is rejected.
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 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. There is no trimming whitespace, no lowercasing the host, and no de-duplicating identical URLs that differ only in trailing slashes or casing. Those are real concerns, but each one is a design decision with consequences. De-duplication in particular interacts with the unique-constraint and collision work coming later. It is usually better handled once a real database is in play. Note that the WHATWG URL parser already normalizes 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. Everything else gets a 400.
With validation at the edge, every later chapter operates on already-valid data. This makes it the right moment to introduce a real database. Next, we will stand up PostgreSQL with Docker and Docker Compose, using a pinned version and a separate test database.