Introduction to Resource-Based Design
At the heart of REST architecture is the concept of resources. A resource is any meaningful entity in your application that can be identified, named, and manipulated through representations. When designing RESTful APIs, organizing your endpoints around resources rather than actions creates a more intuitive, consistent, and maintainable API.
Think of resources as nouns in your application domain. For example, in a blog application, resources might include users, posts, comments, and categories. Each resource can be created, retrieved, updated, and deleted using standard HTTP methods - these are the verbs that act on your resource nouns.
This approach is analogous to how we interact with physical objects in the real world. Just as you can pick up (GET), create (POST), modify (PUT), or throw away (DELETE) a book, you can perform similar operations on digital resources in your API.
Identifying Resources
The first step in designing a resource-based API is identifying the key resources in your application. Resources typically correspond to the main entities or data models in your domain.
Guidelines for Identifying Resources
- Use domain modeling: Identify the primary entities in your domain and their relationships
- Focus on nouns, not verbs: Resources should be things, not actions
- Consider what users need to access: Resources should be relevant to client needs
- Think about what needs to be created, read, updated, or deleted: Resources should support CRUD operations
- Be consistent in resource granularity: Keep resources at a similar level of abstraction
Types of Resources
| Resource Type | Description | Example |
|---|---|---|
| Collection Resources | Groups of similar items | /users, /posts, /comments |
| Instance Resources | Individual items within a collection | /users/123, /posts/456, /comments/789 |
| Nested Resources | Resources that belong to other resources | /users/123/posts, /posts/456/comments |
| Singleton Resources | Resources that exist as a single instance | /profile, /dashboard, /settings |
| Controller Resources | Special resources that handle complex operations | /search, /calculate-tax, /generate-report |
Example: E-commerce Application Resources
-
Products: The items being sold
- Attributes: ID, name, description, price, inventory, category, etc.
- Operations: List products, get product details, create/update/delete products
-
Users: Customers and administrators
- Attributes: ID, name, email, address, payment info, etc.
- Operations: Register, get profile, update profile, delete account
-
Orders: Purchases made by customers
- Attributes: ID, user ID, items, total, status, date, etc.
- Operations: Create order, get order details, update status, cancel order
-
Reviews: Customer feedback on products
- Attributes: ID, product ID, user ID, rating, text, date
- Operations: Submit review, get reviews for product, update/delete review
-
Shopping Cart: Items selected for potential purchase
- Attributes: User ID, items, quantities
- Operations: Add item, update quantity, remove item, clear cart
Designing Resource URIs
Once you've identified your resources, you need to design URIs (Uniform Resource Identifiers) that will be used to access them. Well-designed URIs make your API intuitive and easy to use.
URI Design Principles
-
Use nouns, not verbs: URIs should identify resources, not actions
✓ /users
✗ /getUsers -
Use plural nouns for collections: Collections should use plural forms
✓ /posts
✗ /post -
Use concrete names: Names should be specific and clear
✓ /invoices
✗ /data -
Use hierarchical structure for relationships: Nest related resources
✓ /users/123/orders
✗ /orders?user_id=123 -
Use kebab-case or snake_case consistently: Choose one format for multi-word resource names
✓ /order-items or /order_items
✗ /orderItems -
Keep URIs reasonably short: Long URIs are harder to read and use
✓ /users/123/addresses
✗ /platform/api/v1/account/users/identifier/123/address-collection
URI Patterns for Common Resources
| Resource Type | URI Pattern | Examples |
|---|---|---|
| Collection | /resources | /users, /products, /orders |
| Instance | /resources/:id | /users/123, /products/xyz, /orders/789 |
| Nested Collection | /resources/:id/sub-resources | /users/123/orders, /products/xyz/reviews |
| Nested Instance | /resources/:id/sub-resources/:sub-id | /users/123/orders/456, /products/xyz/reviews/789 |
| Singleton | /resource | /profile, /cart, /settings |
| Controller | /controller-name | /search, /calculate, /validate |
Query Parameters vs Path Parameters
When designing URIs, you need to decide when to use path parameters (e.g., /users/:id) and when to use query parameters (e.g., /users?role=admin).
-
Path parameters: Used to identify a specific resource or resource instance
Examples: /users/123, /orders/456, /products/xyz -
Query parameters: Used for filtering, sorting, paging, and other operations that don't identify a specific resource
Examples: /users?role=admin, /products?category=electronics, /orders?status=pending
As a general rule, if a parameter is required to identify the resource, it should be a path parameter. If it's optional or used for filtering, sorting, or other operations, it should be a query parameter.
HTTP Methods and CRUD Operations
In a RESTful API, HTTP methods (verbs) are used to perform operations on resources. The most common methods correspond to CRUD operations (Create, Read, Update, Delete).
Main HTTP Methods
| HTTP Method | CRUD Operation | Description | Example |
|---|---|---|---|
| GET | Read | Retrieve resource(s) | GET /users, GET /users/123 |
| POST | Create | Create a new resource | POST /users |
| PUT | Update | Replace a resource completely | PUT /users/123 |
| PATCH | Update (partial) | Update parts of a resource | PATCH /users/123 |
| DELETE | Delete | Remove a resource | DELETE /users/123 |
Less Common HTTP Methods
| HTTP Method | Description | Example Use |
|---|---|---|
| HEAD | Like GET but returns only headers, no body | Check if a resource exists or has been modified |
| OPTIONS | Get information about available methods | CORS preflight requests, API discovery |
HTTP Method Properties
| Property | Description | Methods |
|---|---|---|
| Safe | Does not modify resources | GET, HEAD, OPTIONS |
| Idempotent | Multiple identical requests have same effect as a single request | GET, HEAD, PUT, DELETE, OPTIONS |
| Cacheable | Responses can be cached | GET, HEAD (POST can be in some cases) |
Common Resource Operations
/resources] --> B[GET
List all resources] A --> C[POST
Create a new resource] D[Instance Resource
/resources/:id] --> E[GET
Retrieve a specific resource] D --> F[PUT/PATCH
Update a resource] D --> G[DELETE
Remove a resource]
Understanding these properties is important for designing predictable and reliable APIs. For example, if a method is idempotent, clients can safely retry requests without worrying about unintended side effects.
Resource Relations and Nested Resources
Resources in an API often have relationships with other resources. For example, a user can have multiple orders, or a product can have multiple reviews. RESTful APIs typically represent these relationships through nested resources.
Types of Resource Relationships
-
One-to-Many: One resource has many related resources
Example: A user has many orders - /users/123/orders -
Many-to-One: Many resources belong to one resource
Example: Many orders belong to one user - /orders/456 (with user_id field) -
Many-to-Many: Many resources relate to many other resources
Example: Products can be in many categories, categories can have many products -
One-to-One: One resource relates to exactly one other resource
Example: A user has one profile - /users/123/profile
Representing Relationships in URIs
-
Nested Resources: Use hierarchical URIs to represent parent-child relationships
Examples: /users/123/orders, /orders/456/items -
Reference Fields: Include reference IDs in resource representations
Example: Order resource includes user_id to reference the owner -
Link Relations: Include links to related resources in representations
Example: User resource includes a link to its orders collection
Examples of Nested Resources
# Get all orders for a specific user
GET /users/123/orders
# Get a specific order for a user
GET /users/123/orders/456
# Create a new order for a user
POST /users/123/orders
# Get all reviews for a product
GET /products/xyz/reviews
# Add a review to a product
POST /products/xyz/reviews
Avoiding Deep Nesting
While nested resources are useful for representing relationships, deep nesting can lead to overly complex URIs. As a general rule, try to limit nesting to one or two levels.
# Too deep - avoid this
GET /users/123/orders/456/items/789/attributes
# Better alternatives
GET /order-items/789
GET /order-items?order_id=456
GET /item-attributes?item_id=789
Resource Linking with HATEOAS
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of REST architecture where clients interact with the application entirely through hypermedia provided dynamically by the server. In practice, this means including links to related resources in API responses.
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"profile": { "href": "/users/123/profile" }
}
}
This approach allows clients to navigate the API without having to hardcode URIs, making the API more resilient to changes.
Resource Representation
A resource representation is the format in which a resource is presented to clients. In RESTful APIs, representations are typically in JSON or XML, with JSON being the most common.
Elements of a Good Resource Representation
- Meaningful field names: Use clear, descriptive names for fields
- Consistent naming conventions: Use the same style (e.g., camelCase or snake_case) throughout
- Appropriate data types: Use the most appropriate type for each field
- Required vs. optional fields: Clearly document which fields are required
- Resource identifiers: Include unique identifiers for resources
- Timestamps: Include creation and last update timestamps when relevant
- Links to related resources: Include links to related resources when appropriate
JSON Representation Example
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"role": "customer",
"created_at": "2023-01-15T10:30:00Z",
"updated_at": "2023-05-20T14:45:00Z",
"address": {
"street": "123 Main St",
"city": "Anytown",
"state": "CA",
"zip": "12345"
},
"orders_count": 5,
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" }
}
}
Collection Representation
Collections should be represented as arrays of objects, often with metadata about the collection:
{
"total": 100,
"count": 10,
"page": 1,
"per_page": 10,
"items": [
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"_links": { "self": { "href": "/users/123" } }
},
{
"id": 124,
"name": "Jane Smith",
"email": "jane@example.com",
"_links": { "self": { "href": "/users/124" } }
}
// ... more items ...
],
"_links": {
"self": { "href": "/users?page=1&per_page=10" },
"next": { "href": "/users?page=2&per_page=10" },
"last": { "href": "/users?page=10&per_page=10" }
}
}
Field Filtering and Projection
APIs should allow clients to request only the fields they need, reducing bandwidth and improving performance:
# Request only specific fields
GET /users/123?fields=id,name,email
# Response
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
Content Negotiation
RESTful APIs should support content negotiation, allowing clients to request different representations of the same resource:
# Request JSON format
GET /users/123
Accept: application/json
# Request XML format
GET /users/123
Accept: application/xml
The server should respond with the requested format or return a 406 Not Acceptable status if it can't provide the requested format.
Implementing Resources in Flask-RESTful
Now let's see how to implement resource-based design in Flask-RESTful. We'll create a complete API for a task management application with tasks, categories, and users.
Domain Model
First, let's define our domain model:
-
User: A person who can create and manage tasks
- Attributes: id, username, email, password, created_at
-
Category: A way to classify tasks
- Attributes: id, name, description, user_id (owner)
-
Task: A specific activity that needs to be completed
- Attributes: id, title, description, due_date, completed, priority, user_id (owner), category_id
Resource URIs
Now, let's define the URIs for our resources:
| Resource | URI | Methods | Description |
|---|---|---|---|
| Users Collection | /users | GET, POST | List all users, Create a new user |
| User Instance | /users/:id | GET, PUT, DELETE | Get, Update, or Delete a user |
| Categories Collection | /categories | GET, POST | List all categories, Create a new category |
| Category Instance | /categories/:id | GET, PUT, DELETE | Get, Update, or Delete a category |
| Tasks Collection | /tasks | GET, POST | List all tasks, Create a new task |
| Task Instance | /tasks/:id | GET, PUT, DELETE | Get, Update, or Delete a task |
| User Tasks | /users/:id/tasks | GET | List all tasks for a user |
| Category Tasks | /categories/:id/tasks | GET | List all tasks in a category |
| Task Completion | /tasks/:id/complete | PUT | Mark a task as complete |
Database Models
Let's implement the SQLAlchemy models for our resources:
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50), unique=True, nullable=False)
email = db.Column(db.String(100), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
tasks = db.relationship('Task', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
categories = db.relationship('Category', backref='owner', lazy='dynamic', cascade='all, delete-orphan')
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)
def __repr__(self):
return f''
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
tasks = db.relationship('Task', backref='category', lazy='dynamic')
def __repr__(self):
return f''
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
due_date = db.Column(db.DateTime)
completed = db.Column(db.Boolean, default=False)
priority = db.Column(db.Integer, default=1) # 1=Low, 2=Medium, 3=High
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'), nullable=True)
def __repr__(self):
return f''
Resource Fields for Marshaling
Now, let's define the field definitions for marshaling our resources:
from flask_restful import fields
# User fields
user_fields = {
'id': fields.Integer,
'username': fields.String,
'email': fields.String,
'created_at': fields.DateTime(dt_format='iso8601'),
'tasks_count': fields.Integer(attribute=lambda x: x.tasks.count()),
'categories_count': fields.Integer(attribute=lambda x: x.categories.count())
}
# Category fields
category_fields = {
'id': fields.Integer,
'name': fields.String,
'description': fields.String,
'user_id': fields.Integer,
'tasks_count': fields.Integer(attribute=lambda x: x.tasks.count())
}
# Task fields
task_fields = {
'id': fields.Integer,
'title': fields.String,
'description': fields.String,
'due_date': fields.DateTime(dt_format='iso8601'),
'completed': fields.Boolean,
'priority': fields.Integer,
'created_at': fields.DateTime(dt_format='iso8601'),
'updated_at': fields.DateTime(dt_format='iso8601'),
'user_id': fields.Integer,
'category_id': fields.Integer,
'category_name': fields.String(attribute=lambda x: x.category.name if x.category else None)
}
Resources Implementation
Now let's implement our resources in Flask-RESTful:
from flask import request, g
from flask_restful import Resource, marshal_with, reqparse, abort
from sqlalchemy import desc
# User resources
class UserResource(Resource):
@marshal_with(user_fields)
def get(self, user_id):
user = User.query.get_or_404(user_id)
return user
@marshal_with(user_fields)
def put(self, user_id):
user = User.query.get_or_404(user_id)
# In a real app, check permissions here
parser = reqparse.RequestParser()
parser.add_argument('username', type=str)
parser.add_argument('email', type=str)
args = parser.parse_args()
if args['username']:
user.username = args['username']
if args['email']:
user.email = args['email']
db.session.commit()
return user
def delete(self, user_id):
user = User.query.get_or_404(user_id)
# In a real app, check permissions here
db.session.delete(user)
db.session.commit()
return {'message': 'User deleted successfully'}, 200
class UserListResource(Resource):
@marshal_with(user_fields)
def get(self):
users = User.query.all()
return users
@marshal_with(user_fields)
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('username', type=str, required=True, help='Username is required')
parser.add_argument('email', type=str, required=True, help='Email is required')
parser.add_argument('password', type=str, required=True, help='Password is required')
args = parser.parse_args()
# Check if username or email already exists
if User.query.filter_by(username=args['username']).first():
abort(409, message=f"Username {args['username']} already exists")
if User.query.filter_by(email=args['email']).first():
abort(409, message=f"Email {args['email']} already exists")
user = User(username=args['username'], email=args['email'])
user.set_password(args['password'])
db.session.add(user)
db.session.commit()
return user, 201
class UserTasksResource(Resource):
@marshal_with(task_fields)
def get(self, user_id):
user = User.query.get_or_404(user_id)
# Parse query parameters for filtering
parser = reqparse.RequestParser()
parser.add_argument('completed', type=str, location='args')
parser.add_argument('sort', type=str, location='args')
args = parser.parse_args()
# Start with all tasks for this user
query = user.tasks
# Filter by completion status if specified
if args['completed'] is not None:
completed = args['completed'].lower() == 'true'
query = query.filter_by(completed=completed)
# Sort tasks
if args['sort'] == 'priority':
query = query.order_by(desc(Task.priority))
elif args['sort'] == 'due_date':
query = query.order_by(Task.due_date)
else:
query = query.order_by(desc(Task.created_at))
tasks = query.all()
return tasks
# Category resources
class CategoryResource(Resource):
@marshal_with(category_fields)
def get(self, category_id):
category = Category.query.get_or_404(category_id)
return category
@marshal_with(category_fields)
def put(self, category_id):
category = Category.query.get_or_404(category_id)
# In a real app, check permissions here
parser = reqparse.RequestParser()
parser.add_argument('name', type=str)
parser.add_argument('description', type=str)
args = parser.parse_args()
if args['name']:
category.name = args['name']
if args['description']:
category.description = args['description']
db.session.commit()
return category
def delete(self, category_id):
category = Category.query.get_or_404(category_id)
# In a real app, check permissions here
db.session.delete(category)
db.session.commit()
return {'message': 'Category deleted successfully'}, 200
class CategoryListResource(Resource):
@marshal_with(category_fields)
def get(self):
# Optionally filter by user_id
user_id = request.args.get('user_id', type=int)
if user_id:
categories = Category.query.filter_by(user_id=user_id).all()
else:
categories = Category.query.all()
return categories
@marshal_with(category_fields)
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('name', type=str, required=True, help='Name is required')
parser.add_argument('description', type=str)
parser.add_argument('user_id', type=int, required=True, help='User ID is required')
args = parser.parse_args()
# Check if user exists
user = User.query.get(args['user_id'])
if not user:
abort(404, message=f"User with id {args['user_id']} not found")
category = Category(
name=args['name'],
description=args['description'],
user_id=args['user_id']
)
db.session.add(category)
db.session.commit()
return category, 201
class CategoryTasksResource(Resource):
@marshal_with(task_fields)
def get(self, category_id):
category = Category.query.get_or_404(category_id)
tasks = category.tasks.all()
return tasks
# Task resources
class TaskResource(Resource):
@marshal_with(task_fields)
def get(self, task_id):
task = Task.query.get_or_404(task_id)
return task
@marshal_with(task_fields)
def put(self, task_id):
task = Task.query.get_or_404(task_id)
# In a real app, check permissions here
parser = reqparse.RequestParser()
parser.add_argument('title', type=str)
parser.add_argument('description', type=str)
parser.add_argument('due_date', type=str) # ISO format date string
parser.add_argument('completed', type=bool)
parser.add_argument('priority', type=int)
parser.add_argument('category_id', type=int)
args = parser.parse_args()
# Update fields if provided
if args['title']:
task.title = args['title']
if args['description'] is not None: # Allow empty description
task.description = args['description']
if args['due_date']:
try:
task.due_date = datetime.fromisoformat(args['due_date'])
except ValueError:
abort(400, message="Invalid due_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
if args['completed'] is not None:
task.completed = args['completed']
if args['priority']:
task.priority = args['priority']
if args['category_id'] is not None:
# Check if category exists and belongs to the same user
if args['category_id'] == 0: # Special case: remove category
task.category_id = None
else:
category = Category.query.get(args['category_id'])
if not category:
abort(404, message=f"Category with id {args['category_id']} not found")
if category.user_id != task.user_id:
abort(403, message="Category belongs to a different user")
task.category_id = args['category_id']
db.session.commit()
return task
def delete(self, task_id):
task = Task.query.get_or_404(task_id)
# In a real app, check permissions here
db.session.delete(task)
db.session.commit()
return {'message': 'Task deleted successfully'}, 200
class TaskListResource(Resource):
@marshal_with(task_fields)
def get(self):
# Parse query parameters for filtering
parser = reqparse.RequestParser()
parser.add_argument('user_id', type=int, location='args')
parser.add_argument('category_id', type=int, location='args')
parser.add_argument('completed', type=str, location='args')
parser.add_argument('sort', type=str, location='args')
args = parser.parse_args()
# Start with base query
query = Task.query
# Apply filters if provided
if args['user_id']:
query = query.filter_by(user_id=args['user_id'])
if args['category_id']:
query = query.filter_by(category_id=args['category_id'])
if args['completed'] is not None:
completed = args['completed'].lower() == 'true'
query = query.filter_by(completed=completed)
# Apply sorting
if args['sort'] == 'priority':
query = query.order_by(desc(Task.priority))
elif args['sort'] == 'due_date':
query = query.order_by(Task.due_date)
else:
query = query.order_by(desc(Task.created_at))
tasks = query.all()
return tasks
@marshal_with(task_fields)
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('title', type=str, required=True, help='Title is required')
parser.add_argument('description', type=str)
parser.add_argument('due_date', type=str) # ISO format date string
parser.add_argument('priority', type=int, default=1)
parser.add_argument('user_id', type=int, required=True, help='User ID is required')
parser.add_argument('category_id', type=int)
args = parser.parse_args()
# Check if user exists
user = User.query.get(args['user_id'])
if not user:
abort(404, message=f"User with id {args['user_id']} not found")
# Check if category exists and belongs to the user
if args['category_id']:
category = Category.query.get(args['category_id'])
if not category:
abort(404, message=f"Category with id {args['category_id']} not found")
if category.user_id != args['user_id']:
abort(403, message="Category belongs to a different user")
# Parse due_date if provided
due_date = None
if args['due_date']:
try:
due_date = datetime.fromisoformat(args['due_date'])
except ValueError:
abort(400, message="Invalid due_date format. Use ISO format (YYYY-MM-DDTHH:MM:SS)")
task = Task(
title=args['title'],
description=args['description'],
due_date=due_date,
priority=args['priority'],
user_id=args['user_id'],
category_id=args['category_id']
)
db.session.add(task)
db.session.commit()
return task, 201
class TaskCompleteResource(Resource):
@marshal_with(task_fields)
def put(self, task_id):
task = Task.query.get_or_404(task_id)
# In a real app, check permissions here
task.completed = True
db.session.commit()
return task
Registering the Resources
Finally, let's register our resources with the Flask-RESTful API:
from flask import Flask
from flask_restful import Api
from models import db
from resources import (
UserResource, UserListResource, UserTasksResource,
CategoryResource, CategoryListResource, CategoryTasksResource,
TaskResource, TaskListResource, TaskCompleteResource
)
def create_app():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Initialize extensions
db.init_app(app)
api = Api(app)
# Register resources
api.add_resource(UserListResource, '/users')
api.add_resource(UserResource, '/users/')
api.add_resource(UserTasksResource, '/users//tasks')
api.add_resource(CategoryListResource, '/categories')
api.add_resource(CategoryResource, '/categories/')
api.add_resource(CategoryTasksResource, '/categories//tasks')
api.add_resource(TaskListResource, '/tasks')
api.add_resource(TaskResource, '/tasks/')
api.add_resource(TaskCompleteResource, '/tasks//complete')
# Create database tables
with app.app_context():
db.create_all()
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True)
This implementation demonstrates a complete resource-based API structure for a task management application, following RESTful principles.
Best Practices for Resource Design
Consistency
- Use consistent naming conventions for resources and fields
- Apply HTTP methods consistently across resources
- Maintain consistent URL patterns
- Use consistent error formats
Granularity
- Keep resources at an appropriate level of granularity
- Avoid too fine-grained resources that lead to chatty APIs
- Avoid too coarse-grained resources that lead to large, complex payloads
Versioning
- Consider including API version in the URL (e.g., /api/v1/users)
- Maintain backward compatibility within a version
- Document changes between versions
Pagination
- Implement pagination for collection resources
- Use limit and offset or page and per_page parameters
- Include metadata about pagination in responses
- Provide links to next, previous, first, and last pages
Filtering, Sorting, and Searching
- Use query parameters for filtering resources
- Implement sorting through query parameters
- Provide search functionality for text-based fields
- Document all supported parameters
Error Handling
- Use appropriate HTTP status codes
- Provide descriptive error messages
- Include error codes or types for client parsing
- Be consistent in error response format
Authentication and Authorization
- Use standard authentication methods (OAuth, JWT, etc.)
- Implement fine-grained authorization at the resource level
- Return 401 Unauthorized or 403 Forbidden as appropriate
Common API Design Patterns
CRUD Operations
The basic pattern for CRUD operations on a resource:
| Operation | HTTP Method | URI | Description |
|---|---|---|---|
| Create | POST | /resources | Create a new resource |
| Read (List) | GET | /resources | Get all resources |
| Read (Single) | GET | /resources/:id | Get a specific resource |
| Update | PUT/PATCH | /resources/:id | Update a resource |
| Delete | DELETE | /resources/:id | Delete a resource |
Controller Resources
Sometimes operations don't fit neatly into CRUD. Controller resources can be used for actions:
# Convert a document to PDF
POST /documents/123/convert?format=pdf
# Send a message
POST /messages/send
# Calculate tax
POST /calculator/tax
Keep controller resources focused on specific operations that don't fit the standard CRUD model.
Bulk Operations
For operating on multiple resources at once:
# Bulk create
POST /users/bulk
[
{"username": "user1", "email": "user1@example.com"},
{"username": "user2", "email": "user2@example.com"}
]
# Bulk update
PUT /users/bulk
[
{"id": 1, "username": "new_user1"},
{"id": 2, "username": "new_user2"}
]
# Bulk delete
DELETE /users?ids=1,2,3
Filtering and Searching
Approaches for filtering and searching resources:
# Basic filtering
GET /products?category=electronics&min_price=100&max_price=500
# Full-text search
GET /products/search?q=wireless+headphones
# Advanced filtering (complex criteria)
POST /products/filter
{
"category": "electronics",
"price": {"min": 100, "max": 500},
"attributes": {"color": "black", "wireless": true}
}
Nested Resources vs. Query Parameters
Two approaches to handling related resources:
# Using nested resources
GET /users/123/orders # Get all orders for user 123
GET /users/123/orders/456 # Get order 456 for user 123
# Using query parameters
GET /orders?user_id=123 # Get all orders for user 123
GET /orders/456?user_id=123 # Get order 456 with a user check
The nested approach is more RESTful and reflects resource relationships more clearly, while the query parameter approach is simpler and can avoid deep nesting.
Practical Activity: Designing a RESTful API
Let's put our knowledge into practice by designing a RESTful API for a simple e-commerce system.
Domain Model
-
User: A customer or administrator
- Attributes: id, username, email, password, role (customer/admin), created_at
-
Product: An item available for purchase
- Attributes: id, name, description, price, inventory, category_id, created_at, updated_at
-
Category: A way to classify products
- Attributes: id, name, description
-
Order: A purchase made by a customer
- Attributes: id, user_id, status, total, created_at
-
OrderItem: A product included in an order
- Attributes: id, order_id, product_id, quantity, price
-
Review: A customer review of a product
- Attributes: id, product_id, user_id, rating, text, created_at
Task
Design a resource-based API for this e-commerce system:
- Identify all resources (collections and instances)
- Design URI patterns for each resource
- Specify HTTP methods for each URI
- Define the representations (JSON structure) for each resource
- Consider relationships between resources
- Plan for filtering, sorting, and pagination
- Design any special endpoints or controller resources
This activity will help you apply the principles of resource-based API design to a realistic scenario.
Key Takeaways
- Resource-based design organizes APIs around resources (nouns) rather than actions (verbs)
- Well-designed URI patterns make APIs intuitive and easy to use
- HTTP methods map to CRUD operations on resources
- Resource relationships can be represented through nested resources or reference fields
- Resource representations should be consistent and include appropriate metadata
- Following RESTful principles leads to more maintainable, scalable, and predictable APIs
- Flask-RESTful provides a structured way to implement resource-based APIs
- Best practices include consistency, appropriate granularity, proper error handling, and security