Building RESTful APIs with Flask
In todayβs interconnected digital world, APIs (Application Programming Interfaces) are the backbone of modern applications. They allow different software systems to communicate and share data seamlessly. RESTful APIs, in particular, have become the standard approach for building web services due to their simplicity, scalability, and statelessness.
Flask, with its lightweight and flexible nature, is an excellent choice for building RESTful APIs in Python. In this comprehensive guide, weβll explore how to build robust and scalable RESTful APIs using Flask, from basic setup to advanced features like authentication, validation, and documentation.
Table of Contents
- Understanding REST Principles
- Setting Up Your Flask API Environment
- Building Basic CRUD Endpoints
- Using Flask-RESTful for Structured APIs
- Working with SQLAlchemy for Database Operations
- Implementing Authentication and Authorization
- Request Validation and Error Handling
- Rate Limiting and Caching
- API Versioning Strategies
- Documenting Your API
- Testing Your API
- Deploying Your Flask API
Understanding REST Principles
Before diving into code, letβs review the key principles of RESTful API design:
- Statelessness: Each request from client to server must contain all information needed to understand and process the request.
- Client-Server Architecture: Separates client and server concerns, improving portability and scalability.
- Cacheable: Responses must define themselves as cacheable or non-cacheable.
- Layered System: Client cannot tell whether it is connected directly to the end server or intermediary.
- Uniform Interface: Simplifies and decouples the architecture, enabling each part to evolve independently.
- Resource-Based: Resources are identified in requests and manipulated through representations.
HTTP Methods in REST
RESTful APIs use standard HTTP methods to perform operations on resources:
| HTTP Method | CRUD Operation | Description |
|---|---|---|
| GET | Read | Retrieve resources |
| POST | Create | Create new resources |
| PUT | Update | Update existing resources (complete replacement) |
| PATCH | Update | Partially update resources |
| DELETE | Delete | Remove resources |
URL Structure Best Practices
- Use nouns, not verbs (e.g.,
/usersnot/getUsers) - Use plural nouns for collections (e.g.,
/usersnot/user) - Use parameters for specific resources (e.g.,
/users/123) - Use query parameters for filtering (e.g.,
/users?role=admin) - Use nesting for related resources (e.g.,
/users/123/posts)
Setting Up Your Flask API Environment
Letβs start by setting up a clean development environment for our Flask API project:
Project Structure
flask-api-tutorial/
βββ app/
β βββ __init__.py
β βββ config.py
β βββ models/
β β βββ __init__.py
β β βββ user.py
β βββ resources/
β β βββ __init__.py
β β βββ user.py
β βββ schemas/
β β βββ __init__.py
β β βββ user.py
β βββ utils/
β βββ __init__.py
β βββ auth.py
βββ migrations/
βββ tests/
β βββ __init__.py
β βββ test_user_resource.py
βββ .env.example
βββ .gitignore
βββ requirements.txt
βββ run.py
Environment Setup
First, create a virtual environment and install the necessary packages:
# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install packages
pip install flask flask-restful flask-sqlalchemy flask-migrate flask-jwt-extended marshmallow flask-marshmallow marshmallow-sqlalchemy python-dotenv
Create a requirements.txt file:
flask==2.3.3
flask-restful==0.3.10
flask-sqlalchemy==3.1.1
flask-migrate==4.0.5
flask-jwt-extended==4.5.3
marshmallow==3.20.1
flask-marshmallow==0.15.0
marshmallow-sqlalchemy==0.30.0
python-dotenv==1.0.0
Basic Application Setup
Letβs set up the basic Flask application with configurations for different environments:
app/config.py
import os
from datetime import timedelta
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-for-development-only')
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-dev-key-for-development-only')
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL',
f"sqlite:///{os.path.join(basedir, 'dev.db')}")
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL',
f"sqlite:///{os.path.join(basedir, 'test.db')}")
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
app/init.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_marshmallow import Marshmallow
from flask_restful import Api
from .config import config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
ma = Marshmallow()
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
ma.init_app(app)
# Create API
api = Api(app)
# Import and register resources
from .resources.user import UserResource, UserListResource
# Add resources to API
api.add_resource(UserListResource, '/api/users')
api.add_resource(UserResource, '/api/users/<int:user_id>')
# Register error handlers
@app.errorhandler(404)
def not_found(e):
return {"error": "Not found"}, 404
@app.errorhandler(500)
def internal_server_error(e):
return {"error": "Internal server error"}, 500
return app
run.py
import os
from dotenv import load_dotenv
load_dotenv() # Load environment variables from .env file
from app import create_app
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000)))
.env.example
FLASK_CONFIG=development
SECRET_KEY=your-secret-key
JWT_SECRET_KEY=your-jwt-secret-key
Building Basic CRUD Endpoints
Letβs start by creating a basic user model and the corresponding CRUD (Create, Read, Update, Delete) endpoints.
User Model
app/models/user.py
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from .. import db
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@property
def password(self):
raise AttributeError('password is not a readable attribute')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
User Schema
Letβs create a schema for serializing and deserializing user objects using Marshmallow:
app/schemas/user.py
from .. import ma
from ..models.user import User
from marshmallow import fields, validates, ValidationError
class UserSchema(ma.SQLAlchemySchema):
class Meta:
model = User
load_instance = True
id = ma.auto_field(dump_only=True)
username = ma.auto_field(required=True)
email = ma.auto_field(required=True)
password = fields.String(required=True, load_only=True)
is_active = ma.auto_field(dump_only=True)
created_at = ma.auto_field(dump_only=True)
updated_at = ma.auto_field(dump_only=True)
@validates('username')
def validate_username(self, value):
if len(value) < 3:
raise ValidationError('Username must be at least 3 characters long')
if User.query.filter_by(username=value).first():
raise ValidationError('Username already exists')
@validates('email')
def validate_email(self, value):
if User.query.filter_by(email=value).first():
raise ValidationError('Email already exists')
user_schema = UserSchema()
users_schema = UserSchema(many=True)
User Resources
Now, letβs create the RESTful resources for user management:
app/resources/user.py
from flask import request
from flask_restful import Resource
from flask_jwt_extended import jwt_required
from .. import db
from ..models.user import User
from ..schemas.user import user_schema, users_schema
class UserListResource(Resource):
def get(self):
"""Get all users"""
users = User.query.all()
return users_schema.dump(users)
def post(self):
"""Create a new user"""
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
try:
# Validate and deserialize input
user = user_schema.load(json_data)
# Save user to database
db.session.add(user)
db.session.commit()
return user_schema.dump(user), 201
except ValidationError as err:
return {'message': err.messages}, 422
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
class UserResource(Resource):
def get(self, user_id):
"""Get a user by ID"""
user = User.query.get_or_404(user_id)
return user_schema.dump(user)
@jwt_required()
def put(self, user_id):
"""Update a user"""
user = User.query.get_or_404(user_id)
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
try:
# Update fields
if 'username' in json_data:
user.username = json_data['username']
if 'email' in json_data:
user.email = json_data['email']
if 'password' in json_data:
user.password = json_data['password']
db.session.commit()
return user_schema.dump(user)
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
@jwt_required()
def delete(self, user_id):
"""Delete a user"""
user = User.query.get_or_404(user_id)
try:
db.session.delete(user)
db.session.commit()
return {'message': 'User deleted successfully'}
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
Using Flask-RESTful for Structured APIs
Flask-RESTful provides an extension to Flask that adds support for quickly building REST APIs. It encourages best practices and is designed to be flexible and customizable.
Authentication Resource
Letβs create an authentication resource for login and registration:
app/resources/init.py
# Empty file to mark directory as a package
app/resources/auth.py
from flask import request
from flask_restful import Resource
from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity
from .. import db
from ..models.user import User
from ..schemas.user import user_schema
class RegisterResource(Resource):
def post(self):
"""Register a new user"""
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
try:
user = user_schema.load(json_data)
db.session.add(user)
db.session.commit()
return {'message': 'User created successfully'}, 201
except ValidationError as err:
return {'message': err.messages}, 422
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
class LoginResource(Resource):
def post(self):
"""Login and get access token"""
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
username = json_data.get('username', '')
password = json_data.get('password', '')
user = User.query.filter_by(username=username).first()
if not user or not user.verify_password(password):
return {'message': 'Invalid credentials'}, 401
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return {
'access_token': access_token,
'refresh_token': refresh_token,
'user': user_schema.dump(user)
}, 200
class RefreshResource(Resource):
@jwt_required(refresh=True)
def post(self):
"""Refresh access token"""
current_user = get_jwt_identity()
new_access_token = create_access_token(identity=current_user)
return {'access_token': new_access_token}, 200
Letβs update our app/__init__.py to include these new resources:
# Inside create_app function, after importing user resources
from .resources.auth import RegisterResource, LoginResource, RefreshResource
# Add to API resources
api.add_resource(RegisterResource, '/api/auth/register')
api.add_resource(LoginResource, '/api/auth/login')
api.add_resource(RefreshResource, '/api/auth/refresh')
Working with SQLAlchemy for Database Operations
SQLAlchemy provides a powerful and flexible ORM for database operations. Letβs create another model for blog posts to demonstrate relationships:
app/models/post.py
from datetime import datetime
from .. import db
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
published = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign key relationship with User model
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
user = db.relationship('User', backref=db.backref('posts', lazy=True))
def __repr__(self):
return f'<Post {self.title}>'
Letβs also update the User model to include the relationship with posts:
# Add this to User model if not already there:
posts = db.relationship('Post', backref='author', lazy=True, cascade='all, delete-orphan')
Post Schema
app/schemas/post.py
from .. import ma
from ..models.post import Post
from marshmallow import fields, validates, ValidationError
class PostSchema(ma.SQLAlchemySchema):
class Meta:
model = Post
load_instance = True
id = ma.auto_field(dump_only=True)
title = ma.auto_field(required=True)
content = ma.auto_field(required=True)
published = ma.auto_field()
created_at = ma.auto_field(dump_only=True)
updated_at = ma.auto_field(dump_only=True)
user_id = ma.auto_field(required=True, load_only=True)
# Include a nested field for user
user = fields.Nested('UserSchema', only=('id', 'username'), dump_only=True)
@validates('title')
def validate_title(self, value):
if len(value) < 5:
raise ValidationError('Title must be at least 5 characters long')
post_schema = PostSchema()
posts_schema = PostSchema(many=True)
Post Resources
app/resources/post.py
from flask import request
from flask_restful import Resource
from flask_jwt_extended import jwt_required, get_jwt_identity
from .. import db
from ..models.post import Post
from ..models.user import User
from ..schemas.post import post_schema, posts_schema
class PostListResource(Resource):
def get(self):
"""Get all posts"""
# By default, only return published posts
published = request.args.get('published', 'true').lower() == 'true'
if published:
posts = Post.query.filter_by(published=True).all()
else:
posts = Post.query.all()
return posts_schema.dump(posts)
@jwt_required()
def post(self):
"""Create a new post"""
json_data = request.get_json()
user_id = get_jwt_identity()
if not json_data:
return {'message': 'No input data provided'}, 400
# Set the user_id from the JWT token
json_data['user_id'] = user_id
try:
post = post_schema.load(json_data)
db.session.add(post)
db.session.commit()
return post_schema.dump(post), 201
except ValidationError as err:
return {'message': err.messages}, 422
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
class PostResource(Resource):
def get(self, post_id):
"""Get a post by ID"""
post = Post.query.get_or_404(post_id)
# If post is not published, only the author can see it
if not post.published:
jwt_required()(lambda: None)() # Clever way to make this endpoint conditionally protected
current_user_id = get_jwt_identity()
if current_user_id != post.user_id:
return {'message': 'Post not found'}, 404
return post_schema.dump(post)
@jwt_required()
def put(self, post_id):
"""Update a post"""
json_data = request.get_json()
current_user_id = get_jwt_identity()
post = Post.query.get_or_404(post_id)
# Only the author can update the post
if current_user_id != post.user_id:
return {'message': 'Unauthorized'}, 403
try:
# Update fields
if 'title' in json_data:
post.title = json_data['title']
if 'content' in json_data:
post.content = json_data['content']
if 'published' in json_data:
post.published = json_data['published']
db.session.commit()
return post_schema.dump(post)
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
@jwt_required()
def delete(self, post_id):
"""Delete a post"""
current_user_id = get_jwt_identity()
post = Post.query.get_or_404(post_id)
# Only the author can delete the post
if current_user_id != post.user_id:
return {'message': 'Unauthorized'}, 403
try:
db.session.delete(post)
db.session.commit()
return {'message': 'Post deleted successfully'}
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
class UserPostsResource(Resource):
def get(self, user_id):
"""Get all posts by a specific user"""
user = User.query.get_or_404(user_id)
# By default, only return published posts
published = request.args.get('published', 'true').lower() == 'true'
if published:
posts = Post.query.filter_by(user_id=user_id, published=True).all()
else:
# If requesting unpublished posts, require authentication
jwt_required()(lambda: None)()
current_user_id = get_jwt_identity()
# Only the author can see their unpublished posts
if current_user_id != user_id:
posts = Post.query.filter_by(user_id=user_id, published=True).all()
else:
posts = Post.query.filter_by(user_id=user_id).all()
return posts_schema.dump(posts)
Letβs update our app/__init__.py to include these new resources:
# Inside create_app function, after importing auth resources
from .resources.post import PostListResource, PostResource, UserPostsResource
# Add to API resources
api.add_resource(PostListResource, '/api/posts')
api.add_resource(PostResource, '/api/posts/<int:post_id>')
api.add_resource(UserPostsResource, '/api/users/<int:user_id>/posts')
Implementing Authentication and Authorization
Weβve already added basic JWT authentication in our resources. Letβs enhance it with additional security features.
JWT Configuration
Letβs update our JWT configuration in app/__init__.py:
# Inside create_app function, after initializing jwt
@jwt.user_identity_loader
def user_identity_lookup(user_id):
return user_id
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
identity = jwt_data["sub"]
return User.query.filter_by(id=identity).one_or_none()
@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
return {"message": "Token has expired"}, 401
@jwt.invalid_token_loader
def invalid_token_callback(error):
return {"message": "Signature verification failed"}, 401
@jwt.unauthorized_loader
def missing_token_callback(error):
return {"message": "Request does not contain an access token"}, 401
Role-Based Access Control
Letβs add roles to our User model:
app/models/user.py (updated)
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from .. import db
# Define roles
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
def __repr__(self):
return f'<Role {self.name}>'
# User-Role association table for many-to-many relationship
user_roles = db.Table('user_roles',
db.Column('user_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'), primary_key=True)
)
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship with Role model
roles = db.relationship('Role', secondary=user_roles, lazy='subquery',
backref=db.backref('users', lazy=True))
@property
def password(self):
raise AttributeError('password is not a readable attribute')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
def has_role(self, role_name):
"""Check if user has a specific role"""
return any(role.name == role_name for role in self.roles)
def is_admin(self):
"""Check if user is an admin"""
return self.has_role('admin')
def __repr__(self):
return f'<User {self.username}>'
Authorization Utility
Letβs create a utility for checking user roles:
app/utils/auth.py
from functools import wraps
from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity
from flask import jsonify
from ..models.user import User
def role_required(role_name):
"""
Decorator that checks if the current user has the required role
"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
verify_jwt_in_request()
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user or not user.has_role(role_name):
return {'message': 'Insufficient permissions'}, 403
return fn(*args, **kwargs)
return wrapper
return decorator
def admin_required(fn):
"""
Decorator that checks if the current user is an admin
"""
@wraps(fn)
def wrapper(*args, **kwargs):
verify_jwt_in_request()
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user or not user.is_admin():
return {'message': 'Admin privileges required'}, 403
return fn(*args, **kwargs)
return wrapper
Using Role-Based Authorization
Now we can use these decorators in our resources. For example, letβs create an admin resource:
app/resources/admin.py
from flask import request
from flask_restful import Resource
from .. import db
from ..models.user import User, Role
from ..schemas.user import users_schema
from ..utils.auth import admin_required
class AdminUserResource(Resource):
@admin_required
def get(self):
"""Get all users (admin only)"""
users = User.query.all()
return users_schema.dump(users)
@admin_required
def post(self):
"""Create a new user with roles (admin only)"""
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
username = json_data.get('username')
email = json_data.get('email')
password = json_data.get('password')
role_names = json_data.get('roles', [])
try:
# Create user
user = User(username=username, email=email, password=password)
# Add roles
for role_name in role_names:
role = Role.query.filter_by(name=role_name).first()
if role:
user.roles.append(role)
db.session.add(user)
db.session.commit()
return {'message': 'User created successfully', 'user': user_schema.dump(user)}, 201
except Exception as e:
db.session.rollback()
return {'message': str(e)}, 500
Add this resource to app/__init__.py:
# Inside create_app function
from .resources.admin import AdminUserResource
# Add to API resources
api.add_resource(AdminUserResource, '/api/admin/users')
Request Validation and Error Handling
Validation is crucial for APIs. Weβve already implemented basic validation using Marshmallow. Letβs enhance our error handling with custom exceptions and a more structured approach.
Custom Exceptions
app/utils/exceptions.py
class APIException(Exception):
"""Base exception for API errors"""
def __init__(self, message, status_code=400, payload=None):
super().__init__()
self.message = message
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv['message'] = self.message
return rv
class ResourceNotFound(APIException):
"""Exception raised when a resource is not found"""
def __init__(self, message="Resource not found", payload=None):
super().__init__(message, status_code=404, payload=payload)
class ValidationError(APIException):
"""Exception raised when validation fails"""
def __init__(self, message="Validation error", payload=None):
super().__init__(message, status_code=422, payload=payload)
class AuthorizationError(APIException):
"""Exception raised when user is not authorized"""
def __init__(self, message="Unauthorized", payload=None):
super().__init__(message, status_code=403, payload=payload)
Error Handler
Update app/__init__.py to register these custom exceptions:
# Inside create_app function, after registering other error handlers
from .utils.exceptions import APIException, ResourceNotFound, ValidationError, AuthorizationError
@app.errorhandler(APIException)
def handle_api_exception(e):
return e.to_dict(), e.status_code
@app.errorhandler(ResourceNotFound)
def handle_resource_not_found(e):
return e.to_dict(), e.status_code
@app.errorhandler(ValidationError)
def handle_validation_error(e):
return e.to_dict(), e.status_code
@app.errorhandler(AuthorizationError)
def handle_authorization_error(e):
return e.to_dict(), e.status_code
Rate Limiting and Caching
Rate limiting is essential for protecting your API against abuse. Letβs implement rate limiting using Flask-Limiter.
First, install the extension:
pip install flask-limiter
Add to requirements.txt:
flask-limiter==3.5.0
Rate Limiting Implementation
app/init.py (updated)
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_marshmallow import Marshmallow
from flask_restful import Api
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_caching import Cache
from .config import config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
ma = Marshmallow()
limiter = Limiter(key_func=get_remote_address)
cache = Cache()
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Configure caching
app.config['CACHE_TYPE'] = 'simple' # Use 'redis' in production
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
ma.init_app(app)
limiter.init_app(app)
cache.init_app(app)
# Create API with rate limiting
api = Api(app)
# Apply rate limiting to all endpoints
limiter.limit("200/day;50/hour;10/minute")(app)
# Import and register resources...
return app
Applying Rate Limits to Specific Endpoints
You can apply different rate limits to specific endpoints using decorators. Letβs update our login resource:
app/resources/auth.py (updated)
from flask import request
from flask_restful import Resource
from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity
from .. import db, limiter, cache
from ..models.user import User
from ..schemas.user import user_schema
class LoginResource(Resource):
# Apply more strict rate limiting to login endpoint
decorators = [limiter.limit("5/minute;20/hour")]
def post(self):
"""Login and get access token"""
json_data = request.get_json()
if not json_data:
return {'message': 'No input data provided'}, 400
username = json_data.get('username', '')
password = json_data.get('password', '')
user = User.query.filter_by(username=username).first()
if not user or not user.verify_password(password):
return {'message': 'Invalid credentials'}, 401
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return {
'access_token': access_token,
'refresh_token': refresh_token,
'user': user_schema.dump(user)
}, 200
Implementing Caching
Letβs cache some of our read-only endpoints for better performance:
app/resources/post.py (updated)
# Add caching to the PostListResource get method
@cache.cached(timeout=60) # Cache for 60 seconds
def get(self):
"""Get all posts"""
# By default, only return published posts
published = request.args.get('published', 'true').lower() == 'true'
if published:
posts = Post.query.filter_by(published=True).all()
else:
posts = Post.query.all()
return posts_schema.dump(posts)
For the PostResource.get method, we can use a function to generate a cache key based on the post ID:
def make_cache_key():
"""Generate a cache key based on the request parameters"""
post_id = request.view_args.get('post_id')
return f'post_{post_id}'
@cache.cached(timeout=300, key_prefix=make_cache_key)
def get(self, post_id):
"""Get a post by ID"""
post = Post.query.get_or_404(post_id)
# If post is not published, only the author can see it
if not post.published:
jwt_required()(lambda: None)()
current_user_id = get_jwt_identity()
if current_user_id != post.user_id:
return {'message': 'Post not found'}, 404
return post_schema.dump(post)
API Versioning Strategies
API versioning is crucial for maintaining backward compatibility as your API evolves. Letβs implement versioning in our Flask API.
URL Path Versioning
This is the most straightforward approach. Weβll modify our resources to include the version in the URL:
app/init.py (updated)
# Define API version
API_VERSION = 'v1'
# Inside create_app function, when registering resources
api.add_resource(UserListResource, f'/api/{API_VERSION}/users')
api.add_resource(UserResource, f'/api/{API_VERSION}/users/<int:user_id>')
api.add_resource(RegisterResource, f'/api/{API_VERSION}/auth/register')
api.add_resource(LoginResource, f'/api/{API_VERSION}/auth/login')
api.add_resource(RefreshResource, f'/api/{API_VERSION}/auth/refresh')
api.add_resource(PostListResource, f'/api/{API_VERSION}/posts')
api.add_resource(PostResource, f'/api/{API_VERSION}/posts/<int:post_id>')
api.add_resource(UserPostsResource, f'/api/{API_VERSION}/users/<int:user_id>/posts')
api.add_resource(AdminUserResource, f'/api/{API_VERSION}/admin/users')
Header-Based Versioning
Another approach is to use HTTP headers for versioning. Letβs create a decorator for this:
app/utils/versioning.py
from functools import wraps
from flask import request, abort
def api_version(version):
"""
Decorator to check API version from Accept header
Example: Accept: application/json; version=1.0
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
headers = request.headers
accept_header = headers.get('Accept', '')
# Check if header contains version info
if 'version=' in accept_header:
header_version = accept_header.split('version=')[1].split(';')[0].strip()
if header_version != version:
return {'message': f'API version {header_version} not supported. Current version is {version}'}, 400
return f(*args, **kwargs)
return decorated_function
return decorator
Then, apply this decorator to your resources:
@api_version('1.0')
def get(self):
# method implementation
Documenting Your API
API documentation is essential for developers who will use your API. Letβs integrate Swagger/OpenAPI documentation using Flask-RESTX.
First, install the extension:
pip install flask-restx
Add to requirements.txt:
flask-restx==1.1.0
Implementing API Documentation
Letβs refactor our app to use Flask-RESTX instead of Flask-RESTful:
app/init.py (updated)
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_marshmallow import Marshmallow
from flask_restx import Api
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_caching import Cache
from .config import config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
ma = Marshmallow()
limiter = Limiter(key_func=get_remote_address)
cache = Cache()
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
authorizations = {
'Bearer Auth': {
'type': 'apiKey',
'in': 'header',
'name': 'Authorization',
'description': 'Add a JWT token to the header with the format "Bearer {token}"'
},
}
api = Api(api_bp, version='1.0', title='Flask API',
description='A RESTful API built with Flask',
authorizations=authorizations, security='Bearer Auth',
doc='/docs')
# Import namespaces
from .resources.user import api as user_ns
from .resources.auth import api as auth_ns
from .resources.post import api as post_ns
from .resources.admin import api as admin_ns
# Add namespaces
api.add_namespace(user_ns)
api.add_namespace(auth_ns)
api.add_namespace(post_ns)
api.add_namespace(admin_ns)
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Configure caching
app.config['CACHE_TYPE'] = 'simple' # Use 'redis' in production
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
ma.init_app(app)
limiter.init_app(app)
cache.init_app(app)
# Register blueprint
app.register_blueprint(api_bp)
# Apply rate limiting to all endpoints
limiter.limit("200/day;50/hour;10/minute")(app)
# Register error handlers
# ...
return app
Implementing Namespaces with Flask-RESTX
Letβs update one of our resources to use Flask-RESTX namespaces:
app/resources/user.py (with Flask-RESTX)
from flask import request
from flask_restx import Namespace, Resource, fields
from flask_jwt_extended import jwt_required, get_jwt_identity
from .. import db
from ..models.user import User
from ..schemas.user import user_schema, users_schema
api = Namespace('users', description='User operations')
# Define models for documentation
user_model = api.model('User', {
'id': fields.Integer(readonly=True, description='User identifier'),
'username': fields.String(required=True, description='User username'),
'email': fields.String(required=True, description='User email'),
'is_active': fields.Boolean(description='User status'),
'created_at': fields.DateTime(readonly=True, description='Creation timestamp'),
'updated_at': fields.DateTime(readonly=True, description='Update timestamp')
})
user_input_model = api.model('UserInput', {
'username': fields.String(required=True, description='User username'),
'email': fields.String(required=True, description='User email'),
'password': fields.String(required=True, description='User password')
})
@api.route('/')
class UserList(Resource):
@api.doc('list_users')
@api.marshal_list_with(user_model)
def get(self):
"""List all users"""
users = User.query.all()
return users_schema.dump(users)
@api.doc('create_user')
@api.expect(user_input_model)
@api.marshal_with(user_model, code=201)
def post(self):
"""Create a new user"""
json_data = request.get_json()
if not json_data:
api.abort(400, "No input data provided")
try:
user = user_schema.load(json_data)
db.session.add(user)
db.session.commit()
return user_schema.dump(user), 201
except Exception as e:
db.session.rollback()
api.abort(500, str(e))
@api.route('/<int:user_id>')
@api.param('user_id', 'The user identifier')
@api.response(404, 'User not found')
class UserResource(Resource):
@api.doc('get_user')
@api.marshal_with(user_model)
def get(self, user_id):
"""Get a user by ID"""
user = User.query.get_or_404(user_id)
return user_schema.dump(user)
@api.doc('update_user')
@api.expect(user_input_model)
@api.marshal_with(user_model)
@jwt_required()
def put(self, user_id):
"""Update a user"""
user = User.query.get_or_404(user_id)
current_user_id = get_jwt_identity()
# Only the user themselves or an admin can update
if current_user_id != user_id:
api.abort(403, "Unauthorized")
json_data = request.get_json()
if not json_data:
api.abort(400, "No input data provided")
try:
if 'username' in json_data:
user.username = json_data['username']
if 'email' in json_data:
user.email = json_data['email']
if 'password' in json_data:
user.password = json_data['password']
db.session.commit()
return user_schema.dump(user)
except Exception as e:
db.session.rollback()
api.abort(500, str(e))
@api.doc('delete_user')
@api.response(204, 'User deleted')
@jwt_required()
def delete(self, user_id):
"""Delete a user"""
user = User.query.get_or_404(user_id)
current_user_id = get_jwt_identity()
# Only the user themselves or an admin can delete
if current_user_id != user_id:
api.abort(403, "Unauthorized")
try:
db.session.delete(user)
db.session.commit()
return '', 204
except Exception as e:
db.session.rollback()
api.abort(500, str(e))
Convert all other resources to use Flask-RESTX in a similar fashion.
Testing Your API
Testing is crucial for ensuring the reliability and correctness of your API. Letβs set up tests using pytest.
First, install the necessary packages:
pip install pytest pytest-flask
Add to requirements.txt:
pytest==7.4.0
pytest-flask==1.2.0
Setting Up Tests
tests/conftest.py
import pytest
from app import create_app, db
from app.models.user import User, Role
from app.models.post import Post
@pytest.fixture
def app():
"""Create and configure a Flask app for testing"""
app = create_app('testing')
# Establish application context
with app.app_context():
db.create_all()
# Create test roles
admin_role = Role(name='admin')
user_role = Role(name='user')
db.session.add_all([admin_role, user_role])
# Create test users
admin_user = User(username='admin', email='admin@example.com', password='password')
admin_user.roles.append(admin_role)
regular_user = User(username='user', email='user@example.com', password='password')
regular_user.roles.append(user_role)
db.session.add_all([admin_user, regular_user])
db.session.commit()
yield app
# Clean up
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""A test client for the app"""
return app.test_client()
@pytest.fixture
def auth_tokens(client):
"""Get authentication tokens for testing"""
# Get admin token
admin_response = client.post('/api/v1/auth/login', json={
'username': 'admin',
'password': 'password'
})
admin_token = admin_response.json['access_token']
# Get regular user token
user_response = client.post('/api/v1/auth/login', json={
'username': 'user',
'password': 'password'
})
user_token = user_response.json['access_token']
return {
'admin': admin_token,
'user': user_token
}
Writing Tests
tests/test_auth.py
def test_register_user(client):
"""Test user registration"""
response = client.post('/api/v1/auth/register', json={
'username': 'testuser',
'email': 'testuser@example.com',
'password': 'password123'
})
assert response.status_code == 201
assert 'message' in response.json
assert response.json['message'] == 'User created successfully'
def test_login_user(client):
"""Test user login"""
# First register a user
client.post('/api/v1/auth/register', json={
'username': 'logintest',
'email': 'logintest@example.com',
'password': 'password123'
})
# Now login
response = client.post('/api/v1/auth/login', json={
'username': 'logintest',
'password': 'password123'
})
assert response.status_code == 200
assert 'access_token' in response.json
assert 'refresh_token' in response.json
assert 'user' in response.json
def test_login_invalid_credentials(client):
"""Test login with invalid credentials"""
response = client.post('/api/v1/auth/login', json={
'username': 'nonexistent',
'password': 'wrongpassword'
})
assert response.status_code == 401
assert 'message' in response.json
assert response.json['message'] == 'Invalid credentials'
tests/test_user_resource.py
def test_get_users(client):
"""Test getting all users"""
response = client.get('/api/v1/users')
assert response.status_code == 200
assert isinstance(response.json, list)
assert len(response.json) >= 2 # At least admin and regular user
def test_get_user(client):
"""Test getting a specific user"""
# Get the first user's ID
users_response = client.get('/api/v1/users')
first_user_id = users_response.json[0]['id']
response = client.get(f'/api/v1/users/{first_user_id}')
assert response.status_code == 200
assert 'id' in response.json
assert response.json['id'] == first_user_id
def test_update_user(client, auth_tokens):
"""Test updating a user"""
# Get the user's ID
users_response = client.get('/api/v1/users')
user_id = None
for user in users_response.json:
if user['username'] == 'user':
user_id = user['id']
break
response = client.put(
f'/api/v1/users/{user_id}',
json={'username': 'updated_user'},
headers={'Authorization': f"Bearer {auth_tokens['user']}"}
)
assert response.status_code == 200
assert response.json['username'] == 'updated_user'
def test_unauthorized_update(client, auth_tokens):
"""Test unauthorized user update"""
# Get the admin's ID
users_response = client.get('/api/v1/users')
admin_id = None
for user in users_response.json:
if user['username'] == 'admin':
admin_id = user['id']
break
response = client.put(
f'/api/v1/users/{admin_id}',
json={'username': 'hacked_admin'},
headers={'Authorization': f"Bearer {auth_tokens['user']}"}
)
assert response.status_code == 403
Running Tests
To run the tests:
python -m pytest -v
Deploying Your Flask API
When itβs time to deploy your Flask API to production, there are several options to consider:
Using Gunicorn and Nginx
- Install Gunicorn:
pip install gunicorn
- Create a
wsgi.pyfile:
from app import create_app
app = create_app('production')
if __name__ == '__main__':
app.run()
- Run with Gunicorn:
gunicorn --workers=4 --bind=0.0.0.0:8000 wsgi:app
- Set up Nginx as a reverse proxy:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Deploying to a Cloud Platform
Heroku
- Create a
Procfile:
web: gunicorn wsgi:app
- Create a
runtime.txt:
python-3.10.x
- Deploy to Heroku:
git init
git add .
git commit -m "Initial commit"
heroku create
git push heroku master
Docker Deployment
- Create a
Dockerfile:
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV FLASK_APP=run.py
ENV FLASK_CONFIG=production
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "wsgi:app"]
- Create a
docker-compose.ymlfile:
version: '3'
services:
api:
build: .
ports:
- "5000:5000"
environment:
- FLASK_APP=run.py
- FLASK_CONFIG=production
- DATABASE_URL=postgresql://user:password@db:5432/api_db
depends_on:
- db
db:
image: postgres:13
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=api_db
volumes:
postgres_data:
- Build and run the Docker containers:
docker-compose up -d
Conclusion
In this comprehensive guide, weβve covered everything you need to know to build robust, scalable RESTful APIs with Flask. From setting up the basic structure to implementing advanced features like authentication, rate limiting, and comprehensive documentation, you now have the knowledge to build production-ready APIs.
Remember these key principles when designing your API:
- Follow REST best practices
- Secure your endpoints properly
- Validate inputs and handle errors gracefully
- Document your API thoroughly
- Test your API extensively
- Monitor and scale your API in production
By combining Flaskβs flexibility with powerful extensions like Flask-RESTful (or Flask-RESTX), SQLAlchemy, and JWT, you can create APIs that are both developer-friendly and ready for production use.
As you continue to build and evolve your API, always keep security, performance, and developer experience in mind. Happy coding!
Did you find this tutorial helpful? Check out my other Python guides, including Getting Started with Flask and Python Installation Guide.