Skip to main content

JSON and Routing

In this chapter, we'll expand our Task API with better JSON handling and advanced routing. We'll cover:

  • Returning JSON responses properly
  • Advanced routing with Flask
  • Request parameters and query strings
  • Request body parsing and validation
  • Error handling in APIs
  • Testing JSON endpoints

Proper JSON responses​

In the previous chapter, Flask automatically converted dicts to JSON. Let's be more explicit:

from flask import jsonify


@app.route('/api/tasks')
def get_tasks():
tasks = task_service.get_all()
return jsonify(tasks)

jsonify provides:

  • Proper Content-Type: application/json header
  • Correct encoding of Python objects

Testing JSON responses​

def test_response_has_json_content_type(client):
response = client.get('/api/tasks')

assert response.content_type == 'application/json'

Request validation​

User requirement​

As a user, I want clear error messages when I submit invalid data.

Write the test​

def test_create_task_requires_title(client):
response = client.post('/api/tasks', json={
'description': 'Missing title'
})

assert response.status_code == 400
assert 'title' in response.json['error'].lower()


def test_create_task_rejects_empty_title(client):
response = client.post('/api/tasks', json={
'title': ''
})

assert response.status_code == 400
assert 'title' in response.json['error'].lower()

Implement validation​

@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.json

if not data:
return jsonify({'error': 'Request body is required'}), 400

title = data.get('title', '').strip()
if not title:
return jsonify({'error': 'Title is required'}), 400

task = task_service.create(title, data.get('description', ''))
return jsonify(task), 201

Query parameters​

User requirement​

As a user, I want to filter tasks by completion status.

Write the test​

def test_filter_tasks_by_completion(client):
# Create tasks
client.post('/api/tasks', json={'title': 'Task 1'})
client.post('/api/tasks', json={'title': 'Task 2'})

# Complete one task
response = client.get('/api/tasks')
task_id = response.json[0]['id']
client.put(f'/api/tasks/{task_id}', json={'completed': True})

# Filter by completed
completed = client.get('/api/tasks?completed=true')
assert len(completed.json) == 1

incomplete = client.get('/api/tasks?completed=false')
assert len(incomplete.json) == 1


def test_get_all_tasks_without_filter(client):
client.post('/api/tasks', json={'title': 'Task 1'})
client.post('/api/tasks', json={'title': 'Task 2'})

response = client.get('/api/tasks')

assert len(response.json) == 2

Implement filtering​

from flask import request


@app.route('/api/tasks')
def get_tasks():
tasks = task_service.get_all()

completed_filter = request.args.get('completed')
if completed_filter is not None:
is_completed = completed_filter.lower() == 'true'
tasks = [t for t in tasks if t['completed'] == is_completed]

return jsonify(tasks)

URL path parameters​

Nested resources​

As a user, I want to add notes to tasks.

Write the tests​

def test_add_note_to_task(client):
# Create a task
task_response = client.post('/api/tasks', json={'title': 'My Task'})
task_id = task_response.json['id']

# Add a note
response = client.post(f'/api/tasks/{task_id}/notes', json={
'content': 'This is a note'
})

assert response.status_code == 201
assert response.json['content'] == 'This is a note'


def test_get_task_notes(client):
# Create a task with notes
task_response = client.post('/api/tasks', json={'title': 'My Task'})
task_id = task_response.json['id']
client.post(f'/api/tasks/{task_id}/notes', json={'content': 'Note 1'})
client.post(f'/api/tasks/{task_id}/notes', json={'content': 'Note 2'})

# Get notes
response = client.get(f'/api/tasks/{task_id}/notes')

assert response.status_code == 200
assert len(response.json) == 2

Implement nested routes​

First, update the TaskService:

class TaskService:
def __init__(self, store=None):
self.store = store if store is not None else []

def create(self, title, description=''):
task = {
'id': str(uuid.uuid4()),
'title': title,
'description': description,
'completed': False,
'notes': [] # Add notes list
}
self.store.append(task)
return task

def add_note(self, task_id, content):
task = self.get_by_id(task_id)
if task:
note = {
'id': str(uuid.uuid4()),
'content': content,
'created_at': datetime.utcnow().isoformat()
}
task['notes'].append(note)
return note
return None

def get_notes(self, task_id):
task = self.get_by_id(task_id)
if task:
return task['notes']
return None

Add the routes:

@app.route('/api/tasks/<task_id>/notes', methods=['POST'])
def add_note(task_id):
data = request.json
content = data.get('content', '').strip()

if not content:
return jsonify({'error': 'Content is required'}), 400

note = task_service.add_note(task_id, content)
if note:
return jsonify(note), 201
return jsonify({'error': 'Task not found'}), 404


@app.route('/api/tasks/<task_id>/notes')
def get_notes(task_id):
notes = task_service.get_notes(task_id)
if notes is not None:
return jsonify(notes)
return jsonify({'error': 'Task not found'}), 404

URL converters​

Flask provides URL converters for type validation:

# Integer parameter
@app.route('/api/items/<int:item_id>')
def get_item(item_id): # item_id is already an int
pass

# Float parameter
@app.route('/api/coordinates/<float:lat>/<float:lng>')
def get_location(lat, lng):
pass

