REST APIs & jsonify
Every Bookshelf view so far has returned HTML — a rendered template a browser displays directly.
But a browser tab isn't the only thing that might want Bookshelf's data: a mobile app, a separate
JavaScript frontend, or another developer's script all need the same book and shelf information
back as plain, structured JSON instead. This lesson builds Bookshelf's first REST
API — a set of routes that return JSON instead of HTML — starting with Flask's own built-in
jsonify() function.
jsonify(): Flask's Built-In JSON Response
Flask ships jsonify() in its core, no extension required. It takes a Python data
structure — a dict, a list of dicts, or a mix of JSON-friendly values — and returns a proper
Flask Response object with that data serialized to JSON.
Example: A Minimal JSON Endpoint
# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/api/books')
def api_book_list():
books = Book.query.all()
return jsonify([{'id': b.id, 'title': b.title} for b in books])
Visiting /api/books returns a JSON array like
[{"id": 1, "title": "Dune"}, {"id": 2, "title": "Educated"}] — no template, no HTML,
just data.
Why jsonify() Instead of Plain json.dumps()
It's tempting to reach for Python's built-in json module directly — after all,
json.dumps(data) also turns a dict into a JSON string. jsonify() is
preferred for two concrete reasons, not just convention:
-
It sets the correct response header automatically.
jsonify()returns a fullResponsewithContent-Type: application/jsonalready set — the signal a client (a browser'sfetch(), a mobile HTTP library) relies on to know it received JSON rather than plain text. Returning a rawjson.dumps()string from a view sends it back astext/htmlby default, which many API clients handle incorrectly or not at all. -
It safely handles edge cases plain
json.dumps()doesn't out of the box — for example, older Flask versions restricted top-level lists specifically to guard against a historical JSON security issue in some browsers, andjsonify()has always applied Flask's own sensible defaults consistently, rather than leaving that judgment call to you.
Building Full CRUD for the Book Resource
A REST API conventionally exposes one resource — here, Bookshelf's Book — through a
small, predictable set of routes, each pairing an HTTP method with a URL. Here is the complete
set, built by hand with jsonify() and request.get_json():
Example: List and Get-by-ID
# app.py
from flask import Flask, jsonify, request
@app.route('/api/books', methods=['GET'])
def list_books():
books = Book.query.all()
return jsonify([book.to_dict() for book in books])
@app.route('/api/books/<int:book_id>', methods=['GET'])
def get_book(book_id):
book = Book.query.get(book_id)
if book is None:
return jsonify(error='Book not found'), 404
return jsonify(book.to_dict())
Example: Create, Update, and Delete
@app.route('/api/books', methods=['POST'])
def create_book():
data = request.get_json()
if not data or 'title' not in data:
return jsonify(error='title is required'), 400
book = Book(
title=data['title'],
author=data.get('author'),
pages=data.get('pages'),
status=data.get('status', 'want_to_read'),
user_id=current_user_id(),
)
db.session.add(book)
db.session.commit()
return jsonify(book.to_dict()), 201
@app.route('/api/books/<int:book_id>', methods=['PUT'])
def update_book(book_id):
book = Book.query.get(book_id)
if book is None:
return jsonify(error='Book not found'), 404
data = request.get_json() or {}
book.status = data.get('status', book.status)
book.rating = data.get('rating', book.rating)
db.session.commit()
return jsonify(book.to_dict())
@app.route('/api/books/<int:book_id>', methods=['DELETE'])
def delete_book(book_id):
book = Book.query.get(book_id)
if book is None:
return jsonify(error='Book not found'), 404
db.session.delete(book)
db.session.commit()
return jsonify(message='Book deleted'), 200
Returning Proper HTTP Status Codes
Notice every error path above returns a tuple: jsonify(...) paired with a status
code, like return jsonify(error='Book not found'), 404. Flask reads that second tuple
element as the response's HTTP status code — a small detail that matters enormously to any
well-behaved API client, which typically branches its own logic on the status code first, before
ever looking at the JSON body.
| Status code | Meaning in Bookshelf's API |
|---|---|
200 OK | A GET, PUT, or DELETE succeeded (the default when no code is given) |
201 Created | A POST successfully created a new Book |
400 Bad Request | The submitted JSON was missing a required field |
404 Not Found | The requested book_id doesn't exist |
{"error": "..."} and ignores the status code is fragile — always return the status code that actually matches what happened, and let clients rely on it.A to_dict() Helper on the Model
Every route above calls book.to_dict() rather than rebuilding the same dict inline.
That method lives directly on the Book model, right alongside its columns — one
place that defines exactly what a Book looks like as JSON, reused by every endpoint that needs it.
Example: to_dict() on the Book Model
# models.py
class Book(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
author = db.Column(db.String(120))
pages = db.Column(db.Integer)
status = db.Column(db.String(20), default='want_to_read')
rating = db.Column(db.Integer)
shelf_id = db.Column(db.Integer, db.ForeignKey('shelf.id'))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'author': self.author,
'pages': self.pages,
'status': self.status,
'rating': self.rating,
'shelf_id': self.shelf_id,
}
Without this, every single route in the CRUD set above would repeat the same six key-value pairs,
and adding a field to Book would mean hunting down and editing every route that
builds a dict by hand. With to_dict(), it's one edit, in one place. This same pattern
foreshadows the next lesson: to_dict() is a genuinely useful habit for a small API,
but it's still just a hand-written method with no awareness of validation — the next lesson
replaces it with a tool built specifically for this job.
The Honest Limit of This Approach
Everything in this lesson is real, idiomatic Flask, and it's completely sufficient for a small
API with a handful of endpoints. But look back at create_book() above: the only
validation is a single if 'title' not in data check. What happens if pages
arrives as the string "three hundred" instead of a number? What if status
is set to a value that isn't one of the three Bookshelf actually recognizes? Right now, nothing
catches that — it just gets saved. Hand-building JSON responses and checks this way works fine
until an API needs genuine input validation, and that repetitive, error-prone
hand-checking is exactly the problem the next lesson's tool — Marshmallow — is built to solve.
to_dict()/manual-validation approach with a declarative schema, and adds token-based authentication so this API can be used outside a browser session.