Practice 4 — Node.js Express Backend
Build REST API for Review Aggregator with Database Integration
Course lectures and practices for JavaScript full‑stack web development with AI‑assisted workflows.
Practice Overview
Prerequisites: Node.js Express lecture completed
Project: Multi-Source Product Review Aggregator backend
Objective: Build Express REST API with in-memory data storage
Complete Express backend for the review aggregator project:
- ✅ Express server with proper middleware and routing
- ✅ REST API endpoints for product reviews (CRUD operations)
- ✅ In-memory data storage using JavaScript arrays
- ✅ Review aggregation logic (statistics calculation)
- ✅ External scraper integration (simulated)
- ✅ Error handling and input validation
Technologies: Node.js, Express.js, JavaScript arrays, async/await
Foundation: Using fullstack-minimal-app/backend as starting point
Note: This practice focuses on Express fundamentals. In Session 5, you’ll upgrade this API to use MySQL database for persistent storage!
Part 0: Initialize Project and Scripts
Run these commands from the parent folder to create and prepare the backend project.
# Create and enter project directory
mkdir my-review-aggregator-backend
cd my-review-aggregator-backend
# Initialize package.json
npm init -y
# Create src structure
mkdir -p src/routes src/services src/data src/middleware
# Install runtime deps
npm i express joi cors morgan dotenv
# Install dev deps to Enable auto-restart on code changes
npm i -D nodemonAdd scripts to package.json:
{
// DO NOT replace entire package.json, just add the scripts section
// DO NO TOUCH FROM "dependencies" TO "devDependencies"
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"joi": "^18.0.1",
"morgan": "^1.10.1"
},
"devDependencies": {
"nodemon": "^3.1.10"
}, // ADD THE COMMA IF MISSING and THEN ADD:
"name": "my-review-aggregator-backend",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
}
}Create the server entry (loads env and starts the HTTP server):
// src/server.js
require('dotenv').config();
const app = require('./app');
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`API listening on http://localhost:${PORT}`);
});Note:
- Do not run node src/app.js. Use npm run dev or npm start.
- Ensure src/data/mockData.js exists before starting to avoid “Cannot find module ‘../data/mockData’”.
- Ensure you mounted the products router before the 404 handler:
- app.use(‘/api/products’, require(‘./routes/products’));
- app.use(‘/api/reviews’, require(‘./routes/reviews’));
- then the 404 handler, then error handler.
Part 1: Project Setup
Step 1.2: Create In-Memory Data Store
Create src/data/mockData.js:
// In-memory data store for Session 4
// In Session 5, we'll replace this with MySQL database
let products = [
{
id: 1,
name: 'Wireless Bluetooth Headphones',
description: 'Premium noise-canceling headphones with 30-hour battery life',
price: 199.99,
image_url: '/images/headphones.jpg',
created_at: new Date('2024-09-01')
},
{
id: 2,
name: 'Gaming Mechanical Keyboard',
description: 'RGB backlit mechanical keyboard with blue switches',
price: 149.99,
image_url: '/images/keyboard.jpg',
created_at: new Date('2024-09-02')
},
{
id: 3,
name: '4K Webcam',
description: 'Ultra HD webcam with auto-focus and noise reduction',
price: 89.99,
image_url: '/images/webcam.jpg',
created_at: new Date('2024-09-03')
}
];
let reviews = [
{
id: 1,
product_id: 1,
source: 'amazon',
external_id: 'AMZ-12345',
reviewer_name: 'TechReviewer01',
rating: 5.0,
title: 'Excellent sound quality!',
content: 'These headphones exceeded my expectations. The noise cancellation is fantastic and battery life is as advertised.',
review_date: '2024-10-01',
verified_purchase: true,
helpful_votes: 15,
fetched_at: new Date('2024-10-02')
},
{
id: 2,
product_id: 1,
source: 'amazon',
external_id: 'AMZ-12346',
reviewer_name: 'BudgetBuyer',
rating: 3.5,
title: 'Good but pricey',
content: 'Sound quality is great but I think they are overpriced for what you get.',
review_date: '2024-10-02',
verified_purchase: true,
helpful_votes: 8,
fetched_at: new Date('2024-10-03')
}
];
// Auto-increment IDs
let nextProductId = 4;
let nextReviewId = 3;
module.exports = {
products,
reviews,
getNextProductId: () => nextProductId++,
getNextReviewId: () => nextReviewId++
};We’re using JavaScript arrays to store data in this practice. This lets us focus on Express fundamentals without database complexity.
Limitations of in-memory storage:
- ❌ Data lost when server restarts
- ❌ Not suitable for production
- ❌ No concurrent access protection
In Session 5, you’ll learn to replace these arrays with MySQL database for persistent, production-ready storage!
Step 1.3: Environment Configuration
Create .env file in backend root. This file will hold environment variables to configure the server.
# Server Configuration
PORT=4000
NODE_ENV=development
# Note: No database configuration needed for Session 4
# We'll add MySQL configuration in Session 5Part 2: Products API Routes
The routes are the entry points for the API to interact with products and reviews. Use AI assistance (like GitHub Copilot) to speed up coding and to get explanations.
Exercise 2.1: Create Products Routes
Create src/routes/products.js:
const express = require('express');
const router = express.Router();
const { products, reviews, getNextProductId, getNextReviewId } = require('../data/mockData');
const { fetchReviewsFromSources } = require('../services/mockScraper');
// GET /api/products - Get all products
router.get('/', (req, res) => {
try {
res.json({
success: true,
data: products,
count: products.length
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to fetch products'
});
}
});
// GET /api/products/:id - Get single product
router.get('/:id', (req, res) => {
try {
const productId = parseInt(req.params.id);
const product = products.find(p => p.id === productId);
if (!product) {
return res.status(404).json({
success: false,
error: 'Product not found'
});
}
res.json({
success: true,
data: product
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to fetch product'
});
}
});
// POST /api/products - Create new product
router.post('/', (req, res) => {
try {
const { name, description, price, image_url } = req.body;
// Basic validation
if (!name || !price) {
return res.status(400).json({
success: false,
error: 'Name and price are required'
});
}
const newProduct = {
id: getNextProductId(),
name,
description: description || '',
price: parseFloat(price),
image_url: image_url || '',
created_at: new Date()
};
products.push(newProduct);
res.status(201).json({
success: true,
data: newProduct
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to create product'
});
}
});
// POST /api/products/:id/fetch - Fetch reviews from external sources for a product
router.post('/:id/fetch', async (req, res) => {
try {
const productId = parseInt(req.params.id);
const product = products.find(p => p.id === productId);
if (!product) {
return res.status(404).json({ success: false, error: 'Product not found' });
}
const scrapedReviews = await fetchReviewsFromSources(productId, req.body || {});
let added = 0;
let skipped = 0;
for (const s of scrapedReviews) {
const exists = reviews.find(r =>
r.product_id === productId &&
r.source === s.source &&
r.external_id === s.external_id
);
if (exists) {
skipped++;
continue;
}
reviews.push({
id: getNextReviewId(),
product_id: productId,
...s,
fetched_at: new Date()
});
added++;
}
res.json({
success: true,
message: `Fetched ${scrapedReviews.length} reviews`,
data: { added, skipped, total: scrapedReviews.length }
});
} catch (err) {
res.status(500).json({ success: false, error: err.message || 'Failed to fetch reviews' });
}
});
// GET /api/products/:id/reviews - List reviews for a product
router.get('/:id/reviews', (req, res) => {
const productId = parseInt(req.params.id);
const productReviews = reviews.filter(r => r.product_id === productId);
res.json({ success: true, data: productReviews, count: productReviews.length });
});
// GET /api/products/:id/aggregate - Aggregated stats for a product
router.get('/:id/aggregate', (req, res) => {
try {
const productId = parseInt(req.params.id);
const productReviews = reviews.filter(r => r.product_id === productId);
if (productReviews.length === 0) {
return res.json({
success: true,
data: {
overall: { average_rating: 0, total_reviews: 0, min_rating: 0, max_rating: 0 },
by_source: [],
rating_histogram: { "5": 0, "4": 0, "3": 0, "2": 0, "1": 0 }
}
});
}
const totalRating = productReviews.reduce((sum, r) => sum + r.rating, 0);
const avgRating = totalRating / productReviews.length;
const minRating = Math.min(...productReviews.map(r => r.rating));
const maxRating = Math.max(...productReviews.map(r => r.rating));
const bySource = {};
productReviews.forEach(r => {
bySource[r.source] ??= { total: 0, count: 0 };
bySource[r.source].total += r.rating;
bySource[r.source].count += 1;
});
const sourceBreakdown = Object.keys(bySource).map(source => ({
source,
average_rating: Math.round((bySource[source].total / bySource[source].count) * 10) / 10,
review_count: bySource[source].count
}));
const histogram = { "5": 0, "4": 0, "3": 0, "2": 0, "1": 0 };
productReviews.forEach(r => {
const bucket = Math.round(r.rating).toString();
if (histogram[bucket] !== undefined) histogram[bucket]++;
});
res.json({
success: true,
data: {
overall: {
average_rating: Math.round(avgRating * 10) / 10,
total_reviews: productReviews.length,
min_rating: minRating,
max_rating: maxRating
},
by_source: sourceBreakdown,
rating_histogram: histogram
}
});
} catch {
res.status(500).json({ success: false, error: 'Failed to calculate aggregate statistics' });
}
});
module.exports = router;Exercise 2.2: Update App Configuration
app.js is already set up with basic middleware. Now, integrate the products routes.
Update src/app.js to use the products routes:
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const app = express();
const errorHandler = require('./middleware/errorHandler');
const requestLogger = require('./middleware/requestLogger');
// Middleware
const corsOptions = {
origin: 'http://localhost:5173',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
};
app.use(cors(corsOptions));
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
// Routes
app.use('/api/products', require('./routes/products'));
app.use('/api/reviews', require('./routes/reviews')); // general reviews endpoints
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
storage: 'in-memory'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: {
message: `Route not found: ${req.method} ${req.originalUrl}`,
status: 404
}
});
});
// Error handling (must be last)
app.use(errorHandler);
module.exports = app;Part 3: Reviews API Routes
Exercise 3.1: Create Review Routes Structure
Now we’ll create routes to manage reviews directly. We separate these from product routes for clarity. But we still link reviews to products via product_id.
Create src/routes/reviews.js:
const express = require('express');
const router = express.Router();
const { reviews, products, getNextReviewId } = require('../data/mockData');
// GET /api/reviews - Get all reviews (with optional filtering)
router.get('/', (req, res) => {
try {
const { product_id, source, min_rating } = req.query;
let filteredReviews = [...reviews];
// Filter by product_id
if (product_id) {
filteredReviews = filteredReviews.filter(r => r.product_id === parseInt(product_id));
}
// Filter by source
if (source) {
filteredReviews = filteredReviews.filter(r => r.source.toLowerCase() === source.toLowerCase());
}
// Filter by minimum rating
if (min_rating) {
filteredReviews = filteredReviews.filter(r => r.rating >= parseFloat(min_rating));
}
res.json({
success: true,
data: filteredReviews,
count: filteredReviews.length
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to fetch reviews'
});
}
});
// GET /api/reviews/:id - Get single review
router.get('/:id', (req, res) => {
try {
const reviewId = parseInt(req.params.id);
const review = reviews.find(r => r.id === reviewId);
if (!review) {
return res.status(404).json({
success: false,
error: 'Review not found'
});
}
res.json({
success: true,
data: review
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to fetch review'
});
}
});
// POST /api/reviews - Create new review
router.post('/', (req, res) => {
try {
const {
product_id,
source,
external_id,
reviewer_name,
rating,
title,
content
} = req.body;
// Validation
if (!product_id || !source || !rating || !content) {
return res.status(400).json({
success: false,
error: 'Missing required fields: product_id, source, rating, content'
});
}
// Check if product exists
const product = products.find(p => p.id === parseInt(product_id));
if (!product) {
return res.status(404).json({
success: false,
error: 'Product not found'
});
}
// Check for duplicate review (same product, source, external_id)
if (external_id) {
const duplicate = reviews.find(r =>
r.product_id === parseInt(product_id) &&
r.source === source &&
r.external_id === external_id
);
if (duplicate) {
return res.status(409).json({
success: false,
error: 'Review already exists',
code: 'DUPLICATE_REVIEW'
});
}
}
const newReview = {
id: getNextReviewId(),
product_id: parseInt(product_id),
source: source.toLowerCase(),
external_id: external_id || null,
reviewer_name: reviewer_name || 'Anonymous',
rating: parseFloat(rating),
title: title || '',
content,
review_date: new Date().toISOString().split('T')[0],
verified_purchase: false,
helpful_votes: 0,
fetched_at: new Date()
};
reviews.push(newReview);
res.status(201).json({
success: true,
data: newReview
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to create review'
});
}
});
// DELETE /api/reviews/:id - Delete review
router.delete('/:id', (req, res) => {
try {
const reviewId = parseInt(req.params.id);
const index = reviews.findIndex(r => r.id === reviewId);
if (index === -1) {
return res.status(404).json({
success: false,
error: 'Review not found'
});
}
const deletedReview = reviews.splice(index, 1)[0];
res.json({
success: true,
message: 'Review deleted successfully',
data: deletedReview
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to delete review'
});
}
});
module.exports = router;Exercise 3.2: Create Mock Scraper Service
We will not connect to real external services like amazon to get the reviews. Instead, we’ll create a mock scraper that simulates fetching reviews. A mock is a simplified version of a service that mimics its behavior for testing purposes. It is free and simple to use. For real-world applications, you would replace this with actual API calls or web scraping logic.
Create src/services/mockScraper.js:
// Mock external scraper service
// Simulates fetching reviews from external sources (Amazon, BestBuy, Walmart)
const SOURCES = ['amazon', 'bestbuy', 'walmart'];
const SAMPLE_REVIEWERS = [
'TechEnthusiast', 'HappyCustomer', 'CriticalBuyer', 'GadgetLover',
'ValueSeeker', 'QualityFirst', 'SmartShopper', 'ProductTester'
];
const SAMPLE_TITLES = [
'Great product!', 'Exceeded expectations', 'Good value for money',
'Disappointed', 'Amazing quality', 'Could be better',
'Highly recommend', 'Not worth the price', 'Perfect for my needs'
];
const SAMPLE_CONTENT = [
'This product works exactly as described. Very satisfied with my purchase.',
'Quality is excellent and shipping was fast. Would buy again!',
'Had some issues initially but customer service helped resolve them.',
'Not what I expected based on the description. Returning it.',
'Absolutely love this! Best purchase I\'ve made in a while.',
'Decent product but there are better alternatives available.',
'Works well for the price point. Good value overall.',
'Build quality could be better but functionality is solid.'
];
function getRandomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
function getRandomRating() {
// Weighted towards higher ratings (more realistic)
const rand = Math.random();
if (rand < 0.4) return 5.0;
if (rand < 0.7) return 4.0;
if (rand < 0.85) return 3.0;
if (rand < 0.95) return 2.0;
return 1.0;
}
function getRandomDate() {
// Random date within last 6 months
const now = new Date();
const sixMonthsAgo = new Date(now.setMonth(now.getMonth() - 6));
const randomTime = sixMonthsAgo.getTime() + Math.random() * (Date.now() - sixMonthsAgo.getTime());
return new Date(randomTime).toISOString().split('T')[0];
}
async function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Simulate fetching reviews from external sources
* @param {number} productId - The product ID to fetch reviews for
* @param {Object} options - Options for scraping
* @param {number} options.count - Number of reviews to generate (default: 5-10 random)
* @param {Array<string>} options.sources - Specific sources to use
* @returns {Promise<Array>} - Array of review objects
*/
async function fetchReviewsFromSources(productId, options = {}) {
// Simulate network delay (1-3 seconds)
const delayMs = 1000 + Math.random() * 2000;
await delay(delayMs);
// 10% chance to simulate network error
if (Math.random() < 0.1) {
throw new Error('Failed to fetch reviews from external source: Network timeout');
}
const count = options.count || (5 + Math.floor(Math.random() * 6)); // 5-10 reviews
const sourcesToUse = options.sources || SOURCES;
const reviews = [];
for (let i = 0; i < count; i++) {
const source = getRandomElement(sourcesToUse);
const rating = getRandomRating();
reviews.push({
source,
external_id: `${source.toUpperCase()}-${Date.now()}-${i}`,
reviewer_name: getRandomElement(SAMPLE_REVIEWERS),
rating,
title: getRandomElement(SAMPLE_TITLES),
content: getRandomElement(SAMPLE_CONTENT),
review_date: getRandomDate(),
verified_purchase: Math.random() > 0.3, // 70% verified
helpful_votes: Math.floor(Math.random() * 50)
});
}
return reviews;
}
module.exports = {
fetchReviewsFromSources
};Exercise 4: Middleware and Error Handling
Now we’ll create middleware for error handling and request logging. Logging is the process of recording information about requests and responses for monitoring and debugging purposes. It helps track the behavior of the application and identify issues.
Exercise 4.1: Create Error Handling Middleware
Create src/middleware/errorHandler.js:
const errorHandler = (err, req, res, next) => {
console.error('Error:', err);
// Joi validation errors
if (err.isJoi) {
return res.status(400).json({
success: false,
message: 'Validation error',
errors: err.details.map(detail => detail.message)
});
}
// MySQL duplicate entry error
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({
success: false,
message: 'Duplicate review - already exists in database'
});
}
// MySQL foreign key constraint error
if (err.code === 'ER_NO_REFERENCED_ROW_2') {
return res.status(404).json({
success: false,
message: 'Product not found'
});
}
// Default server error
res.status(500).json({
success: false,
message: 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
module.exports = errorHandler;Exercise 4.2: Create Request Logging Middleware
Create src/middleware/requestLogger.js:
const requestLogger = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const { method, originalUrl } = req;
const { statusCode } = res;
console.log(`${method} ${originalUrl} ${statusCode} - ${duration}ms`);
});
next();
};
module.exports = requestLogger;Exercise 4.3: Update App Configuration
Update src/app.js to use middleware:
// ...existing code...
const errorHandler = require('./middleware/errorHandler');
const requestLogger = require('./middleware/requestLogger');
// Middleware
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
// Routes
app.use('/api/products', require('./routes/products'));
app.use('/api/reviews', require('./routes/reviews')); // general reviews endpoints
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
storage: 'in-memory'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ success: false, error: 'Endpoint not found' });
});
// Error handling (must be last)
app.use(errorHandler);
module.exports = app;Part 5: Testing & Integration
Exercise 5.1: Test API Endpoints
To test the API endpoints, you can use the vscode extension “REST Client”.
Create a file api-tests.http in the project root with the following content:
### Fetch reviews for product 1
POST http://localhost:4000/api/products/1/fetch
Content-Type: application/json
{
"sources": ["amazon", "bestbuy"],
"count": 8
}
### Get all reviews for product 1
GET http://localhost:4000/api/products/1/reviews
### Get aggregate statistics for product 1
GET http://localhost:4000/api/products/1/aggregate
### Get reviews filtered by product and source (use /api/reviews)
GET http://localhost:4000/api/reviews?product_id=1&source=amazon&min_rating=4
### Delete a specific review (replace :id with actual review ID)
DELETE http://localhost:4000/api/reviews/1
You can run each request individually in VSCode by clicking “Send Request” above each request.
Exercise 5.2: Test Complete Flow
Start the server:
npm run devTest sequence:
- POST to fetch reviews → Should return success with count
- GET reviews → Should return the stored reviews
- GET aggregate → Should return calculated statistics
- Test error cases (invalid product ID, malformed requests)