CSE 341: Web Services

W05 Learning Activity: Searching and Filtering

Overview

Searching and filtering help API clients find the records they need without downloading every record. A search usually looks across text fields. A filter usually limits results by structured values such as category, availability, price, or order status.

For example, a shopper might want to find products in a specific category, products that mention wireless features, or products that are currently in stock. An administrator might want to find orders by status or review orders for one customer. Good searching and filtering makes the API more useful and reduces unnecessary data transfer.

Search and filter are different

A search usually asks, "Which records match this text?" A filter usually asks, "Which records match this exact condition?" A single endpoint can support both.

Query Design

Use clear query parameters that map to real client needs.

GET /api/products?q=wireless&category=electronics&page=1&limit=10

This request searches for products that match the word wireless and are also in the electronics category. Because search results can still be large, search and filtering should usually work together with pagination.

Choosing Good Query Parameters

Query parameters should be predictable and consistent across endpoints. If q means general text search for products, it should mean the same thing for customers or orders if those endpoints support text search.

Good query parameters are:

Examples by Endpoint

Different endpoints need different query options. Do not add every possible filter to every endpoint. Add filters that match the way clients will use the data.

Endpoint Useful Query Parameters
GET /api/products q, category, inStock, minPrice, maxPrice
GET /api/orders status, customerId, createdAfter, createdBefore
GET /api/customers q, accountStatus, createdAfter, createdBefore
GET /api/reviews productId, minRating, maxRating

Exact Match Filters

Exact match filters are usually the simplest filters. They compare a field to one expected value.

const query = {};

if (req.query.category) {
  query.category = req.query.category;
}

if (req.query.inStock === 'true') {
  query.inStock = true;
}

if (req.query.inStock === 'false') {
  query.inStock = false;
}

Exact match filters are useful for fields such as category, availability, order status, customer id, and product id.

Range Filters

Range filters are useful for numbers and dates. For example, a client might request products between $20 and $80, or orders created after a certain date.

if (req.query.minPrice || req.query.maxPrice) {
  query.price = {};

  if (req.query.minPrice) {
    query.price.$gte = Number(req.query.minPrice);
  }

  if (req.query.maxPrice) {
    query.price.$lte = Number(req.query.maxPrice);
  }
}

In this query, $gte means greater than or equal to, and $lte means less than or equal to. When both values are present, MongoDB returns products whose price is at least the minimum price and no more than the maximum price. For example, minPrice=20&maxPrice=80 includes products priced at $20 and $80 as well as prices between them.

Range values must be parsed and validated. If a client sends minPrice=abc, the API should return a 400 response instead of sending a broken query to MongoDB.

Run the query

After the API has built and validated the query object, pass it to the Mongoose model. MongoDB applies every property in the object together, so a request can use an exact category filter, availability filter, and price range at the same time.

const products = await Product.find(query);

For example, if query contains a category of electronics and a price range, this call returns only electronics products within that range. Add pagination and sorting to the same query when the endpoint supports those features.

Text Search with MongoDB

For simple text search, use a MongoDB text index on the fields that should be searchable. A text index tells MongoDB which fields should be searched together when a client sends a search term such as q=wireless.

const productSchema = new mongoose.Schema({
  name: String,
  description: String,
  tags: [String],
  category: String
});

productSchema.index({
  name: 'text',
  description: 'text',
  tags: 'text'
});

This schema is trimmed down to show only the fields involved in the text index. Your actual Product schema will also include fields such as price and inStock so the filters described above continue to work.

After the schema has a text index, your query can use $text and $search. You can still combine the text search with exact filters such as category.

const query = {};

if (req.query.category) {
  query.category = req.query.category;
}

if (req.query.q !== undefined) {
  if (typeof req.query.q !== 'string') {
    return res.status(400).json({
      errors: [{ field: 'q', message: 'Search text must be a single string.' }]
    });
  }

  const searchText = req.query.q.trim();

  if (!searchText || searchText.length > 100) {
    return res.status(400).json({
      errors: [{ field: 'q', message: 'Search text must be between 1 and 100 characters.' }]
    });
  }

  query.$text = { $search: searchText };
}

