Checkpoint: Build a REST API

Reviewed & published by Brayan K

A REST API is a set of HTTP endpoints that expose your data as JSON, letting clients create, read, and page through resources using standard methods and status codes.

Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

This checkpoint combines everything from the API track — models, queries, pagination, validation, and serialization — into one runnable REST endpoint you build and test yourself.

What You've Learned So Far

You've covered the full toolkit for building production APIs. Here's the recap:

The Build Challenge: A Tasks REST API

You'll build a small JSON REST API for a task list, combining four skills into one file:

Everything runs in one process with an in-memory database and the test client. The starter below is fully runnable — read it, run it, and watch all four pieces work together.

# api.py — a complete, runnable Tasks REST API (model + schema + pagination + validation)
import math
from flask import Flask, request, jsonify
from sqlalchemy import create_engine, String, Integer, select, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from marshmallow import Schema, fields, validate, ValidationError

app = Flask(__name__)

# 1) THE MODEL
class Base(DeclarativeBase): pass

class Task(Base):
    __tablename__ = "tasks"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(100))
    done: Mapped[int] = mapped_column(Integer, default=0)

# 2) THE SCHEMA
class TaskSchema(Schema):
    id = fields.Int(dump_only=True)
    title = fields.Str(required=True, validate=validate.Length(min=1))
    done = fields.Boolean(load_default=False)

task_schema = TaskSchema()
tasks_schema = TaskSchema(many=True)

# set up the database and seed 15 tasks
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add_all([Task(title=f"Task {i}", done=i % 2) for i in range(1, 16)])
    s.commit()

# 3) THE PAGINATED GET
@app.route("/api/tasks")
def list_tasks():
    page = request.args.get("page", 1, type=int)
    per_page = request.args.get("per_page", 5, type=int)
    with Session(engine) as s:
        total = s.scalar(select(func.count()).select_from(Task))
        total_pages = math.ceil(total / per_page)
        rows = s.scalars(
            select(Task).order_by(Task.id).limit(per_page).offset((page - 1) * per_page)
        ).all()
        return jsonify({
            "items": tasks_schema.dump(rows),
            "page": page,
            "total_pages": total_pages,
        })

# 4) THE VALIDATED POST
@app.route("/api/tasks", methods=["POST"])
def add_task():
    try:
        data = task_schema.load(request.get_json())     # validate input
    except ValidationError as err:
        return jsonify(errors=err.messages), 400        # 400 on bad data
    with Session(engine) as s:
        t = Task(title=data["title"], done=int(data["done"]))
        s.add(t); s.commit()
        return task_schema.dump(t), 201                 # 201 Created

# TEST IT with the in-process client
with app.test_client() as c:
    d = c.get("/api/tasks?page=2&per_page=5").get_json()
    print("page", d["page"], "of", d["total_pages"], "->", [t["title"] for t in d["items"]])
    created = c.post("/api/tasks", json={"title": "Write tests"})
    print("created:", created.status_code, created.get_json())
    bad = c.post("/api/tasks", json={"title": ""})
    print("rejected:", bad.status_code, bad.get_json()["errors"])

# ✅ Expected output:
# page 2 of 3 -> ['Task 6', 'Task 7', 'Task 8', 'Task 9', 'Task 10']
# created: 201 {'done': 0, 'id': 16, 'title': 'Write tests'}
# rejected: 400 {'title': ['Shorter than minimum length 1.']}

Output: page 2 lists Task 6…Task 10; the POST returns 201 with the new task; the empty-title POST returns 400 with a validation error. That's a real REST API.

📝 Checkpoint Quiz

Test yourself — click each question to reveal the answer.

schema.load(data) validates and deserializes input. On bad data it raises a ValidationError, whose .messages is a dict of per-field errors — return it with a 400.

offset = (page - 1) * per_page. You pass it to .offset(...) alongside .limit(per_page) so the database returns only that page's rows.

201 Created. Return it as the second element of the tuple, e.g. return schema.dump(obj), 201. A failed validation returns 400 instead.

The id is assigned by the database, not the client. dump_only=True means it appears in output (dump) but is ignored on input (load), so a client cannot try to set it.

Use a schema created with many=True — e.g. TaskSchema(many=True).dump(rows) — or pass many=True to the call. It returns a list of dicts.

404 Not Found, typically with a small JSON error body like {'error': 'Task not found'}. Use session.get(Task, id) and check for None.

Common Errors & Quick Reference

❌ 500 instead of 400 on bad input

You forgot to catch ValidationError around schema.load(). Wrap it and return err.messages with a 400.

