Post

API Design Best Practices and RESTful Standards

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);
});

4. Uniform Interface

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'
    });
  }
});

3. Header Versioning

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" })
        };
    }
}

4. Content Negotiation (Accept Header)

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);
    }
}

Rate Limit Headers

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

Pagination

Offset-Based Pagination

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
        }
    })

Cursor-Based Pagination

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
    }
  });
});

Keyset Pagination

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

Consistent Error Response Format

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)

Input Validation

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
    }
})

Security Headers

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
This post is licensed under CC BY 4.0 by the author.