Build a Support Ticket API
Build a layered Express API that creates, filters, and closes support tickets.
Introduction
30 Second Summary
When someone reports a broken printer, the message can bounce between people without a clear owner. The problem gets worse when nobody can see whether the request was accepted, rejected, or resolved.
In this project, you will build a local support ticket API with Express. You'll use a demo client to follow real HTTP requests through the backend's layers.
What You'll Build
You'll run one demo that prints how your backend handles every support request, including the status code and JSON response.
By the end of this project, you'll have:
- Ticket creation you can see: submit a title padded with spaces. The API returns a normalized ticket with 201.
- Visible validation failures: send an empty title or unsupported priority. You'll receive a structured 400 response without storing the bad ticket.
- A complete request trail: filter tickets with ?priority=high. Close ticket 1 through PATCH /tickets/1/close. Match each response to its server log using the request ID.
- Secret Mission: add GET /tickets/:id so one ticket can be retrieved directly. Missing tickets return the same structured 404 response.
Are there any prerequisites?
You need Node.js and Visual Studio Code installed on Windows. Basic familiarity with files and folders is enough because the project introduces JavaScript syntax as you use it.
Before We Start
Before any hands-on work, commit to building a local support ticket API. Tracing each HTTP request through middleware, validation, routes, controllers, services, and error handling reveals how backend layers cooperate.
Set Up and Run the Server
Your support ticket API needs a dependable local runtime before you can trace an HTTP request through the backend. A small health endpoint gives you immediate proof that a request reached your code.
You will verify Node.js with npm. You will then install Express and run the first endpoint from Visual Studio Code.
In this step, get ready to:
- Open the project folder and verify Node.js with npm.
- Install the pinned Express dependency.
- Run a health endpoint and view its response.
Verify the installed tools
Node.js runs your server code. npm reads the project's dependency file and installs the packages your server needs.
- Press the Windows key to open Windows search.
- Type Visual Studio Code and press Enter to open it.
- Select File from the top menu.
- Select Open Folder....
- Choose your Desktop in the folder dialog.
- Select New Folder.
- Enter support-ticket-api as the folder name.
- Select Select Folder to open the new folder.
You should see support-ticket-api at the top of the Explorer sidebar. This keeps every project file in one known location.
- Select View from the top menu.
- Select Terminal to open the integrated terminal inside support-ticket-api.
- Check the installed Node.js version by running this command:
node --version
What does this command check?
This prints the installed Node.js version. Express 5.2.1 requires Node.js 18 or higher.
✔️ I see version 18 or higher
Your Node.js runtime supports this project. Continue to the npm check below.
ⓧ I see an older version
Your installed version is below the minimum required by Express. Update Node.js before installing the project dependency.
- Visit the official Node.js download page.
- Download the Windows Installer for an LTS release at version 18 or higher.
- Run the downloaded .msi installer.
- Complete the on-screen installation prompts.
- Restart Visual Studio Code after the installation finishes.
- Return to the support-ticket-api folder from your recent folders.
- Open a fresh integrated terminal.
- Verify the updated version by running this command:
node --version
What should this confirm?
The new terminal should print Node.js version 18 or higher. That version can run Express 5.2.1.
ⓧ Command not found
Windows cannot currently find Node.js. Install it before continuing with the project.
- Visit the official Node.js download page.
- Download the Windows Installer for an LTS release at version 18 or higher.
- Run the downloaded .msi installer.
- Complete the on-screen installation prompts.
- Restart Visual Studio Code after the installation finishes.
- Return to the support-ticket-api folder from your recent folders.
- Open a fresh integrated terminal.
- Verify the installation by running this command:
node --version
What should this confirm?
A version number confirms that Windows can now find Node.js. Continue when the reported version is 18 or higher.
- Check that npm is available by running this command:
npm --version
What does this command check?
This prints the installed npm version. A version number confirms that the package manager is ready to install the dependency declared by your project.
Your terminal should now show a Node.js version of 18 or higher. It should also show an npm version number.
Does npm fail to run?
Open a fresh integrated terminal after installing or updating Node.js. If npm is still unavailable, rerun the official Node.js installer and complete every installation prompt.
Help me verify Node.js and npm on Windows.
Install the pinned dependency
A package.json file records the exact dependency required by the project. Pinning Express to 5.2.1 keeps the project reproducible.
- Create package.json inside support-ticket-api by selecting the new-file icon in the Explorer sidebar.
- Paste this content into package.json:
{
"name": "support-ticket-api",
"version": "1.0.0",
"description": "A beginner backend API built from first principles.",
"private": true,
"dependencies": {
"express": "5.2.1"
}
}
What does this file define?
- The project metadata identifies the local application.
- The private setting prevents accidental publication as an npm package.
- The dependency entry tells npm to install exactly Express 5.2.1.
- Save package.json.
- Install the declared dependency by running this command:
npm install
What does this command create?
npm reads package.json and installs Express inside node_modules. It also creates package-lock.json to record the resolved package tree.
You should see the installation finish without an error. The Explorer sidebar should list node_modules and package-lock.json.
Did the dependency installation fail?
Check that the integrated terminal is inside the support-ticket-api folder. Confirm that package.json matches the reference below before running the installation again.
Help me fix my Express installation.
✔️ Awesome, I've got everything!
Great. Confirm that package.json is saved and that the Explorer lists the installed dependency files.
ⓧ I'd like to double check the full code
{
"name": "support-ticket-api",
"version": "1.0.0",
"description": "A beginner backend API built from first principles.",
"private": true,
"dependencies": {
"express": "5.2.1"
}
}
Run the first visible endpoint
A health endpoint answers a simple question about the server: can it receive a request and return a response? The GET /health route provides that first proof.
- Create app.js inside support-ticket-api by selecting the new-file icon in the Explorer sidebar.
- Paste this server code into app.js:
const express = require("express");
const app = express();
const port = 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
What does this code do?
- The first section loads Express and creates the application.
- The express.json() middleware prepares the server to parse incoming JSON bodies.
- The health route responds with status 200 and a small JSON object.
- The final section starts the server on port 3000.
- Save app.js.
Before you start the server, what do you expect the health URL to return when the request reaches your route?
- Start the Express server by running this command:
node app.js
What does this command do?
Node.js runs app.js and keeps the server listening for requests. The terminal should show the local address from the startup message.
- Keep the integrated terminal running.
- Open your browser's address bar.
- Load http://localhost:3000/health.
You should see {"status":"ok"} in the browser. That first response is live.
Your browser has reached the Express route and received JSON from your local server.
Can't load the health endpoint?
Confirm that the integrated terminal is still running the server. Check that the browser address uses port 3000 and the path /health.
If the terminal mentions a missing dependency, return to the installation section and confirm that node_modules exists.
Help me get my health endpoint running.
✔️ Awesome, I've got everything!
Your health endpoint is responding. Keep the server running in the integrated terminal for the next step.
ⓧ I'd like to double check the full code
const express = require("express");
const app = express();
const port = 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
Your runtime and first route are working. Next, you will add ticket creation and listing so you can observe a complete request and response cycle.
Build a Naive Ticket API
Your health endpoint proved that the Express server can receive an HTTP request. The next goal is to turn a request body into stored ticket data.
This first version trusts each JSON body exactly as received. The demo client lets you judge that boundary from its responses.
In this step, get ready to:
- Add in-memory state for created tickets.
- Map ticket requests to create or list behavior.
- Run a demo client to expose what happens when request data is trusted.
Add in-memory ticket state
An array keeps created tickets available while the server process runs. A counter supplies a different numeric ID for each ticket.
- In the app.js editor, find this line:
const port = 3000;
Why Start Below the Port?
The port is application configuration. Placing the ticket state below it keeps shared values together near the top of app.js.
- Add the ticket collection below const port = 3000; so this section looks like:
const port = 3000;
const tickets = [];
let nextId = 1;
What Does This State Hold?
- The tickets array holds each created ticket.
- The nextId counter starts the first ticket at ID 1.
These values live outside the route handlers. Every request sees the same collection while the process stays running.
Map HTTP methods to ticket behavior
The POST /tickets route creates one ticket from the incoming body. The GET /tickets route returns the current collection.
- In app.js, locate the closing line of the health route.
- Add the create route directly below the health route by copying this code:
app.post("/tickets", (req, res) => {
const ticket = {
id: nextId,
...req.body,
};
nextId += 1;
tickets.push(ticket);
res.status(201).json(ticket);
});
What Does the Create Route Do?
- The id field uses the current value of nextId.
- The ...req.body syntax copies the submitted fields into the ticket.
- The handler stores the ticket before returning a 201 response.
- Save app.js.
- Return to the integrated terminal that is running the server.
- Press Ctrl+C to stop the current process.
- Restart the server with the new create route by running this command:
node app.js
What Does This Command Do?
The command starts the updated server from app.js. The terminal stays occupied while the server listens for requests.
- Return to the health page from the previous step.
- Refresh the page.
You should still see {"status":"ok"}. This confirms the updated file starts without a syntax error.
Server Fails After the Create Route?
Check the braces around the ticket object. Confirm that the route ends with });.
Compare the property name nextId with the counter near the top of app.js.
Help me debug my create route.
- Add the list route directly below the create route by copying this code:
app.get("/tickets", (req, res) => {
res.status(200).json(tickets);
});
What Does the List Route Do?
The handler serializes the current tickets array into the response. A successful request receives status 200.
- Save app.js.
- Return to the terminal running the server.
- Press Ctrl+C to stop the process.
- Restart the server with both ticket routes by running this command:
node app.js
What Does This Restart Prove?
The restart loads the ticket routes into the running process. It also resets tickets to an empty array.
Before you check, what do you expect the ticket list to contain before any tickets have been created?
- Load http://localhost:3000/tickets in your browser.
You should see []. The empty array confirms that the list route can read the in-memory collection.
Tickets Page Does Not Load?
Check that the server terminal still shows the listening message. Confirm that the browser URL ends with /tickets.
Help me troubleshoot the list route.
✔️ Awesome, I've got everything!
Your server now has shared ticket state plus create and list routes. Keep the server running in this terminal.
ⓧ I'd like to double check the full code
const express = require("express");
const app = express();
const port = 3000;
const tickets = [];
let nextId = 1;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.post("/tickets", (req, res) => {
const ticket = {
id: nextId,
...req.body,
};
nextId += 1;
tickets.push(ticket);
res.status(201).json(ticket);
});
app.get("/tickets", (req, res) => {
res.status(200).json(tickets);
});
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
Exercise the request and response cycle
A small Node.js client can send repeatable requests without a separate API tool. Its fetch() calls expose each status code plus the response body.
- Select the New File... button in the Explorer view.
- Enter demo.js as the file name.
- Press Enter to create the file.
You should see demo.js in the Explorer. The new file opens in the editor.
- Build the reusable request helper in demo.js by copying this code:
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
What Does the Request Helper Do?
- The baseUrl value points every request at your local server.
- The headers object identifies requests that contain a JSON body.
- The two log calls print the response status plus a readable response body.
- Add the request sequence below the request() function by copying this code:
async function run() {
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: "Printer is offline",
priority: "high",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({}),
});
await request("/tickets");
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
What Does the Demo Send?
- The first request submits a title plus a priority.
- The second request submits an empty JSON object.
- The final request retrieves every stored ticket.
- The catch handler reports a client failure through process.exitCode.
- Save demo.js.
- Select the plus button in the terminal panel to create a second integrated terminal.
Before you run this, do you think the two POST requests will receive the same status?
- Run the demo from the second terminal by running this command:
node demo.js
What Does This Command Do?
The command runs demo.js against the server in the first terminal. Each await keeps the output in request order.
You should see both POST /tickets requests return 201. The final GET /tickets response contains ticket IDs 1 and 2.
The Empty Ticket Passed
The server gave an ID to the empty object because the create route trusts req.body. The collection now contains data with no title or priority.
Demo Requests Do Not Complete?
Confirm that the first terminal still shows the server listening on port 3000. Make sure the second terminal is inside the support-ticket-api folder.
Help me troubleshoot the demo client.
✔️ Awesome, I've got everything!
Your demo creates two tickets and lists the stored collection. Keep both app.js and demo.js saved.
ⓧ I'd like to double check the full code
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function run() {
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: "Printer is offline",
priority: "high",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({}),
});
await request("/tickets");
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
You have exposed the first important weakness in your API through a real request cycle. Next, you'll add a validation boundary that keeps incomplete tickets out of the collection.
Reject Bad Ticket Data
Your naive Express API from the previous step can create tickets. It also accepts an empty object as valid data.
The next goal is a validation boundary before storage. Middleware will normalize valid input.
Invalid input will return every field problem in one structured HTTP response. This keeps bad values out of the in-memory ticket list.
In this step, get ready to:
- Define the accepted ticket shape.
- Normalize incoming values before storage.
- Return all field errors in one response.
Define and normalize the ticket shape
The API needs one canonical set of priority values. Normalization converts each accepted request into the same predictable shape.
- In app.js, find const port = 3000;.
- Add the accepted priority list directly below that line by inserting:
const PRIORITIES = ["low", "medium", "high"];
Why Keep an Accepted List?
The PRIORITIES array gives the API one source of truth for valid values. Every priority check can use the same list.
- In app.js, find app.use(express.json());.
- Add the normalization middleware directly below that line by inserting:
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string"
? body.priority.trim().toLowerCase()
: "";
const details = [];
req.body = { title, priority };
return next();
}
What Does This Middleware Do?
- The body fallback gives the middleware a safe object when no request body exists.
- The title value loses surrounding whitespace through trim().
- The priority value becomes lowercase after its surrounding whitespace is removed.
- The new req.body contains only the normalized fields that the ticket handler needs.
- The next() call passes the normalized request to the next handler.
- In app.js, find the existing app.post("/tickets" route.
- Replace the current route with this version:
app.post("/tickets", validateTicket, (req, res) => {
const ticket = {
id: nextId,
...req.body,
status: "open",
createdAt: new Date().toISOString(),
};
nextId += 1;
tickets.push(ticket);
res.status(201).json(ticket);
});
How Does the Request Flow Change?
The route now runs validateTicket before the create handler. The handler receives the normalized request body after the middleware calls next().
Each stored ticket also receives an open status. Its creation time is recorded through createdAt.
- Save app.js.
- Switch back to the server terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Restart the updated server by running this command:
node app.js
What Does This Command Do?
Node.js runs the updated app.js file. You should see Support Ticket API listening at http://localhost:3000 in the terminal.
- Switch back to the second integrated terminal from earlier.
Before the run, what response do you expect for the empty payload after normalization? The next command tests that prediction.
- Exercise the normalized create route by running:
node demo.js
What Should You See?
The first ticket receives 201 with status set to open. It also contains a timestamp in createdAt.
The empty payload still receives 201. Its normalized title value is empty.
That second response confirms the missing boundary. The middleware cleans values without rejecting invalid ones.
Demo Cannot Reach the Server?
Check the server terminal for the listening message. Keep that process running while you use the second terminal.
Confirm both terminals are inside the support-ticket-api folder.
Help me diagnose the connection between my demo client and local Express server.
Reject every invalid field
A validation boundary should collect every problem before returning a response. This gives the client one complete list of fields to fix.
- In validateTicket, find const details = [];.
- Insert the validation checks directly below that line:
if (!title) {
details.push({ field: "title", message: "Title is required" });
}
if (!PRIORITIES.includes(priority)) {
details.push({
field: "priority",
message: "Priority must be low, medium, or high",
});
}
if (details.length > 0) {
return res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Ticket data is invalid",
details,
},
});
}
How Does Fail-Fast Validation Work?
- The first condition records a problem when the normalized title is empty.
- The second condition compares the normalized priority with PRIORITIES.
- Both checks run before the response is created. This allows details to hold every field error.
- A non-empty error list ends the request with a structured 400 response.
- Calling next() remains limited to valid requests.
- Save app.js.
- Switch back to the server terminal.
- Stop the current server by pressing Ctrl+C.
- Restart the server with the validation checks by running:
node app.js
Why Restart the Server?
The running Node.js process still has the previous code in memory. Restarting loads the new validation checks from app.js.
- Return to the second terminal from earlier.
Before the run, how many validation details do you expect from an empty object? The next command reveals the result.
- Test the new validation boundary by running:
node demo.js
What Should You See Now?
The valid ticket still receives 201. The empty ticket now receives 400 with VALIDATION_ERROR.
The details array reports the missing title. It also reports the unsupported priority.
The final ticket list contains only the valid ticket. The invalid payload never reaches tickets.push(ticket).
Still Seeing 201 for the Empty Ticket?
Confirm the validation blocks appear before req.body = { title, priority }; inside validateTicket.
Check that the POST route includes validateTicket before its handler.
Help me find why my invalid ticket still receives 201.
Test normalization and rejection together
The current demo proves that missing fields fail. A stronger test will expose title trimming plus priority normalization.
- In demo.js, find the current run() function:
async function run() {
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: "Printer is offline",
priority: "high",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({}),
});
await request("/tickets");
}
What Does the Current Test Cover?
The current first payload already uses normalized values. The second payload checks how the API handles missing fields.
The next version makes normalization visible. It also tests two invalid values in one request.
- Replace the current run() function with this version:
async function run() {
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: " Printer is offline ",
priority: "HIGH",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets");
}
What Does the Final Demo Prove?
- The first payload tests whether surrounding title spaces are removed.
- The uppercase priority tests whether normalization produces high.
- The second payload tests an empty title.
- The urgent value tests the accepted priority list.
- The final GET request proves that rejected data never entered storage.
- Save demo.js.
✔️ Awesome, I've got everything!
Your validation code is ready. Double check that both files are saved before the final run.
ⓧ I'd like to double check the full code
const express = require("express");
const app = express();
const port = 3000;
const PRIORITIES = ["low", "medium", "high"];
const tickets = [];
let nextId = 1;
app.use(express.json());
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string"
? body.priority.trim().toLowerCase()
: "";
const details = [];
if (!title) {
details.push({ field: "title", message: "Title is required" });
}
if (!PRIORITIES.includes(priority)) {
details.push({
field: "priority",
message: "Priority must be low, medium, or high",
});
}
if (details.length > 0) {
return res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Ticket data is invalid",
details,
},
});
}
req.body = { title, priority };
return next();
}
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.post("/tickets", validateTicket, (req, res) => {
const ticket = {
id: nextId,
...req.body,
status: "open",
createdAt: new Date().toISOString(),
};
nextId += 1;
tickets.push(ticket);
res.status(201).json(ticket);
});
app.get("/tickets", (req, res) => {
res.status(200).json(tickets);
});
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function run() {
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: " Printer is offline ",
priority: "HIGH",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets");
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
- Switch back to the server terminal.
- Stop the running server by pressing Ctrl+C.
- Restart the server with an empty in-memory ticket list by running:
node app.js
Why Start With an Empty List?
Restarting the process resets the in-memory tickets array. The final list can now prove exactly which requests passed validation.
- Return to the second terminal from earlier.
Before the final run, which payload do you expect to reach the ticket list? The next command checks your prediction.
- Run the complete validation demo by entering:
node demo.js
What Should the Final Demo Show?
- The first POST receives 201.
- The returned title is Printer is offline without surrounding spaces.
- The returned priority is lowercase high.
- The invalid POST receives a structured 400 response.
- The error details identify both title and priority.
- The final GET response contains only the normalized valid ticket.
Final Output Does Not Match?
Check that the valid title still contains spaces inside demo.js. Confirm its priority remains uppercase before the request is sent.
Confirm the invalid body contains an empty title. Its priority must be urgent.
Help me compare my validation demo with the expected 201 and 400 responses.
Your API now normalizes useful input while blocking invalid tickets. Next up, you'll separate this working behavior into routes, controllers, services, and middleware files.
Separate the Backend into Layers
Your Express API now rejects invalid ticket data before it reaches the in-memory collection. That working behavior currently lives in one app.js file.
This step introduces layered architecture so each file owns one responsibility. You'll preserve every current response while adding a route that closes a ticket.
In this step, get ready to:
- Move ticket state into a service module.
- Connect controller functions through an Express router for each HTTP method.
- Complete the layered close-ticket flow with validation middleware.
Move state into the service layer
A service layer owns business behavior that does not depend on a particular route. Here it manages ticket storage through focused functions.
- Click the New Folder icon in the Visual Studio Code Explorer sidebar.
- Enter services as the folder name.
- Select the new services folder in the Explorer sidebar.
- Click the New File icon.
- Enter ticketService.js as the file name.
- Build the service module by adding this code to services/ticketService.js:
const PRIORITIES = ["low", "medium", "high"];
const tickets = [];
let nextId = 1;
function createTicket({ title, priority }) {
const ticket = { id: nextId, title, priority, status: "open", createdAt: new Date().toISOString() };
nextId += 1;
tickets.push(ticket);
return ticket;
}
function listTickets() {
return [...tickets];
}
function closeTicket(id) {
const ticket = tickets.find((item) => item.id === id);
if (!ticket) return null;
ticket.status = "closed";
ticket.closedAt = new Date().toISOString();
return ticket;
}
module.exports = { PRIORITIES, createTicket, listTickets, closeTicket };
What Does This Service Do?
- The service keeps tickets private to this module so routes cannot modify the array directly.
- The createTicket() function assigns each ticket its stored fields.
- The listTickets() function returns a new array so callers do not receive the internal collection.
- The closeTicket() function records the closed state or returns null when no ticket matches.
- Save services/ticketService.js.
- Confirm that ticketService.js remains listed inside the services folder in the Explorer sidebar.
Can't Find the Service File?
Check that ticketService.js sits inside the services folder. A file beside that folder produces the wrong import path later.
Confirm that the file name ends in .js once. Windows can hide known file extensions.
Help me check my service module location.
Connect controllers and routes
Controllers translate incoming requests into service calls. A router maps each request path to the controller that should handle it.
- Select the support-ticket-api folder in the Explorer sidebar.
- Click the New Folder icon.
- Enter controllers as the folder name.
- Select the new controllers folder.
- Click the New File icon.
- Enter ticketController.js as the file name.
- Connect request handlers to the service by adding this code to controllers/ticketController.js:
const ticketService = require("../services/ticketService");
function sendData(res, statusCode, data) {
return res.status(statusCode).json({ data });
}
function createTicket(req, res) {
return sendData(res, 201, ticketService.createTicket(req.body));
}
function listTickets(req, res) {
return sendData(res, 200, ticketService.listTickets());
}
function closeTicket(req, res) {
const ticket = ticketService.closeTicket(Number(req.params.id));
if (!ticket) return res.status(404).json({ error: { code: "TICKET_NOT_FOUND", message: "Ticket not found" } });
return sendData(res, 200, ticket);
}
module.exports = { createTicket, listTickets, closeTicket };
How Do the Controllers Work?
- The sendData() helper gives successful responses one consistent data wrapper.
- Each controller reads request data before calling the matching service function.
- The closeTicket() controller converts the route parameter into a number before looking up the ticket.
- A missing ticket receives a 404 response with a stable error code.
- Save controllers/ticketController.js.
- Confirm that ticketController.js appears inside the controllers folder in the Explorer sidebar.
Controller Import Not Resolving?
Check that the controllers folder sits beside the services folder. The relative import depends on that structure.
Compare the capitalization of ticketController.js with its file name in the Explorer sidebar.
Help me trace the controller import.
- Select the support-ticket-api folder in the Explorer sidebar.
- Click the New Folder icon.
- Enter routes as the folder name.
- Select the new routes folder.
- Click the New File icon.
- Enter tickets.js as the file name.
- Map the ticket endpoints to their handlers by adding this code to routes/tickets.js:
const express = require("express");
const ticketController = require("../controllers/ticketController");
const { validateTicket } = require("../middleware/validateTicket");
const router = express.Router();
router.get("/", ticketController.listTickets);
router.post("/", validateTicket, ticketController.createTicket);
router.patch("/:id/close", ticketController.closeTicket);
module.exports = router;
What Does the Router Control?
- The router groups every ticket endpoint under one module.
- The POST route runs validateTicket before the create controller.
- The PATCH route captures the ticket ID from :id before calling the close controller.
- Save routes/tickets.js.
- Confirm that tickets.js appears inside the routes folder in the Explorer sidebar.
Router File in the Wrong Place?
Make sure routes/tickets.js sits inside the routes folder. The app imports the router from that exact location.
Keep the route file name lowercase so it matches the later import.
Help me verify my router structure.
Extract validation and rewire the app
Validation belongs at the boundary where client data first enters the ticket flow. Application composition then becomes a short map of shared parsing, health routing, and ticket routing.
- Select the support-ticket-api folder in the Explorer sidebar.
- Click the New Folder icon.
- Enter middleware as the folder name.
- Select the new middleware folder.
- Click the New File icon.
- Enter validateTicket.js as the file name.
- Move ticket validation into middleware/validateTicket.js by adding this code:
const { PRIORITIES } = require("../services/ticketService");
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string"
? body.priority.trim().toLowerCase()
: "";
const details = [];
if (!title) {
details.push({ field: "title", message: "Title is required" });
}
if (!PRIORITIES.includes(priority)) {
details.push({ field: "priority", message: "Priority must be low, medium, or high" });
}
if (details.length > 0) {
return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Ticket data is invalid", details } });
}
req.body = { title, priority };
return next();
}
module.exports = { validateTicket };
What Happens at the Validation Boundary?
- The validator imports PRIORITIES from the service so the accepted values have one owner.
- Invalid fields still return together in one structured response.
- Valid input replaces req.body with normalized fields before the controller receives it.
- Save middleware/validateTicket.js.
- Confirm that validateTicket.js appears inside the middleware folder in the Explorer sidebar.
Validation Module Not Loading?
Check that validateTicket.js sits inside the middleware folder. Confirm that ticketService.js sits inside the sibling services folder.
Help me trace the validation module.
- Select app.js in the Explorer sidebar.
- Replace the contents of app.js with this application composition:
const express = require("express");
const ticketRouter = require("./routes/tickets");
const app = express();
const port = 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({ data: { status: "ok" } });
});
app.use("/tickets", ticketRouter);
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
What Does the App Compose?
- The app applies JSON parsing before requests reach the ticket router.
- The health endpoint remains available at /health.
- The router mounts at /tickets so its relative paths keep the same public URLs.
- State, validation, and ticket behavior now live outside the application entry point.
- Save app.js.
- Switch back to the integrated terminal running the server.
- Press Ctrl+C to stop the existing process.
- Restart the layered server by running:
node app.js
What Does This Command Start?
Node.js loads the new app.js composition. Its imports connect the router, controllers, service, and validator before the server listens on port 3000.
- Return to the browser tab from earlier.
- Refresh http://localhost:3000/health.
You should see {"data":{"status":"ok"}}. This proves the new application composition starts successfully.
Health Endpoint Not Loading?
Check the server terminal for a module path problem. Compare every folder name with the Explorer sidebar.
Confirm that the server terminal is inside the support-ticket-api folder before starting app.js.
Help me start the layered server.
- Select demo.js in the Explorer sidebar.
- Replace the existing run() function with this complete request sequence:
async function run() {
await request("/health");
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: " Printer is offline ",
priority: "HIGH",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets");
await request("/tickets/1/close", { method: "PATCH" });
}
What Does the Complete Demo Prove?
- The first request confirms that the health endpoint remains available after the refactor.
- The create requests confirm that normalization and validation still run in the expected order.
- The list request confirms that the service retains valid tickets in memory.
- The final request exercises the new close route through every layer.
- Save demo.js.
✔️ Awesome, I've got everything!
Great. Double check that every project file is saved before you run the complete demo.
ⓧ I'd like to double check the full code
const express = require("express");
const ticketRouter = require("./routes/tickets");
const app = express();
const port = 3000;
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({ data: { status: "ok" } });
});
app.use("/tickets", ticketRouter);
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
const ticketService = require("../services/ticketService");
function sendData(res, statusCode, data) {
return res.status(statusCode).json({ data });
}
function createTicket(req, res) {
return sendData(res, 201, ticketService.createTicket(req.body));
}
function listTickets(req, res) {
return sendData(res, 200, ticketService.listTickets());
}
function closeTicket(req, res) {
const ticket = ticketService.closeTicket(Number(req.params.id));
if (!ticket) return res.status(404).json({ error: { code: "TICKET_NOT_FOUND", message: "Ticket not found" } });
return sendData(res, 200, ticket);
}
module.exports = { createTicket, listTickets, closeTicket };
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function run() {
await request("/health");
await request("/tickets", {
method: "POST",
body: JSON.stringify({
title: " Printer is offline ",
priority: "HIGH",
}),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets");
await request("/tickets/1/close", { method: "PATCH" });
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
const { PRIORITIES } = require("../services/ticketService");
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string"
? body.priority.trim().toLowerCase()
: "";
const details = [];
if (!title) {
details.push({ field: "title", message: "Title is required" });
}
if (!PRIORITIES.includes(priority)) {
details.push({ field: "priority", message: "Priority must be low, medium, or high" });
}
if (details.length > 0) {
return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Ticket data is invalid", details } });
}
req.body = { title, priority };
return next();
}
module.exports = { validateTicket };
{
"name": "support-ticket-api",
"version": "1.0.0",
"description": "A beginner backend API built from first principles.",
"private": true,
"dependencies": {
"express": "5.2.1"
}
}
const express = require("express");
const ticketController = require("../controllers/ticketController");
const { validateTicket } = require("../middleware/validateTicket");
const router = express.Router();
router.get("/", ticketController.listTickets);
router.post("/", validateTicket, ticketController.createTicket);
router.patch("/:id/close", ticketController.closeTicket);
module.exports = router;
const PRIORITIES = ["low", "medium", "high"];
const tickets = [];
let nextId = 1;
function createTicket({ title, priority }) {
const ticket = { id: nextId, title, priority, status: "open", createdAt: new Date().toISOString() };
nextId += 1;
tickets.push(ticket);
return ticket;
}
function listTickets() {
return [...tickets];
}
function closeTicket(id) {
const ticket = tickets.find((item) => item.id === id);
if (!ticket) return null;
ticket.status = "closed";
ticket.closedAt = new Date().toISOString();
return ticket;
}
module.exports = { PRIORITIES, createTicket, listTickets, closeTicket };
Before you run the complete demo, what status do you expect ticket 1 to have after the final request?
- Switch back to the second integrated terminal from earlier.
- Run the complete request sequence with this command:
node demo.js
What Does This Command Run?
Node.js runs the local demo client against the server process in the other terminal. Each printed status code is followed by the corresponding JSON response.
You should see the valid ticket receive 201. The invalid ticket still receives 400.
The final request should print PATCH /tickets/1/close -> 200. Its response should contain ticket 1 with "status":"closed".
Close Request Not Succeeding?
Restart the server before rerunning the demo so the in-memory ticket counter begins at 1. Keep that server process running while the second terminal executes the client.
Check that router.patch("/:id/close", ticketController.closeTicket); appears in routes/tickets.js.
Help me trace the close request.
That's the refactor proven. Your API preserves its create, validation, and list behavior while the new close request travels through the layered structure.
Your ticket API now has clear boundaries plus a working close-ticket route. Next up, you'll add request IDs, centralized errors, and priority filtering.
Add Request Context, Errors, and Filtering
Your layered Express API now creates tickets through separate routes, controllers, services, and validation. Its responses still lack the traceability needed to follow one request through those layers.
This step adds request-scoped context to every request. Centralized errors provide one JSON contract. Validated query parameters make priority filtering safe and predictable.
In this step, get ready to:
- Attach a unique request ID before other middleware runs.
- Send expected failures through one error contract.
- Verify priority filtering through the complete demo.
Attach request-scoped context first
A request ID gives each incoming request a value that logs and responses can share. Mounting this middleware first makes the same context available throughout the request pipeline.
- Select the existing middleware folder in the Visual Studio Code Explorer sidebar.
- Click the New File button in the Explorer sidebar.
- Enter requestContext.js as the file name.
You should see a blank requestContext.js tab inside the middleware folder.
- Add the request context middleware to middleware/requestContext.js by pasting this code:
const { randomUUID } = require("node:crypto");
function requestContext(req, res, next) {
req.context = { requestId: randomUUID() };
console.log(JSON.stringify({ requestId: req.context.requestId, method: req.method, path: req.originalUrl }));
next();
}
module.exports = { requestContext };
What does this middleware do?
- The randomUUID() function creates a unique value for each request.
- The req.context object keeps that value available to downstream handlers.
- The log records the request ID with the HTTP method and requested path.
- The next() call passes control to the next middleware in the pipeline.
- Save middleware/requestContext.js.
The file now appears beneath the other middleware files in the Explorer sidebar.
- In app.js, add this import directly below the ticketRouter import:
const { requestContext } = require("./middleware/requestContext");
Why import the middleware here?
The application composition file controls middleware order. Importing requestContext here lets the application mount it before request parsing begins.
- In app.js, replace the existing JSON parser and health handler with this code:
app.use(requestContext);
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({
data: { status: "ok" },
meta: { requestId: req.context.requestId },
});
});
Why does order matter?
Express runs middleware in registration order. Placing requestContext before express.json() gives downstream processing access to the request ID.
The health response reads the same ID from req.context. This connects the terminal log to the response body.
- Save app.js.
- Switch back to the server terminal from earlier.
- Press Ctrl+C to stop the running process.
- Start the updated server by running this command:
node app.js
What should the server show?
The terminal confirms that the Support Ticket API is listening on port 3000. Each incoming request now adds a JSON log line beneath that message.
- Load http://localhost:3000/health in your browser.
You should see the health data plus meta.requestId. The server terminal should show the same ID beside GET and /health.
Missing the request ID?
Check that app.use(requestContext) appears before app.use(express.json()). Confirm that requestContext.js sits inside the middleware folder.
Restart the server after saving both files. A process started before the edits still runs the previous code.
Help me trace a missing request ID.
Centralize the error contract
Validation currently writes its own response. A shared error class lets middleware and controllers describe failures while one final handler decides the response shape.
- Select the existing middleware folder in the Explorer sidebar.
- Click the New File button.
- Enter errors.js as the file name.
You should see a blank errors.js tab beside validateTicket.js.
- Define the shared error contract in middleware/errors.js by pasting this code:
class AppError extends Error {
constructor(statusCode, code, message, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
function notFoundHandler(req, res, next) {
next(new AppError(404, "ROUTE_NOT_FOUND", `No route for ${req.method} ${req.originalUrl}`));
}
function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
const statusCode = err.statusCode || err.status || 500;
const code = err.code || "REQUEST_ERROR";
const message = statusCode >= 500 ? "Internal server error" : err.message;
const requestId = req.context?.requestId || null;
console.error(JSON.stringify({ requestId, code, message: err.message }));
return res.status(statusCode).json({ error: { code, message, details: err.details || [] }, meta: { requestId } });
}
module.exports = { AppError, notFoundHandler, errorHandler };
How does the error pipeline work?
- The AppError class carries an HTTP status, a stable code, a message, and optional details.
- The notFoundHandler converts unmatched routes into a normal application error.
- The errorHandler logs the failure with its request ID.
- The final response uses the same error and meta structure for every handled failure.
- Save middleware/errors.js.
The new file now appears inside the middleware folder.
- Replace the contents of app.js with this application composition:
const express = require("express");
const ticketRouter = require("./routes/tickets");
const { requestContext } = require("./middleware/requestContext");
const { notFoundHandler, errorHandler } = require("./middleware/errors");
const app = express();
const port = 3000;
app.use(requestContext);
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({
data: { status: "ok" },
meta: { requestId: req.context.requestId },
});
});
app.use("/tickets", ticketRouter);
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
What changed in the application?
The ticket router still handles known ticket routes. The catch-all handler now receives anything left over.
The four-argument errorHandler is mounted last. That position lets it receive errors passed from earlier middleware and routes.
- Save app.js.
- Replace the contents of middleware/validateTicket.js with this validation middleware:
const { AppError } = require("./errors");
const { PRIORITIES } = require("../services/ticketService");
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string" ? body.priority.trim().toLowerCase() : "";
const details = [];
if (!title) details.push({ field: "title", message: "Title is required" });
if (!PRIORITIES.includes(priority)) details.push({ field: "priority", message: "Priority must be low, medium, or high" });
if (details.length > 0) return next(new AppError(400, "VALIDATION_ERROR", "Ticket data is invalid", details));
req.body = { title, priority };
return next();
}
module.exports = { validateTicket };
Why pass validation errors onward?
The validator still collects every field problem before failing. It now sends one AppError through next().
The centralized handler adds the request ID and preserves the validation details. Every error now follows one public contract.
- Save middleware/validateTicket.js.
- Switch back to the server terminal from earlier.
- Press Ctrl+C to stop the running process.
- Start the updated server by running this command:
node app.js
What should restart successfully?
The listening message should return without a module or syntax error. The server is now using the request context and centralized error middleware.
Before you run the client, consider whether the invalid ticket response now matches the shared error envelope.
- Switch back to the second integrated terminal from earlier.
- Run the existing demo client with this command:
node demo.js
What changed in the invalid response?
The invalid ticket still receives 400. Its response now contains error.code, error.message, error.details, and meta.requestId.
The server terminal logs the same request ID with VALIDATION_ERROR. That shared value gives you a direct trail from client response to server log.
Seeing HTML or a JSON parsing error?
Check that validateTicket calls next(new AppError(...)). Confirm that errorHandler is mounted after the ticket router.
Restart the server after saving the middleware files. The demo expects every response body to contain valid JSON.
Help me debug the error pipeline.
Validate filtering and run the demo
A query string arrives as client input. The controller needs to normalize and validate the requested priority before the service uses it to filter stored tickets.
- Replace the contents of services/ticketService.js with this service code:
const PRIORITIES = ["low", "medium", "high"];
const tickets = [];
let nextId = 1;
function createTicket({ title, priority }) {
const ticket = { id: nextId, title, priority, status: "open", createdAt: new Date().toISOString() };
nextId += 1;
tickets.push(ticket);
return ticket;
}
function listTickets(priority) {
if (!priority) return [...tickets];
return tickets.filter((ticket) => ticket.priority === priority);
}
function closeTicket(id) {
const ticket = tickets.find((item) => item.id === id);
if (!ticket) return null;
ticket.status = "closed";
ticket.closedAt = new Date().toISOString();
return ticket;
}
module.exports = { PRIORITIES, createTicket, listTickets, closeTicket };
How does the service filter tickets?
Calling listTickets() without a priority still returns a copied collection. Passing a priority returns tickets whose stored value matches it.
The service receives an already validated value. Business logic stays focused on selecting data.
- Save services/ticketService.js.
- Replace the contents of controllers/ticketController.js with this controller code:
const ticketService = require("../services/ticketService");
const { AppError } = require("../middleware/errors");
function sendData(req, res, statusCode, data) {
return res.status(statusCode).json({ data, meta: { requestId: req.context.requestId } });
}
function createTicket(req, res) { return sendData(req, res, 201, ticketService.createTicket(req.body)); }
function listTickets(req, res) {
const rawPriority = req.query.priority;
let priority;
if (rawPriority !== undefined) {
if (typeof rawPriority !== "string") throw new AppError(400, "INVALID_PRIORITY", "Priority must be one value");
priority = rawPriority.trim().toLowerCase();
if (!ticketService.PRIORITIES.includes(priority)) throw new AppError(400, "INVALID_PRIORITY", "Priority must be low, medium, or high");
}
return sendData(req, res, 200, ticketService.listTickets(priority));
}
function closeTicket(req, res) {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) throw new AppError(400, "INVALID_TICKET_ID", "Ticket ID must be a positive integer");
const ticket = ticketService.closeTicket(id);
if (!ticket) throw new AppError(404, "TICKET_NOT_FOUND", "Ticket not found");
return sendData(req, res, 200, ticket);
}
module.exports = { createTicket, listTickets, closeTicket };
What does the controller enforce?
- The sendData() helper adds meta.requestId to every successful ticket response.
- The listTickets() controller normalizes the optional priority to lowercase.
- Unsupported priorities become INVALID_PRIORITY errors before the service runs.
- The closeTicket() controller throws shared errors for invalid or missing IDs.
- Save controllers/ticketController.js.
- Switch back to the server terminal from earlier.
- Press Ctrl+C to stop the running process.
- Start the updated server by running this command:
node app.js
Why restart before checking?
Restarting loads the updated service and controller modules. It also resets the in-memory ticket collection so the next demo begins with ticket ID 1.
- Switch back to the second integrated terminal from earlier.
- Run the current demo client with this command:
node demo.js
What does this checkpoint prove?
The existing health, creation, listing, and closing requests should still complete. Successful ticket responses now include meta.requestId.
The close request still returns ticket 1 with a closed status. The controller now reaches that result through the shared response and error helpers.
- Replace the contents of demo.js with this complete client:
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function run() {
await request("/health");
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: " Printer is offline ", priority: "HIGH" }),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets?priority=high");
await request("/tickets/1/close", { method: "PATCH" });
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
What does the complete demo test?
The client sends health, valid creation, invalid creation, filtered listing, and ticket-closing requests in sequence. Each printed status sits beside the JSON body returned by the server.
The filtered request uses ?priority=high to exercise the controller and service path you just added.
- Save demo.js.
- Switch back to the server terminal from earlier.
- Press Ctrl+C to stop the running process.
- Start a fresh server process by running this command:
node app.js
Why use a fresh process?
The service stores tickets in memory. A fresh process clears earlier demo data and resets the next ticket ID to 1.
Before you run the complete demo, consider which fields should stay consistent across successful and failed responses.
- Switch back to the second integrated terminal from earlier.
- Run the complete demo with this command:
node demo.js
What should the complete demo show?
- Every response contains a value at meta.requestId.
- The valid ticket receives 201 with a trimmed title and lowercase priority.
- The invalid ticket receives 400 with the shared error shape and field details.
- The GET /tickets?priority=high response contains only the high-priority ticket.
- The PATCH /tickets/1/close response returns ticket 1 with "status":"closed".
That completes the request pipeline. You can now match each client response to its server log while successes, validation failures, filters, and controller errors share predictable contracts.
Filtering or request IDs look wrong?
Check that demo.js requests /tickets?priority=high. Confirm that the controller passes priority into ticketService.listTickets(priority).
If a successful response lacks meta.requestId, check that every controller calls sendData(req, res, ...). Restart the server after saving any correction.
Help me debug the completed API.
✔️ Awesome, I've got everything!
Great. Double check that every file is saved while the server remains running in its terminal.
ⓧ I'd like to double check the full code
const express = require("express");
const ticketRouter = require("./routes/tickets");
const { requestContext } = require("./middleware/requestContext");
const { notFoundHandler, errorHandler } = require("./middleware/errors");
const app = express();
const port = 3000;
app.use(requestContext);
app.use(express.json());
app.get("/health", (req, res) => {
res.status(200).json({
data: { status: "ok" },
meta: { requestId: req.context.requestId },
});
});
app.use("/tickets", ticketRouter);
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(port, () => {
console.log(`Support Ticket API listening at http://localhost:${port}`);
});
const ticketService = require("../services/ticketService");
const { AppError } = require("../middleware/errors");
function sendData(req, res, statusCode, data) {
return res.status(statusCode).json({ data, meta: { requestId: req.context.requestId } });
}
function createTicket(req, res) { return sendData(req, res, 201, ticketService.createTicket(req.body)); }
function listTickets(req, res) {
const rawPriority = req.query.priority;
let priority;
if (rawPriority !== undefined) {
if (typeof rawPriority !== "string") throw new AppError(400, "INVALID_PRIORITY", "Priority must be one value");
priority = rawPriority.trim().toLowerCase();
if (!ticketService.PRIORITIES.includes(priority)) throw new AppError(400, "INVALID_PRIORITY", "Priority must be low, medium, or high");
}
return sendData(req, res, 200, ticketService.listTickets(priority));
}
function closeTicket(req, res) {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) throw new AppError(400, "INVALID_TICKET_ID", "Ticket ID must be a positive integer");
const ticket = ticketService.closeTicket(id);
if (!ticket) throw new AppError(404, "TICKET_NOT_FOUND", "Ticket not found");
return sendData(req, res, 200, ticket);
}
module.exports = { createTicket, listTickets, closeTicket };
const baseUrl = "http://localhost:3000";
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const body = await response.json();
const method = options.method || "GET";
console.log(`\n${method} ${path} -> ${response.status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function run() {
await request("/health");
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: " Printer is offline ", priority: "HIGH" }),
});
await request("/tickets", {
method: "POST",
body: JSON.stringify({ title: "", priority: "urgent" }),
});
await request("/tickets?priority=high");
await request("/tickets/1/close", { method: "PATCH" });
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
class AppError extends Error {
constructor(statusCode, code, message, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
function notFoundHandler(req, res, next) {
next(new AppError(404, "ROUTE_NOT_FOUND", `No route for ${req.method} ${req.originalUrl}`));
}
function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
const statusCode = err.statusCode || err.status || 500;
const code = err.code || "REQUEST_ERROR";
const message = statusCode >= 500 ? "Internal server error" : err.message;
const requestId = req.context?.requestId || null;
console.error(JSON.stringify({ requestId, code, message: err.message }));
return res.status(statusCode).json({ error: { code, message, details: err.details || [] }, meta: { requestId } });
}
module.exports = { AppError, notFoundHandler, errorHandler };
const { randomUUID } = require("node:crypto");
function requestContext(req, res, next) {
req.context = { requestId: randomUUID() };
console.log(JSON.stringify({ requestId: req.context.requestId, method: req.method, path: req.originalUrl }));
next();
}
module.exports = { requestContext };
const { AppError } = require("./errors");
const { PRIORITIES } = require("../services/ticketService");
function validateTicket(req, res, next) {
const body = req.body || {};
const title = typeof body.title === "string" ? body.title.trim() : "";
const priority = typeof body.priority === "string" ? body.priority.trim().toLowerCase() : "";
const details = [];
if (!title) details.push({ field: "title", message: "Title is required" });
if (!PRIORITIES.includes(priority)) details.push({ field: "priority", message: "Priority must be low, medium, or high" });
if (details.length > 0) return next(new AppError(400, "VALIDATION_ERROR", "Ticket data is invalid", details));
req.body = { title, priority };
return next();
}
module.exports = { validateTicket };
{
"name": "support-ticket-api",
"version": "1.0.0",
"description": "A beginner backend API built from first principles.",
"private": true,
"dependencies": {
"express": "5.2.1"
}
}
const express = require("express");
const ticketController = require("../controllers/ticketController");
const { validateTicket } = require("../middleware/validateTicket");
const router = express.Router();
router.get("/", ticketController.listTickets);
router.post("/", validateTicket, ticketController.createTicket);
router.patch("/:id/close", ticketController.closeTicket);
module.exports = router;
const PRIORITIES = ["low", "medium", "high"];
const tickets = [];
let nextId = 1;
function createTicket({ title, priority }) {
const ticket = { id: nextId, title, priority, status: "open", createdAt: new Date().toISOString() };
nextId += 1;
tickets.push(ticket);
return ticket;
}
function listTickets(priority) {
if (!priority) return [...tickets];
return tickets.filter((ticket) => ticket.priority === priority);
}
function closeTicket(id) {
const ticket = tickets.find((item) => item.id === id);
if (!ticket) return null;
ticket.status = "closed";
ticket.closedAt = new Date().toISOString();
return ticket;
}
module.exports = { PRIORITIES, createTicket, listTickets, closeTicket };
Secret mission
Retrieve One Ticket by ID
Your API can list a collection of tickets. This challenge adds a single-ticket lookup that returns the matching resource or sends a missing ticket through the existing error contract.
Clean Up Your Resources
Clean Up Your Resources
Your API runs entirely on your computer, so there are no cloud resources or ongoing costs. Decide whether to keep the project running, pause it for later, or delete it entirely.
Resources you used:
- The support-ticket-api folder with its source files plus installed dependencies.
- The local Express server process listening on port 3000.
Keep everything running
No action needed. Choose this if you are still experimenting with your support ticket API.
- Leave the server terminal running.
- Keep the support-ticket-api folder in its current location.
- Visit http://localhost:3000/health whenever you want to confirm that the API is responding.
Pause - I'll come back to this later
Shut down the local server to free port 3000. Your project files stay in place so you can continue later.
- Switch back to the server terminal from earlier in Visual Studio Code.
- Press Ctrl+C to stop the server.
- Refresh http://localhost:3000/health in your browser.
Your browser should report that it cannot connect. This confirms that the local server has stopped.
- Restart the server later by running this command from the existing project terminal:
node app.js
What Does This Command Do?
Node.js executes app.js. The application starts listening for local requests on port 3000.
- Reload http://localhost:3000/health after the server starts.
Your API is ready again. The browser should show JSON containing "status":"ok".
Server Does Not Restart?
- Confirm that the terminal is inside the support-ticket-api folder.
- Return to any older server terminal that is still running.
- Press Ctrl+C before trying again.
- Help me diagnose why my local support ticket server does not start.
Delete - I don't want to use this again
Deleting the project folder is permanent. The cleanup steps remove the source files plus the installed dependencies.
- Switch back to the server terminal from earlier.
- Press Ctrl+C to stop the server.
macOS
- Delete the support-ticket-api folder from the stopped server terminal by running these commands:
cd ..
rm -rf support-ticket-api
ls
What Does This Cleanup Do?
- The cd command moves the terminal into the folder that contains support-ticket-api.
- The rm command removes the project folder plus everything inside it.
- The ls command lists the remaining items so you can verify the deletion.
You should see the parent folder listing without support-ticket-api.
Folder Still Listed?
- Close any file from support-ticket-api that remains open in an editor.
- Return to the stopped server terminal.
- Run the cleanup commands again.
- Help me remove the local project folder safely.
Windows
- Delete the support-ticket-api folder from the stopped server terminal by running these commands:
cd ..
cmd /c "rmdir /s /q support-ticket-api"
dir
What Does This Cleanup Do?
- The cd command moves the terminal into the folder that contains support-ticket-api.
- The rmdir command removes the project folder plus everything inside it.
- The dir command lists the remaining items so you can verify the deletion.
You should see the parent folder listing without support-ticket-api.
Folder Still Listed?
- Close any file from support-ticket-api that remains open in an editor.
- Return to the stopped server terminal.
- Run the cleanup commands again.
- Help me remove the local project folder safely.
Nice Work!
Nice Work!
You did it! Your layered Express API now turns local HTTP requests into ticket responses with traceable request IDs.
You've learned how to:
- Trace the request pipeline from ordered middleware to a JSON response.
- Protect ticket state with a validation boundary that normalizes accepted input before storage.
- Use the demo client to observe create-to-close behavior through status codes. Correlate each response with server logs through its request ID.
- Secret Mission: Extend every API layer with GET /tickets/:id. Preserve the error contract through TICKET_NOT_FOUND for missing tickets.
Ready to quiz yourself?