The client must send Content-Type: application/json (the test client's json= argument does this automatically). Otherwise the body isn't parsed.

EndpointReturns
GET list (paginated)200 + items & metadata
POST create201 or 400
GET one (missing)404
DELETE204 or 404

❓ Frequently Asked Questions

Checkpoint complete — you can ship a real REST API!

You combined a SQLAlchemy model, a Marshmallow schema, pagination, and validation into working JSON endpoints with correct status codes — and tested them in-process.

Practice quiz

Which Marshmallow method validates and deserializes incoming JSON, and what does it raise on bad data?

  • schema.dump(), raising TypeError
  • schema.parse(), raising KeyError
  • schema.load(), raising ValidationError
  • schema.validate(), raising HTTPError

Answer: schema.load(), raising ValidationError. schema.load(data) validates and deserializes input; on bad data it raises ValidationError whose .messages is a dict of per-field errors.

How do you compute the database offset for a given page when paginating?

  • (page - 1) * per_page
  • page * per_page
  • (page + 1) * per_page
  • per_page / page

Answer: (page - 1) * per_page. offset = (page - 1) * per_page, passed to .offset() alongside .limit(per_page) so the database returns only that page's rows.

What status code should a successful POST that creates a new resource return?

  • 200 OK
  • 204 No Content
  • 302 Found
  • 201 Created

Answer: 201 Created. 201 Created signals a new resource was made; return it as the second tuple element, e.g. return schema.dump(obj), 201.

Why mark the id field as dump_only=True in a Marshmallow schema?

  • So it is required on input
  • So it appears in output but is ignored on input
  • So it is hashed before storage
  • So it is excluded from output

Answer: So it appears in output but is ignored on input. The id is assigned by the database, not the client; dump_only=True includes it in dump output but ignores it on load input.

How do you serialize a list of model rows instead of a single object?

  • Use a schema created with many=True
  • Call schema.dump_many(rows)
  • Loop and call dump() per row only
  • Pass a list to fields.List()

Answer: Use a schema created with many=True. A schema with many=True (e.g. TaskSchema(many=True).dump(rows)) returns a list of dicts for a collection of rows.

What should GET /api/tasks/<id> return when the requested id does not exist?

  • 200 with an empty body
  • 400 Bad Request
  • 404 Not Found
  • 500 Internal Server Error

Answer: 404 Not Found. Use session.get(Task, id); if it returns None, respond 404 Not Found, typically with a small JSON error body.

Which SQLAlchemy methods together fetch just one page's rows from the database?

  • .filter() and .group_by()
  • .limit() and .offset()
  • .join() and .having()
  • .slice() and .count()

Answer: .limit() and .offset(). .limit(per_page) caps the number of rows and .offset(...) skips earlier pages, so only the requested slice is fetched.

When schema.load() raises a ValidationError, what status code should the endpoint return?

  • 201 Created
  • 401 Unauthorized
  • 404 Not Found
  • 400 Bad Request

Answer: 400 Bad Request. Invalid client input is a 400 Bad Request; return err.messages so the client sees which fields failed.

Why does the checkpoint use create_engine('sqlite://') and app.test_client() together?

  • To require a running PostgreSQL server
  • To run the whole API in one process with no external setup
  • To deploy the API to production
  • To enable real network sockets

Answer: To run the whole API in one process with no external setup. An in-memory SQLite database plus Flask's in-process test client let the entire API run in a single process with no external server.

What status code does a successful DELETE that removes a resource and returns no body typically use?

  • 200 OK with the deleted object
  • 201 Created
  • 204 No Content
  • 404 Not Found

Answer: 204 No Content. After deleting a resource with no body to return, 204 No Content is the conventional response (404 if the id was missing).

Continue this course

Frequently asked questions

What does this checkpoint cover?

It ties together the whole API track: defining a model with SQLAlchemy, querying and paginating it, serializing and validating with a Marshmallow schema, and exposing it all through JSON endpoints. You build a small but complete REST API and test it with Flask's in-process test client.

Do I need a real database or server running?

No. The checkpoint uses an in-memory SQLite database (create_engine('sqlite://')) and Flask's test client (app.test_client()), so the entire API — routes, queries, and responses — runs in one process with no external setup.

What status codes should a REST API return?

Use 200 OK for a successful GET, 201 Created when a POST creates a resource, 400 Bad Request when validation fails (return the error messages), 401/403 for auth problems, and 404 when a resource is not found. Returning the right code is part of a well-behaved API.

How do pagination and serialization fit together?

You page the query at the database with limit/offset to fetch one slice, then pass that slice to a Marshmallow schema with many=True to serialize it. The endpoint returns the serialized items alongside pagination metadata like page and total_pages.