RESTful APIs
Lesson 1 of 5
1RESTful APIs: Consuming, Building & Securing
The big pictureHow programs talk to each other over HTTP: you read the REST contract and the protocol basics, consume an API with curl and Python, build one with http.server and then Flask, secure it, and document it with the OpenAPI standard.
What the picture shows: The client sends POST to create a drink and the server answers 201, then sends GET to read it and gets 200 with the data.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
An API is like a restaurant order window: you do not walk into the kitchen; you write your order in an agreed format ("I want dish number 3"), and the dish comes back with a code saying it worked or that the dish does not exist. REST is a set of rules that makes every window work the same way.
In REST each resource has a URL like /tasks/7, and the verb sets the action: GET reads, POST creates, PUT/PATCH update and DELETE removes. The response carries a status code (200, 201, 404, 500) and a JSON body. You consume it with curl or requests, build it with Flask, and describe it with OpenAPI.
Required Fundamentals
Dicts and lists (the Data structures lesson)
Request and response bodies are usually JSON, which in Python is a dict or a list. Whoever is comfortable with dict.get will be comfortable with APIs.
JSON serialization (the Serialization lesson)
json.dumps and json.loads are what happen on every request and response, even when libraries hide them.
Decorators (the Design patterns lesson)
Flask binds URLs to functions with a decorator: @app.get("/drinks"). And you will write an authentication decorator yourself.
the Python and databases lesson: databases
A real API usually stands in front of a database; here we use an in-memory dict to keep the focus on HTTP, and swapping it for the session you just learned is easy.
Core Concepts
An API is a contract between two programs: “send me a request shaped like this and I will give you a response shaped like that.” REST is a style for designing that contract on top of HTTP so it stays uniform and scalable.
1What is REST? The six constraints
Roy Fielding defined these constraints in his dissertation. An interface that respects them is called RESTful:
| Constraint | What it means | In practice |
|---|---|---|
| Client–server | Separate the interface from data storage so each can evolve independently. | A mobile app and a website use the same server. |
| Stateless | Each request carries everything needed to understand it; the server does not remember “who you are” between requests. | The user’s identity travels with every request (the Authorization header). |
| Cacheable | Responses are labeled cacheable or not. | A read GET can be cached; POST usually is not. |
| Uniform interface | Resources have identifiers, are manipulated through representations (such as JSON), and messages describe themselves. | /drinks/2 always means “drink 2”, and the method (GET/DELETE) decides the action. |
| Layered system | Each component sees only the adjacent layer. | You can put a load balancer or CDN in front of the server without changing the client. |
| Code on demand (optional) | Clients may download code that extends their abilities. | Rarely needed for JSON APIs. |
2Anatomy of HTTP: request and response
POST /drinks HTTP/1.1 <- request line: method + path + version
Host: localhost:5055 <- headers
Content-Type: application/json
Authorization: Bearer <token>
<- blank line
{"name": "V60", "price_halalas": 1800} <- body
HTTP/1.1 201 CREATED <- status line: version + code + reason
Content-Type: application/json
Location: /drinks/3
<- blank line
{"id": 3, "name": "V60", "price_halalas": 1800}The URL itself has parts: https://api.cafe.example:443/drinks/2?size=large#top = scheme (https) + host + port + path + query parameters (after ?) + fragment (after #, which never even reaches the server).
3HTTP vs. HTTPS
HTTPS is the same HTTP inside a channel encrypted with TLS. It gives you three things: confidentiality (intermediaries cannot read the content), integrity (nobody can alter it undetected), and server identity (a certificate proving you are talking to the right site). The default port is 80 for HTTP and 443 for HTTPS. Note that it protects the road, not the server: a badly designed API stays bad over HTTPS.
4Methods: the requested action
| Method | Usual action | Safe? | Idempotent? | Example |
|---|---|---|---|---|
GET | Read a resource | Yes | Yes | GET /drinks/2 |
POST | Create a new resource (or run an action) | No | No | POST /drinks |
PUT | Replace a resource entirely | No | Yes | PUT /drinks/2 |
PATCH | Partial update | No | No (may be) | PATCH /drinks/2 |
DELETE | Delete a resource | No | Yes | DELETE /drinks/2 |
HEAD / OPTIONS | Headers only / what is allowed | Yes | Yes | OPTIONS /drinks |
Safe = does not change server state. Idempotent = repeating the request several times has the same effect as once: deleting drink 2 twice leaves the same final state (although the response may change from 204 to 404). Repeating POST creates new copies, which is why clients are careful about auto-retrying it after a dropped connection.
5Status codes
| Family | Meaning | Codes you will use |
|---|---|---|
2xx | Success | 200 OK · 201 Created (with a Location header) · 204 No Content (success, no body) |
3xx | Redirection | 301 Moved Permanently · 304 Not Modified |
4xx | Client error | 400 Bad Request · 401 Unauthorized · 403 Forbidden · 404 Not Found · 405 Method Not Allowed · 409 Conflict · 415 Unsupported Media Type · 429 Too Many Requests |
5xx | Server error | 500 Internal Server Error · 502 · 503 Service Unavailable |
200 OK and hiding failure inside the body ({"success": false}). Clients, intermediaries and tools read the status code first; a lying code breaks retries, caching and monitoring. The code says what happened, the body explains why.
6Designing resources and URLs
Nouns, not verbs
GET /drinks, not /getDrinks. The method carries the verb.
Plural for collections
/drinks is a collection and /drinks/2 one item in it.
Filtering in the query string
/drinks?max_price=1500&sort=name, not a new path for every filter.
Versioning
/v1/drinks lets you change the contract later without breaking old clients.
7Consuming an API from the command line: curl
| Option | What it does |
|---|---|
-i | Prints response headers along with the body |
-s | Silent: hides the progress meter |
-X METHOD | Sets the method (POST, DELETE…) |
-H "Name: value" | Adds a header |
-d '...' | Sends the request body (and defaults the method to POST) |
-w "%{http_code}" | Prints information such as the status code after finishing |
-o /dev/null | Discards the body (handy with -w) |
-L | Follows redirects |
8Consuming an API from Python: requests
Always set timeout
Without it your program can wait forever; requests sets no timeout by default.
raise_for_status()
Raises HTTPError for 4xx/5xx. A successful r.json() does not mean the request succeeded, because error messages are JSON too.
json= and params=
json= encodes the dict and sets Content-Type; params= builds the query string.
Exceptions
ConnectionError (network), Timeout and HTTPError all inherit from RequestException.
9Building an API: http.server, then Flask
http.server in the standard library shows the mechanism bare: a class you inherit and write do_GET and do_POST in, composing the response by hand (send_response, then send_header, then end_headers, then writing to wfile). There is no routing, no validation and no JSON conversion, and Python’s own documentation warns that it is not recommended for production and implements only basic security checks. Flask gives you what is missing: a routing table through decorators, path converters like <int:drink_id>, jsonify, and error handlers.
http.server | Flask | |
|---|---|---|
| Routing | if self.path == … by hand | decorator @app.get("/drinks/<int:id>") |
| JSON | json.dumps + encoding + Content-Length | jsonify or returning a dict |
| Errors | You compose every response | return …, 404 and @app.errorhandler |
| Use | Learning the mechanism and tiny tools | Real APIs (behind a production server) |
Security and authentication
Authentication
Who are you? You prove your identity. Failing it means 401 Unauthorized with a WWW-Authenticate header telling the client how to try.
Authorization
What are you allowed to do? Your identity is known but your permission is not enough: 403 Forbidden. (A server may return 404 to hide that the resource exists.)
Basic
Authorization: Basic base64(user:password). Remember: base64 is a reversible encoding, not encryption, so it is only acceptable over HTTPS.
Bearer token
Authorization: Bearer <token>: “whoever bears this ticket is its owner.” Hand out the ticket after login and send it with every request (consistent with statelessness).
JWT
A three-part ticket header.payload.signature: the first two are base64url-encoded JSON (readable without any key!), the third is a signature that prevents forgery. Put no secrets in it, and verify the signature and the exp expiry.
Passwords
Never stored as they are: store a hash from a dedicated slow algorithm (such as scrypt or bcrypt), and never write them in code, in the repository, or in the URL.
Everyday checks
Validate every input (type, length, range), reject what you do not support with 405 (method) or 415 (content type), limit the request rate (429), and log authentication failures.
1️⃣1️⃣ Standards and documentation: OpenAPI
OpenAPI (formerly the Swagger Specification) is a YAML or JSON format that describes an API: paths, methods, parameters, request and response shapes, and authentication. Its strength is being readable by humans and machines alike: from the same file you can generate an interactive docs page (Swagger UI), ready-made clients, server stubs, and automatically check responses in tests.
| File section | Describes |
|---|---|
openapi / info / servers | Spec version, the API name and version, and server addresses |
paths | Every path with its methods, parameters, request bodies, and responses by code |
components/schemas | Reusable data shapes (such as Drink) referenced with $ref |
components/securitySchemes | Authentication methods (such as bearer) and which operations require them |
How an API Works, Step by Step
Four pictures: the path of one request from client to server, the status-code decision tree, the difference between http.server and Flask, and the authentication ticket.
Diagram 32, The journey of a create request: the response carries 201 and the new resource’s address in Location so the client knows where to find it.
Diagram 33, The status-code decision tree: ask the questions in this order (identity, permission, input validity, resource existence) and the right code falls out automatically.
Diagram 34, The same job along two paths: in http.server you build every step, in Flask the framework builds routing and encoding and leaves you the business logic.
Diagram 35, The authentication ticket: issued once after login and sent with every later request, with no server-side session kept between requests.
Worked Example: the Coffee Menu API from Scratch
We build the coffee menu API (/drinks) and try it with all three tools. Every piece of code and output below was actually run.
Step 1, Design the resources before writing a line
| Request | Action | Success response | Expected errors |
|---|---|---|---|
GET /drinks | List the drinks | 200 | , |
GET /drinks/{id} | One drink | 200 | 404 |
POST /drinks | Create a drink | 201 + Location | 400 |
DELETE /drinks/{id} | Delete a drink | 204 | 404 · 401 |
Step 2, The server with Flask
from flask import Flask, jsonify, request
app = Flask(__name__)
drinks = {1: {"id": 1, "name": "Flat White", "price_halalas": 1400},
2: {"id": 2, "name": "Cortado", "price_halalas": 1200}}
next_id = 3
@app.get("/drinks")
def list_drinks():
return jsonify(list(drinks.values()))
@app.get("/drinks/<int:drink_id>")
def get_drink(drink_id):
drink = drinks.get(drink_id)
if drink is None:
return jsonify(error="drink not found"), 404
return jsonify(drink)
@app.post("/drinks")
def create_drink():
global next_id
data = request.get_json(silent=True)
if not data or "name" not in data or not isinstance(data.get("price_halalas"), int):
return jsonify(error="name and integer price_halalas are required"), 400
drink = {"id": next_id, "name": data["name"], "price_halalas": data["price_halalas"]}
drinks[next_id] = drink
next_id += 1
resp = jsonify(drink)
resp.status_code = 201
resp.headers["Location"] = f"/drinks/{drink['id']}"
return resp
@app.delete("/drinks/<int:drink_id>")
def delete_drink(drink_id):
if drinks.pop(drink_id, None) is None:
return jsonify(error="drink not found"), 404
return "", 204
if __name__ == "__main__":
app.run(port=5055)Step 3, Try it with curl
$ curl -i localhost:5055/drinks
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 99
[{"id":1,"name":"Flat White","price_halalas":1400},{"id":2,"name":"Cortado","price_halalas":1200}]
$ curl -i localhost:5055/drinks/99
HTTP/1.1 404 NOT FOUND
{"error":"drink not found"}
$ curl -i -X POST localhost:5055/drinks -H 'Content-Type: application/json' \
-d '{"name":"V60","price_halalas":1800}'
HTTP/1.1 201 CREATED
Location: /drinks/3
{"id":3,"name":"V60","price_halalas":1800}
$ curl -i -X POST localhost:5055/drinks -H 'Content-Type: application/json' -d '{"name":"V60"}'
HTTP/1.1 400 BAD REQUEST
{"error":"name and integer price_halalas are required"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:5055/drinks/3
204Step 4, Consume it from Python
import requests
BASE = "http://localhost:5055"
r = requests.get(f"{BASE}/drinks", timeout=5)
print(r.status_code, r.headers["Content-Type"], r.json()[0])
# 200 application/json {'id': 1, 'name': 'Flat White', 'price_halalas': 1400}
r = requests.get(f"{BASE}/drinks/99", timeout=5)
print(r.status_code, r.json()) # 404 {'error': 'drink not found'}
try:
r.raise_for_status()
except requests.HTTPError as e:
print("HTTPError:", e) # HTTPError: 404 Client Error: NOT FOUND for url: ...
r = requests.post(f"{BASE}/drinks", json={"name": "Mocha", "price_halalas": 1600}, timeout=5)
print(r.status_code, r.headers["Location"], r.json())
# 201 /drinks/3 {'id': 3, 'name': 'Mocha', 'price_halalas': 1600}Step 5, The bare version with http.server (to see what Flask hides)
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
DRINKS = [{"id": 1, "name": "Flat White"}, {"id": 2, "name": "Cortado"}]
class Handler(BaseHTTPRequestHandler):
def _send(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/drinks":
self._send(200, DRINKS)
else:
self._send(404, {"error": "not found"})
HTTPServer(("127.0.0.1", 5056), Handler).serve_forever()We tried it: curl -i localhost:5056/drinks returns HTTP/1.0 200 OK with Content-Type: application/json; charset=utf-8, and any other path returns 404. Notice that you wrote the encoding, the content length and every header by hand, and there is no room for POST except by reading self.rfile.read(length) yourself and validating it by hand.
Step 6, Protect deletion with a token
import hmac
from functools import wraps
from flask import request, jsonify
API_TOKEN = "..." # in real life: from an environment variable, never from the code
def require_token(view):
@wraps(view)
def wrapper(*args, **kwargs):
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
if scheme != "Bearer" or not hmac.compare_digest(token, API_TOKEN):
return jsonify(error="authentication required"), 401, {"WWW-Authenticate": "Bearer"}
return view(*args, **kwargs)
return wrapper
@app.delete("/drinks/<int:drink_id>")
@require_token
def delete_drink(drink_id):
...We tried it with Flask’s test client: a request with no header returns 401 with WWW-Authenticate: Bearer, a wrong token returns 401, and the correct token returns 204. Note the use of hmac.compare_digest for a constant-time comparison instead of ==.
from werkzeug.security import generate_password_hash, check_password_hash
h = generate_password_hash("correct horse") # starts with scrypt:... and carries its salt inside
check_password_hash(h, "correct horse") # True
check_password_hash(h, "nope") # FalseStep 7, See what is inside a JWT (to understand why you put no secrets in it)
import base64, hashlib, hmac, json
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": "noura", "role": "barista", "exp": 1790000000}, separators=(",", ":")).encode())
sig = b64(hmac.new(b"demo-secret", f"{header}.{payload}".encode(), hashlib.sha256).digest())
token = f"{header}.{payload}.{sig}"
print(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
# b'{"sub":"noura","role":"barista","exp":1790000000}' <- without any key!exp, iss and aud, and never let the token’s header choose the verification algorithm.
Step 8, Document the contract with OpenAPI
One YAML file (we checked its structure parses with a YAML reader) describes the API we built. Paste it into the Swagger Editor to see the interactive docs page:
openapi: 3.0.3
info:
title: Coffee Menu API
version: "1.0.0"
servers:
- url: http://localhost:5055
paths:
/drinks:
get:
summary: List all drinks
responses:
"200":
description: A JSON array of drinks
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Drink"
post:
summary: Create a drink
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewDrink"
responses:
"201":
description: Created
headers:
Location:
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
"400":
description: Invalid input
/drinks/{drinkId}:
parameters:
- name: drinkId
in: path
required: true
schema:
type: integer
get:
summary: Get one drink
responses:
"200":
description: The drink
content:
application/json:
schema:
$ref: "#/components/schemas/Drink"
"404":
description: Not found
delete:
summary: Delete a drink
security:
- bearerAuth: []
responses:
"204":
description: Deleted
"401":
description: Missing or invalid token
"404":
description: Not found
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
NewDrink:
type: object
required: [name, price_halalas]
properties:
name:
type: string
price_halalas:
type: integer
minimum: 0
Drink:
allOf:
- $ref: "#/components/schemas/NewDrink"
- type: object
properties:
id:
type: integerMyth vs. Reality
| The myth | The reality |
|---|---|
| “REST = JSON over HTTP.” | JSON is just a common representation. The heart of REST is the design constraints: resources, a uniform interface, statelessness, layers. The representation could be XML or something else. |
| “HTTPS makes my API secure.” | It protects the road only. Authentication, authorization, input validation and error handling remain your responsibility. |
| “base64 hides the password in Basic Auth.” | base64 is an encoding undone in one line. The only protection is HTTPS. |
| “PUT and POST are the same.” | PUT is idempotent: repeating gives the same result. POST is not: every repetition may create new resources. |
| “The dev server is enough to deploy.” | http.server and flask run are not meant for production, according to their own documentation. |
Where Are APIs Used in Practice?
Mobile and web apps
The front end talks to the server through REST; the same API serves every client.
Connecting systems
Payments, maps, messaging and calendars are all APIs you consume instead of building.
AI tools
Calling a language model from your program is at heart a POST request with a JSON body and an authentication header.
Testing and automation
curl, requests and the Flask test client let you check the API automatically on every change.
Try it yourself
The server created a new resource after a POST. Which status code fits?
201 says "something new was made", usually with the new resource's address or data.
Quick Review Flashcards
Check Your Understanding
1. You delete a drink with DELETE /drinks/2 and it succeeds (204), then repeat the same request and get 404. Did the API violate idempotency?
No. Idempotency is about the final server state, not the response code: after the first and second request the drink is gone in both cases. A different response (204 then 404) is normal.
2. An API returns 200 with {"error": "not found"} when a resource does not exist. What is wrong?
The code lies: tools and intermediaries read the code first and think the request succeeded (and may cache it). The right answer is 404 with the message in the body.
3. (Spot the bug) A logged-in user asks to delete a drink they are not permitted to, and the API returns 401. What is the more precise code?
403 Forbidden: his identity is known but his permission is not enough. 401 is for when identity is not established at all.
4. Why not store passwords as they are even in a private database?
Because a leak of the database (a backup, an injection, an employee) exposes all of them. Storing a slow salted hash makes recovery expensive even after the leak.
5. (Decision) You want GET /drinks public and DELETE for authenticated users only. How do you do that in Flask without duplicating code?
With a require_token decorator placed only above the protected view functions, so the authentication logic lives in one place (as in step 6).
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs3
Done with this lesson?
Sign in to save your progress.