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 full Response with Content-Type: application/json already set — the signal a client (a browser's fetch(), a mobile HTTP library) relies on to know it received JSON rather than plain text. Returning a raw json.dumps() string from a view sends it back as text/html by 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, and jsonify() has always applied Flask's own sensible defaults consistently, rather than leaving that judgment call to you.
Note: Every route below is correct, working Flask code — but as with every lesson in this track, this website's own project doesn't run a live Flask server, so you won't see these requests actually execute on this page.

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 codeMeaning in Bookshelf's API
200 OKA GET, PUT, or DELETE succeeded (the default when no code is given)
201 CreatedA POST successfully created a new Book
400 Bad RequestThe submitted JSON was missing a required field
404 Not FoundThe requested book_id doesn't exist
Note: A client that only ever checks the JSON body for something like {"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.

Next up: Marshmallow Serialization & JWT Auth replaces this hand-rolled to_dict()/manual-validation approach with a declarative schema, and adds token-based authentication so this API can be used outside a browser session.