Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 11

Retrieving Single Resources with `findById()`

Recommended resources

Introduction

In our last lesson, you built the GET /api/items endpoint, which successfully retrieves all documents from your database using Model.find(). This is the "Read" part of CRUD, but for an entire collection. Now, we'll focus on a more specific task: retrieving a single item by its unique ID.

This lesson addresses a core requirement for your exam: implementing a GET route to fetch a specific resource. We will create the GET /api/items/:id endpoint. You'll learn how to capture dynamic parts of a URL (route parameters), use the Mongoose Model.findById() method, and, crucially, handle the different possible outcomes:

  • Success (200): The item is found and returned.
  • Not Found (404): The ID has a valid format, but no item with that ID exists in the database.
  • Bad Request (400): The client provides an ID in an invalid format.

By the end of this lesson, you'll have a robust endpoint for fetching individual items, a critical piece of any REST API.


1. Route Parameters and Model.findById()

To fetch a specific item, the client needs to tell us which item they want. The standard RESTful way to do this is by including the item's unique identifier in the URL path. Express allows us to define routes that capture these dynamic segments.

A route path like /api/items/:id uses a route parameter denoted by the colon (:). When a request comes in to a URL like /api/items/60d21b4667d0d8992e610c85, Express captures the ID part and makes it available in the req.params object, specifically as req.params.id.

Once we have the id, we can use the Mongoose method designed for this exact purpose: Model.findById(id).

  • Model.findById(id): This method takes a single argument, the _id of the document you want to find.
  • Return Value:
    • If a document with that _id is found, it returns the document object.
    • If no document is found, it returns null.
    • If the id string is not a valid MongoDB ObjectId format, it throws a CastError.

Your job is to handle all three of these possibilities correctly.


2. The Implementation Pattern

The most direct way to implement this route is to use an async handler with a try...catch block. This allows you to await the result from findById and gracefully handle any errors that occur.

For a clear, concise, and production-ready implementation pattern, review the following code snippet from an article by OneUptime.

How to Use MongoDB with Express and Mongoose - OneUptime

The article 'How to Use MongoDB with Express and Mongoose' provides a perfect code example for a 'Get single user by ID' route. It demonstrates exactly how to use findById, check for a null result to return a 404, and handle invalid ID formats with a 400 status.

In the 'Read' section of the code block, focus on the second route handler: router.get('/:id', async (req, res) => { ... });. Pay close attention to the if (!user) check and the catch block's logic for handling error.name === 'CastError'.

As you saw in the article, the logic is straightforward:

  1. Attempt to find the item by its ID.
  2. If it returns null (!user in the example), the item doesn't exist. You must stop execution and return a 404 Not Found.
  3. If it's found, send it back with a 200 OK.
  4. If the database query itself fails (e.g., CastError for a bad ID), the catch block is executed. Here, you inspect the error to decide if it's a client mistake (400 Bad Request) or a true server issue (500 Internal Server Error).

The pattern of handling an invalid ObjectId format specifically is a hallmark of a robust API. You'll see this reflected in test suites, which often have dedicated tests for this scenario.

API Test Suite Output for ToDo Endpoints
This image shows a typical test suite output. Note the tests for the `GET /todos/:id` endpoint: there are specific checks for when a todo is not found and for when a non-object ID is provided. Your implementation must pass these kinds of tests.

3. A Reusable Middleware Pattern (Advanced)

While the direct implementation is fine, in larger applications you often need to fetch a resource by ID in multiple routes (GET /:id, PUT /:id, DELETE /:id). To avoid repeating code, you can extract the logic into a reusable middleware function.

Watch this segment from the Web Dev Simplified video to see this powerful pattern in action.

Build A REST API With Node.js, Express, & MongoDB - Quick

In 'Build A REST API With Node.js, Express, & MongoDB - Quick', the presenter creates a middleware function called getSubscriber that finds a resource by ID and attaches it to the response object. This is a very clean and professional way to structure your code for reusability.

