Backend Development

RESTful API Design: Best Practices and Patterns

·6 min read
RESTful API Design: Best Practices and Patterns

RESTful API Design: Best Practices and Patterns

Throughout my career designing and building APIs, I've learned that good API design is crucial for creating maintainable and developer-friendly systems. Let's explore the best practices and patterns for designing RESTful APIs.

API Design Principles

1. Resource Modeling

Design clear and intuitive resource endpoints:

// Example resource endpoints
interface APIEndpoints {
  // Collection endpoints
  "GET /users": () => User[];
  "POST /users": (user: CreateUserDTO) => User;

  // Single resource endpoints
  "GET /users/:id": (id: string) => User;
  "PUT /users/:id": (id: string, user: UpdateUserDTO) => User;
  "DELETE /users/:id": (id: string) => void;

  // Nested resources
  "GET /users/:id/orders": (userId: string) => Order[];
  "POST /users/:id/orders": (userId: string, order: CreateOrderDTO) => Order;

  // Filtering and pagination
  "GET /users?role=admin&status=active&page=1&limit=10": () => PaginatedResponse<User>;
}

// Response types
interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
}

2. Request/Response Structure

Implement consistent data structures:

// Request DTOs
interface CreateUserDTO {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  role: UserRole;
}

interface UpdateUserDTO {
  email?: string;
  firstName?: string;
  lastName?: string;
  role?: UserRole;
}

// Response structure
interface APIResponse<T> {
  data: T;
  meta?: Record<string, any>;
  links?: {
    self: string;
    next?: string;
    prev?: string;
  };
}

// Error response
interface APIError {
  status: number;
  code: string;
  message: string;
  details?: Record<string, any>;
  timestamp: string;
}

// Example implementation
class APIResponseBuilder {
  static success<T>(data: T, meta?: Record<string, any>): APIResponse<T> {
    return {
      data,
      meta,
      links: {
        self: `${process.env.API_BASE_URL}/current-path`,
      },
    };
  }

  static error(error: Error): APIError {
    return {
      status: 400,
      code: "BAD_REQUEST",
      message: error.message,
      timestamp: new Date().toISOString(),
    };
  }
}

API Implementation

1. Middleware Setup

Implement common middleware:

// middleware/error-handler.ts
import { Request, Response, NextFunction } from "express";

export function errorHandler(
  error: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  console.error("Error:", error);

  if (error.name === "ValidationError") {
    return res.status(400).json(
      APIResponseBuilder.error({
        status: 400,
        code: "VALIDATION_ERROR",
        message: "Invalid request data",
        details: error.details,
      })
    );
  }

  if (error.name === "UnauthorizedError") {
    return res.status(401).json(
      APIResponseBuilder.error({
        status: 401,
        code: "UNAUTHORIZED",
        message: "Authentication required",
      })
    );
  }

  return res.status(500).json(
    APIResponseBuilder.error({
      status: 500,
      code: "INTERNAL_ERROR",
      message: "Internal server error",
    })
  );
}

// middleware/validation.ts
import { validate } from "class-validator";

export function validateRequest(schema: any) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const errors = await validate(Object.assign(new schema(), req.body));
      if (errors.length > 0) {
        throw new ValidationError("Invalid request data", errors);
      }
      next();
    } catch (error) {
      next(error);
    }
  };
}

2. Controller Implementation

Create clean controller classes:

// controllers/user.controller.ts
@Controller("users")
export class UserController {
  constructor(private userService: UserService) {}

  @Get()
  async getUsers(
    @Query() query: GetUsersQueryDTO
  ): Promise<APIResponse<User[]>> {
    const users = await this.userService.findAll(query);
    return APIResponseBuilder.success(users, {
      total: users.length,
      filtered: query.role ? true : false,
    });
  }

  @Post()
  @UseGuards(AuthGuard)
  @Validate(CreateUserDTO)
  async createUser(
    @Body() userData: CreateUserDTO
  ): Promise<APIResponse<User>> {
    const user = await this.userService.create(userData);
    return APIResponseBuilder.success(user);
  }

  @Get(":id")
  async getUser(@Param("id") id: string): Promise<APIResponse<User>> {
    const user = await this.userService.findById(id);
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return APIResponseBuilder.success(user);
  }
}

API Documentation

1. OpenAPI Specification

Document your API using OpenAPI:

# openapi.yaml
openapi: 3.0.0
info:
  title: User Management API
  version: 1.0.0
  description: API for managing users and their data

paths:
  /users:
    get:
      summary: Get all users
      parameters:
        - in: query
          name: role
          schema:
            type: string
            enum: [admin, user]
        - in: query
          name: page
          schema:
            type: integer
            minimum: 1
            default: 1
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedUserResponse"
    post:
      summary: Create a new user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserDTO"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserResponse"

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        firstName:
          type: string
        lastName:
          type: string
        role:
          type: string
          enum: [admin, user]
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - email
        - role

2. API Versioning

Implement API versioning:

// config/versioning.ts
import { VersioningType } from "@nestjs/common";

export const versioningConfig = {
  type: VersioningType.URI,
  prefix: "v",
  defaultVersion: "1",
};

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableVersioning(versioningConfig);
  await app.listen(3000);
}

// controllers/user.controller.ts
@Controller({
  version: "1",
  path: "users",
})
export class UserControllerV1 {
  // V1 implementation
}

@Controller({
  version: "2",
  path: "users",
})
export class UserControllerV2 {
  // V2 implementation with new features
}

Security Implementation

1. Authentication

Implement JWT authentication:

// auth/jwt.strategy.ts
import { PassportStrategy } from "@nestjs/passport";
import { Strategy, ExtractJwt } from "passport-jwt";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  async validate(payload: any) {
    return {
      userId: payload.sub,
      email: payload.email,
      role: payload.role,
    };
  }
}

// auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private userService: UserService
  ) {}

  async validateUser(email: string, password: string): Promise<any> {
    const user = await this.userService.findByEmail(email);
    if (user && (await bcrypt.compare(password, user.password))) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = {
      email: user.email,
      sub: user.id,
      role: user.role,
    };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

2. Rate Limiting

Implement rate limiting:

// middleware/rate-limit.ts
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";

export const rateLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: "rate-limit:",
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: {
    status: 429,
    code: "RATE_LIMIT_EXCEEDED",
    message: "Too many requests, please try again later",
  },
});

// Apply to specific routes
app.use("/api/v1/users", rateLimiter);

Best Practices

  1. Use HTTP Methods Correctly: GET for reading, POST for creating, etc.
  2. Implement Proper Status Codes: Use appropriate HTTP status codes
  3. Version Your API: Plan for changes and backwards compatibility
  4. Secure Your Endpoints: Implement authentication and authorization
  5. Document Everything: Provide comprehensive API documentation

Implementation Checklist

  1. Define API resources
  2. Design request/response structures
  3. Implement authentication
  4. Add validation
  5. Set up error handling
  6. Document endpoints
  7. Implement versioning
  8. Add security measures

Conclusion

Good API design is crucial for creating maintainable and developer-friendly systems. Focus on consistency, security, and documentation to create APIs that developers love to use.

Resources