API Design and REST
how programs talk to each other. the backbone of every app.
what is an API
API (Application Programming Interface) is a contract between two programs. one program exposes a set of operations, the other calls them.
when your React frontend fetches users from your backend, it uses an API. when your app calls the Groq AI service, it uses an API. when Supabase saves your goals, it uses an API.
why APIs matter: - frontend and backend are decoupled (can change independently) - one backend can serve web app, mobile app, third-party apps - multiple teams can work simultaneously - you can version your API to not break existing clients
REST
REST (Representational State Transfer) is the standard architecture for web APIs. it uses HTTP methods to perform operations on resources.
resources — the things your API manages. users, posts, goals, orders. endpoints — the URLs where you access resources. HTTP methods — the operations you perform.
HTTP method + URL = action
GET /users → list all users
GET /users/123 → get user with id 123
POST /users → create a new user
PUT /users/123 → replace user 123 completely
PATCH /users/123 → update specific fields of user 123
DELETE /users/123 → delete user 123
URL design
good URL design is clear, consistent, and predictable.
rules:
use nouns, not verbs (resources, not actions)
use plural for collections
use lowercase and hyphens
nest related resources
# good
GET /users
GET /users/123
GET /users/123/posts
GET /posts/456/comments
POST /users
DELETE /users/123
# bad
GET /getUsers
GET /user/123
GET /fetchAllPosts
POST /createNewUser
GET /user-posts/123
resource hierarchy:
/users → collection of users
/users/:id → specific user
/users/:id/posts → posts belonging to that user
/users/:id/posts/:postId → specific post of that user
/organizations/:orgId/members/:userId → member in org
query parameters for filtering, sorting, pagination:
/posts?published=true → filter
/users?city=Lucknow&age=20 → multiple filters
/posts?sort=created_at&order=desc → sort
/users?page=2&limit=20 → pagination
/users?page=2&per_page=20 → alternative pagination
/posts?fields=id,title,created_at → field selection
/users?search=abhishek → search
/posts?include=author,comments → include relations
HTTP methods in detail
GET — read data. safe and idempotent. no body.
GET /users/123
→ returns user data
→ calling it 10 times has same result as calling once
→ never changes anything
POST — create a new resource. not idempotent.
POST /users
body: { "name": "Abhishek", "email": "a@b.com" }
→ creates new user
→ calling twice creates two users
PUT — replace resource entirely. idempotent.
PUT /users/123
body: { "name": "Abhishek Kumar", "email": "a@b.com", "age": 20 }
→ replaces all fields of user 123
→ fields not in body are set to null/default
PATCH — update specific fields. idempotent.
PATCH /users/123
body: { "age": 21 }
→ updates only age, other fields unchanged
→ most common for partial updates
DELETE — remove resource. idempotent.
DELETE /users/123
→ deletes user 123
→ calling again: still deleted (idempotent)
HTTP status codes
always return the right status code. clients use these to understand what happened.
2xx — success
200 OK → standard success
201 Created → resource created (POST)
204 No Content → success, no body (DELETE)
3xx — redirect
301 Moved Permanently → resource moved, update your URL
302 Found → temporary redirect
304 Not Modified → cached response still valid
4xx — client error (caller's fault)
400 Bad Request → invalid input, validation failed
401 Unauthorized → not authenticated (not logged in)
403 Forbidden → authenticated but no permission
404 Not Found → resource does not exist
405 Method Not Allowed → wrong HTTP method
409 Conflict → conflict (duplicate email)
410 Gone → resource permanently deleted
422 Unprocessable → validation error with details
429 Too Many Requests → rate limited
5xx — server error (your fault)
500 Internal Server Error → something broke on server
502 Bad Gateway → upstream server failed
503 Service Unavailable → server overloaded or down
504 Gateway Timeout → upstream server timed out
request and response format
request headers:
Content-Type: application/json → body format
Authorization: Bearer <jwt-token> → auth token
Accept: application/json → what format you want back
X-Request-ID: abc-123 → tracking ID
request body (POST/PUT/PATCH):
{
"name": "Abhishek Kumar",
"email": "abhishek@example.com",
"age": 20
}response structure — be consistent:
{
"data": {
"id": "123",
"name": "Abhishek Kumar",
"email": "abhishek@example.com",
"created_at": "2026-03-06T10:00:00Z"
},
"message": "User created successfully"
}error response:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email address",
"details": [
{ "field": "email", "message": "Must be a valid email" },
{ "field": "age", "message": "Must be at least 18" }
]
}
}list response with pagination:
{
"data": [...],
"pagination": {
"page": 2,
"per_page": 20,
"total": 150,
"total_pages": 8,
"has_next": true,
"has_prev": true
}
}authentication
how JWT works:
1. user logs in with email + password
2. server verifies credentials
3. server creates JWT containing user ID and signs it
4. server sends JWT to client
5. client stores JWT (localStorage or cookie)
6. every request: client sends JWT in Authorization header
7. server validates JWT on every request
8. server knows who the user is from JWT payload
JWT structure:
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJleHAiOjE3...
decoded:
header: { "alg": "HS256", "typ": "JWT" }
payload: { "userId": "123", "email": "a@b.com", "exp": 1735689600 }
signature: HMAC-SHA256(header + payload, secret_key)
the signature is what makes it tamper-proof. if anyone changes the payload, the signature becomes invalid.
sending JWT in requests:
fetch('/api/goals', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})API key authentication (for server-to-server):
x-api-key: your-secret-api-key
building a REST API with FastAPI
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
app = FastAPI(title="StudentOS API", version="1.0.0")
# CORS — allow frontend to call API
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.abhishekkumar.xyz", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# models
class GoalCreate(BaseModel):
title: str
date: str
class GoalUpdate(BaseModel):
title: Optional[str] = None
completed: Optional[bool] = None
class GoalResponse(BaseModel):
id: str
title: str
completed: bool
date: str
created_at: datetime
# routes
@app.get("/api/goals", response_model=list[GoalResponse])
async def get_goals(user_id: str = Depends(get_current_user)):
goals = await db.fetch_goals(user_id)
return goals
@app.post("/api/goals", response_model=GoalResponse, status_code=201)
async def create_goal(
goal: GoalCreate,
user_id: str = Depends(get_current_user)
):
created = await db.create_goal({ **goal.dict(), "user_id": user_id })
return created
@app.patch("/api/goals/{goal_id}", response_model=GoalResponse)
async def update_goal(
goal_id: str,
updates: GoalUpdate,
user_id: str = Depends(get_current_user)
):
goal = await db.get_goal(goal_id)
if not goal:
raise HTTPException(status_code=404, detail="Goal not found")
if goal.user_id != user_id:
raise HTTPException(status_code=403, detail="Not your goal")
updated = await db.update_goal(goal_id, updates.dict(exclude_none=True))
return updated
@app.delete("/api/goals/{goal_id}", status_code=204)
async def delete_goal(
goal_id: str,
user_id: str = Depends(get_current_user)
):
goal = await db.get_goal(goal_id)
if not goal:
raise HTTPException(status_code=404, detail="Goal not found")
if goal.user_id != user_id:
raise HTTPException(status_code=403, detail="Not your goal")
await db.delete_goal(goal_id)validation
validate everything that comes from the client. never trust input.
from pydantic import BaseModel, validator, EmailStr, constr
class UserCreate(BaseModel):
name: constr(min_length=2, max_length=50)
email: EmailStr
password: constr(min_length=8)
age: int
@validator('age')
def age_must_be_valid(cls, v):
if v < 13 or v > 120:
raise ValueError('Age must be between 13 and 120')
return v
@validator('name')
def name_must_be_clean(cls, v):
if not v.replace(' ', '').isalpha():
raise ValueError('Name must contain only letters')
return v.strip()rate limiting
prevent abuse by limiting how many requests a client can make.
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/api/login")
@limiter.limit("5/minute") # max 5 login attempts per minute
async def login(request: Request, credentials: LoginForm):
...
@app.get("/api/goals")
@limiter.limit("100/minute") # 100 reads per minute is fine
async def get_goals():
...API versioning
as your API evolves, you need to add features without breaking existing clients.
URL versioning (most common):
/api/v1/users
/api/v2/users
header versioning:
API-Version: 2
accept header versioning:
Accept: application/vnd.myapi.v2+json
# FastAPI versioning
from fastapi import APIRouter
v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")
@v1.get("/users")
async def get_users_v1():
return [{ "id": 1, "name": "Abhishek" }]
@v2.get("/users")
async def get_users_v2():
return {
"data": [{ "id": 1, "name": "Abhishek", "avatar": "..." }],
"pagination": { "total": 1 }
}
app.include_router(v1)
app.include_router(v2)API documentation
FastAPI auto-generates documentation. access at /docs
(Swagger) and /redoc.
app = FastAPI(
title="StudentOS API",
description="API for tracking student progress",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
@app.post("/api/goals", response_model=GoalResponse, status_code=201,
summary="Create a new goal",
description="Creates a daily micro goal for the authenticated user")
async def create_goal(
goal: GoalCreate = Body(..., example={
"title": "Study React hooks for 1 hour",
"date": "2026-03-06"
})
):
"""
Create a new goal with:
- **title**: what you want to achieve
- **date**: the date for this goal
"""
...calling APIs from frontend
// base API client
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const api = {
async get(path) {
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}${path}`, {
headers: {
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json'
}
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.detail || 'Request failed')
}
return res.json()
},
async post(path, body) {
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}${path}`, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.detail || 'Request failed')
}
return res.json()
},
async patch(path, body) { /* similar */ },
async delete(path) { /* similar */ }
}
// usage
const goals = await api.get('/api/goals')
const newGoal = await api.post('/api/goals', { title: 'Study SQL', date: '2026-03-06' })=^._.^= a good API is a joy to use. a bad one is a nightmare.