# Path parameter (can include slashes)
@app.route('/api/files/<path:filepath>')
def get_file(filepath):
pass

Error handling​

Global error handlers​

from flask import Flask, jsonify


def create_app(task_service=None):
app = Flask(__name__)

@app.errorhandler(400)
def bad_request(error):
return jsonify({'error': 'Bad request'}), 400

@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Not found'}), 404

@app.errorhandler(500)
def internal_error(error):
return jsonify({'error': 'Internal server error'}), 500

# ... rest of app

Testing error handlers​

def test_404_returns_json(client):
response = client.get('/nonexistent')

assert response.status_code == 404
assert response.content_type == 'application/json'
assert 'error' in response.json

Custom exceptions​

class TaskNotFoundError(Exception):
pass


class ValidationError(Exception):
def __init__(self, message):
self.message = message


@app.errorhandler(TaskNotFoundError)
def handle_task_not_found(error):
return jsonify({'error': 'Task not found'}), 404


@app.errorhandler(ValidationError)
def handle_validation_error(error):
return jsonify({'error': error.message}), 400

Request body validation with schemas​

For complex validation, use a schema:

def validate_task(data):
errors = []

if not data:
return ['Request body is required']

title = data.get('title', '').strip()
if not title:
errors.append('Title is required')
elif len(title) > 200:
errors.append('Title must be 200 characters or less')

description = data.get('description', '')
if len(description) > 1000:
errors.append('Description must be 1000 characters or less')

return errors


@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.json
errors = validate_task(data)

if errors:
return jsonify({'errors': errors}), 400

task = task_service.create(
data['title'].strip(),
data.get('description', '')
)
return jsonify(task), 201

Testing validation​

def test_task_title_max_length(client):
long_title = 'a' * 201
response = client.post('/api/tasks', json={'title': long_title})

assert response.status_code == 400
assert 'title' in str(response.json['errors']).lower()


def test_returns_multiple_errors(client):
response = client.post('/api/tasks', json={
'title': '',
'description': 'x' * 1001
})

assert response.status_code == 400
assert len(response.json['errors']) == 2

Pagination​

User requirement​

As a user, I want to paginate through large lists of tasks.

Write the test​

def test_pagination(client):
# Create 25 tasks
for i in range(25):
client.post('/api/tasks', json={'title': f'Task {i}'})

# Get first page
page1 = client.get('/api/tasks?page=1&per_page=10')
assert len(page1.json['items']) == 10
assert page1.json['total'] == 25
assert page1.json['page'] == 1
assert page1.json['pages'] == 3

# Get second page
page2 = client.get('/api/tasks?page=2&per_page=10')
assert len(page2.json['items']) == 10

# Get third page
page3 = client.get('/api/tasks?page=3&per_page=10')
assert len(page3.json['items']) == 5

Implement pagination​

@app.route('/api/tasks')
def get_tasks():
tasks = task_service.get_all()

# Apply filters
completed_filter = request.args.get('completed')
if completed_filter is not None:
is_completed = completed_filter.lower() == 'true'
tasks = [t for t in tasks if t['completed'] == is_completed]

# Pagination
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
per_page = min(per_page, 100) # Max 100 per page

total = len(tasks)
start = (page - 1) * per_page
end = start + per_page

return jsonify({
'items': tasks[start:end],
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page
})

Sorting​

Write the test​

def test_sort_tasks_by_title(client):
client.post('/api/tasks', json={'title': 'Zebra'})
client.post('/api/tasks', json={'title': 'Apple'})
client.post('/api/tasks', json={'title': 'Mango'})

response = client.get('/api/tasks?sort=title')
titles = [t['title'] for t in response.json['items']]

assert titles == ['Apple', 'Mango', 'Zebra']


def test_sort_tasks_descending(client):
client.post('/api/tasks', json={'title': 'Zebra'})
client.post('/api/tasks', json={'title': 'Apple'})

response = client.get('/api/tasks?sort=title&order=desc')
titles = [t['title'] for t in response.json['items']]

assert titles == ['Zebra', 'Apple']

Implement sorting​

@app.route('/api/tasks')
def get_tasks():
tasks = task_service.get_all()

# Sorting
sort_by = request.args.get('sort')
if sort_by in ['title', 'created_at']:
reverse = request.args.get('order', 'asc') == 'desc'
tasks = sorted(tasks, key=lambda t: t.get(sort_by, ''), reverse=reverse)

# ... rest of filtering and pagination

Wrapping up​

We've covered:

  • JSON responses - Use jsonify() for proper content types
  • Request validation - Validate input before processing
  • Query parameters - Use request.args for filtering
  • Path parameters - URL patterns like /<task_id>
  • Nested resources - Routes like /tasks/<id>/notes
  • Error handling - Global error handlers for consistent responses
  • Pagination - Return paginated results with metadata
  • Sorting - Allow clients to sort results

Key takeaways​

  1. Validate all input and return clear error messages
  2. Use consistent response formats
  3. Handle errors gracefully with proper status codes
  4. Support filtering, pagination, and sorting for lists
  5. Test edge cases and error conditions

In the next chapter, we'll persist our data with database integration.