Introduction
Application Programming Interfaces (APIs) are the building blocks of modern software architecture. They enable systems to communicate, share data, and provide services to other applications. A well-designed API is intuitive, consistent, secure, and scalable—making it a pleasure to use for developers.
RESTful APIs have become the de facto standard for web services, leveraging HTTP protocols and conventions to create predictable, stateless, and cacheable interfaces. However, designing a good REST API requires more than just following HTTP methods—it demands careful consideration of resource modeling, error handling, versioning, security, and documentation.
This comprehensive guide explores API design best practices, covering REST principles, resource naming conventions, HTTP methods and status codes, versioning strategies, authentication and authorization, rate limiting, pagination, error handling, documentation with OpenAPI/Swagger, and security considerations.
REST Principles
Understanding REST
REST (Representational State Transfer) is an architectural style that defines constraints for creating web services. A RESTful API is one that adheres to these constraints.
The Six Constraints of REST
1. Client-Server Architecture
Separation of concerns between client and server allows independent evolution:
1
2
3
4
5
| Client <---HTTP Request/Response---> Server
| |
|- UI/UX |- Business Logic
|- User State |- Data Storage
|- Presentation |- Processing
|
2. Statelessness
Each request contains all information needed to process it. The server stores no client context:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # Bad - Server-side session state
@app.route('/cart/add', methods=['POST'])
def add_to_cart():
# Relies on server-side session
session['cart'].append(request.json['product_id'])
return {'message': 'Added to cart'}
# Good - Stateless
@app.route('/cart', methods=['POST'])
def update_cart(cart_id):
cart = request.json['cart']
cart.append(request.json['product_id'])
# Store cart in database
save_cart(cart_id, cart)
return {'cart': cart}
|
3. Cacheability
Responses must define themselves as cacheable or non-cacheable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Express.js - Setting cache headers
app.get('/api/products', (req, res) => {
const products = getProducts();
// Cache for 1 hour
res.set('Cache-Control', 'public, max-age=3600');
res.json(products);
});
app.get('/api/user/profile', (req, res) => {
const profile = getUserProfile(req.user.id);
// Don't cache personal data
res.set('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.json(profile);
});
|
Consistent interface simplifies and decouples the architecture:
- Resource identification: URLs identify resources
- Resource manipulation through representations: JSON, XML, etc.
- Self-descriptive messages: Each message includes enough information
- HATEOAS: Hypermedia as the Engine of Application State
5. Layered System
Client cannot tell if connected directly to the end server:
1
| Client <-> Load Balancer <-> API Gateway <-> Application Server <-> Database
|
6. Code on Demand (Optional)
Servers can extend client functionality by transferring executable code:
1
2
3
4
5
| // Optional: Server can send JavaScript to execute on client
{
"data": { ... },
"script": "function processData(data) { return data.filter(...); }"
}
|
Resource Naming Conventions
Use Nouns, Not Verbs
Resources should be nouns representing entities:
1
2
3
4
5
6
7
8
9
10
11
| # Bad - verbs in URLs
GET /getUsers
POST /createUser
PUT /updateUser
DELETE /deleteUser
# Good - nouns with HTTP methods
GET /users
POST /users
PUT /users/{id}
DELETE /users/{id}
|
Use Plural Nouns
Use plural forms for consistency:
1
2
3
4
5
6
7
8
9
| # Good
GET /users
GET /users/{id}
GET /products
GET /products/{id}
# Avoid mixing
GET /user/{id}
GET /products/{id}
|
Use Hierarchical Structure
Model relationships hierarchically:
1
2
3
4
5
6
7
8
9
| # Good - clear hierarchy
GET /users/{userId}/orders
GET /users/{userId}/orders/{orderId}
GET /users/{userId}/orders/{orderId}/items
# Access patterns
GET /organizations/{orgId}/teams/{teamId}/members
GET /posts/{postId}/comments
GET /posts/{postId}/comments/{commentId}/replies
|
Use Lowercase and Hyphens
Use lowercase letters and hyphens for readability:
1
2
3
4
5
6
7
| # Bad
GET /userProfiles
GET /user_profiles
# Good
GET /user-profiles
GET /order-items
|
Avoid Deep Nesting
Limit nesting to 2-3 levels:
1
2
3
4
5
6
| # Bad - too deep
GET /users/{userId}/orders/{orderId}/items/{itemId}/reviews/{reviewId}/replies
# Good - flatten with query parameters
GET /reviews/{reviewId}/replies
GET /replies?reviewId={reviewId}
|
Examples of Good Resource Naming
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # Users
GET /users # List all users
GET /users/{id} # Get specific user
POST /users # Create new user
PUT /users/{id} # Update user
DELETE /users/{id} # Delete user
# User's orders
GET /users/{id}/orders # Get user's orders
GET /users/{id}/orders/{orderId} # Get specific order
# Products
GET /products # List products
GET /products/{id} # Get product details
POST /products # Create product
PUT /products/{id} # Update product
DELETE /products/{id} # Delete product
# Search and filter
GET /products?category=electronics&minPrice=100
GET /users?role=admin&status=active
|
HTTP Methods and Status Codes
HTTP Methods
GET - Retrieve Resources
Safe and idempotent. Does not modify state:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| # Flask example
@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
user = User.query.get(user_id)
if not user:
return {'error': 'User not found'}, 404
return {
'id': user.id,
'username': user.username,
'email': user.email
}, 200
# List with pagination
@app.route('/api/users', methods=['GET'])
def get_users():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
users = User.query.paginate(page=page, per_page=per_page)
return {
'users': [user.to_dict() for user in users.items],
'total': users.total,
'page': page,
'pages': users.pages
}, 200
|
POST - Create Resources
Not idempotent. Creates new resources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // Express example
app.post('/api/users', async (req, res) => {
try {
const { username, email, password } = req.body;
// Validate input
if (!username || !email || !password) {
return res.status(400).json({
error: 'Missing required fields'
});
}
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({
error: 'User already exists'
});
}
// Create user
const user = await User.create({
username,
email,
password: hashPassword(password)
});
// Return created resource with Location header
res.status(201)
.location(`/api/users/${user.id}`)
.json({
id: user.id,
username: user.username,
email: user.email
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
|
PUT - Update/Replace Resources
Idempotent. Replaces entire resource:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // ASP.NET Core example
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(int id, [FromBody] UserUpdateDto dto)
{
var user = await _context.Users.FindAsync(id);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
// Replace entire resource
user.Username = dto.Username;
user.Email = dto.Email;
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Ok(new
{
id = user.Id,
username = user.Username,
email = user.Email
});
}
|
PATCH - Partial Updates
Idempotent. Updates specific fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Flask example
@app.route('/api/users/<int:user_id>', methods=['PATCH'])
def patch_user(user_id):
user = User.query.get(user_id)
if not user:
return {'error': 'User not found'}, 404
data = request.json
# Update only provided fields
if 'email' in data:
user.email = data['email']
if 'first_name' in data:
user.first_name = data['first_name']
if 'last_name' in data:
user.last_name = data['last_name']
db.session.commit()
return user.to_dict(), 200
|
DELETE - Remove Resources
Idempotent. Deletes resources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Express example
app.delete('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
await user.remove();
// 204 No Content - successful deletion with no response body
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
|
HTTP Status Codes
Success Codes (2xx)
1
2
3
4
5
| 200 OK - Request succeeded
201 Created - Resource created successfully
202 Accepted - Request accepted for processing
204 No Content - Success with no response body
206 Partial Content - Partial GET request succeeded
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Examples of success responses
@app.route('/api/users', methods=['POST'])
def create_user():
user = create_user_from_request()
return user.to_dict(), 201 # Created
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
delete_user_by_id(user_id)
return '', 204 # No Content
@app.route('/api/reports', methods=['POST'])
def generate_report():
# Long-running task
task_id = start_report_generation()
return {'task_id': task_id, 'status': 'processing'}, 202 # Accepted
|
Client Error Codes (4xx)
1
2
3
4
5
6
7
8
| 400 Bad Request - Invalid request syntax
401 Unauthorized - Authentication required
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource doesn't exist
405 Method Not Allowed - HTTP method not supported
409 Conflict - Request conflicts with current state
422 Unprocessable - Validation errors
429 Too Many Requests - Rate limit exceeded
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // Examples of client error responses
app.post('/api/users', async (req, res) => {
// 400 Bad Request
if (!req.body.email || !req.body.password) {
return res.status(400).json({
error: 'Bad Request',
message: 'Email and password are required'
});
}
// 409 Conflict
const existing = await User.findOne({ email: req.body.email });
if (existing) {
return res.status(409).json({
error: 'Conflict',
message: 'User with this email already exists'
});
}
// 422 Unprocessable Entity
const errors = validateUser(req.body);
if (errors.length > 0) {
return res.status(422).json({
error: 'Validation Failed',
details: errors
});
}
});
// 401 Unauthorized
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required'
});
}
next();
});
// 403 Forbidden
app.delete('/api/users/:id', (req, res) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions'
});
}
});
|
Server Error Codes (5xx)
1
2
3
4
| 500 Internal Server Error - Generic server error
502 Bad Gateway - Invalid response from upstream
503 Service Unavailable - Server temporarily unavailable
504 Gateway Timeout - Upstream server timeout
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // ASP.NET Core - Global error handling
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var error = context.Features.Get<IExceptionHandlerFeature>();
if (error != null)
{
var ex = error.Error;
await context.Response.WriteAsJsonAsync(new
{
error = "Internal Server Error",
message = "An unexpected error occurred",
requestId = context.TraceIdentifier
});
// Log the exception
logger.LogError(ex, "Unhandled exception occurred");
}
});
});
|
API Versioning Strategies
Why Version Your API
- Allow backward compatibility
- Support multiple client versions
- Enable gradual migration
- Communicate breaking changes
Versioning Approaches
1. URL Path Versioning
Most common and explicit approach:
1
2
3
4
5
6
| # Version in URL path
GET /api/v1/users
GET /api/v2/users
GET /api/v3/users
# Implementation
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # Flask with blueprints
from flask import Blueprint
# Version 1
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
@v1.route('/users')
def get_users_v1():
return {'users': [...], 'version': 'v1'}
# Version 2
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')
@v2.route('/users')
def get_users_v2():
return {
'data': [...],
'metadata': {...},
'version': 'v2'
}
app.register_blueprint(v1)
app.register_blueprint(v2)
|
2. Query Parameter Versioning
Version as a query parameter:
1
2
| GET /api/users?version=1
GET /api/users?version=2
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Express implementation
app.get('/api/users', (req, res) => {
const version = req.query.version || '1';
if (version === '1') {
return res.json(getUsersV1());
} else if (version === '2') {
return res.json(getUsersV2());
} else {
return res.status(400).json({
error: 'Unsupported version'
});
}
});
|
Version in custom header:
1
2
3
4
5
6
7
| GET /api/users
Headers:
API-Version: 1
GET /api/users
Headers:
API-Version: 2
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // ASP.NET Core
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult GetUsers()
{
var version = Request.Headers["API-Version"].ToString();
return version switch
{
"1" => Ok(GetUsersV1()),
"2" => Ok(GetUsersV2()),
_ => BadRequest(new { error = "Unsupported version" })
};
}
}
|
Use media type versioning:
1
2
3
4
5
6
7
| GET /api/users
Headers:
Accept: application/vnd.myapi.v1+json
GET /api/users
Headers:
Accept: application/vnd.myapi.v2+json
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # Flask implementation
@app.route('/api/users')
def get_users():
accept_header = request.headers.get('Accept', '')
if 'vnd.myapi.v1+json' in accept_header:
return jsonify(get_users_v1()), 200, {
'Content-Type': 'application/vnd.myapi.v1+json'
}
elif 'vnd.myapi.v2+json' in accept_header:
return jsonify(get_users_v2()), 200, {
'Content-Type': 'application/vnd.myapi.v2+json'
}
else:
return jsonify({'error': 'Unsupported version'}), 406
|
Version Deprecation
Communicate deprecation clearly:
1
2
3
4
5
6
7
| // Add deprecation warnings
app.get('/api/v1/users', (req, res) => {
res.set('Warning', '299 - "API v1 is deprecated. Please migrate to v2 by 2024-12-31"');
res.set('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT');
res.json(getUsersV1());
});
|
Authentication and Authorization
Authentication Methods
1. API Keys
Simple but less secure:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # API Key authentication
@app.before_request
def authenticate():
api_key = request.headers.get('X-API-Key')
if not api_key:
return jsonify({'error': 'API key required'}), 401
user = User.query.filter_by(api_key=api_key).first()
if not user:
return jsonify({'error': 'Invalid API key'}), 401
g.current_user = user
|
2. OAuth 2.0
Industry standard for authorization:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| // OAuth 2.0 with JWT tokens
const jwt = require('jsonwebtoken');
// Token generation
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
const user = await authenticateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.json({
accessToken,
refreshToken,
expiresIn: 900
});
});
// Token verification middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
// Protected route
app.get('/api/users/me', authenticateToken, async (req, res) => {
const user = await User.findById(req.user.userId);
res.json(user);
});
|
3. JWT (JSON Web Tokens)
Stateless authentication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| // ASP.NET Core JWT authentication
public class AuthController : ControllerBase
{
private readonly IConfiguration _configuration;
[HttpPost("login")]
public IActionResult Login([FromBody] LoginDto dto)
{
var user = AuthenticateUser(dto.Username, dto.Password);
if (user == null)
{
return Unauthorized(new { error = "Invalid credentials" });
}
var token = GenerateJwtToken(user);
return Ok(new
{
accessToken = token,
tokenType = "Bearer",
expiresIn = 900
});
}
private string GenerateJwtToken(User user)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["Jwt:Secret"]));
var credentials = new SigningCredentials(
securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role)
};
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
// Configure authentication
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]))
};
});
|
Authorization
Role-Based Access Control (RBAC)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # Decorator for role checking
from functools import wraps
def require_role(role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not g.current_user:
return jsonify({'error': 'Authentication required'}), 401
if g.current_user.role != role:
return jsonify({'error': 'Insufficient permissions'}), 403
return f(*args, **kwargs)
return decorated_function
return decorator
# Usage
@app.route('/api/admin/users')
@require_role('admin')
def admin_users():
return jsonify(User.query.all())
|
Permission-Based Access Control
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // Permission middleware
const hasPermission = (permission) => {
return async (req, res, next) => {
const user = await User.findById(req.user.id)
.populate('permissions');
if (!user.permissions.includes(permission)) {
return res.status(403).json({
error: 'Forbidden',
message: `Permission '${permission}' required`
});
}
next();
};
};
// Usage
app.delete('/api/users/:id',
authenticateToken,
hasPermission('users.delete'),
async (req, res) => {
// Delete user
}
);
|
Rate Limiting
Why Rate Limiting
- Prevent abuse
- Ensure fair usage
- Protect server resources
- Manage costs
Implementation
Fixed Window
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # Flask with Redis
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app,
key_func=get_remote_address,
storage_uri="redis://localhost:6379"
)
@app.route('/api/users')
@limiter.limit("100 per hour")
def get_users():
return jsonify(users)
@app.route('/api/expensive-operation')
@limiter.limit("10 per hour")
def expensive_operation():
return jsonify(result)
|
Sliding Window
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| // Express with rate-limiter-flexible
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redisClient = new Redis();
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'ratelimit',
points: 100, // Number of requests
duration: 3600, // Per 1 hour
});
const rateLimiterMiddleware = async (req, res, next) => {
try {
const userId = req.user?.id || req.ip;
const rateLimitResult = await rateLimiter.consume(userId);
// Add rate limit headers
res.set({
'X-RateLimit-Limit': 100,
'X-RateLimit-Remaining': rateLimitResult.remainingPoints,
'X-RateLimit-Reset': new Date(Date.now() + rateLimitResult.msBeforeNext)
});
next();
} catch (error) {
res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: Math.ceil(error.msBeforeNext / 1000)
});
}
};
app.use('/api/', rateLimiterMiddleware);
|
Token Bucket
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| // ASP.NET Core custom rate limiter
public class TokenBucketRateLimiter
{
private readonly int _capacity;
private readonly int _refillRate;
private int _tokens;
private DateTime _lastRefill;
public TokenBucketRateLimiter(int capacity, int refillRate)
{
_capacity = capacity;
_refillRate = refillRate;
_tokens = capacity;
_lastRefill = DateTime.UtcNow;
}
public bool TryConsume()
{
Refill();
if (_tokens > 0)
{
_tokens--;
return true;
}
return false;
}
private void Refill()
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastRefill).TotalSeconds;
var tokensToAdd = (int)(elapsed * _refillRate);
if (tokensToAdd > 0)
{
_tokens = Math.Min(_capacity, _tokens + tokensToAdd);
_lastRefill = now;
}
}
}
// Middleware
public class RateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly Dictionary<string, TokenBucketRateLimiter> _limiters;
public async Task InvokeAsync(HttpContext context)
{
var userId = context.User.Identity.Name ?? context.Connection.RemoteIpAddress.ToString();
if (!_limiters.ContainsKey(userId))
{
_limiters[userId] = new TokenBucketRateLimiter(capacity: 100, refillRate: 10);
}
if (!_limiters[userId].TryConsume())
{
context.Response.StatusCode = 429;
await context.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded"
});
return;
}
await _next(context);
}
}
|
Always include rate limit information in responses:
1
2
3
4
| X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1609459200
Retry-After: 3600
|
Simple but can have performance issues with large datasets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| # Flask offset pagination
@app.route('/api/users')
def get_users():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Validate pagination parameters
if page < 1 or per_page < 1 or per_page > 100:
return jsonify({'error': 'Invalid pagination parameters'}), 400
offset = (page - 1) * per_page
users = User.query.offset(offset).limit(per_page).all()
total = User.query.count()
return jsonify({
'data': [user.to_dict() for user in users],
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page
},
'links': {
'self': f'/api/users?page={page}&per_page={per_page}',
'first': f'/api/users?page=1&per_page={per_page}',
'last': f'/api/users?page={(total + per_page - 1) // per_page}&per_page={per_page}',
'next': f'/api/users?page={page + 1}&per_page={per_page}' if page * per_page < total else None,
'prev': f'/api/users?page={page - 1}&per_page={per_page}' if page > 1 else None
}
})
|
More efficient for large datasets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // Express cursor pagination
app.get('/api/posts', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const cursor = req.query.cursor;
let query = Post.find();
if (cursor) {
// Decode cursor (base64 encoded ID)
const decodedCursor = Buffer.from(cursor, 'base64').toString('ascii');
query = query.where('_id').gt(decodedCursor);
}
const posts = await query
.limit(limit + 1) // Fetch one extra to check if there are more
.sort({ _id: 1 })
.exec();
const hasMore = posts.length > limit;
const results = hasMore ? posts.slice(0, -1) : posts;
const nextCursor = hasMore
? Buffer.from(results[results.length - 1]._id.toString()).toString('base64')
: null;
res.json({
data: results,
pagination: {
limit,
hasMore,
nextCursor
},
links: {
self: `/api/posts?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`,
next: nextCursor ? `/api/posts?limit=${limit}&cursor=${nextCursor}` : null
}
});
});
|
Most efficient for ordered datasets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // ASP.NET Core keyset pagination
[HttpGet]
public async Task<IActionResult> GetProducts(
[FromQuery] DateTime? after,
[FromQuery] int limit = 20)
{
if (limit > 100) limit = 100;
var query = _context.Products.AsQueryable();
if (after.HasValue)
{
query = query.Where(p => p.CreatedAt > after.Value);
}
var products = await query
.OrderBy(p => p.CreatedAt)
.Take(limit + 1)
.ToListAsync();
var hasMore = products.Count > limit;
var results = hasMore ? products.Take(limit).ToList() : products;
var nextAfter = hasMore
? results.Last().CreatedAt
: (DateTime?)null;
return Ok(new
{
data = results,
pagination = new
{
limit,
hasMore,
nextAfter
}
});
}
|
Error Handling
Use a consistent structure for all errors:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Standard error response format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format"
},
{
"field": "password",
"message": "Password must be at least 8 characters"
}
],
"timestamp": "2024-02-10T10:30:00Z",
"path": "/api/users",
"requestId": "abc-123-def"
}
}
|
Error Handler Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| // Express global error handler
class ApiError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
// Error handler middleware
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const errorResponse = {
error: {
code,
message: err.message,
details: err.details,
timestamp: new Date().toISOString(),
path: req.path,
requestId: req.id
}
};
// Don't expose internal error details in production
if (process.env.NODE_ENV === 'production' && statusCode === 500) {
errorResponse.error.message = 'An internal error occurred';
delete errorResponse.error.details;
}
// Log error
logger.error({
error: err.message,
stack: err.stack,
requestId: req.id,
path: req.path,
method: req.method
});
res.status(statusCode).json(errorResponse);
});
// Usage
app.post('/api/users', async (req, res, next) => {
try {
const errors = validateUser(req.body);
if (errors.length > 0) {
throw new ApiError(422, 'VALIDATION_ERROR', 'Validation failed', errors);
}
const user = await createUser(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
|
Validation Errors
Provide clear validation error messages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # Flask validation with marshmallow
from marshmallow import Schema, fields, ValidationError
class UserSchema(Schema):
username = fields.Str(required=True, validate=lambda x: len(x) >= 3)
email = fields.Email(required=True)
password = fields.Str(required=True, validate=lambda x: len(x) >= 8)
age = fields.Int(validate=lambda x: 18 <= x <= 120)
@app.route('/api/users', methods=['POST'])
def create_user():
schema = UserSchema()
try:
data = schema.load(request.json)
except ValidationError as err:
return jsonify({
'error': {
'code': 'VALIDATION_ERROR',
'message': 'Validation failed',
'details': err.messages
}
}), 422
user = User.create(**data)
return jsonify(user.to_dict()), 201
|
Documentation with OpenAPI/Swagger
OpenAPI Specification
Document your API with OpenAPI (formerly Swagger):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
| # openapi.yaml
openapi: 3.0.0
info:
title: User API
version: 1.0.0
description: API for managing users
contact:
name: API Support
email: support@example.com
servers:
- url: https://api.example.com/v1
description: Production server
- url: https://staging-api.example.com/v1
description: Staging server
paths:
/users:
get:
summary: List all users
description: Returns a paginated list of users
tags:
- Users
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/TooManyRequests'
post:
summary: Create a new user
tags:
- Users
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserInput'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/BadRequest'
'422':
$ref: '#/components/responses/ValidationError'
/users/{userId}:
get:
summary: Get user by ID
tags:
- Users
parameters:
- name: userId
in: path
required: true
schema:
type: integer
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
User:
type: object
properties:
id:
type: integer
username:
type: string
email:
type: string
format: email
createdAt:
type: string
format: date-time
UserInput:
type: object
required:
- username
- email
- password
properties:
username:
type: string
minLength: 3
email:
type: string
format: email
password:
type: string
minLength: 8
Pagination:
type: object
properties:
page:
type: integer
per_page:
type: integer
total:
type: integer
pages:
type: integer
Error:
type: object
properties:
code:
type: string
message:
type: string
details:
type: array
items:
type: object
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ValidationError:
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
|
Auto-generating Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| # Flask with flask-restx (Swagger UI)
from flask import Flask
from flask_restx import Api, Resource, fields
app = Flask(__name__)
api = Api(
app,
version='1.0',
title='User API',
description='API for managing users',
doc='/docs'
)
ns = api.namespace('users', description='User operations')
user_model = api.model('User', {
'id': fields.Integer(readonly=True),
'username': fields.String(required=True, min_length=3),
'email': fields.String(required=True),
'created_at': fields.DateTime(readonly=True)
})
@ns.route('/')
class UserList(Resource):
@ns.doc('list_users')
@ns.param('page', 'Page number', type=int, default=1)
@ns.param('per_page', 'Items per page', type=int, default=20)
@ns.marshal_list_with(user_model)
def get(self):
"""List all users"""
return get_users()
@ns.doc('create_user')
@ns.expect(user_model)
@ns.marshal_with(user_model, code=201)
def post(self):
"""Create a new user"""
return create_user(api.payload), 201
@ns.route('/<int:id>')
@ns.response(404, 'User not found')
@ns.param('id', 'The user identifier')
class User(Resource):
@ns.doc('get_user')
@ns.marshal_with(user_model)
def get(self, id):
"""Get user by ID"""
return get_user(id)
|
Security Best Practices
HTTPS Only
Always use HTTPS in production:
1
2
3
4
| # Flask - Force HTTPS
from flask_talisman import Talisman
talisman = Talisman(app, force_https=True)
|
Validate and sanitize all inputs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Express with express-validator
const { body, validationResult } = require('express-validator');
app.post('/api/users',
[
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).trim(),
body('username').isAlphanumeric().trim().escape()
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// Process request
}
);
|
SQL Injection Prevention
Use parameterized queries:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // Bad - vulnerable to SQL injection
string query = $"SELECT * FROM Users WHERE Email = '{email}'";
// Good - parameterized query
var user = await _context.Users
.Where(u => u.Email == email)
.FirstOrDefaultAsync();
// Or with raw SQL
var user = await _context.Users
.FromSqlRaw("SELECT * FROM Users WHERE Email = @email",
new SqlParameter("@email", email))
.FirstOrDefaultAsync();
|
CORS Configuration
Configure CORS properly:
1
2
3
4
5
6
7
8
9
10
11
12
| # Flask-CORS
from flask_cors import CORS
CORS(app, resources={
r"/api/*": {
"origins": ["https://example.com"],
"methods": ["GET", "POST", "PUT", "DELETE"],
"allow_headers": ["Content-Type", "Authorization"],
"expose_headers": ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
"max_age": 3600
}
})
|
Add security headers to responses:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Express helmet middleware
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
|
Request Size Limiting
Limit request body size:
1
2
| # Flask
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
|
API Key Storage
Never expose API keys in responses or logs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Bad
res.json({
user: user,
apiKey: user.apiKey // Don't expose secrets
});
// Good
res.json({
user: {
id: user.id,
username: user.username,
email: user.email
}
});
|
Conclusion
Designing a great API requires careful consideration of many factors beyond just making endpoints work. A well-designed API is intuitive, consistent, secure, well-documented, and maintainable. It follows RESTful principles while adapting to the specific needs of your application and users.
Key takeaways for API design:
- Follow REST principles and HTTP standards
- Use clear, hierarchical resource naming with nouns
- Apply appropriate HTTP methods and status codes
- Implement versioning from the start
- Secure your API with proper authentication and authorization
- Protect against abuse with rate limiting
- Provide efficient pagination for large datasets
- Return consistent, informative error responses
- Document thoroughly with OpenAPI/Swagger
- Apply security best practices at every level
- Design for backwards compatibility and evolution
Remember that an API is a contract with your users. Changes should be carefully considered, properly versioned, and clearly communicated. Invest time in good API design upfront—it pays dividends in reduced support burden, faster integration, and happier developers.
References
- Roy Fielding - REST Dissertation: https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
- OpenAPI Specification: https://swagger.io/specification/
- RFC 7231 - HTTP/1.1 Semantics and Content
- RFC 6749 - OAuth 2.0 Authorization Framework
- RFC 7519 - JSON Web Token (JWT)
- Microsoft REST API Guidelines: https://github.com/microsoft/api-guidelines
- Google API Design Guide: https://cloud.google.com/apis/design
- Stripe API Documentation: https://stripe.com/docs/api
- GitHub REST API: https://docs.github.com/en/rest