Building a JSON API Endpoint
Serve data instead of a page
In this lesson
Explain it like I’m 5
Building an API means your app answers with the ingredients instead of the finished meal, so another program can cook with them.
A route that returns data
from flask import Flask, jsonify
app = Flask(__name__)
TASKS = [
{"id": 1, "title": "Write tests", "done": False},
{"id": 2, "title": "Ship it", "done": True},
]
@app.route("/api/tasks")
def list_tasks():
return jsonify({"tasks": TASKS, "count": len(TASKS)})
Pick a response shape and keep it
The single kindest thing you can do for whoever calls your API, including future you, is to answer with the same shape every time. If a successful list request returns {"tasks": [...], "count": 2}, then an empty result should return {"tasks": [], "count": 0}, not null, not a bare [], and not a different key.
Wrapping the payload in an object rather than returning a bare list also leaves you room to add fields later, count, paging info, a warning, without breaking every existing caller.
def list_response(tasks):
return {"tasks": tasks, "count": len(tasks)}
print(list_response([{"id": 1, "title": "Write tests"}]))
print(list_response([]))
{'tasks': [{'id': 1, 'title': 'Write tests'}], 'count': 1}
{'tasks': [], 'count': 0}
Write the endpoint’s data function. api_tasks() should return a dict with a tasks list and a count, then print the count followed by each task’s id and title.
TASKS = [
{"id": 1, "title": "Write tests", "done": False},
{"id": 2, "title": "Ship it", "done": True},
]
def api_tasks():
return {} # TODO: return tasks and count
result = api_tasks()
print(result["count"])
for task in result["tasks"]:
print(task["id"], task["title"])
Return {"tasks": TASKS, "count": len(TASKS)}. len() on the list gives the count, so the two can never disagree.
TASKS = [
{"id": 1, "title": "Write tests", "done": False},
{"id": 2, "title": "Ship it", "done": True},
]
def api_tasks():
return {"tasks": TASKS, "count": len(TASKS)}
result = api_tasks()
print(result["count"])
for task in result["tasks"]:
print(task["id"], task["title"])
2
1 Write tests
2 Ship it
Accepting data, and testing without a browser
A GET endpoint hands data out. To take data in, a route accepts POST and reads the request body, usually JSON, with request.get_json(). You will meet the full set of methods in the next lesson; the shape is what matters here.
You cannot click a POST endpoint in a browser, so test from the command line with curl, or better, with Flask’s test client, which calls your app in-process with no server running.
@app.route("/api/tasks", methods=["POST"])
def create_task():
payload = request.get_json()
title = (payload or {}).get("title", "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
task = {"id": len(TASKS) + 1, "title": title, "done": False}
TASKS.append(task)
return jsonify({"task": task}), 201
def test_list_tasks():
client = app.test_client()
response = client.get("/api/tasks")
assert response.status_code == 200
data = response.get_json()
assert data["count"] == len(data["tasks"])
Common mistake: Returning a bare list from an endpoint
The data is a list, so returning it directly feels honest.
Wrap it in an object: {"tasks": [...]}. You can then add fields later without breaking callers, and you avoid a long-standing security wrinkle with top-level JSON arrays.
Common mistake: Changing the response shape between cases
Returning null or an error string for the empty case seems more informative.
Keep the keys identical and let the values be empty. Callers should never need to check which shape they got.
Common mistake: Storing what the caller sent without validating it
The payload arrives as a convenient dict, so it looks ready to use.
Check required fields exist and are non-blank before doing anything with them, and return 400 with a clear message when they aren’t. Never trust an incoming request.
What does jsonify do that returning a plain string does not?
jsonify both serializes the data and labels the response so the caller knows how to read it.
An endpoint returns no matching tasks. What should it send?
An empty result is still a successful request. Keeping the shape means callers never special-case emptiness.
Why use Flask's test client instead of curl?
The test client makes requests directly against your app, which is fast and works in automated test runs.
Mini exercise (medium)
Write api_response(items, page_size) that returns a consistent API shape: the key items holding at most page_size entries, count for how many were returned, and has_more saying whether any were left over. It must return the same keys even when items is empty.
Practice here. Fill in the missing piece and click Run to try your answer in place.
def api_response(items, page_size):
return {} # TODO: items (at most page_size), count, has_more
print(api_response(["a", "b", "c"], 2))
print(api_response([], 2))
Slice with items[:page_size]. count is the length of the slice, and has_more is len(items) > page_size.
def api_response(items, page_size):
page = items[:page_size]
return {"items": page, "count": len(page), "has_more": len(items) > page_size}
print(api_response(["a", "b", "c"], 2))
print(api_response([], 2))
{'items': ['a', 'b'], 'count': 2, 'has_more': True}
{'items': [], 'count': 0, 'has_more': False}
r = api_response([], 5)
assert set(r) == {"items", "count", "has_more"}, "the empty case must return the same keys"
assert api_response([1, 2], 5)["has_more"] is False, "has_more is False when nothing is left over"
print("✓ Looks good!")