Watch from 20:20 to 23:21. Focus on how the getSubscriber middleware function is created. It takes req, res, and next as arguments. It finds the subscriber, handles the 404 case, attaches the result to res, and then calls next() to pass control to the actual route handler.

For the exam, either the direct implementation or the middleware pattern is acceptable. The middleware pattern shows a deeper understanding of Express and can make your code cleaner if you need to perform the same lookup for multiple endpoints.


4. Your Turn: Implement the Route

Now, let's add the GET /api/items/:id route to your item.routes.js file. We will use the direct implementation pattern, as it's the most straightforward for a single route.

Action: Open src/routes/item.routes.js and add the following code below your existing GET / route.

// src/routes/item.routes.js

const express = require('express');
const router = express.Router();
const Item = require('../models/item.model.js');
const mongoose = require('mongoose'); // Import mongoose

// @route   GET /api/items
// @desc    Get all items
// @access  Public
router.get('/', async (req, res) => {
    try {
        const items = await Item.find();
        res.status(200).json(items);
    } catch (error) {
        res.status(500).json({ message: "Failed to retrieve items", error: error.message });
    }
});

// @route   GET /api/items/:id
// @desc    Get a single item by ID
// @access  Public
router.get('/:id', async (req, res) => {
    try {
        const { id } = req.params;

        // Check if the ID is a valid ObjectId format before querying
        if (!mongoose.Types.ObjectId.isValid(id)) {
            return res.status(400).json({ message: 'Invalid item ID format' });
        }

        const item = await Item.findById(id);

        if (!item) {
            // The ID is valid, but no item was found
            return res.status(404).json({ message: 'Item not found' });
        }

        // Item was found
        res.status(200).json(item);

    } catch (error) {
        // For other unexpected errors
        res.status(500).json({ message: 'Server error while retrieving item', error: error.message });
    }
});


module.exports = router;

Code Refinement:
In this implementation, I've used mongoose.Types.ObjectId.isValid(id) as an explicit check. This is slightly different from catching the CastError but achieves the same result and can be more readable. It immediately rejects improperly formatted IDs with a 400 Bad Request before even attempting a database query.


5. Testing Your Endpoint

It's crucial to test all three scenarios.

  1. Make sure your server is running (npm run dev).

  2. Test the Success Case (200 OK):

    • First, send a GET request to http://localhost:5001/api/items to get a list of your items.
    • Copy the _id from one of the items.
    • Now, send a new GET request to http://localhost:5001/api/items/YOUR_COPIED_ID.
    • Result: You should get a 200 OK status and the JSON object for that single item.
  3. Test the Not Found Case (404 Not Found):

    • Take a valid _id and change its last character (e.g., ...c85 becomes ...c86). The format is still valid, but it won't exist in the database.
    • Send a GET request to http://localhost:5001/api/items/YOUR_MODIFIED_ID.
    • Result: You should get a 404 Not Found status and the JSON message { "message": "Item not found" }.
  4. Test the Bad Request Case (400 Bad Request):

    • Send a GET request using an obviously invalid ID.
    • URL: http://localhost:5001/api/items/123.
    • Result: You should get a 400 Bad Request status and the JSON message { "message": "Invalid item ID format" }.

Conclusion

You have now implemented and tested a robust endpoint for retrieving a single resource by its ID. This is a fundamental skill that will be heavily utilized in your exam and in any backend project.

Key Takeaways:

  • Route parameters like :id are used to capture dynamic values from the URL, accessible via req.params.
  • Model.findById(id) is the primary tool for fetching a document by its unique ID.
  • A production-ready implementation must handle three distinct cases:
    • Success (200): The resource is found.
    • Not Found (404): The ID is valid, but the resource doesn't exist.
    • Bad Request (400): The ID itself is malformed.

Next Up:
You've mastered reading one and all items. The next logical step in our CRUD journey is creating new ones. In the following lesson, you will implement the POST /api/items route, where you'll learn to handle incoming request bodies and use Model.create() to add new documents to your database.