14. Backend: FastAPI
An API (Application Programming Interface) is just a small program that answers requests. You send it a question like “give me item 42,” it sends back an answer in JSON.
Think of it as a helpful librarian for your data.
Note on JSON: JSON (JavaScript Object Notation) is a lightweight, text-based format for structuring data as key–value pairs and arrays that’s easy for humans to read and for machines to parse.
FastAPI publishes a menu of everything your API serves (paths, inputs, outputs).
Generally, you would open http://127.0.0.1:8000/docs and you get buttons you can click to try the API. That “menu” (called OpenAPI) is also usable by other tools to interact with your app.
In full stack, FastAPI is typically the backend: it runs on the server, exposes HTTP APIs, handles business logic, talks to databases/queues, and is served by an ASGI server like Uvicorn; while the frontend (e.g., Next.js/React) calls those FastAPI endpoints over HTTP.
ASGI (Asynchronous Server Gateway Interface) is the Python standard that connects web servers to async-capable apps (like FastAPI), enabling concurrency and long-lived connections (i.e., can handle multiple requests in overlapping time).
[1]:
!pip install "fastapi>=0.115" "uvicorn[standard]>=0.30"
Collecting fastapi>=0.115
Downloading fastapi-0.121.0-py3-none-any.whl.metadata (28 kB)
Collecting uvicorn>=0.30 (from uvicorn[standard]>=0.30)
Using cached uvicorn-0.38.0-py3-none-any.whl.metadata (6.8 kB)
Collecting starlette<0.50.0,>=0.40.0 (from fastapi>=0.115)
Downloading starlette-0.49.3-py3-none-any.whl.metadata (6.4 kB)
Collecting pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4 (from fastapi>=0.115)
Using cached pydantic-2.12.3-py3-none-any.whl.metadata (87 kB)
Requirement already satisfied: typing-extensions>=4.8.0 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from fastapi>=0.115) (4.15.0)
Collecting annotated-doc>=0.0.2 (from fastapi>=0.115)
Using cached annotated_doc-0.0.3-py3-none-any.whl.metadata (6.6 kB)
Collecting click>=7.0 (from uvicorn>=0.30->uvicorn[standard]>=0.30)
Using cached click-8.3.0-py3-none-any.whl.metadata (2.6 kB)
Requirement already satisfied: h11>=0.8 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from uvicorn>=0.30->uvicorn[standard]>=0.30) (0.16.0)
Collecting httptools>=0.6.3 (from uvicorn[standard]>=0.30)
Using cached httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl.metadata (3.5 kB)
Collecting python-dotenv>=0.13 (from uvicorn[standard]>=0.30)
Downloading python_dotenv-1.2.1-py3-none-any.whl.metadata (25 kB)
Requirement already satisfied: pyyaml>=5.1 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from uvicorn[standard]>=0.30) (6.0.3)
Collecting uvloop>=0.15.1 (from uvicorn[standard]>=0.30)
Using cached uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl.metadata (4.9 kB)
Collecting watchfiles>=0.13 (from uvicorn[standard]>=0.30)
Using cached watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl.metadata (4.9 kB)
Requirement already satisfied: websockets>=10.4 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from uvicorn[standard]>=0.30) (15.0.1)
Collecting annotated-types>=0.6.0 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi>=0.115)
Using cached annotated_types-0.7.0-py3-none-any.whl.metadata (15 kB)
Collecting pydantic-core==2.41.4 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi>=0.115)
Using cached pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl.metadata (7.3 kB)
Collecting typing-inspection>=0.4.2 (from pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4->fastapi>=0.115)
Using cached typing_inspection-0.4.2-py3-none-any.whl.metadata (2.6 kB)
Requirement already satisfied: anyio<5,>=3.6.2 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from starlette<0.50.0,>=0.40.0->fastapi>=0.115) (4.11.0)
Requirement already satisfied: idna>=2.8 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from anyio<5,>=3.6.2->starlette<0.50.0,>=0.40.0->fastapi>=0.115) (3.10)
Requirement already satisfied: sniffio>=1.1 in /Users/boris/MPhil/ResearchComputing/venvs/c1_base_env/lib/python3.12/site-packages (from anyio<5,>=3.6.2->starlette<0.50.0,>=0.40.0->fastapi>=0.115) (1.3.1)
Downloading fastapi-0.121.0-py3-none-any.whl (109 kB)
Using cached uvicorn-0.38.0-py3-none-any.whl (68 kB)
Using cached annotated_doc-0.0.3-py3-none-any.whl (5.5 kB)
Using cached click-8.3.0-py3-none-any.whl (107 kB)
Using cached httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl (110 kB)
Using cached pydantic-2.12.3-py3-none-any.whl (462 kB)
Using cached pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl (1.9 MB)
Downloading python_dotenv-1.2.1-py3-none-any.whl (21 kB)
Downloading starlette-0.49.3-py3-none-any.whl (74 kB)
Using cached uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl (1.4 MB)
Using cached watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl (391 kB)
Using cached annotated_types-0.7.0-py3-none-any.whl (13 kB)
Using cached typing_inspection-0.4.2-py3-none-any.whl (14 kB)
Installing collected packages: uvloop, typing-inspection, python-dotenv, pydantic-core, httptools, click, annotated-types, annotated-doc, watchfiles, uvicorn, starlette, pydantic, fastapi
Successfully installed annotated-doc-0.0.3 annotated-types-0.7.0 click-8.3.0 fastapi-0.121.0 httptools-0.7.1 pydantic-2.12.3 pydantic-core-2.41.4 python-dotenv-1.2.1 starlette-0.49.3 typing-inspection-0.4.2 uvicorn-0.38.0 uvloop-0.22.1 watchfiles-1.1.1
[notice] A new release of pip is available: 25.0 -> 25.3
[notice] To update, run: pip install --upgrade pip
You can put these dependencies inside a requirements file:
#requirements.text
fastapi>=0.115
uvicorn[standard]>=0.30
numpy
scipy
matplotlib
And install with:
pip install -r requirements.txt
To test a simple example, let us create a main.py file.
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
@app.get("/")
def hello():
return {"message": "Hello 👋"}
@app.post("/items")
def create(item: Item):
return {"ok": True, "item": item}
You can then run the app with:
uvicorn main:app --reload
A common error that main appear is:
ERROR: [Errno 48] Address already in use
You can see which ports are busy with
lsof -iTCP -sTCP:LISTEN -n -P
Or if a specific port is busy with:
lsof -i :8000
Then visit http://127.0.0.1:8000/docs.
click POST /items, try sending {"name": 123, "price": "oops"} and see it politely refuse with a helpful error.
Then send {"name":"apple","price":1.2} and it works.
You can also curl to your app:
[6]:
!curl -X 'POST' \
'http://127.0.0.1:8000/items' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"name":"apple","price":1.2}'
{"ok":true,"item":{"name":"apple","price":1.2}}
[4]:
!curl -X 'POST' \
'http://127.0.0.1:8000/items' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"name":"apple","price":"p"}'
{"detail":[{"type":"float_parsing","loc":["body","price"],"msg":"Input should be a valid number, unable to parse string as a number","input":"p"}]}
[4]:
!curl http://127.0.0.1:8000/
{"message":"Hello 👋"}
In these curl commands the -X option is setting teh HTTP method. You usually don’t need -X for GET as curl defaults to GET if no body is sent.
Let us take a closer look at the program.
14.1. Declaration
The first two lines simply import the dependencies:
FastAPIis the web framework class you’ll instantiate.BaseModel(from Pydantic) is used to define data shapes with type hints and validation.
Then,
app = FastAPI()
creates the app. app is the ASGI application, run/served by Uvicorn.
Then,
14.2. Shape of data
class Item(BaseModel):
name: str
price: float
Defines the shape of data your API expects for an Item. BaseModel makes it a Pydantic model:
Validates types (e.g., name must be text, price must be a number).
Converts compatible types (e.g., “1.2” → 1.2) and raises a 422 Unprocessable Entity if invalid.
14.3. Endpoint
@app.get("/") declares a GET endpoint at the root path /
@app.get("/")
def hello():
return {"message": "Hello 👋"}
When a GET request hits /, FastAPI calls hello() and serializes the returned Python dict to JSON:
Response body: {“message”: “Hello 👋”}
Default status code:
200 OK
@app.post("/items") declares a POST endpoint at /items.
@app.post("/items")
def create(item: Item):
return {"ok": True, "item": item}
The parameter item: Item tells FastAPI:
Read the request body as JSON, parse & validate it against the Item model.
If the body is missing/invalid → return 422 with error details.
If valid, you get a Python Item instance inside the function.
Returning {“ok”: True, “item”: item}:
FastAPI sees item is a Pydantic model, converts it to JSON automatically.
Default status code is 200 OK
14.4. Port
``http://`` — the protocol (how we talk). Here it is plain HTTP.
``127.0.0.1`` — the loopback address, aka localhost. It always means “this computer”. Only apps on the same machine can reach it.
``:8000`` — the port number (like a door on the house). Uvicorn’s dev server defaults to 8000 unless you change it.
(No extra path) ``/`` — the root of your API. The “root path” / is a URL path, not a filesystem path. It just means “the endpoint at the top of the API” (e.g., GET /).
So http://127.0.0.1:8000 = “Use HTTP to talk to the API running on my machine, on port 8000, at the root path.”
14.5. Handy extras
You can also use:
http://localhost:8000/The docs live at:
http://127.0.0.1:8000/docsChange port/host when running Uvicorn:
# different port uvicorn main:app --reload --port 3000 # allow other devices on your network to access it (dev only!) uvicorn main:app --host 127.0.0.1 --port 8000
Print just body with
curl
curl -s http://127.0.0.1:8000/ | jq
The -s option silences the progress bars.
14.5.1. Adding Endpoint
In this example we add an FFT endpoint
@app.get("/fft")
def fft_endpoint(n: int = 4096, L: float = 2*np.pi, k: int = 0):
"""
Compute FFT of sin(5x)*cos(9x) on [0,L) with N samples and return the value at harmonic k.
- n: number of samples
- L: domain length
- k: harmonic index (e.g., 0, ±4, ±14). Maps to FFT bin m ≈ k*L/(2π).
"""
try:
return fft_at_k(k=k, n=n, L=L)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
where fft_at_k is defined somewhere else (maybe in the same file, or in another package).
14.6. Endpoint types
Here /fft endpoint is a pure read-only calculation: given inputs (k, n, L) it returns a result and doesn’t create/modify/delete server state. It is therefore a GET request.
Other endpoints include POST, PUT, DELETE. Here is a brief summary of what these do:
GET — Read-only, safe, idempotent, cacheable. Use for fetching or computing without changing server state.
curl -s -X GET "http://127.0.0.1:8000/fft?k=14&n=4096&L=6.283185307179586"
Typical codes:
200 OK,304 Not Modified,404 Not Found.POST — Create or submit; not idempotent. Use for creating resources, submitting forms/jobs, or sending complex inputs in the body.
curl -s -X POST http://127.0.0.1:8000/items \ -H "Content-Type: application/json" \ -d '{"name":"notebook","price":12.5}'
Typical codes:
201 Created,202 Accepted,400/422validation errors.PUT — Replace (or create at known URL); idempotent. Use to fully replace a resource. Same request repeated → same result.
curl -s -X PUT http://127.0.0.1:8000/items/42 \ -H "Content-Type: application/json" \ -d '{"name":"notebook","price":13.0}'
Typical codes:
200 OK,201 Created,204 No Content.PATCH — Partial update; not necessarily idempotent (often treated as such). Use to modify part of a resource.
curl -s -X PATCH http://127.0.0.1:8000/items/42 \ -H "Content-Type: application/json" \ -d '{"price":13.5}'
Typical codes:
200 OK,204 No Content.DELETE — Remove; idempotent by convention. Repeating a successful DELETE keeps it gone.
curl -s -X DELETE http://127.0.0.1:8000/items/42
Typical codes:
200 OK,202 Accepted,204 No Content,404 Not Found.HEAD — Headers only (like GET without body). Useful for checks.
curl -I http://127.0.0.1:8000/fft?k=14
OPTIONS — What methods are allowed / CORS preflight.
curl -s -X OPTIONS http://127.0.0.1:8000/fft -i
Rules of thumb
Read-only? → GET.
Create/submit/long payloads? → POST.
Full replace at known URL? → PUT.
Partial update? → PATCH.
Delete? → DELETE.
(And for APIs: document expected status codes and request/response schemas.)
In this context, Idempotent means that doing the same request multiple times has the same effect as doing it once.
14.6.1. Status codes
Sending HTTP requests usually ends with status codes being received.
1xx — Informational
100 Continue – Client may send the body.
101 Switching Protocols – Upgrading (e.g., to WebSocket).
102 Processing – Server has accepted but not finished (WebDAV).
2xx — Success
200 OK – Standard successful response (GET/PUT/PATCH/DELETE).
201 Created – New resource created (usually with
Locationheader).202 Accepted – Accepted for async processing (job queued).
204 No Content – Success, no response body (e.g., DELETE or PUT with no body).
206 Partial Content – Ranged responses.
3xx — Redirection
301 Moved Permanently – Resource has a new URL.
302 Found – Temporary redirect (common but legacy semantics).
303 See Other – Redirect for POST/redirect/GET pattern.
304 Not Modified – Use cached copy (conditional GET).
307 Temporary Redirect – Redirect, preserve method.
308 Permanent Redirect – Permanent, preserve method.
4xx — Client Errors
400 Bad Request – Malformed syntax/params.
401 Unauthorized – Missing/invalid auth (use with
WWW-Authenticate).403 Forbidden – Authenticated but not allowed.
404 Not Found – No such resource/route.
405 Method Not Allowed – Wrong HTTP method for this route.
406 Not Acceptable – Content negotiation failed (e.g.,
Acceptheader).408 Request Timeout – Client took too long to send.
409 Conflict – Version/edit conflict, duplicate, business rule clash.
410 Gone – Resource intentionally removed.
412 Precondition Failed – ETag/time precondition failed.
413 Payload Too Large – Body too big.
415 Unsupported Media Type – Wrong
Content-Type.418 I’m a teapot – Easter egg (don’t use in prod 😄).
422 Unprocessable Entity – Semantically invalid (FastAPI/Pydantic validation).
429 Too Many Requests – Rate limit hit (include
Retry-After).
5xx — Server Errors
500 Internal Server Error – Generic server crash/bug.
501 Not Implemented – Method/feature not supported.
502 Bad Gateway – Upstream error (proxy/gateway).
503 Service Unavailable – Temporarily overloaded/maintenance (use
Retry-After).504 Gateway Timeout – Upstream didn’t respond in time.
When to use what:
GET success:
200(or206if partial),304if cached.POST (create):
201+Location: /items/{id}.POST (enqueue job):
202+ job status URL.PUT/PATCH (update):
200with body or204without body.DELETE:
204(or200with a body),404if not found.Validation failure:
422(FastAPI default).Auth:
401(no/invalid creds),403(not allowed).Conflict:
409(duplicate, version mismatch).
You don’t need to define every status code. FastAPI/Uvicorn handle many for you. You only set codes when you want something different from the defaults.
What FastAPI handles automatically:
200 OK on success if you just return … (your /fft and /items do this).
404 Not Found when the route doesn’t exist (e.g., POST /items/42 if not defined).
405 Method Not Allowed when the path exists but the HTTP method doesn’t.
422 Unprocessable Entity when request data fails validation (e.g., price:”p”).
500 Internal Server Error for uncaught exceptions.
14.6.2. Interactive API docs page
Visit http://127.0.0.1:8000/docs. This is an interactive API docs page that FastAPI serves automatically.
It’s powered by Swagger UI and reads your app’s OpenAPI schema to render endpoints, parameters, models, and example responses.
You can “Try it out”: send real requests from the browser, see status codes, headers, and JSON.
OpenAPI is the specification/standard for describing HTTP APIs (paths, methods, params, schemas, errors). It used to be called Swagger.
You can customize the doc header, for example:
app = FastAPI(
title="Signal Lab API",
description="Endpoints for FFT demos (sin-cos combos) and simple items CRUD.",
version="0.1.0",
contact={"name": "Boris Bolliet"},
license_info={"name": "MIT"},
)
14.6.3. Note on REST
There is a link between REST and FastAPI.
REST is an architectural style (resources + HTTP verbs + status codes + statelessness).
FastAPI is a web framework that gives you the tools to implement that style cleanly:
Decorators for HTTP methods: @app.get/post/put/patch/delete.
Path params & query params → map naturally to resource identifiers and filters.
Pydantic models enforce request/response schemas (great for REST contracts).
Status codes & headers are simple to set (status_code=201, Location, etc.).
OpenAPI docs auto-generated so your REST contract is visible at /docs.
FastAPI encourages REST best practices but doesn’t force them—.
If you follow REST conventions (nouns for paths, proper verbs, status codes, headers), FastAPI gives you type-safe validation and live docs “for free.”
15. Frontend: Next js
Broadly the structyre will look like:
backend/ # your FastAPI app
main.py
requirements.txt
frontend/ # your Next.js app
package.json
app/
You can just create the backend folder, move the main.py script there, and then execute:
npx create-next-app@latest frontend --ts --eslint --tailwind
It scaffolds a ready-to-run Next.js project named frontend with TypeScript, ESLint, and Tailwind preconfigured. Breakdown:
npx: runs the package without installing it globally (grabs the latestcreate-next-app).create-next-app@latest: the official Next.js project generator (using the newest version).frontend: the folder/name of your new app.--ts: sets up TypeScript (tsconfig.json,next-env.d.ts,.tsxpages/components).--eslint: adds ESLint with Next.js rules (includesnext/core-web-vitals) and a.eslintrc. ESLint is a static analysis (linting) tool for JavaScript/TypeScript. It scans your code without running it and flags problems—bugs, anti-patterns, style issues, and security foot-guns—based on a set of rules.--tailwind: installs and wires Tailwind CSS (tailwind.config.js,postcss.config.js, adds@tailwinddirectives toglobals.css).
We get:
A Next.js app (App Router by default) with sensible defaults.
Dependencies installed (
next,react,react-dom,tailwindcss,postcss,autoprefixer, ESLint plugins).Git initialized (unless disabled),
.gitignore,README.md, and npm scripts inpackage.json.
After that we get:
% tree -L 2
.
├── backend
│ └── main.py
└── frontend
├── README.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── src
└── tsconfig.json
How to run it:
cd frontend
npm run dev
This starts the dev server on http://localhost:3000.
It tells us:
To get started, edit the page.tsx file.
This file is in frontend/src/app/page.tsx. You can play around modifying the text.
Now we want to call our backend from the frontend.
We add CORS:
# fastapi main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # your Next.js dev URL
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
We add an environment variable to link the ports. For that we create: frontend/.env.local and write:
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
Then, in frontend/src/app/page.tsx, we paste:
// app/page.tsx
"use client";
import { useState } from "react";
type FftResponse = {
k: number; n: number; L: number;
bin_index: number;
ideal_bin_float: number;
bin_offset_from_integer: number;
Y_m_real: number; Y_m_imag: number; Y_m_abs: number;
Y_m_norm_real: number; Y_m_norm_imag: number; Y_m_norm_abs: number;
note: string;
};
export default function Page() {
const [n, setN] = useState(4096);
const [L, setL] = useState(2 * Math.PI);
const [k, setK] = useState(14);
const [data, setData] = useState<FftResponse | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const run = async () => {
try {
setLoading(true); setErr(null);
const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/fft`);
url.search = new URLSearchParams({
n: String(n),
L: String(L),
k: String(k),
}).toString();
const res = await fetch(url.toString());
if (!res.ok) throw new Error(await res.text());
const j = (await res.json()) as FftResponse;
setData(j);
} catch (e: any) {
setErr(e.message ?? "Request failed");
} finally {
setLoading(false);
}
};
return (
<main className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Signal Lab UI</h1>
<div className="grid grid-cols-3 gap-3 max-w-xl">
<label className="flex flex-col">
<span>N</span>
<input className="border rounded p-2" type="number" value={n}
onChange={e => setN(parseInt(e.target.value || "0"))}/>
</label>
<label className="flex flex-col">
<span>L</span>
<input className="border rounded p-2" type="number" step="any" value={L}
onChange={e => setL(parseFloat(e.target.value || "0"))}/>
</label>
<label className="flex flex-col">
<span>k</span>
<input className="border rounded p-2" type="number" value={k}
onChange={e => setK(parseInt(e.target.value || "0"))}/>
</label>
</div>
<button
onClick={run}
disabled={loading}
className="px-4 py-2 rounded-xl shadow bg-black text-white disabled:opacity-50"
>
{loading ? "Computing…" : "Compute FFT bin"}
</button>
{err && <p className="text-red-600">{err}</p>}
{data && (
<pre className="p-4 rounded-xl bg-gray-100 overflow-auto">
{JSON.stringify(data, null, 2)}
</pre>
)}
</main>
);
}
You can kill a process using a port as follows:
List the ports:
$ lsof -i :3000
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 57630 boris 16u IPv6 0x87525578ebb38ad4 0t0 TCP *:hbci (LISTEN)
kill the process:
kill 57630 # ask it to terminate nicely
# or, if it won’t die:
kill -9 57630 # force kill (SIGKILL)
16. Deploying an app
See the relevant files here:
Dockerfile for the backend [link]
Dockerfile for the frontend [link]
Docker compose configuration [link]
The Docker compose configuration launches both containers for the frontend and backend services.
You should have shell scripts that orchestrate these files.
For instance, such that you can do:
./scripts/docker-start.sh
To launch the Docker container. Inside this script, you would have commands like:
docker compose up
to launch your services. Or
docker compose up -d
with the -d option to run them in the background, so your terminal is freed; with Docker just printing container IDs and exits.
etc.