Introduction
Building scalable web APIs is a critical skill in modern software development. Flask, a micro web framework for Python, and SQLAlchemy, a SQL toolkit and Object-Relational Mapping (ORM) library, are two powerful tools that can help you achieve this. This tutorial will guide you through creating a scalable web API using Flask and SQLAlchemy, focusing on best practices and advanced techniques suitable for non-beginners.
Prerequisites
Before we begin, ensure you have the following prerequisites:
- Python 3.6+ installed
- Basic understanding of Flask and SQLAlchemy
- Familiarity with RESTful APIs
- Basic knowledge of Docker (optional but recommended)
- Basic understanding of Git
1. Setting Up the Environment
Installing Required Packages
First, let’s set up a virtual environment and install the required packages. Open your terminal and run the following commands:
# Create a virtual environment
python3 -m venv venv
# Activate the virtual environment
# On Windows
venv\Scripts\activate
# On macOS/Linux
source venv/bin/activate
# Install Flask and SQLAlchemy
pip install Flask SQLAlchemy Flask-Migrate Flask-JWT-Extended
Code language: Arduino (arduino)
Setting Up the Database
For this tutorial, we will use PostgreSQL as our database. Make sure you have PostgreSQL installed and running on your machine. Create a new database:
# Replace 'your_db_user' and 'your_db_password' with your PostgreSQL credentials
createdb flask_api_db
Code language: Bash (bash)
2. Project Structure
A well-structured project is crucial for scalability. Here’s a recommended project structure:
flask_api/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── models.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── user_routes.py
│ │ └── item_routes.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── item_service.py
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── pagination.py
│ │ └── error_handlers.py
├── migrations/
├── tests/
│ ├── __init__.py
│ ├── test_user.py
│ └── test_item.py
├── Dockerfile
├── requirements.txt
├── run.py
Code language: Markdown (markdown)
3. Initializing Flask and SQLAlchemy
Creating the Application Factory
In app/__init__.py
, create an application factory function:
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from .config import Config
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
from .routes import user_routes, item_routes
app.register_blueprint(user_routes.bp)
app.register_blueprint(item_routes.bp)
from .utils.error_handlers import register_error_handlers
register_error_handlers(app)
return app
Code language: Python (python)
Configuration
Create a config.py
file in the app
directory to store the configuration:
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://your_db_user:your_db_password@localhost/flask_api_db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'super-secret'
Code language: Python (python)
Entry Point
Create a run.py
file at the root of your project:
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run()
Code language: Python (python)
4. Creating Models
In app/models.py
, define your database models. For this example, we will create User
and Item
models:
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True, nullable=False)
email = db.Column(db.String(120), index=True, unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Item(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False)
description = db.Column(db.String(256))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
Code language: Python (python)
5. Database Migrations
Initializing Migrations
Initialize migrations with Flask-Migrate:
flask db init
flask db migrate -m "Initial migration"
flask db upgrade
Code language: Python (python)
Running Migrations
Whenever you make changes to your models, run the following commands to update the database schema:
flask db migrate -m "Describe your change"
flask db upgrade
Code language: Python (python)
6. Creating and Registering Blueprints
Blueprints help you organize your application into modules. In app/routes/user_routes.py
, define routes related to users:
from flask import Blueprint, request, jsonify
from app.models import User, db
bp = Blueprint('user_routes', __name__)
@bp.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not username or not email or not password:
return jsonify({'error': 'Missing required fields'}), 400
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
return jsonify({'id': user.id, 'username': user.username, 'email': user.email}), 201
Code language: Python (python)
In app/routes/item_routes.py
, define routes related to items:
from flask import Blueprint, request, jsonify
from app.models import Item, db
bp = Blueprint('item_routes', __name__)
@bp.route('/items', methods=['POST'])
def create_item():
data = request.get_json()
name = data.get('name')
description = data.get('description')
user_id = data.get('user_id')
if not name or not user_id:
return jsonify({'error': 'Missing required fields'}), 400
item = Item(name=name, description=description, user_id=user_id)
db.session.add(item)
db.session.commit()
return jsonify({'id': item.id, 'name': item.name, 'description': item.description, 'user_id': item.user_id}), 201
Code language: Python (python)
7. Implementing CRUD Operations
User Routes
Extend the user_routes.py
with additional CRUD operations:
@bp.route('/users/<int:id>', methods=['GET'])
def get_user(id):
user = User.query.get_or_404(id)
return jsonify({'id': user.id, 'username': user.username, 'email': user.email})
@bp.route('/users/<int:id>', methods=['PUT'])
def update_user(id):
user = User.query.get_or_404(id)
data = request.get_json()
user.username = data.get('username', user.username)
user.email = data.get('email', user.email)
if 'password' in data:
user.set_password(data['password'])
db.session.commit()
return jsonify({'id': user.id, 'username': user.username, 'email': user.email})
@bp.route('/users/<int:id>', methods=['DELETE'])
def delete_user(id):
user = User.query.get_or_404(id)
db.session.delete(user)
db.session.commit()
return jsonify({'message': 'User deleted'})
Code language: Python (python)
Item Routes
Extend the item_routes.py
with additional CRUD operations:
@bp.route('/items/<int:id>', methods=['GET'])
def get_item(id):
item = Item.query.get_or_404(id)
return jsonify({'id': item.id, 'name': item.name, 'description': item.description, 'user_id': item.user_id})
@bp.route('/items/<int:id>', methods=['PUT'])
def update_item
(id):
item = Item.query.get_or_404(id)
data = request.get_json()
item.name = data.get('name', item.name)
item.description = data.get('description', item.description)
db.session.commit()
return jsonify({'id': item.id, 'name': item.name, 'description': item.description, 'user_id': item.user_id})
@bp.route('/items/<int:id>', methods=['DELETE'])
def delete_item(id):
item = Item.query.get_or_404(id)
db.session.delete(item)
db.session.commit()
return jsonify({'message': 'Item deleted'})
Code language: Python (python)
8. Adding Authentication and Authorization
User Registration and Login
In user_routes.py
, add routes for user registration and login:
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
@bp.route('/register', methods=['POST'])
def register():
data = request.get_json()
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not username or not email or not password:
return jsonify({'error': 'Missing required fields'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username already exists'}), 400
if User.query.filter_by(email=email).first():
return jsonify({'error': 'Email already exists'}), 400
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
access_token = create_access_token(identity=user.id)
return jsonify({'access_token': access_token}), 201
@bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'error': 'Missing required fields'}), 400
user = User.query.filter_by(username=username).first()
if user is None or not user.check_password(password):
return jsonify({'error': 'Invalid credentials'}), 400
access_token = create_access_token(identity=user.id)
return jsonify({'access_token': access_token}), 200
Code language: Python (python)
Protecting Routes
Protect routes using JWT:
@bp.route('/protected', methods=['GET'])
@jwt_required()
def protected():
current_user = get_jwt_identity()
user = User.query.get(current_user)
return jsonify({'username': user.username, 'email': user.email}), 200
Code language: Python (python)
9. Implementing Pagination and Filtering
Create a utility for pagination in app/utils/pagination.py
:
from flask import request
def paginate(query, model):
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
pagination = query.paginate(page, per_page, False)
items = [item.to_dict() for item in pagination.items]
return {
'items': items,
'total': pagination.total,
'page': page,
'per_page': per_page,
}
Code language: Python (python)
Add a method in the models to convert objects to dictionaries:
class User(db.Model):
# ...
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'created_at': self.created_at.isoformat()
}
class Item(db.Model):
# ...
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'user_id': self.user_id,
'created_at': self.created_at.isoformat()
}
Code language: Python (python)
Use pagination in your routes:
from app.utils.pagination import paginate
@bp.route('/users', methods=['GET'])
def get_users():
query = User.query
return jsonify(paginate(query, User))
Code language: Python (python)
10. Error Handling
Create custom error handlers in app/utils/error_handlers.py
:
from flask import jsonify
def register_error_handlers(app):
@app.errorhandler(404)
def not_found_error(error):
return jsonify({'error': 'Not found'}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({'error': 'Internal server error'}), 500
@app.errorhandler(400)
def bad_request_error(error):
return jsonify({'error': 'Bad request'}), 400
@app.errorhandler(401)
def unauthorized_error(error):
return jsonify({'error': 'Unauthorized'}), 401
Code language: Python (python)
Register error handlers in the application factory:
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
from .routes import user_routes, item_routes
app.register_blueprint(user_routes.bp)
app.register_blueprint(item_routes.bp)
from .utils.error_handlers import register_error_handlers
register_error_handlers(app)
return app
Code language: Python (python)
11. Testing the API
Setting Up Testing Environment
Create a tests
directory with an __init__.py
file to hold your test cases.
Writing Tests
Write tests for user routes in tests/test_user.py
:
import unittest
from app import create_app, db
from app.models import User
class UserTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app_context = self.app.app_context()
self.app_context.push()
self.client = self.app.test_client()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_user_registration(self):
response = self.client.post('/users', json={
'username': 'testuser',
'email': '[email protected]',
'password': 'password'
})
self.assertEqual(response.status_code, 201)
self.assertIn('access_token', response.get_json())
def test_user_login(self):
user = User(username='testuser', email='[email protected]')
user.set_password('password')
db.session.add(user)
db.session.commit()
response = self.client.post('/login', json={
'username': 'testuser',
'password': 'password'
})
self.assertEqual(response.status_code, 200)
self.assertIn('access_token', response.get_json())
if __name__ == '__main__':
unittest.main()
Code language: Python (python)
Run your tests:
python -m unittest discover tests
Code language: Bash (bash)
12. Optimizing for Scalability
Database Indexing
Ensure you have proper indexing on frequently queried columns:
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True, nullable=False)
email = db.Column(db.String(120), index=True, unique=True, nullable=False)
# ...
class Item(db.Model):
__tablename__ = 'items'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), index=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False)
# ...
Code language: Python (python)
Caching
Implement caching to reduce database load:
from flask_caching import Cache
cache = Cache()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
cache.init_app(app, config={'CACHE_TYPE': 'simple'})
# ...
return app
@bp.route('/items/<int:id>', methods=['GET'])
@cache.cached(timeout=60, key_prefix='item_{id}')
def get_item(id):
item = Item.query.get_or_404(id)
return jsonify(item.to_dict())
Code language: Python (python)
Load Balancing
Use a load balancer like Nginx to distribute incoming traffic across multiple instances of your Flask application.
13. Deployment with Docker
Dockerfile
Create a Dockerfile
at the root of your project:
# Use an official Python runtime as a parent image
FROM python:3.9-slim
# Set the working directory
WORKDIR /app
# Copy the current directory contents into the container
ADD . /app
# Install any needed packages specified in requirements.txt
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
# Make port 80 available to the world outside this container
EXPOSE 80
# Run app.py when the container launches
CMD ["python", "run.py"]
Code language: Dockerfile (dockerfile)
Docker Compose
Create a docker-compose.yml
file:
version: '3.8'
services:
db:
image: postgres:13
environment:
POSTGRES_USER: your_db_user
POSTGRES_PASSWORD: your_db_password
POSTGRES_DB: flask_api_db
ports:
- "5432:5432"
web:
build: .
command: python run.py
ports:
- "5000:80"
depends_on:
- db
environment:
- DATABASE_URL=postgresql://your_db_user:your_db_password@db/flask_api_db
- SECRET_KEY=your
_secret_key
- JWT_SECRET_KEY=your_jwt_secret_key
Code language: YAML (yaml)
Building and Running the Containers
Build and run your Docker containers:
docker-compose up --build
Code language: Bash (bash)
14. Conclusion
Congratulations! You have built a scalable web API using Flask and SQLAlchemy. This tutorial covered setting up the environment, project structure, initializing Flask and SQLAlchemy, creating models, implementing CRUD operations, adding authentication and authorization, implementing pagination and filtering, error handling, testing, optimizing for scalability, and deploying with Docker.