Project: A Flask micro web app
Build a small Flask JSON service with an app factory, validated requests, honest status codes, and tests that never start a live server.
By the end, you’ll be able to…
- Explain the request/response contract of a small web service.
- Organize a Flask app with a factory and a blueprint.
- Validate JSON at the boundary and return consistent errors.
- Test routes with Flask's test client.
01A web app is a request/response contract
A route maps a method and a path to a handler. The handler reads the request, applies a rule, and returns a response with a status code. GET reads, POST creates, PUT or PATCH updates, and DELETE removes. The status code is part of the contract: 201 for a created resource, 404 for a missing one, and 422 for a well-formed request with invalid fields.
Design the resource before the code. A task has an id, a title, and a completed flag. Decide which fields are accepted on create, which are returned, and what an error body looks like. Consistency matters more than cleverness: a client should be able to handle every failure the same way.
GET /tasks -> 200 [task, ...]POST /tasks -> 201 task | 400/422 errorGET /tasks/<id> -> 200 task | 404 errorPATCH /tasks/<id> -> 200 task | 404/422 errorDELETE /tasks/<id> -> 204 no body | 404 error02Use an app factory
An app factory is a function that creates and configures the Flask application. It accepts configuration, registers blueprints, and returns the app. Tests can call the factory with a test configuration instead of importing a globally configured app, which keeps test state isolated.
A blueprint groups related routes. The factory registers the blueprint under a URL prefix. Keep the entry point thin: create the app and run it only under the main guard, or let a production server import the factory.
from flask import Flask, Blueprint, current_app, jsonify, requestfrom validation import parse_createapi = Blueprint("api", __name__)@api.get("/tasks")def list_tasks(): return jsonify(list(current_app.extensions["tasks"].values()))@api.post("/tasks")def create_task(): payload = request.get_json(silent=True) fields, error = parse_create(payload) if error is not None: return error store = current_app.extensions["tasks"] task_id = current_app.extensions["next_id"] current_app.extensions["next_id"] = task_id + 1 task = {"id": task_id, **fields} store[task_id] = task return jsonify(task), 201def create_app(config=None): app = Flask(__name__) if config: app.config.update(config) app.extensions["tasks"] = {} app.extensions["next_id"] = 1 app.register_blueprint(api, url_prefix="/api") return appif __name__ == "__main__": create_app().run(debug=True)03Validate JSON at the boundary
Use request.get_json(silent=True) so malformed JSON becomes a controlled 400 rather than an HTML error page. Check that the body is a dictionary, that required keys are present, and that each value has the expected type. Return a 422 with a field-level message when the shape is valid but the content is not.
Do not trust a client-supplied id or completed flag on create. Assign the id on the server and set the initial state from your own rule. This prevents a caller from choosing an identifier that collides with an existing record or marking a new task complete before it exists.
from flask import jsonifydef parse_create(payload): if not isinstance(payload, dict): return None, (jsonify(error="body must be a JSON object"), 400) title = payload.get("title") if not isinstance(title, str) or not title.strip(): return None, (jsonify(error="title is required"), 422) return {"title": title.strip(), "completed": False}, None04Test with the test client
Flask's test client sends requests to the app without opening a port. A pytest fixture creates a fresh app for each test, so a task created in one test does not leak into another. Assert the status code and the JSON body, not just that the request succeeded.
Cover the happy path, a validation failure, and a missing resource. Those three tests catch most boundary regressions. When the service grows, add a test for duplicate creation and for an unsupported method.
import pytestfrom app_factory import create_app@pytest.fixturedef client(): app = create_app({"TESTING": True}) with app.test_client() as test_client: yield test_clientdef test_list_is_empty(client): response = client.get("/api/tasks") assert response.status_code == 200 assert response.get_json() == []def test_create_rejects_blank_title(client): response = client.post("/api/tasks", json={"title": " "}) assert response.status_code == 422 assert "title" in response.get_json()["error"]The ideas worth keeping.
- A route is a method, a path, a handler, and a status code.
- The app factory keeps configuration and test state isolated.
- Validate at the boundary and test with the client, not a live server.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Add a read-one route
Add GET /tasks/<int:task_id> that returns the task or a 404 JSON error. Use the same error shape as the other routes.
Explore a worked solution
from flask import current_app, jsonify@api.get("/tasks/<int:task_id>")def get_task(task_id): task = current_app.extensions["tasks"].get(task_id) if task is None: return jsonify(error="task not found"), 404 return jsonify(task)The converter <int:task_id> rejects non-numeric paths before the handler runs. The handler still checks existence because a valid integer can identify a missing record.
Test a validation failure
Write a test that posts a task with a numeric title and asserts a 422 response with a field-level error. Use the test client fixture.
Explore a worked solution
def test_create_rejects_non_string_title(client): response = client.post("/api/tasks", json={"title": 42}) assert response.status_code == 422 body = response.get_json() assert body["error"] == "title is required"A numeric title is syntactically valid JSON but violates the field contract. The test documents the difference between malformed JSON (400) and invalid fields (422).
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Add a read-one route
Add GET /tasks/<int:task_id> that returns the task or a 404 JSON error. Use the same error shape as the other routes.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this lesson, when you want a second angle or the full details.
One lesson closer.
Reading is a start. Trying it yourself makes the connection.