Building a Products Search API with Custom Filters Using C# and MongoDB
This blog post showcases a process of Yamesant developing a small Products Search project. The project exposes a single API endpoint to find products in a MongoDB collection. The collection is small (on the order of thousands of documents) and read-only. There is no simpler starting point.
The search request consists of a set of pre-defined filters, each of which is optional. The API returns a list of products that meet all the filters included in the request. The request model is as follows:
public sealed class ProductsSearchRequest
{
public bool? OnlyMinimumPrice { get; set; }
public bool? OnlyMaximumPrice { get; set; }
public decimal? PriceGreaterThanOrEqualTo { get; set; }
public decimal? PriceLessThanOrEqualTo { get; set; }
public bool? IsFantastic { get; set; }
public bool? OnlyMinimumRating { get; set; }
public bool? OnlyMaximumRating { get; set; }
public decimal? RatingGreaterThanOrEqualTo { get; set; }
public decimal? RatingLessThanOrEqualTo { get; set; }
}
The source code is available on GitHub at https://github.com/yamesant/ProductsSearch
MongoDB
The project uses MongoDB, a popular document database. The key concepts in document databases include documents, collections, and databases. A document is the basic unit of data storage, and it represents data in a JSON-like format. This project models products as documents. A collection is a grouping of documents, and the project searches within a collection for specific documents. A database is a grouping of collections.
MongoDB provides the MongoDB.Driver
NuGet library to work with MongoDB in C#. The best resource to get familiar with it is the official documentation.
There are two ways to query documents with MongoDB.Driver
: LINQ and Builder Classes. The documentation advises on when to use each. Following the advice for those who are proficient in C# but new to MongoDB, the project proceeds with LINQ.
Implementation
The implementation consists of two steps: architecture and filtering.
The first step involves setting up the solution, organising the program flow between components, and adding boilerplate code to connect to the database and set up the API. By the end of this step, the project includes an API endpoint to retrieve all documents from the products collection.
The second step introduces customisations. It implements several custom filters to refine the set of retrieved products.
Architecture
The first commit sets up the data model for products. This model corresponds to the product representations within the products collection in MongoDB. The documentation outlines many ways to customise it. Relevant attributes for this case include BsonElement
for manual mapping between property names and BsonRepresentation
for manual mapping between property types.
The second commit adds the Minimal API boilerplate code. At this stage, the products/search
endpoint simply returns the message "All good."
The third commit adds the boilerplate code for MongoDB configuration, following the Options Pattern. Connecting to a MongoDB collection requires three pieces of information: the connection string, the database name, and the collection name. The connection string is stored as a user secret due to containing sensitive information, while the database and collection names are kept in appsettings.json
and are tracked with Git.
The fourth commit establishes the program flow from the API endpoint to the IProductService
service and then to the IProductsCollection
collection. The service is responsible for the filtering logic, while the collection encapsulates access to MongoDB.
The collection exposes products as IQueryable<Product>
. This approach allows non-Mongo repositories to adhere to the same interface, such as the InMemoryProductsCollection
class that will be introduced in the next section. However, this approach gives up the async
support provided by MongoDB.Driver
through the .AsListAsync()
method on IMongoQueryable
s. In this case, this does not matter because the collection is small and read-only.
Filtering
The filters are similar to each other, which allows for easily copy-pasteable implementations. Each filter implementation consists of three parts:
- Adding a property to the
ProductsSearchRequest
class - Adding one or more unit tests in a class inheriting from
FilterTests
- Adding the filtering logic in the
ProductService
class
The first two commits one and two implement the first filter and set up the testing project. The following one, two, three, four, five, six, seven, and eight commits implement all the remaining filters.
The unit testing project takes advantage of the similarity of filters to set up the system under test (the products service) in the abstract base class FilterTests
. Test classes for specific filters simply inherit from this base class to get access to the setup. Having a separate test class for each filter helps in ensuring that all filters have associated unit tests.
Conclusion
The result is a small Products Search application.
From here, there are many ways to extend the project:
- Try hosting solutions.
- Try increasing the size of the data by thousands, millions, and more.
- Try implementing a user interface.
- Try tracking search patterns.
- Try breaking the solution by adding more filters.
- Try generating realistic fake data.
- Try breaking the solution with changes to the product schema.
- Try integration testing with Dockerised MongoDB.
- Try integrating full-text search capabilities.
- And so on ...