Develop, validate, and test a JSON API
Outcome
Section titled “Outcome”You will develop the Delivery Board JSON API behind four route contracts:
GET /api/healthreports process availability;GET /api/work-itemsreturns the current collection;GET /api/work-items/:itemIdreturns one known item or a specific not-found error; andPATCH /api/work-items/:itemId/statusaccepts one validated status change.
The API will keep its data in process memory. It will validate request JSON as unknown, return deliberate status codes and stable JSON error shapes, and expose no raw internal error to the client. Eight Vitest and Supertest cases will exercise the Express application through its HTTP interface without using the normal port 3000.
Why this matters
Section titled “Why this matters”An API is a contract between independently running software. A TypeScript type in the client does not cross the network with a request, and a TypeScript type in the server cannot make received JSON trustworthy. The server must validate the runtime value before it changes application state.
Good API tests operate through the same method, path, headers, body, status, and JSON boundaries that a client uses. They can prove selected server contracts without depending on a browser interface or a manually managed development port.
What you will practice
- Design a small JSON API from observable request and response contracts before route implementation.
- Choose GET for retrieval and PATCH for a partial status update.
- Distinguish route parameters, request bodies, trusted internal values, and untrusted external values.
- Narrow unknown JSON through object-shape, property, and union-value checks.
- Use an in-memory store that protects its owned collection from external mutation.
- Return deliberate 200, 400, 404, 413, and 500 JSON contracts.
- Place normal middleware, routes, not-found handling, and error middleware in effective order.
- Create a fresh Express application and fresh store state for each automated test.
- Use Supertest to exercise the application without importing the normal network listener.
- Prove one test is sensitive to an incorrectly accepted status value.
- Explain which API and validation concepts transfer to PHP despite framework syntax differences.
What is new and what is reused
Section titled “What is new and what is reused”- New: API resource, representation, collection endpoint, item endpoint, route parameter,
PATCH, request body,express.json, body-size limit, runtime parser, discriminated validation result, over-posting boundary, in-memory store factory, application factory, JSON error envelope, not-found middleware, error middleware, Supertest, Node test environment, isolated seed data, and sensitivity mutation. - Reused:
WorkItem,WorkStatus, accepted fictional fixture, Express 5 application and listener separation, Node 24,NodeNext, type stripping, strict TypeScript, Vitest, npm scripts, HTTP method and path, status, headers, JSON,feature/express-api, complete client tests, source checks, and focused commits.
Starting point
Before you start
- The server-foundation commit exists on a clean feature/express-api branch.
- server/app.ts exports app, and server/index.ts imports it and owns the local listener.
- GET /api/health returns the documented 200 JSON contract with Cache-Control no-store.
- tsconfig.server.json checks server TypeScript with strict Node ESM and erasable-syntax rules.
- The accepted client fixture has at least five fictional WorkItem records and no private data.
- The existing client test suite, complete typecheck, lint, and production build pass before API changes.
- No database, authentication system, account, secret, schema package, public server, or client fetch is required in this lesson.
- Current state
- The Express process exposes only health information. Work-item data still belongs to the React fixture, request bodies have no parsing or validation path, API errors are not consistently JSON, and no automated test exercises the server.
- First action
- Verify the clean server-foundation commit, add the four-route contract table to README.md, and write the eight API test names before installing Supertest or changing server source.
- First checkpoint
- A fresh createApp instance returns the copied server collection and one item through JSON, while unknown item IDs and routes return distinct 404 error codes.
- Help trigger
- Use the nearest recovery note or ask for help if client and server fixture responsibilities are unclear, request.body is treated as trusted, route order makes a valid route return 404, error middleware returns HTML, one test changes another test's data, Supertest opens the normal port, an HTTP status disagrees with the response body, a controlled wrong validator still passes the selected test, or a server change breaks an existing client check.
Required result
Section titled “Required result”You have completed the lesson when:
- work continues from the clean server-foundation commit on
feature/express-api; - README documents the four required routes, request bodies, success responses, and error responses before implementation;
server/data/initial-work-items.tscontains the accepted fictional fixture under server ownership for the current migration stage;- the server data imports the existing
WorkItemtype without claiming that a shared type validates network JSON; createWorkItemStoreowns a copied collection and returns copied records from list, lookup, and update operations;parseStatusUpdatestarts withunknown, accepts only an object with one ownstatusproperty, and accepts onlyplanned,active, ordone;- a reusable helper sends the documented JSON error envelope;
createAppcreates a new Express application and store from supplied or default seed data;- every
/apiresponse usesCache-Control: no-store, andX-Powered-Byremains disabled; express.jsonaccepts JSON bodies up to10kbbefore the routes readrequest.body;GET /api/work-itemsreturns status200and{ "data": [...] };GET /api/work-items/:itemIdreturns status200with one known item or status404withWORK_ITEM_NOT_FOUND;PATCH /api/work-items/:itemId/statusreturns status200with the updated item, status400for an invalid body, or status404for an unknown item;- malformed JSON returns status
400withMALFORMED_JSON; - a JSON body above
10 KBreturns status413withPAYLOAD_TOO_LARGE; - every unmatched method and path returns status
404withROUTE_NOT_FOUND; - unexpected application errors return status
500withINTERNAL_ERRORand no raw stack, source path, or exception message in the response; supertestand@types/supertestare development dependencies;- the shared test setup resets React DOM state only when
documentexists, so jsdom client tests and Node API tests can share the configuration; server/app.test.tsuses the Node environment, fresh test data, and a freshcreateAppboundary;- eight API tests cover health, collection, known item, unknown item, valid persistent update, rejected update without mutation, malformed JSON, and unmatched route;
- a controlled validator mistake causes the rejected-update test to fail with expected
400but actual200, and the valid validator is restored; - the complete client and API test suite, typecheck, lint, build, and manual HTTP checks pass; and
- README records API contracts, state lifetime, test evidence, limits, and recovery before the focused API commit.
Define the API before writing route code
Section titled “Define the API before writing route code”Run the accepted baseline from the repository root:
git switch feature/express-apigit statusgit log -1 --onelinenpm cinpm testnpm run typechecknpm run lintnpm run buildStop if the branch is dirty or the current commit is not the verified server foundation. A new route must not hide an existing client, test, configuration, or build failure.
Add this route table to README:
| Method and path | Request body | Success | Expected client errors |
|---|---|---|---|
GET /api/health |
None | 200 and health object |
None for a valid request |
GET /api/work-items |
None | 200 and { "data": WorkItem[] } |
None for a valid request |
GET /api/work-items/:itemId |
None | 200 and { "data": WorkItem } |
404 WORK_ITEM_NOT_FOUND |
PATCH /api/work-items/:itemId/status |
{ "status": WorkStatus } |
200 and { "data": updated WorkItem } |
400 INVALID_REQUEST_BODY or 404 WORK_ITEM_NOT_FOUND |
| Any unmatched method and path | Not specified | None | 404 ROUTE_NOT_FOUND |
All normal and error responses use JSON and Cache-Control: no-store. Body-parser failures add 400 MALFORMED_JSON and 413 PAYLOAD_TOO_LARGE. An unexpected server failure uses 500 INTERNAL_ERROR without exposing the internal error.
Choose method and status deliberately
Section titled “Choose method and status deliberately”GETretrieves a representation and must not change the store.PATCHchanges one part of an existing resource: its status.200 OKmeans the requested representation or updated representation is in the body.400 Bad Requestmeans the request body does not meet the route contract.404 Not Foundmeans the selected resource or route does not exist for this method and path.413 Content Too Largemeans the parser rejected the body before route validation.500 Internal Server Errormeans the server could not fulfill a request because of an unexpected internal failure.
The API does not use 201 Created because it creates no resource. It does not use 204 No Content because successful updates return the current representation. It does not use 401 or 403 because authentication and authorization are outside this stage.
Name the eight tests first
Section titled “Name the eight tests first”Write these outcomes in README or a temporary test plan:
- Health returns the stable JSON contract.
- Collection returns the supplied seed records.
- Known route ID returns one matching record.
- Unknown route ID returns the work-item error contract.
- Valid status update returns and retains the new value for a later request.
- Unsupported status returns
400and leaves the record unchanged. - Malformed JSON returns the parser error contract.
- Unmatched method and path return the route error contract.
Each name combines a scenario and an observable outcome. The tests do not inspect a private array or call a route handler directly.
Move a copy of the accepted fixture behind the server
Section titled “Move a copy of the accepted fixture behind the server”Create the server folders and copy the current accepted fixture:
New-Item -ItemType Directory -Force server/dataNew-Item -ItemType Directory -Force server/httpNew-Item -ItemType Directory -Force server/validationCopy-Item src/data/sample-work-items.ts server/data/initial-work-items.tsOpen server/data/initial-work-items.ts and make two focused changes:
import type { WorkItem } from '../../src/types/work-item.ts';
export const initialWorkItems: WorkItem[] = [ // Keep the complete accepted five-or-more-record fixture here.];Do not replace the array with the comment. Keep every accepted fictional record and change only the import path and export name.
This is a temporary migration step:
- the server now owns the source that will back API responses;
- the client still imports its original fixture until the next lesson connects it;
- the two arrays must match at this checkpoint so the network migration does not also change product content; and
- the client fixture will stop being a runtime source after API consumption works.
The server imports the existing type to prevent an avoidable second WorkItem declaration. That static relationship exists inside the repository. It does not travel in JSON and cannot validate a response received by the future client.
Keep persistence outside the lesson boundary
Section titled “Keep persistence outside the lesson boundary”The store will live inside one createApp result. Updates remain available to later requests handled by that application instance. They disappear when the process restarts or a new application instance is created.
This in-memory lifetime is intentional. It lets the lesson focus on routes, runtime validation, status codes, errors, and tests. A database would add storage schema, connection, migration, failure, test isolation, and deployment responsibilities that are not required by the curriculum goal.
Create a store that owns and copies its data
Section titled “Create a store that owns and copies its data”Create server/work-item-store.ts:
import type { WorkItem, WorkStatus } from '../src/types/work-item.ts';
function copyWorkItem(item: WorkItem): WorkItem { return { ...item, labels: [...item.labels], };}
export function createWorkItemStore(initialItems: readonly WorkItem[]) { let items = initialItems.map(copyWorkItem);
return { list(): WorkItem[] { return items.map(copyWorkItem); },
findById(id: string): WorkItem | undefined { const item = items.find((candidate) => candidate.id === id); return item === undefined ? undefined : copyWorkItem(item); },
updateStatus(id: string, status: WorkStatus): WorkItem | undefined { const currentItem = items.find((candidate) => candidate.id === id);
if (currentItem === undefined) { return undefined; }
const updatedItem: WorkItem = { ...currentItem, status, };
items = items.map((item) => (item.id === id ? updatedItem : item)); return copyWorkItem(updatedItem); }, };}The store owns its array. It copies every seed item and nested labels array. It also returns copies. A route or test therefore cannot change store state by mutating a returned object.
updateStatus replaces one record and the containing array. This follows the immutable update model used in React, but for a different reason: the store protects its ownership boundary and makes changes explicit. The server could use controlled internal mutation, but it must not leak a mutable reference to callers.
Check the store through its public methods
Section titled “Check the store through its public methods”Do not add a separate store test in the required path. The API suite will exercise list, lookup, and update through HTTP. You can still inspect the ownership model before routes exist:
initialItemsenters the factory as read-only input.itemsis a private closure value.listreturns every current record as a copy.findByIdreturns one copy orundefined.updateStatusreturns the updated copy orundefined.
No caller can replace items or read it directly.
Validate the request body as unknown
Section titled “Validate the request body as unknown”Create server/validation/status-update.ts:
import type { WorkStatus } from '../../src/types/work-item.ts';
const workStatuses = [ 'planned', 'active', 'done',] as const satisfies readonly WorkStatus[];
type ValidationResult<T> = | { ok: true; value: T } | { ok: false; issue: string };
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value);}
export function isWorkStatus(value: unknown): value is WorkStatus { return ( typeof value === 'string' && workStatuses.some((status) => status === value) );}
export function parseStatusUpdate( input: unknown,): ValidationResult<WorkStatus> { if (!isRecord(input)) { return { ok: false, issue: 'The body must be a JSON object.' }; }
const keys = Object.keys(input);
if (keys.length !== 1 || !Object.hasOwn(input, 'status')) { return { ok: false, issue: 'The body must contain only the status property.', }; }
if (!isWorkStatus(input.status)) { return { ok: false, issue: 'status must be planned, active, or done.', }; }
return { ok: true, value: input.status };}The parser rejects:
undefined,null, arrays, strings, numbers, and booleans;- objects without an own
statusproperty; - objects with extra properties;
- non-string status values; and
- strings outside the
WorkStatusunion.
Rejecting extra properties creates a narrow update contract. A client cannot send title, labels, or another property to this status-only route and assume the server ignores it. This reduces accidental over-posting.
The result is a discriminated union. After if (!result.ok), TypeScript knows that result.issue exists. In the success branch, it knows that result.value is WorkStatus.
Static types do not perform this check
Section titled “Static types do not perform this check”This would be unsafe:
const body = request.body as { status: WorkStatus };store.updateStatus(request.params.itemId, body.status);An assertion changes what the type checker permits. It does not inspect the received bytes or parsed value. A request can still contain { "status": "blocked" }, { "status": 12 }, an array, or no body.
PHP has the same boundary. A PHP type declaration does not make decoded request JSON valid. A PHP handler or framework validator must still check that the decoded value has the required shape and allowed status before it updates trusted state.
Checkpoint: Server data and status input have explicit owners
- What now works
- The server owns a temporary copy of the accepted fixture, the store copies values across its boundary, and the status parser converts unknown input into either one valid WorkStatus or one focused issue.
- Files changed
server/data/initial-work-items.ts, server/work-item-store.ts, server/validation/status-update.ts- What remains
- Create stable error JSON, assemble the application factory, and register the route contracts in effective order.
- Next action
- Create server/http/api-error.ts with the allowed error codes and response helper.
- If it does not work
- Trace one value from request bytes to parsed unknown, validation result, store input, copied output, and JSON response before changing a type.
Define one JSON error envelope
Section titled “Define one JSON error envelope”Create server/http/api-error.ts:
import type { Response } from 'express';
export type ApiErrorCode = | 'INVALID_REQUEST_BODY' | 'MALFORMED_JSON' | 'PAYLOAD_TOO_LARGE' | 'ROUTE_NOT_FOUND' | 'WORK_ITEM_NOT_FOUND' | 'INTERNAL_ERROR';
export function sendApiError( response: Response, status: 400 | 404 | 413 | 500, code: ApiErrorCode, message: string,): void { response.status(status).json({ error: { code, message, }, });}Every error body now has one stable shape:
{ "error": { "code": "WORK_ITEM_NOT_FOUND", "message": "No work item has ID WI-999." }}The code is stable program data. The message is concise human-readable context. A future client can select behavior from the HTTP status and code without parsing English text.
Do not put a stack trace, local path, package version, request body, credential, or private record in the response. The server can record protected operational detail when a real deployment defines an authorized logging process. This local lesson logs only an unexpected error object to the server terminal.
Assemble a fresh application in effective order
Section titled “Assemble a fresh application in effective order”Replace server/app.ts with the complete application factory:
import express, { type Express, type NextFunction, type Request, type Response,} from 'express';import type { WorkItem } from '../src/types/work-item.ts';import { initialWorkItems } from './data/initial-work-items.ts';import { sendApiError } from './http/api-error.ts';import { parseStatusUpdate } from './validation/status-update.ts';import { createWorkItemStore } from './work-item-store.ts';
function hasErrorType( error: unknown, type: string,): error is Error & { type: string } { return error instanceof Error && 'type' in error && error.type === type;}
export function createApp( seedItems: readonly WorkItem[] = initialWorkItems,): Express { const app: Express = express(); const store = createWorkItemStore(seedItems);
app.disable('x-powered-by');
app.use('/api', (_request, response, next) => { response.set('Cache-Control', 'no-store'); next(); });
app.use(express.json({ limit: '10kb', strict: true }));
app.get('/api/health', (_request: Request, response: Response) => { response.status(200).json({ status: 'ok', service: 'delivery-board-api', }); });
app.get('/api/work-items', (_request, response) => { response.status(200).json({ data: store.list() }); });
app.get('/api/work-items/:itemId', (request, response) => { const item = store.findById(request.params.itemId);
if (item === undefined) { sendApiError( response, 404, 'WORK_ITEM_NOT_FOUND', `No work item has ID ${request.params.itemId}.`, ); return; }
response.status(200).json({ data: item }); });
app.patch('/api/work-items/:itemId/status', (request, response) => { const body: unknown = request.body; const result = parseStatusUpdate(body);
if (!result.ok) { sendApiError(response, 400, 'INVALID_REQUEST_BODY', result.issue); return; }
const updatedItem = store.updateStatus( request.params.itemId, result.value, );
if (updatedItem === undefined) { sendApiError( response, 404, 'WORK_ITEM_NOT_FOUND', `No work item has ID ${request.params.itemId}.`, ); return; }
response.status(200).json({ data: updatedItem }); });
app.use((_request, response) => { sendApiError( response, 404, 'ROUTE_NOT_FOUND', 'No API route matches this method and path.', ); });
app.use( ( error: unknown, _request: Request, response: Response, _next: NextFunction, ) => { if (hasErrorType(error, 'entity.parse.failed')) { sendApiError( response, 400, 'MALFORMED_JSON', 'The JSON body is not valid.', ); return; }
if (hasErrorType(error, 'entity.too.large')) { sendApiError( response, 413, 'PAYLOAD_TOO_LARGE', 'The JSON body exceeds 10 KB.', ); return; }
console.error('Unhandled API error', error); sendApiError( response, 500, 'INTERNAL_ERROR', 'The server could not complete the request.', ); }, );
return app;}
export const app = createApp();Keep server/index.ts unchanged. It imports the default app, validates PORT, and opens the normal local listener. Tests import createApp instead.
Read the middleware and route order
Section titled “Read the middleware and route order”Express processes middleware and routes in registration order:
- Disable
X-Powered-Byfor the application. - Add
Cache-Control: no-storeto paths under/api. - Parse matching JSON request bodies with a
10kblimit. - Try the specific health and work-item routes.
- Convert every still-unmatched method and path into JSON
404. - Handle errors raised by the parser or later middleware.
If the not-found middleware appears before the work-item routes, it ends every request with 404 before a valid handler can run. If error middleware does not have four parameters, Express treats it as normal middleware instead of an error handler.
Treat parsed bodies as unknown
Section titled “Treat parsed bodies as unknown”Express populates request.body only when the request content type matches its JSON parser. The runtime value is controlled by the requester. This assignment makes the boundary explicit:
const body: unknown = request.body;const result = parseStatusUpdate(body);The parser must succeed before the store receives a status. Missing content type, missing body, arrays, extra properties, wrong primitive types, and unsupported strings all reach a controlled 400 result instead of a type assertion or crash.
Distinguish item not found from route not found
Section titled “Distinguish item not found from route not found”These are different contracts:
GET /api/work-items/WI-999matches a real route, but its selected resource does not exist. It returnsWORK_ITEM_NOT_FOUND.POST /api/missingmatches no registered method and path. It returnsROUTE_NOT_FOUND.
Both use HTTP 404, while the JSON code preserves the difference for the client and test suite.
Protect the internal-error boundary
Section titled “Protect the internal-error boundary”The final error middleware recognizes parser error types and maps them to client errors. Every other error receives a generic 500 body. The original error stays in the server terminal and does not enter the response.
This handler is a last boundary, not permission to ignore known failure cases. Invalid input and missing resources have specific paths before it.
Run the source checks:
npm run typechecknpm run lintResolve the first diagnostic before starting the server. Do not add an assertion to silence an unknown input error.
Checkpoint: The complete API returns deliberate JSON contracts
- What now works
- Fresh application instances own fresh stores; valid collection, item, and status requests succeed; invalid bodies, missing items, unmatched routes, parser failures, and unexpected failures have bounded JSON responses.
- Files changed
server/app.ts, server/http/api-error.ts, server/work-item-store.ts, server/validation/status-update.ts- What remains
- Install the HTTP test adapter, automate eight API contracts, prove test sensitivity, and repeat manual HTTP checks.
- Next action
- Install supertest and @types/supertest as development dependencies.
- If it does not work
- Check middleware order, method and path, parser content type, validation branch, store result, selected status, and error middleware in that order.
Test the HTTP contract without the normal listener
Section titled “Test the HTTP contract without the normal listener”Install Supertest and its declarations:
npm install --save-dev supertest @types/supertestnpm ls supertest @types/supertest vitestVitest already belongs to the project. Supertest accepts the Express application function, creates a temporary test server on an available port, sends a real HTTP request through it, and closes that test boundary. It does not import server/index.ts or compete for the normal 3000 listener.
Make the shared setup safe in both environments
Section titled “Make the shared setup safe in both environments”The existing src/test/setup.ts runs before every test file. Its React cleanup and title reset require a document, but the API file deliberately uses the Node environment. Update the callback while keeping the same imports:
import '@testing-library/jest-dom/vitest';import { cleanup } from '@testing-library/react';import { afterEach } from 'vitest';
afterEach(() => { if (typeof document === 'undefined') { return; }
cleanup(); document.title = '';});Client tests still receive jsdom, clean their rendered React tree, and reset the title. A Node test has no document, so the callback returns before it touches DOM state. This guard does not create a simulated DOM for the server.
Create server/app.test.ts:
// @vitest-environment node
import request from 'supertest';import { describe, expect, it } from 'vitest';import type { WorkItem } from '../src/types/work-item.ts';import { createApp } from './app.ts';
const seedItems: WorkItem[] = [ { id: 'WI-101', title: 'Verify the API contract', description: 'Check the response through its public HTTP boundary.', status: 'planned', labels: ['api', 'quality'], }, { id: 'WI-102', title: 'Document runtime validation', description: 'Record why external JSON starts as unknown.', status: 'active', labels: ['api', 'documentation'], },];
describe('Delivery Board API', () => { it('returns the health contract as JSON', async () => { const response = await request(createApp(seedItems)) .get('/api/health') .expect('Content-Type', /json/) .expect('Cache-Control', 'no-store') .expect(200);
expect(response.body).toEqual({ status: 'ok', service: 'delivery-board-api', }); });
it('returns the current work-item collection', async () => { const response = await request(createApp(seedItems)) .get('/api/work-items') .expect('Content-Type', /json/) .expect(200);
expect(response.body).toEqual({ data: seedItems }); });
it('returns one known work item by route ID', async () => { const response = await request(createApp(seedItems)) .get('/api/work-items/WI-101') .expect(200);
expect(response.body.data).toEqual(seedItems[0]); });
it('returns a JSON not-found error for an unknown work item', async () => { const response = await request(createApp(seedItems)) .get('/api/work-items/WI-999') .expect('Content-Type', /json/) .expect(404);
expect(response.body).toEqual({ error: { code: 'WORK_ITEM_NOT_FOUND', message: 'No work item has ID WI-999.', }, }); });
it('updates one valid status and keeps it for the next request', async () => { const app = createApp(seedItems);
const updateResponse = await request(app) .patch('/api/work-items/WI-101/status') .send({ status: 'active' }) .expect(200);
expect(updateResponse.body.data.status).toBe('active');
const followUpResponse = await request(app) .get('/api/work-items/WI-101') .expect(200);
expect(followUpResponse.body.data.status).toBe('active'); });
it('rejects an unsupported status without changing the item', async () => { const app = createApp(seedItems);
const errorResponse = await request(app) .patch('/api/work-items/WI-101/status') .send({ status: 'blocked' }) .expect(400);
expect(errorResponse.body.error.code).toBe('INVALID_REQUEST_BODY');
const followUpResponse = await request(app) .get('/api/work-items/WI-101') .expect(200);
expect(followUpResponse.body.data.status).toBe('planned'); });
it('returns a JSON error for malformed JSON', async () => { const response = await request(createApp(seedItems)) .patch('/api/work-items/WI-101/status') .set('Content-Type', 'application/json') .send('{"status":') .expect(400);
expect(response.body.error.code).toBe('MALFORMED_JSON'); });
it('returns a JSON error for an unmatched method and path', async () => { const response = await request(createApp(seedItems)) .post('/api/missing') .expect('Content-Type', /json/) .expect(404);
expect(response.body.error.code).toBe('ROUTE_NOT_FOUND'); });});The file-level environment comment is required because the existing React tests use jsdom. This API file needs Node network and process behavior, not a simulated browser document.
Understand the fresh-state boundary
Section titled “Understand the fresh-state boundary”Most tests call createApp(seedItems) once. The two update tests keep one application instance for two requests because they need to prove state across requests.
The next test still creates another application and store from the unchanged seed fixture. Test order cannot determine its result. Do not reset a private array from test code.
The test fixture is deliberately smaller than the product fixture. It contains only the records needed to expose the selected API contracts. Product data changes must not rewrite every API test expectation.
Run the API file and complete suite
Section titled “Run the API file and complete suite”Run the API boundary first:
npm test -- server/app.test.tsExpected API result: one file and eight passing tests.
Then run every client and server test:
npm testThe total depends on whether Stage 2 added tests beyond its eight required cases. The important result is that every existing client test and all eight new API tests pass in one deterministic run.
Why the normal port stays closed
Section titled “Why the normal port stays closed”server/app.test.ts imports createApp from app.ts. It does not import index.ts. Supertest manages a temporary listening boundary for each request and closes it.
If tests report that port 3000 is occupied or stay open after completion, inspect the imports. The test must not import a module that calls app.listen during evaluation.
What these tests prove
Section titled “What these tests prove”The suite proves selected behavior through Express’s HTTP boundary:
- route method and path matching;
- status and selected headers;
- JSON serialization and parsing;
- route parameter selection;
- validation and error codes;
- state retained by one application instance; and
- isolation between fresh application instances.
It does not prove public-network configuration, TLS, reverse-proxy behavior, database durability, concurrent update policy, authentication, client rendering, browser focus, or deployment safety.
Checkpoint: Eight API contracts pass through the HTTP boundary
- What now works
- Supertest exercises fresh Express applications in the Node environment; success, validation, update, parser, item-not-found, and route-not-found cases produce their documented status and JSON results.
- Files changed
server/app.test.ts, package.json, package-lock.json, Complete Vitest output- What remains
- Prove one validator test detects a plausible defect, inspect the normal local server manually, and finish repository evidence.
- Next action
- Make the controlled blocked-status validator change and run only the rejected-update test.
- If it does not work
- Confirm Node test environment, createApp import, fresh seed input, request method and path, expected status, and first response difference before changing application source.
Prove that the validator test can detect a defect
Section titled “Prove that the validator test can detect a defect”Open server/validation/status-update.ts. Temporarily change only the return condition in isWorkStatus:
return ( typeof value === 'string' && (workStatuses.some((status) => status === value) || value === 'blocked'));Run only the selected test:
npm test -- server/app.test.ts -t "rejects an unsupported status"The test must fail at the HTTP boundary with evidence equivalent to:
expected 400 "Bad Request", got 200 "OK"This is the intended reason: the wrong type guard lets blocked reach the store and success response. The type checker can still accept the incorrect predicate logic, so the behavior test provides different evidence.
Restore the valid condition immediately:
return ( typeof value === 'string' && workStatuses.some((status) => status === value));Run all tests:
npm testDo not commit the deliberate defect, its failed output, or a temporary skip. Record the scenario and restored passing result in README.
Inspect the normal local API
Section titled “Inspect the normal local API”Automated tests use the application factory. Manual checks prove that server/index.ts, process configuration, and the normal listener still connect to the same application.
Start the server in Terminal 1:
npm run dev:serverUse Terminal 2 for these requests.
Retrieve the collection
Section titled “Retrieve the collection”curl.exe -i http://127.0.0.1:3000/api/work-itemsConfirm status 200, JSON content type, Cache-Control: no-store, and the complete server-owned fictional collection.
Retrieve a known and unknown item
Section titled “Retrieve a known and unknown item”Use one ID from your accepted fixture:
curl.exe -i http://127.0.0.1:3000/api/work-items/WI-001curl.exe -i http://127.0.0.1:3000/api/work-items/WI-999The first request must return its exact item with 200. The second must return 404 and WORK_ITEM_NOT_FOUND. If your accepted fixture uses different IDs, replace WI-001 with one real ID and keep WI-999 only when it is absent.
Update one status
Section titled “Update one status”In PowerShell, use single quotes around the JSON text so the inner double quotes reach curl unchanged:
curl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":"active"}' http://127.0.0.1:3000/api/work-items/WI-001/statuscurl.exe -i http://127.0.0.1:3000/api/work-items/WI-001The update must return 200 and the updated item. The following GET must show the same status because the running application retains its store.
Reject invalid and malformed bodies
Section titled “Reject invalid and malformed bodies”curl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":"blocked"}' http://127.0.0.1:3000/api/work-items/WI-001/statuscurl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":' http://127.0.0.1:3000/api/work-items/WI-001/statusThe unsupported value must return 400 INVALID_REQUEST_BODY. The incomplete JSON must return 400 MALFORMED_JSON. Repeat GET and confirm that the invalid requests did not create blocked state.
Confirm restart lifetime
Section titled “Confirm restart lifetime”Stop Terminal 1 with Ctrl+C. Start npm run start:server again and retrieve the same item. Its status must return to the value in server/data/initial-work-items.ts.
This reset is a documented limit, not a defect in the lesson result. Do not add a file write, local storage, or database to hide it.
Stop the server with Ctrl+C after the final request. Confirm that the local URL no longer accepts a connection.
Complete the evidence and commit
Section titled “Complete the evidence and commit”Update README with:
- the complete route contract table;
- success and error JSON envelopes;
- method and status-code decisions;
- the server-owned fixture and temporary client-copy boundary;
- the in-memory store lifetime and restart reset;
- the runtime validation rules and rejection of extra properties;
- the
10kbbody limit; - middleware and error-handler order;
- the API-test dependency and Node environment;
- the document guard that lets jsdom client files and Node API files share setup;
- all eight API test outcomes;
- the controlled validator failure and restored result;
- manual collection, item, update, invalid-body, malformed-body, and restart evidence;
- exact latest test, typecheck, lint, and build results;
- PHP transfer: decoded request data still requires runtime validation and deliberate status and JSON responses; and
- current limits: duplicate migration fixture, memory-only data, one-process state, no concurrency policy, client fetch, database, authentication, authorization, public exposure, or deployment.
Run the final gate with no server process active:
npm testnpm run typechecknpm run lintnpm run buildgit status --shortgit diff --checkgit diffInspect the diff. Confirm that no blocked validator, it.skip, it.only, raw private data, generated output, or running-process artifact remains.
Create one focused lesson commit:
git add package.json package-lock.json README.mdgit add src/test/setup.tsgit add server/app.ts server/app.test.tsgit add server/data/initial-work-items.ts server/work-item-store.tsgit add server/http/api-error.ts server/validation/status-update.tsgit diff --cached --checkgit diff --cachedgit commit -m "feat: add validated work-item API"git statusKeep the branch local and clean. The API-consumption lesson will connect React before Stage 3 review and integration.
Self-check
Complete these checks against the required result.
- Confirm that the lesson started from the clean server-foundation commit on feature/express-api.
- Open README and trace each of the four required methods and paths to its request, success, and error contracts.
- Explain why GET retrieves data while PATCH changes only the selected status field.
- Explain why the API returns 200 with a representation rather than 204 after the update.
- Confirm that the server fixture contains the accepted fictional records and the client copy remains only for the next migration step.
- Explain why importing WorkItem avoids a duplicate declaration but does not validate network JSON.
- Point to where the store copies the seed, nested labels, listed records, found record, and updated record.
- Confirm that one createApp call owns one store and that a new createApp call starts from a fresh copied seed.
- Trace an unknown request body through isRecord, own-key inspection, isWorkStatus, and the validation result union.
- Confirm that arrays, null, missing status, extra properties, wrong primitive values, and blocked are rejected.
- Explain why a type assertion would skip the runtime check rather than perform it.
- Point to the stable error code and human-readable message in the JSON error envelope.
- Confirm that no error response includes a stack trace, local source path, raw request body, or exception message.
- Read server/app.ts in registration order and explain what each middleware or route can do before the next one runs.
- Confirm that every /api response uses no-store and does not expose X-Powered-By.
- Confirm that express.json has a 10kb limit and appears before the PATCH route.
- Trace a known GET, unknown GET, valid PATCH, invalid PATCH, malformed JSON, oversized JSON, unmatched route, and unexpected error to the selected status and code.
- Confirm that supertest and @types/supertest are development dependencies.
- Explain why the API test file selects the Node environment while React tests continue to use jsdom.
- Confirm that shared setup checks for document before React cleanup and title reset.
- Confirm that tests import createApp rather than the listening process entry.
- Run the API file and confirm one file and eight passing tests.
- Run the complete portfolio and confirm every existing client test and every API test passes.
- Perform the controlled blocked-status validator change, confirm expected 400 versus actual 200, restore the valid guard, and rerun every test.
- Start the normal server and verify collection, known item, missing item, update, invalid input, malformed JSON, and restart reset through curl.
- Stop the exact server process and confirm the local URL no longer accepts a connection.
- Run typecheck, lint, and build after the complete test suite.
- Confirm that README records contracts, state lifetime, evidence, recovery, PHP transfer, and current limits.
- Inspect the staged diff, create the focused API commit, and confirm a clean feature branch.
Explanation: validate at every runtime boundary
Section titled “Explanation: validate at every runtime boundary”The same WorkItem declaration supports internal client and server source, but the HTTP boundary still carries bytes. The sender can be an older client, a different program, a manual request, a modified browser, or invalid input. The server must convert parsed unknown data into trusted application values.
This lesson uses a small manual parser because the accepted body has one field and three values. The steps remain visible:
- establish object shape;
- restrict own properties;
- narrow the selected property;
- return a typed success or explicit failure; and
- change state only after success.
A schema library can reduce repetition for larger contracts, but it does not remove the need to design the contract, choose status codes, protect error output, test invalid cases, or understand the generated result. No schema dependency is required here.
The application factory and store factory solve another boundary: test isolation. Each test can use specific seed data and an independent state lifetime. The normal app export still gives server/index.ts one default application for local use.
API tests complement the React suite:
- React tests prove selected rendered behavior in jsdom.
- API tests prove selected HTTP behavior in the Node environment.
- Manual local checks prove that the normal process entry and listener connect.
- Type checking, linting, and building answer separate static and production questions.
No one result replaces the others.
How the concepts transfer to PHP
Section titled “How the concepts transfer to PHP”The Express implementation uses app.get, app.patch, middleware, request.params, request.body, and response.status().json(). A PHP project can express the same responsibilities through a framework router and request or response objects, or through lower-level PHP and web-server configuration.
The transferable sequence is:
- match an HTTP method and path;
- read route and body input;
- treat decoded input as untrusted;
- validate shape and allowed values;
- perform the permitted state operation;
- select a deliberate status and response representation; and
- test success and failure through HTTP.
Modern PHP can declare parameter and return types, but those declarations do not prove that decoded JSON has the required properties and values. Runtime validation remains a separate job in both stacks.
Official references
Section titled “Official references”- Express 5: Routing — methods, paths, route parameters, and handler order
- Express 5: API reference —
express.json, request body, application settings, and response methods - Express 5: Using middleware — normal and error middleware order
- Express 5: Error handling — error propagation and four-parameter handlers
- Vitest: Test environment — Node and jsdom environment selection
- Vitest: Test filtering — file and name filters for focused runs
- Supertest — application-bound HTTP requests, expectations, and temporary listeners
- MDN: HTTP request methods — method semantics
- MDN: HTTP response status codes — response status meanings
Optional extensions
Section titled “Optional extensions”The four routes, validation, stable errors, eight tests, manual checks, documentation, and focused commit complete this lesson. Keep extension work separate from the required evidence.
Next step or safe stopping point
Section titled “Next step or safe stopping point”The required result is safely paused when all eight API tests and all existing client tests pass, the wrong validator has been restored, manual requests match the contracts, the restart resets memory as documented, no server process remains active, typecheck, lint, and build pass, the API commit exists, and git status is clean on feature/express-api.
Continue to Connect a React application to an API. That lesson will treat every response body as external runtime data and will replace the client’s fixture ownership with explicit loading, success, empty, failure, retry, and status-update behavior.