RESTful API in Practice: A Complete Guide to CRUD Design
RESTful APIs are the standard for web services—mastering them enables you to build a well-defined, maintainable API architecture.
Core Concepts and Common Misconceptions of REST: The core of REST (Representational State Transfer) is “resource-oriented”—URLs represent resources, and HTTP methods represent operations. Common misconceptions—1. REST ≠ CRUD: REST is not limited to Create, Read, Update, and Delete; it can also express business actions (e.g., POST /orders/{id}/cancel). The key is that URLs are noun-based; 2. REST ≠ stateless: Statelessness means that each request contains all necessary information (without relying on server-side sessions), but business data is, of course, stateful; 3. REST ≠ must use JSON: REST does not restrict the format; JSON is simply the most popular choice; 4. REST ≠ Perfect: REST does not support real-time push notifications, complex queries, or batch operations well; gRPC and GraphQL are more suitable for these scenarios. Understanding the limitations of REST is more important than memorizing its rules.
RESTful API Maturity Model: The Richardson Maturity Model classifies REST APIs into four levels—Level 0 (HTTP Tunnel): Uses only POST, with all operations performed via a single URL (e.g., SOAP); Level 1 (Resources): Introduces the concept of resources; different resources use different URLs, but only GET and POST are used; Level 2 (HTTP Verbs): Correct use of GET, POST, PUT, and DELETE along with status codes—this is the level most projects achieve; Level 3 (Hypermedia/HATEOAS): Responses include links to related resources (e.g., an order response contains a cancellation link), enabling self-discovery. The biggest leap is from Level 1 to Level 2 (which standardizes the semantics of operations), while Level 3 is rarely used in practice (it increases complexity but offers limited benefits for the front end). The goal of this course is Level 2—building clear APIs by correctly applying HTTP semantics.
1. What You'll Learn
- RESTful API Design Principles
- Complete CRUD Endpoints (GET/POST/PUT/DELETE)
- Request Validation (joi / express-validator)
- Pagination, Sorting, Filtering
- HTTP Status Code Specification
- Standardized Response Format
2. RESTful Design Principles
REST Maturity Model: Leonard Richardson defined four maturity levels for REST APIs—Level 0: Single URL + POST (e.g., SOAP); Level 1: Introduction of the concept of resources (different URLs represent different resources); Level 2: Semantic HTTP methods (GET/POST/PUT/DELETE express operations); Level 3: HATEOAS (the response includes a link to the next action). Most production APIs remain at Level 2; while Level 3 best aligns with REST principles, it is costly to implement.
API Security Design: RESTful API security requires multiple layers of defense—1. Transport layer: Enforce HTTPS to prevent man-in-the-middle attacks; 2. Authentication layer: Use JWT Bearer Tokens to verify user identity; 3. Authorization layer: RBAC role-based access control (customer/admin/moderator); 4. Input layer: joi validation + request body size limit (express.json({limit:'1mb'})); 5. Rate-limiting layer: express-rate-limit to prevent brute-force attacks; 6. Cross-origin layer: CORS whitelist to restrict source domains.
| Security Layer | Defense Objective | Implementation Method |
|---|---|---|
| HTTPS | Eavesdropping/Tampering | TLS Certificates |
| JWT | Identity Forgery | Bearer Token |
| RBAC | Unauthorized Operations | Role + Permission Matrix |
| joi | Injection/Corrupted Data | Schema Validation |
| rate-limit | DDoS | IP Rate Limiting |
| CORS | Cross-Origin Abuse | Whitelisted Domains |
What is REST? REST (Representational State Transfer) is an architectural style whose core principle is that everything is a resource, identified by a URL and operated upon using HTTP methods. REST is not a protocol but a set of constraints—an API that adheres to these constraints is called a RESTful API. In his 2000 doctoral dissertation, Roy Fielding defined six constraints: client-server, statelessness, caching, uniform interface, layered system, and on-demand code.
Core Principles of RESTful Design:
| Principle | Description | Example |
|---|---|---|
| Use nouns for resources | URLs represent resources, not actions | /products ✅ /getProducts ❌ |
| Plural nouns | Plural for collective nouns | /products ✅ /product ❌ |
| HTTP Method Semantics | GET (Read) / POST (Write) / PUT (Replace) / PATCH (Update) / DELETE (Delete) | DELETE /products/:id |
| Nested Resources | Expressing Hierarchical Relationships with Paths | /products/:id/reviews |
| Idempotency | Multiple calls to GET/PUT/DELETE yield the same result | Repeated DELETE calls do not result in an error |
| Stateless | Each request contains all necessary information | JWT token is included with every request |
REST vs RPC vs GraphQL:
| Dimension | REST | RPC | GraphQL |
|---|---|---|---|
| Core Concepts | Resources + HTTP Semantics | Action Calls | On-Demand Queries |
| URL Style | Noun | Verb | Single Endpoint |
| Data Retrieval | Fixed Structure | Fixed Structure | Client-Defined |
| Over-extraction | Common | Common | Avoid |
| Learning Curve | Low | Low | Medium |
| Cache-friendly | Native HTTP caching | Must be implemented manually | Complex |
| Use Cases | CRUD API | Internal Services | Complex Front End |
REST’s Constraints and Freedom: REST’s six constraints—client-server, statelessness, caching, uniform interface, layered system, and on-demand code—are not mandatory requirements. Level 2 REST (resources + HTTP method semantics) already meets 90% of API needs. Overly pursuing RESTful purity (such as Level 3 HATEOAS) actually increases development costs. In real-world projects, there are only three core principles: 1. URLs are nouns (resources), and HTTP methods are verbs (operations); 2. Express results using status codes; do not repeat them in the response body; 3. Stateless authentication (JWT is carried with every request and does not rely on sessions).
When Not to Use REST: REST is not suitable for all scenarios—1. Real-time communication (WebSockets are better; REST’s request-response model does not support server-side push); 2. Batch operations (REST’s per-resource operations are inefficient; RPC-style /batch endpoints are more practical for batch imports/exports); 3. Complex queries (GraphQL offers greater flexibility for filtering with multiple combined conditions; REST’s URL query parameters have limited expressiveness); 4. File uploads (multipart/form-data is not a standard REST interaction method, but it is acceptable in practice). The key to choosing the right approach is pragmatism, not dogmatism.
graph LR
Client[Client] -->|GET| R1[GET /products<br/>List]
Client -->|GET| R2[GET /products/:id<br/>Details]
Client -->|POST| R3[POST /products<br/>Create]
Client -->|PUT| R4[PUT /products/:id<br/>Complete Update]
Client -->|PATCH| R5[PATCH /products/:id<br/>Partial Update]
Client -->|DELETE| R6[DELETE /products/:id<br/>Delete]
style R1 fill:#d4edda
style R3 fill:#cce5ff
style R6 fill:#f8d7da
Alice’s RESTful Design Practices: When Alice designed the product API for ShopHub, she treated URLs as resource paths: /api/products represents a collection of products, /api/products/PHONE-001 represents a specific product, and /api/products/PHONE-001/reviews represents reviews for that product—the URLs themselves serve as self-documenting resources.
Practical Significance of Idempotency: Idempotency is the foundation of API reliability—idempotent operations can be safely retried without causing side effects. Practical scenarios: 1. Network timeout retries—After a PUT/PATCH/DELETE request times out, the front end can automatically retry (with the same result as the first attempt); POST requests cannot be automatically retried (as this may create duplicate data); 2. Weak Network Conditions on Mobile Devices—If the network disconnects after a user clicks “Delete,” retrying after the connection is restored will not delete another record; 3. Microservice Retry—When inter-service calls fail and are retried, idempotent operations do not produce side effects. Non-idempotent operations (such as POST creation) require an idempotency key to ensure idempotency—the client generates a unique ID, and the server checks whether it has already been processed.
Guide to Choosing HTTP Status Codes: Status codes for RESTful APIs should accurately reflect the result—2xx Success (200 OK, 201 Created, 204 No Content), 4xx Client Errors (400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict), and 5xx Server Errors (500 Internal Server Error, 502 Gateway Error, 503 Service Unavailable). Common errors: 1. Returning 200 + {error: "...}} for all errors (violates HTTP semantics; the front end cannot determine the result based on the status code); 2. Mixing 401 and 403 (401 indicates “not authenticated” and requires login, while 403 indicates “authenticated but no permission”); 3. Using 400 as a catch-all for all 4xx errors (the frontend cannot distinguish between “validation failure” and “resource does not exist”). Precise status codes make frontend code more concise—axios interceptors handle requests based on status code branches.
Guidelines for Handling 5xx Errors: A 5xx error indicates a server-side issue— 1. 500 Internal Server Error: An uncaught exception (such as a TypeError or a lost database connection) should be logged with the full stack trace, and a generic error message should be returned to the client (without exposing the stack trace); 2. 502 Bad Gateway: The reverse proxy (Nginx) cannot connect to the Node.js application (application crash or failure to start); trigger an alert; 3. 503 Service Unavailable: The application is overloaded or undergoing maintenance; return a Retry-After header to inform the client when to retry; 4. Unified handling of 5xx errors: The Express error middleware uniformly catches these errors, logs them, sends alerts, and returns a generic message. The 5xx error rate in production should be < 0.1%; exceeding this threshold triggers a PagerDuty alert.
| HTTP Method | Action | Idempotent |
|---|---|---|
| GET | Read | ✅ |
| POST | Create | ❌ |
| PUT | Full update | ✅ |
| PATCH | Partial Update | ✅ |
| DELETE | Delete | ✅ |
| URL Pattern | Meaning |
|---|---|
GET /api/products |
List |
GET /api/products/:id |
Details |
POST /api/products |
Create |
PUT /api/products/:id |
Full Update |
PATCH /api/products/:id |
Partial Update |
DELETE /api/products/:id |
Delete |
3. HTTP Status Code Specification
Why Are Status Codes Important? HTTP status codes act as "traffic lights" for APIs—clients use them to determine the outcome of a request without having to parse the response body. 2xx indicates success, 4xx indicates a client error, and 5xx indicates a server error. Misusing the 200 status code (such as returning a 200 status code along with an error message even when an error occurs) undermines HTTP semantics and prevents clients from handling the response correctly.
The 6 Most Common Status Codes: 90% of API responses in production environments require only 6 status codes—200 OK (success + data returned), 201 Created (creation successful), 204 No Content (deletion or update successful with no data returned), 400 Bad Request (validation failed), 404 Not Found (resource does not exist), and 500 Internal Server Error (server error). Other status codes (401/403/409/422) are used in specific scenarios. Principle: Use the six basic codes whenever possible instead of less common ones; consistency is more important than completeness.
Precise Selection of 4xx Status Codes: 4xx status codes indicate client errors; precise selection helps front-end developers pinpoint the issue—400 (invalid request format/ authentication failed; the front end must modify the input and retry), 401 (not authenticated; the front end should redirect to the login page), 403 (authenticated but no permission; the front end should display a "No Permission" page), 404 (resource does not exist; the front end should display "Not Found"), 409 (conflict/duplicate; the front end should display "Already Exists"), 422 (semantic error; validation passed but business rules not met). Common mistakes: Using 400 to cover all 4xx errors (the frontend cannot distinguish between “not logged in” and “parameter error”), or using 404 instead of 403 (hiding the existence of a resource while creating a security controversy—a 404 says “does not exist” when it actually means “access denied”).
Guidelines for Handling 5xx Errors: A 5xx error indicates a server-side error—the client cannot resolve it and must either retry or wait. Key principles: 1. Never expose internal error details (database errors, file paths, and stack traces are useless to users and pose security risks); 2. Return a request ID (UUID) so users can provide it when reporting issues, and developers can use it to search and pinpoint the issue in logs; 3. Use 500 for unknown errors, 502 for upstream gateway errors (e.g., MongoDB connection lost), and 503 for service overload (use the Retry-After response header to inform the client when to retry); 4. Trigger alerts (a 5xx error rate > 1% triggers a PagerDuty alert).
graph TD
Start[Request Result] --> Success{Success?}
Success -->|Yes| Code2xx[2xx Status Code]
Success -->|No| WhoFault{Whose Fault Is It?}
Code2xx --> HasBody{Has a return value?}
HasBody -->|Yes| OK[200 OK]
HasBody -->|Create a New Resource| Created[201 Created]
HasBody -->|No content| NoContent[204 No Content]
WhoFault -->|Client| ClientErr{Client Error?}
WhoFault -->|Server| ServerError[500 Internal Server Error]
ClientErr -->|Yes| Client4xx[4xx Client Error]
Client4xx --> What4xx{What's the problem?}
What4xx -->|Parameter error| BR[400 Bad Request]
What4xx -->|Not verified| UA[401 Unauthorized]
What4xx -->|No permission| FB[403 Forbidden]
What4xx -->|Does not exist| NF[404 Not Found]
What4xx -->|Resource Conflicts| CF[409 Conflict]
style OK fill:#d4edda
style Created fill:#d4edda
style BR fill:#f8d7da
style UA fill:#fff3cd
| Status Code | Meaning | Scenario |
|---|---|---|
| 200 | OK | Success (GET/PUT/PATCH) |
| 201 | Created | Created successfully (POST) |
| 204 | No Content | Success: No Content (DELETE) |
| 400 | Bad Request | Invalid request parameters |
| 401 | Unauthorized | Not Authenticated |
| 403 | Forbidden | No Permission |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource conflict (e.g., duplication) |
| 500 | Server Error | Server Error |
4. Standardized Response Format
Why standardize the response format? Without a standardized format, one endpoint might return { product }, another { data: product }, and in the event of an error, { message: "error" }—requiring the front end to write different parsing logic for each endpoint. With a standardized format, the front end only needs one set of logic: if (response.success) indicates success, and else triggers the reading of response.error.
Standard Response Structure Design:
| Field | Type | On Success | On Error |
|---|---|---|---|
| success | boolean | true | false |
| data | any | business data | does not exist |
| meta | object | pagination information | does not exist |
| error | object | does not exist | error details |
Error Code Design Principles: Error codes (error.code) should use the uppercase-underscore format (e.g., VALIDATION_ERROR), should not reveal technical details (e.g., do not return MongooseError), and should provide actionable information (e.g., DUPLICATE_KEY + the repeated field name).
HATEOAS and API Self-Discovery: Level 3 of the REST Maturity Model is HATEOAS (Hypermedia as the Engine of Application State)—responses contain not only data but also links to related operations. For example, an order details response might include links: {pay: '/orders/123/pay', cancel: '/orders/123/cancel'}. HATEOAS makes the API self-descriptive—clients do not need to hard-code URLs but can dynamically discover available operations from the response. While most projects only need to reach Level 2, HATEOAS is suitable for open API platforms (such as Stripe and the GitHub API).
Pagination Metadata Design: The pagination metadata (meta) for the list API must include sufficient information for the front end to render the pagination controls—total (total number of records), page (current page), limit (number of records per page), and totalPages (total number of pages = ceil(total/limit)). Additional optional fields: hasNext (whether there is a next page) and hasPrev (whether there is a previous page). The front end uses these fields to determine the pagination display logic: the pagination bar is displayed only if totalPages > 1, and the "Next Page" button is hidden if currentPage == totalPages.
Quantitative Metrics for API Performance: The performance of RESTful APIs needs to be quantified—1. Response time P50/P95/P99 (response times for 50%, 95%, and 99% of requests; a common target is P95 < 200 ms); 2. Throughput (QPS; number of requests processed per second; a single-instance Express + MongoDB setup typically handles 500–2,000 QPS); 3. Error rate (percentage of 5xx errors out of total requests; < 0.1% is considered a healthy standard). Monitoring tools: Prometheus + Grafana for collecting and displaying metrics; Alertmanager for alerts. Performance optimization priority: First optimize the slowest APIs (those with the highest P95), then optimize the most frequently called APIs (listing interfaces with the highest QPS).
API Caching Strategies: APIs with high read and low write volumes (such as product lists and article details) are suitable for caching—1. HTTP caching (Cache-Control: max-age=300; browsers use the cache directly for 5 minutes without sending a request); 2. CDN caching (Cloudflare/CloudFront edge caching, allowing users worldwide to access content from the nearest location); 3. Application caching (Redis caches frequently accessed data with a TTL of 5–10 minutes); 4. Database caching (MongoDB’s WiredTiger caching, automatically managed). Cache expiration strategies: 1. Automatic expiration upon TTL expiration; 2. Proactive expiration upon write (delete the corresponding cache key after an article is updated); 3. Version number control (cache keys include a version number, which is incremented upon updates).
Protection Against Cache Penetration and Cache Avalanches: Cache systems have two classic failure modes—1. Cache penetration: Querying for data that does not exist (e.g., productId=999999); the cache contains no data → query the database → no data found there either → no data is cached → the database is queried again next time. Prevention: Cache even results that return null (TTL 60 seconds), or use a Bloom filter to filter out nonexistent IDs; 2. Cache Avalanche: A large number of caches expire simultaneously (e.g., batch expiration at 3:00 a.m.), causing all requests to flood the database. Prevention: Add a random offset to the TTL (300 ± 30 seconds) to spread out expiration times; 3. Cache Breakthrough: A large number of requests flood the database the moment hot data expires. Mitigation: Use distributed locks for hot data (allowing only one request at a time to rebuild the cache), or implement never-expiring caches with asynchronous updates. The mitigation strategies for these three failures differ and must be implemented separately.
// Successful Response
{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 100 }
}
// Error Response
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": { "email": "Required" }
}
}
// Middleware:Standard Response
const sendSuccess = (res, data, meta = null) => {
res.json({ success: true, data, meta });
};
const sendError = (res, code, message, status = 400, details = null) => {
res.status(status).json({
success: false,
error: { code, message, details }
});
};
5. Request a Trial (joi)
Why is request validation necessary? Never trust client input—empty strings, excessively long text, invalid characters, and missing required fields can all lead to: ① dirty data being written to the database; ② query errors; ③ security vulnerabilities (injection attacks). The validation layer serves as the API’s first line of defense, intercepting invalid requests before the data reaches the controller.
Where the validation layer fits in the request pipeline: The validation middleware should be placed immediately before the Controller—after authentication/authorization (verify identity before validating input) and before business logic (to prevent dirty data from reaching the Controller). Once validation passes, the cleaned data is assigned to req.validated, and the Controller uses only the validated data rather than the original req.body—this ensures that the Controller always receives valid data.
In-Depth Comparison of joi vs. express-validator: joi is a standalone validation library (independent of Express), while express-validator is a middleware specifically designed for Express. Advantages of joi: 1. Schemas can be exported and reused (the frontend and backend share the same set of validation rules); 2. Greater expressiveness for complex validations (cross-field validation, conditional validation); 3. Framework-agnostic (also works with Koa and Hapi). Advantages of express-validator: 1. Native Express middleware with zero-configuration integration; 2. Based on validator.js, offering a rich set of validation rules; 3. Intuitive chained syntax. Joi is recommended for new projects (due to its high reusability), but there is no need to migrate projects that already use express-validator.
sequenceDiagram
participant Client
participant Route as Express Route
participant Validate as joi Verification
participant Controller
participant DB as MongoDB
Client->>Route: POST /api/products
Route->>Validate: validate(req.body)
alt Verification Failed
Validate-->>Client: 400 VALIDATION_ERROR
else Verification Passed
Validate->>Controller: req.validated = value
Controller->>DB: Product.create(validated)
DB-->>Client: 201 Created
end
Layered Defense Strategy for Input Validation: API input validation should be implemented across multiple layers—1. Routing layer (Joi/express-validator): First line of defense, validates format, type, and range; returns a 400 status code plus detailed error information; 2. Controller layer: Validates business rules (e.g., “Category must exist,” “SKU cannot be duplicated”), which requires a database query; 3. Model Layer (Mongoose Schema): The final line of defense, ensuring that data written to MongoDB strictly adheres to structural constraints. Each of these three layers of validation serves a distinct purpose—the routing layer filters out 90% of invalid input (fast-failing), the controller layer handles business logic (requiring database queries), and the model layer acts as a safety net (preventing bypass of the first two layers).
In-Depth Comparison of Joi vs. express-validator: These are the two most commonly used Node.js input validation libraries—Joi: a standalone, framework-agnostic validation library with elegant schema definitions (chained API) and detailed error messages, but it requires manual integration into Express (via middleware wrapping); express-validator: Based on validator.js, deeply integrated with Express (req.check() can be used directly in routes), but its schema definition is less intuitive than Joi’s (uses chained check() methods rather than object-based definitions). Selection Criteria: 1. Use Joi for new projects (schemas are reusable, testable, and can generate Swagger documentation); 2. Continue using express-validator for existing projects (the migration cost isn’t worth it); 3. Joi is more convenient for complex validation (conditional dependencies, cross-field validation).
Design Patterns for Validation Middleware: Encapsulating validation logic as middleware is a best practice in Express— 1. The validation middleware accepts a schema (validate(createProductSchema)), validates req.body, and calls next() if successful; otherwise, it returns a 400 status code along with error details; 2. The middleware attaches the validated values to req.validated (rather than continuing to use req.body), preventing subsequent controllers from processing unvalidated data; 3. Validation schemas are separated by CRUD operations (e.g., createSchema has required fields, while updateSchema has all fields optional) and are not mixed. This pattern ensures that controllers do not need to concern themselves with validation logic at all—they only process validated data.
// validators/productValidator.js
const Joi = require('joi');
const createProductSchema = Joi.object({
sku: Joi.string().required().pattern(/^[A-Z0-9-]+$/),
title: Joi.string().required().min(1).max(200),
price: Joi.number().required().min(0),
category: Joi.string().required().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0).default(0)
});
const updateProductSchema = Joi.object({
title: Joi.string().min(1).max(200),
price: Joi.number().min(0),
category: Joi.string().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0)
}).min(1);
const validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', details: error.details }
});
}
next();
};
module.exports = { createProductSchema, updateProductSchema, validate };
// routes/products.js
const { createProductSchema, updateProductSchema, validate } = require('../validators/productValidator');
router.post('/', validate(createProductSchema), ctrl.createProduct);
router.put('/:sku', validate(updateProductSchema), ctrl.updateProduct);
6. Complete CRUD Endpoints
CRUD Endpoint Design Pattern: Each resource follows a unified pattern of five endpoints—list (GET /), detail (GET /:id), create (POST /), update (PUT /:id), and delete (DELETE /:id). Key decision points: ① Should the list use pagination or a cursor? ② Should updates use PUT or PATCH? ③ Should deletion use hard delete or soft delete?
CRUD Endpoint Permission Matrix: Each endpoint has different permission requirements—1. GET / (List): Public or requires authentication (depending on the business logic); e-commerce product lists are public, while order lists require authentication; 2. GET /:id (Details): Same policy as the list, but may require permission filtering (users can only view their own order details); 3. POST / (Create): Authentication required; some resources require authorization (e.g., only administrators can create products); 4. PUT /:id (Update): Authentication required + resource owner or administrator (users can only edit their own comments); 5. DELETE /:id (Delete): Authentication required + resource owner or administrator. Permission checks should be performed in the middleware (authenticate + authorize); the Controller handles only business logic.
CRUD Endpoint Design Decisions:
| Decision Point | Option A | Option B | Recommendation |
|---|---|---|---|
| Pagination Method | page + limit (offset) | cursor | Use "page" for shallow pagination and "cursor" for deep pagination |
| Update Method | PUT (Full Replacement) | PATCH (Partial Update) | PATCH (More Secure; Does Not Clear Fields Not Specified) |
| Deletion Method | Physical Deletion (remove) | Soft Deletion (isDeleted flag) | Soft Deletion (recoverable, data compliance) |
| ID Format | ObjectId | Custom SKU/Slug | SKU for users, ObjectId for internal use |
| List Sorting | Single Field | Multiple Fields (Combination) | Multiple Fields (Sort + Order) |
Bob's Performance Optimization: At ShopHub, Bob noticed that list queries were executing both find and countDocuments simultaneously. He used Promise.all to execute them in parallel—reducing the query time from 200 ms + 200 ms = 400 ms to max(200 ms, 180 ms) = 200 ms.
// controllers/productController.js
const Product = require('../models/Product');
// GET /api/products
exports.list = async (req, res) => {
const { page = 1, limit = 20, sort = 'createdAt', order = 'desc', category, search } = req.query;
const query = { isActive: true };
if (category) query.category = category;
if (search) query.title = new RegExp(search, 'i');
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price thumbnail rating')
.sort({ [sort]: order === 'desc' ? -1 : 1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) }
});
};
// GET /api/products/:sku
exports.get = async (req, res) => {
const product = await Product.findOne({ sku: req.params.sku }).lean();
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
};
// POST /api/products
exports.create = async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json({ success: true, data: product });
};
// PUT /api/products/:sku
exports.update = async (req, res) => {
const product = await Product.findOneAndUpdate(
{ sku: req.params.sku },
req.body,
{ new: true, runValidators: true }
);
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
};
// DELETE /api/products/:sku
exports.remove = async (req, res) => {
const product = await Product.findOneAndDelete({ sku: req.params.sku });
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.status(204).send();
};
7. Error Handling
Layered API Error Handling Strategy: Error handling is not simply a matter of "adding a try-catch block," but rather a layered defense system—the validation layer intercepts invalid input (4xx), the business logic layer intercepts logical errors (e.g., product does not exist → 404), the database layer intercepts system errors (e.g., connection lost → 5xx), and the outermost layer catches all unexpected errors.
Misclassification and Handling:
| Error Source | Example | Status Code | Resolution |
|---|---|---|---|
| Validation Layer | joi validation failed | 400 | Return field-level error details |
| Business Layer | Product does not exist | 404 | Return "Resource does not exist" |
| Database Layer | Unique Key Conflict | 409 | Return the conflicting field |
| Authentication Layer | Invalid Token | 401 | Returned Unauthenticated |
| Permission Level | Insufficient Role | 403 | No Permission |
| System Level | Unknown Exception | 500 | Returns a generic error (details not disclosed) |
Design Principles for Error-Handling Middleware: Express error-handling middleware is a 4-argument function (err, req, res, next) and must be placed after all routes. Key design points: 1. Error classification priority—check in the order of ValidationError → MongoError → JsonWebTokenError → catch-all 500, because specific error types can provide more precise status codes; 2. Do not expose the stack trace in production—err.stack is returned only in the development environment; in production, a generic message is returned; 3. Log levels—4xx errors are logged as warn (client errors), and 5xx errors are logged as error (server errors); 4. Request context—error logs should include requestId, userId, and path to facilitate troubleshooting.
Security Measures for Error Responses: API error responses are a major source of information leaks—1. Do not return the raw message for database errors (which may contain collection names or query statements); 2. Return stack traces only in the development environment; 3. Mongoose ValidationError can be returned directly (field-level errors are safe), but MongoError must be filtered (unique key conflicts can be returned; others must be masked); 4. Standardize error formatting as {success: false, error: {code, message, details?}} so the front end does not need to parse the response structure. The bottom line of this principle: No 5xx errors should expose internal implementation details to the client.
// middlewares/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error(err);
if (err.name === 'ValidationError') {
return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', message: err.message, details: err.errors }
});
}
if (err.code === 11000) {
return res.status(409).json({
success: false,
error: { code: 'DUPLICATE_KEY', message: 'Duplicate key', details: err.keyValue }
});
}
res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }
});
};
8. Hands-On: The Comments API
Nested Resource Routing Design: Comments are subordinate to products, so the URL is designed as /products/:productId/reviews rather than /reviews?productId=xxx—the former is semantically clearer and more compliant with REST conventions. However, direct operations on comments (update/delete) use /reviews/:reviewId, since the product context is not required in these cases.
Comment System API Design:
| Endpoint | Method | Authentication | Description |
|---|---|---|---|
| /products/:productId/reviews | GET | No | View list of reviews |
| /products/:productId/reviews | POST | Yes | Post a Review |
| /reviews/:reviewId | PUT | Yes (Author) | Edit Review |
| /reviews/:reviewId | DELETE | Yes (Author) | Delete Comment |
| /reviews/:reviewId/like | POST | Yes | Like/Unlike |
// routes/reviews.js
router.get('/products/:productId/reviews', ctrl.listReviews);
router.post('/products/:productId/reviews', authenticate, ctrl.createReview);
router.put('/reviews/:reviewId', authenticate, ctrl.updateReview);
router.delete('/reviews/:reviewId', authenticate, ctrl.deleteReview);
router.post('/reviews/:reviewId/like', authenticate, ctrl.likeReview);
Design Principles for API Endpoints: The table above illustrates the API endpoint design for an e-commerce review system—1. Use plural nouns for resource names (/products, not /product); 2. Use nested resources to express hierarchical relationships (/products/:productId/reviews indicates “reviews for a specific product”); 3. Clearly label authentication requirements—read operations are public (GET), while write operations require authentication (POST/PUT/DELETE); 4. Use verb subpaths for action-based endpoints (/reviews/:reviewId/like rather than the collective /likes); 5. Use a consistent versioning prefix (/api/v1/..., omitted in the table).
Three Strategies for API Versioning: 1. URL prefix (/api/v1/products): The most intuitive and commonly used approach; version changes are clear, but URLs become longer; 2. Request header (Header: Api-Version: 1): The URL remains unchanged, but clients must set the request header, which makes debugging inconvenient; 3. Content negotiation (Accept: application/vnd.api+json; version=1): The most RESTful but also the most complex; rarely used in actual projects. Recommended strategy: 1—developer-friendly, easy to test, and natively supported by Nginx routing. When upgrading versions: Keep v1 unchanged, create a new routing file for v2, gradually migrate clients, and take v1 offline after setting an end-of-life date.
▶ Example 1: Product CRUD + Pagination + Validation
// === Complete Product CRUD API(Includes verification+Pagination+Error Handling)===
const express = require('express');
const Joi = require('joi');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Schema
const ProductSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
title: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
category: { type: String, required: true, enum: ['Electronics', 'Books', 'Clothing'] },
stock: { type: Number, default: 0, min: 0 }
}, { timestamps: true });
const Product = mongoose.model('Product', ProductSchema);
// Joi Verification
const createSchema = Joi.object({
sku: Joi.string().required().pattern(/^[A-Z0-9-]+$/),
title: Joi.string().required().min(1).max(200),
price: Joi.number().required().min(0),
category: Joi.string().required().valid('Electronics', 'Books', 'Clothing'),
stock: Joi.number().integer().min(0).default(0)
});
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', details: error.details } });
req.validated = value;
next();
};
// CRUD
app.get('/api/products', async (req, res) => {
const { page = 1, limit = 20, category } = req.query;
const query = {};
if (category) query.category = category;
const [products, total] = await Promise.all([
Product.find(query).select('sku title price').skip((page-1)*limit).limit(+limit).lean(),
Product.countDocuments(query)
]);
res.json({ success: true, data: products, meta: { page: +page, limit: +limit, total, pages: Math.ceil(total/limit) } });
});
app.post('/api/products', validate(createSchema), async (req, res) => {
const product = await Product.create(req.validated);
res.status(201).json({ success: true, data: product });
});
app.get('/api/products/:sku', async (req, res) => {
const product = await Product.findOne({ sku: req.params.sku }).lean();
if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
res.json({ success: true, data: product });
});
app.use((err, req, res, next) => {
if (err.code === 11000) return res.status(409).json({ success: false, error: { code: 'DUPLICATE_KEY' } });
res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR' } });
});
mongoose.connect('mongodb://localhost:27017/shopdb').then(() => app.listen(3000));
Output: A complete product CRUD API, including Joi validation, paginated queries, error handling, and a standardized response format.
▶ Example 2: Complete RESTful Comments API + joi Validation
// === 1. validators/reviewValidator.js - joi Verification ===
const Joi = require('joi');
const createReviewSchema = Joi.object({
productId: Joi.string().required().pattern(/^[0-9a-fA-F]{24}$/), // ObjectId
content: Joi.string().required().min(10).max(1000),
rating: Joi.number().integer().required().min(1).max(5),
parentId: Joi.string().pattern(/^[0-9a-fA-F]{24}$/).allow(null)
});
const updateReviewSchema = Joi.object({
content: Joi.string().min(10).max(1000),
rating: Joi.number().integer().min(1).max(5)
}).min(1); // At least one field
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
details: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
}
});
}
req.validated = value; // Validated data
next();
};
module.exports = { createReviewSchema, updateReviewSchema, validate };
// === 2. controllers/reviewController.js ===
const Review = require('../models/Review');
const Product = require('../models/Product');
exports.list = async (req, res, next) => {
try {
const { productId } = req.params;
const { page = 1, limit = 20, sort = '-createdAt' } = req.query;
const reviews = await Review.find({ productId, parentId: null })
.populate('userId', 'username avatar')
.populate({
path: 'replies',
populate: { path: 'userId', select: 'username avatar' }
})
.sort(sort)
.skip((page - 1) * limit)
.limit(+limit)
.lean();
const total = await Review.countDocuments({ productId, parentId: null });
res.json({
success: true,
data: reviews,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / limit) }
});
} catch (err) { next(err); }
};
exports.create = async (req, res, next) => {
try {
// 1. Verify Product Availability
const product = await Product.findById(req.validated.productId).lean();
if (!product) {
return res.status(404).json({
success: false,
error: { code: 'PRODUCT_NOT_FOUND' }
});
}
// 2. Verify that the parent comment exists(If this is a reply)
if (req.validated.parentId) {
const parent = await Review.findById(req.validated.parentId);
if (!parent) {
return res.status(404).json({
success: false,
error: { code: 'PARENT_REVIEW_NOT_FOUND' }
});
}
}
// 3. Create a Review
const review = await Review.create({
...req.validated,
userId: req.user._id
});
// 4. Update Product Ratings
await updateProductRating(req.validated.productId);
await review.populate('userId', 'username avatar');
res.status(201).json({ success: true, data: review });
} catch (err) { next(err); }
};
exports.update = async (req, res, next) => {
try {
const review = await Review.findOneAndUpdate(
{ _id: req.params.reviewId, userId: req.user._id }, // Only the author can edit this.
{ $set: { ...req.validated, isEdited: true } },
{ new: true, runValidators: true }
).lean();
if (!review) {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND_OR_NO_PERMISSION' }
});
}
res.json({ success: true, data: review });
} catch (err) { next(err); }
};
exports.remove = async (req, res, next) => {
try {
const review = await Review.findOneAndDelete({
_id: req.params.reviewId,
userId: req.user._id
});
if (!review) {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND_OR_NO_PERMISSION' }
});
}
await updateProductRating(review.productId);
res.status(204).send();
} catch (err) { next(err); }
};
// === 3. routes/reviews.js ===
const router = require('express').Router();
const ctrl = require('../controllers/reviewController');
const { authenticate } = require('../middlewares/auth');
const { createReviewSchema, updateReviewSchema, validate } = require('../validators/reviewValidator');
router.get('/products/:productId/reviews', ctrl.list);
router.post('/products/:productId/reviews',
authenticate, validate(createReviewSchema), ctrl.create);
router.put('/reviews/:reviewId',
authenticate, validate(updateReviewSchema), ctrl.update);
router.delete('/reviews/:reviewId',
authenticate, ctrl.remove);
module.exports = router;
// === 4. Test API ===
// curl http://localhost:3000/api/products/507f1f77bcf86cd799439021/reviews
// curl -X POST http://localhost:3000/api/products/507f1f77bcf86cd799439021/reviews \
// -H "Authorization: Bearer <token>" \
// -H "Content-Type: application/json" \
// -d '{"content":"Great product!","rating":5}'
Output: A complete RESTful comments API that supports listing, creating, updating, and deleting; joi validation ensures data validity; and authentication protects author permissions.
Design Patterns for the Controller Layer: The code above demonstrates standard design patterns for the Controller layer—1. Each exported function corresponds to a route endpoint (exports.list → GET, exports.create → POST); 2. All asynchronous operations are wrapped in a try-catch block, and any catch is uniformly handled by passing the error to the error-handling middleware via next(err); 3. Early return on validation failure (404/403) to prevent entering business logic; 4. Add permission filtering to query conditions (userId: req.user._id ensures users can only modify their own comments); 5. Use lean() to reduce Mongoose document overhead (read-only scenarios do not require Mongoose’s change tracking). These patterns keep the Controller simple—validation → query → response—with each function ranging from 10 to 20 lines.
Combination Patterns for Routes and Middleware: Route definitions illustrate middleware combination strategies—1. Common middleware (router.use(authenticate)): All routes require authentication; 2. Route-level middleware (validate(createReviewSchema)): Only specific routes require validation; 3. Middleware execution order: authenticate → validate → controller, arranged by dependency (validation depends on the authentication result req.user); 4. Conditional middleware: Some routes do not require authentication (e.g., GET list), so they are placed before authenticate or use optional authentication middleware. This composition pattern makes the middleware stack for each route clear and readable.
❓ FAQ
page or cursor for pagination?page for shallow pagination and cursor for deep pagination (for consistent performance).📖 Summary
- The Semantics of the 6 RESTful HTTP Methods
- HTTP status codes: 200/201/204/400/401/403/404/409/500
- Standardized response format: success + data + meta + error
- joi Request for Verification
- Complete CRUD Endpoints (GET/POST/PUT/PATCH/DELETE)
- Error-handling middleware
📝 Exercises
- Basic Exercise (⭐): Implement routes for the 6 HTTP methods (products CRUD).
- Basic Problem (⭐): Implement request validation using Joi (required, length, enumeration).
- Advanced Exercise (⭐⭐): Implement a unified response format (success/data/meta/error).
- Advanced Exercise (⭐⭐): Implement a complete comments API (CRUD + Likes + Error Handling).
- Challenge (⭐⭐⭐): Complete e-commerce API (products + reviews + orders + users + JWT authentication).