const products = await Product.find(query).limit(limit).skip(skip);

This example searches the text-indexed fields and also applies the category filter if the client sends one. For this course, this is the preferred approach for simple text search in MongoDB.

Validate and normalize untrusted query input

Query parameters come from the client, so treat them as untrusted input. Before adding a search value to a database query, confirm that it has the expected type, remove unwanted leading and trailing whitespace, limit its length, and reject values that do not meet the endpoint's rules.

Build a new query object from accepted values instead of passing req.query directly to Mongoose. This keeps unsupported fields and unexpected query values out of the database query.

Validate query parameters

Search and filter values still need validation. Limit allowed sort fields, parse booleans and numbers carefully, and avoid passing raw query parameters directly into database operations.

Validation Rules

Before building a MongoDB query, decide which query parameters are allowed and what each one must look like.

const allowedCategories = ['electronics', 'home', 'clothing', 'books'];
const allowedInStockValues = ['true', 'false'];

if (req.query.category && !allowedCategories.includes(req.query.category)) {
  return res.status(400).json({
    errors: [{ field: 'category', message: 'category is not supported.' }]
  });
}

if (req.query.inStock && !allowedInStockValues.includes(req.query.inStock)) {
  return res.status(400).json({
    errors: [{ field: 'inStock', message: 'inStock must be true or false.' }]
  });
}

Validation is especially important for query parameters because clients can type anything into a URL.

No Matching Results

A search that finds no results should usually return 200 with an empty array, not 404. The endpoint exists and the request was valid; there simply were no matching records.

{
  "data": [],
  "query": {
    "q": "wireless",
    "category": "electronics"
  },
  "pagination": {
    "page": 1,
    "limit": 10,
    "totalItems": 0,
    "totalPages": 0
  }
}

Use 404 when a client asks for one specific resource by id and that resource does not exist. Use an empty array when a list query has no matches.

Search Results

A search response should make it clear what filters were applied and how many records matched.

{
  "data": [],
  "query": {
    "q": "wireless",
    "category": "electronics"
  },
  "pagination": {
    "page": 1,
    "limit": 10,
    "totalItems": 3,
    "totalPages": 1
  }
}

Including the applied query can make debugging easier. It also helps clients confirm that the server understood the filters correctly.

Combining Search, Filters, Sorting, and Pagination

Real list endpoints often combine several ideas:

GET /api/products?q=wireless&category=electronics&sort=price&order=asc&page=1&limit=5

When multiple query features are combined, the API should apply all of them consistently. The total count should count the records that match the search and filters, not the total number of records in the whole collection.

Swagger Documentation

Swagger should document every supported search and filter parameter. Include allowed values when the parameter has a fixed set of choices.

parameters:
  - name: q
    in: query
    schema:
      type: string
    description: Searches product name, description, and tags.
  - name: category
    in: query
    schema:
      type: string
      enum: [electronics, home, clothing, books]
  - name: inStock
    in: query
    schema:
      type: string
      enum: [true, false]

Testing Search and Filters

Useful search and filter tests include:

Activity Instructions

Check your understanding

  1. What is the difference between a search and a filter?
    Click to expand the example answer

    A search finds records that match text, such as a word in a product name or description. A filter limits records using a structured value, such as a product category or whether a product is in stock.

  2. Why should search and filter query parameters have clear, consistent names?
    Click to expand the example answer

    Clear and consistent names help API clients understand how to use an endpoint without learning a different pattern for every resource. They also make Swagger documentation easier to understand.

  3. Why must an API validate query parameters before using them in a MongoDB query?
    Click to expand the example answer

    Clients can send any value in a URL. Validation rejects unsupported categories, invalid booleans, or invalid price values with a clear 400 response before they create unexpected database behavior.

  4. What should a valid product search return when no products match?
    Click to expand the example answer

    It should return 200 with an empty data array. The endpoint exists and the query was valid; no products simply matched it.

  5. Why should a search endpoint count only records that match the search and filters when building pagination metadata?
    Click to expand the example answer

    The client needs metadata that describes the current result set. Counting every product in the collection would produce incorrect page totals and controls for a filtered search.

Other Links: