Chapter 3: Request and Response#
1. The Two Objects You Use Everywhere#
Every route handler receives two arguments: request and response. The request carries what the client sent. The response builds what you send back. These two objects handle every HTTP scenario you will face.
A file-sharing service demonstrates the pattern. A user uploads a document. You validate the file type. You check the session. You return success JSON or an error page. Every step flows through request and response.
2. The Request Object#
The request object opens every piece of the incoming HTTP request. Headers, body, cookies, files, IP address -- all accessible through named properties.
request.method#
The HTTP method arrives as an uppercase string:
from tina4_python.core.router import get, postโ@get("/api/check")async def check_method(request, response): return response.json({"method": request.method})curl http://localhost:7146/api/check{"method":"GET"}request.path#
The URL path strips query parameters:
@get("/api/info")async def info(request, response): return response.json({"path": request.path})curl "http://localhost:7146/api/info?foo=bar"{"path":"/api/info"}Path Parameters (Function Arguments)#
Path parameters captured from the URL pattern arrive as function arguments. The parameter names in your function signature must match the {name} placeholders in the route pattern:
@get("/users/{id:int}/posts/{slug}")async def user_post(id, slug, request, response): return response.json({ "user_id": id, "slug": slug })curl http://localhost:7146/users/5/posts/hello-world{"user_id":5,"slug":"hello-world"}request.params#
Query string parameters live in a dictionary. Each key holds a single value unless the same key appears multiple times -- then Tina4 stores a list:
@get("/search")async def search(request, response): return response.json({ "q": request.params.get("q", ""), "page": int(request.params.get("page", 1)), "sort": request.params.get("sort", "relevance") })curl "http://localhost:7146/search?q=laptop&page=2&sort=price"{"q":"laptop","page":2,"sort":"price"}request.body#
The parsed request body. JSON requests become a dictionary. Form submissions contain form fields. GET requests produce None:
@post("/api/feedback")async def feedback(request, response): name = request.body.get("name", "Anonymous") message = request.body.get("message", "") rating = request.body.get("rating", 0)โ return response.json({ "received": { "name": name, "message": message, "rating": rating } }, 201)curl -X POST http://localhost:7146/api/feedback \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "message": "Great product!", "rating": 5}'{"received":{"name":"Alice","message":"Great product!","rating":5}}request.headers#
A dictionary of HTTP headers. Tina4 normalizes header names to lowercase:
@get("/api/headers")async def show_headers(request, response): return response.json({ "content_type": request.headers.get("content-type", "not set"), "user_agent": request.headers.get("user-agent", "not set"), "accept": request.headers.get("accept", "not set"), "custom": request.headers.get("x-custom-header", "not set") })curl http://localhost:7146/api/headers -H "X-Custom-Header: hello-tina4"{"content_type":"not set","user_agent":"curl/8.1.2","accept":"*/*","custom":"hello-tina4"}request.ip#
The client's IP address. Tina4 respects X-Forwarded-For and X-Real-IP headers behind a reverse proxy:
@get("/api/whoami")async def whoami(request, response): return response.json({"ip": request.ip})curl http://localhost:7146/api/whoami{"ip":"127.0.0.1"}request.cookies#
A dictionary of cookies the client sent:
@get("/api/cookies")async def show_cookies(request, response): return response.json({ "cookies": request.cookies, "session_id": request.cookies.get("session_id", "none") })curl http://localhost:7146/api/cookies -b "session_id=abc123; theme=dark"{"cookies":{"session_id":"abc123","theme":"dark"},"session_id":"abc123"}request.files#
For multipart file uploads, request.files holds the uploaded files. Each file is a dictionary with four keys: filename, type, content (raw bytes), and size (in bytes):
@post("/api/upload")async def upload(request, response): if "document" not in request.files: return response.json({"error": "No file uploaded"}, 400)โ file = request.files["document"]โ return response.json({ "filename": file["filename"], "content_type": file["type"], "size": file["size"] })curl -X POST http://localhost:7146/api/upload \ -F "document=@report.pdf"{"filename":"report.pdf","content_type":"application/pdf","size":45231}To save the file to disk:
@post("/api/upload/save")async def upload_and_save(request, response): if "photo" not in request.files: return response.json({"error": "No photo uploaded"}, 400)โ file = request.files["photo"]โ # Validate file type allowed = ["image/jpeg", "image/png", "image/webp"] if file["type"] not in allowed: return response.json({"error": f"File type {file['type']} not allowed"}, 400)โ # Save to public directory import os save_path = os.path.join("src", "public", "images", file["filename"]) with open(save_path, "wb") as f: f.write(file["content"])โ return response.json({ "message": "Photo uploaded", "url": f"/images/{file['filename']}" }, 201)Handling Multiple File Uploads#
When a form sends multiple files, each file field name maps to one entry in request.files. Use distinct field names for each file input:
@post("/api/upload-many")async def upload_many(request, response): results = []โ for key, file in request.files.items(): if not isinstance(file, dict) or "filename" not in file: continueโ ext = os.path.splitext(file["filename"])[1] unique_name = f"{uuid.uuid4().hex}{ext}" save_path = os.path.join("src", "public", "uploads", unique_name)โ os.makedirs(os.path.dirname(save_path), exist_ok=True) with open(save_path, "wb") as f: f.write(file["content"])โ results.append({ "original_name": file["filename"], "saved_as": unique_name, "url": f"/uploads/{unique_name}" })โ if not results: return response.json({"error": "No files uploaded"}, 400)โ return response.json({"uploaded": results, "count": len(results)}, 201)curl -X POST http://localhost:7146/api/upload-many \ -F "photo=@sunset.jpg" \ -F "document=@invoice.pdf" \ -F "avatar=@profile.png"{ "uploaded": [ {"original_name": "sunset.jpg", "saved_as": "a1b2c3d4.jpg", "url": "/uploads/a1b2c3d4.jpg"}, {"original_name": "invoice.pdf", "saved_as": "e5f6a7b8.pdf", "url": "/uploads/e5f6a7b8.pdf"}, {"original_name": "profile.png", "saved_as": "c9d0e1f2.png", "url": "/uploads/c9d0e1f2.png"} ], "count": 3}The HTML form uses distinct name attributes for each file input. Each field name becomes a key in request.files.
Upload Size Limits#
Tina4 enforces a maximum upload size through the TINA4_MAX_UPLOAD_SIZE environment variable. The value is in bytes. The default is 10485760 (10 MB).
TINA4_MAX_UPLOAD_SIZE=10485760When a client sends a body larger than this limit, Tina4 raises a PayloadTooLarge exception and returns a 413 Payload Too Large response. Your handler never runs. The check fires before body parsing begins.
To allow 50 MB uploads, set this in your .env file:
TINA4_MAX_UPLOAD_SIZE=52428800Calculate the value by multiplying: megabytes times 1,048,576. For 25 MB, that is 26214400. For 100 MB, 104857600. Choose the smallest limit that covers your use case. Large limits invite abuse.
3. The Response Object#
The response object builds HTTP responses. Every route handler must return one. Methods chain, so you can set headers, cookies, and content in a single expression.
response.json()#
Return JSON data with an optional status code:
@get("/api/users")async def users(request, response): users = [ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"} ] return response.json({"users": users, "count": len(users)})โ@post("/api/users")async def create_user(request, response): # 201 Created return response.json({"id": 3, "name": request.body["name"]}, 201)โ@get("/api/error")async def error_example(request, response): # 400 Bad Request return response.json({"error": "Something went wrong"}, 400)response.html()#
Return raw HTML:
@get("/welcome")async def welcome(request, response): return response.html(""" <!DOCTYPE html> <html> <body> <h1>Welcome to My Store</h1> <p>Browse our <a href="/products">products</a>.</p> </body> </html> """)response.text()#
Return plain text:
@get("/robots.txt")async def robots(request, response): return response.text("User-agent: *\nAllow: /")response.render()#
Render a Frond template with data (Chapter 4: Templates covers the engine in full):
@get("/dashboard")async def dashboard(request, response): return response.render("dashboard.html", { "user_name": "Alice", "notifications": 3, "recent_orders": [ {"id": 101, "total": 59.99}, {"id": 102, "total": 124.50} ] })response.redirect()#
Send the client to another URL:
@get("/old-page")async def old_page(request, response): return response.redirect("/new-page")โ@post("/api/logout")async def logout(request, response): # 303 See Other -- redirect after POST return response.redirect("/login", 303)โ@get("/moved")async def moved(request, response): # 301 Permanent redirect return response.redirect("/new-location", 301)The default status code is 302 Found (temporary redirect).
response.file()#
Send a file as the response:
@get("/download/report")async def download_report(request, response): return response.file("data/reports/monthly-report.pdf")Tina4 auto-detects the content type from the file extension. The browser displays or downloads based on the MIME type.
To force a download instead of inline display, pass a download_name:
@get("/download/data")async def download_data(request, response): return response.file("data/export.csv", download_name="sales-data.csv")This sets the Content-Disposition: attachment header with your chosen filename.
response.xml()#
Return XML content:
@get("/api/feed")async def feed(request, response): return response.xml("<feed><entry><title>Hello</title></entry></feed>")response.error()#
Return a structured error envelope:
@post("/api/things")async def create_thing(request, response): return response.error("VALIDATION_FAILED", "Name is required", 400)This produces:
{"error":true,"code":"VALIDATION_FAILED","message":"Name is required","status":400}Three arguments: an error code string, a human-readable message, and the HTTP status code. Clients check error: true and switch on the code field.
4. Status Codes#
Every response method accepts a status code. Here are the codes you will use most:
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Default. Successful GET, PUT, PATCH. |
201 | Created | Successful POST that created a resource. |
204 | No Content | Successful DELETE. No body needed. |
301 | Moved Permanently | URL has changed forever. |
302 | Found | Temporary redirect. |
400 | Bad Request | Invalid input from the client. |
401 | Unauthorized | Missing or invalid authentication. |
403 | Forbidden | Authenticated but not permitted. |
404 | Not Found | Resource does not exist. |
409 | Conflict | Duplicate or conflicting data. Two users claim the same username. |
413 | Payload Too Large | Body exceeds TINA4_MAX_UPLOAD_SIZE. |
422 | Unprocessable Entity | Valid JSON but fails business rules. The data parses but the logic rejects it. |
500 | Internal Server Error | Something broke on the server. |
# 200 OK (default)return response.json({"ok": True})โ# 201 Createdreturn response.json({"id": 1}, 201)โ# 204 No Contentreturn response.json(None, 204)โ# 400 Bad Requestreturn response.json({"error": "Invalid input"}, 400)โ# 401 Unauthorizedreturn response.json({"error": "Login required"}, 401)โ# 403 Forbiddenreturn response.json({"error": "Not allowed"}, 403)โ# 404 Not Foundreturn response.json({"error": "Not found"}, 404)โ# 409 Conflictreturn response.json({"error": "Username already taken"}, 409)โ# 422 Unprocessable Entityreturn response.json({"error": "Start date must precede end date"}, 422)โ# 500 Internal Server Errorreturn response.json({"error": "Server error"}, 500)You can also chain the status with response.status():
return response.status(201).json({"id": 7, "created": True})5. Content Negotiation#
One endpoint can return different formats. The client declares what it wants through the Accept header. Your handler inspects that header and picks the right response method:
@get("/api/products/{id:int}")async def product_detail(id, request, response): product = { "id": id, "name": "Wireless Keyboard", "price": 79.99 }โ accept = request.headers.get("accept", "application/json")โ if "text/html" in accept: return response.render("product-detail.html", {"product": product}) elif "text/plain" in accept: text = f"Product #{id}: {product['name']} - ${product['price']}" return response.text(text) elif "application/xml" in accept: xml = f'<product><id>{id}</id><name>{product["name"]}</name><price>{product["price"]}</price></product>' return response.xml(xml) else: return response.json(product)# JSON (default)curl http://localhost:7146/api/products/1{"id":1,"name":"Wireless Keyboard","price":79.99}# Plain textcurl http://localhost:7146/api/products/1 -H "Accept: text/plain"Product #1: Wireless Keyboard - $79.99# HTML (renders the template)curl http://localhost:7146/api/products/1 -H "Accept: text/html"<!DOCTYPE html><html>...rendered template...</html>The pattern: check request.headers.get("accept"), match against known MIME types, fall back to JSON. Most API clients send application/json or */*. Browsers send text/html. The same route serves both.
6. Custom Headers and Cookies#
Setting Response Headers#
Add custom headers with the header method. Chain calls before the final response:
@get("/api/data")async def data_with_headers(request, response): return response.header("X-Custom-Header", "my-value") \ .header("X-Request-Id", "abc-123") \ .json({"data": [1, 2, 3]})curl -i http://localhost:7146/api/dataHTTP/1.1 200 OKContent-Type: application/jsonX-Custom-Header: my-valueX-Request-Id: abc-123โ{"data":[1,2,3]}Setting Cookies#
Set cookies on the response:
@post("/api/login")async def login(request, response): # Set a session cookie return response.cookie("session_id", "abc123", path="/", max_age=3600, http_only=True, secure=True, same_site="Lax" ).json({"message": "Logged in"})โ@post("/api/logout")async def logout(request, response): # Delete a cookie by setting max_age to 0 return response.cookie("session_id", "", path="/", max_age=0 ).json({"message": "Logged out"})Cookie keyword arguments:
| Argument | Type | Default | Description |
|---|---|---|---|
path | str | "/" | URL path scope |
max_age | int | 3600 | Lifetime in seconds (0 deletes the cookie) |
http_only | bool | True | JavaScript cannot access the cookie |
secure | bool | False | Cookie travels over HTTPS only |
same_site | str | "Lax" | "Strict", "Lax", or "None" |
7. Input Validation#
Tina4 ships a Validator class for declarative input validation. Chain rules together, then check the result.
The Validator Class#
from tina4_python.validator import Validatorโ@post("/api/users")async def create_user(request, response): v = Validator(request.body) v.required("name", "email").email("email").min_length("name", 2)โ if not v.is_valid(): return response.error("VALIDATION_FAILED", v.errors()[0]["message"], 400)โ # proceed with valid dataThe Validator accepts the request body (a dictionary) and provides chainable methods:
| Method | Description |
|---|---|
required(*fields) | Fields must be present and non-empty |
email(field) | Field must be a valid email address |
min_length(field, n) | Field must have at least n characters |
max_length(field, n) | Field must have at most n characters |
integer(field) | Field must be an integer |
min(field, n) | Numeric field must be >= n |
max(field, n) | Numeric field must be <= n |
in_list(field, values) | Field must be one of the allowed values |
regex(field, pattern) | Field must match the regular expression |
Call v.is_valid() to check all rules. Call v.errors() to get the list of validation failures -- each entry holds a field and message key.
Note that required() accepts multiple field names in one call: v.required("name", "email", "subject"). This validates all three fields in a single statement.
8. Real-World Example: File Upload with Validation#
A complete file upload endpoint. It validates type and size, generates a unique filename, and returns the URL:
from tina4_python.core.router import postimport osimport uuidโUPLOAD_DIR = "src/public/uploads"MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MBALLOWED_TYPES = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "application/pdf": ".pdf"}โ@post("/api/files/upload")async def upload_file(request, response): if "file" not in request.files: return response.json({"error": "No file provided. Send a file with field name 'file'"}, 400)โ file = request.files["file"]โ # Check file type if file["type"] not in ALLOWED_TYPES: return response.json({ "error": f"File type '{file['type']}' not allowed", "allowed": list(ALLOWED_TYPES.keys()) }, 400)โ # Check file size if file["size"] > MAX_FILE_SIZE: return response.json({ "error": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)} MB", "size": file["size"] }, 400)โ # Generate a unique filename ext = ALLOWED_TYPES[file["type"]] unique_name = f"{uuid.uuid4().hex}{ext}"โ # Save the file os.makedirs(UPLOAD_DIR, exist_ok=True) save_path = os.path.join(UPLOAD_DIR, unique_name) with open(save_path, "wb") as f: f.write(file["content"])โ return response.json({ "message": "File uploaded successfully", "file": { "original_name": file["filename"], "saved_as": unique_name, "url": f"/uploads/{unique_name}", "size": file["size"], "content_type": file["type"] } }, 201)curl -X POST http://localhost:7146/api/files/upload \ -F "file=@photo.jpg"{ "message": "File uploaded successfully", "file": { "original_name": "photo.jpg", "saved_as": "a1b2c3d4e5f6.jpg", "url": "/uploads/a1b2c3d4e5f6.jpg", "size": 245760, "content_type": "image/jpeg" }}9. Exercise: Build a Contact Form API#
Build an API that processes contact form submissions with full validation.
Requirements#
Create src/routes/contact.py with these endpoints:
| Method | Path | Description |
|---|---|---|
POST | /api/contact | Submit a contact form. Validate all fields. Return 201 on success. |
GET | /api/contact/submissions | List all submissions. Support ?status= filter. |
The contact form body must include: name (required, 2-100 chars), email (required, must contain @), subject (required), message (required, 10+ chars), urgency (optional, one of "low", "medium", "high", default "medium").
Validation rules:
- Return 400 with an
errorslist if any field fails - Store submissions in a Python list with an auto-incremented ID and timestamp
- Each submission starts with a
statusof "new"
Test with:#
# Valid submissioncurl -X POST http://localhost:7146/api/contact \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com", "subject": "Question", "message": "I have a question about your product pricing.", "urgency": "high"}'โ# Invalid submission (missing fields, short message)curl -X POST http://localhost:7146/api/contact \ -H "Content-Type: application/json" \ -d '{"name": "A", "email": "bad-email", "message": "Short"}'โ# List allcurl http://localhost:7146/api/contact/submissionsโ# Filter by statuscurl "http://localhost:7146/api/contact/submissions?status=new"10. Solution#
from tina4_python.core.router import get, postfrom datetime import datetimeโsubmissions = []next_id = 1โโ@post("/api/contact")async def submit_contact(request, response): global next_id body = request.body errors = []โ # Validate name name = body.get("name", "") if not name: errors.append("Name is required") elif len(name) < 2 or len(name) > 100: errors.append("Name must be between 2 and 100 characters")โ # Validate email email = body.get("email", "") if not email: errors.append("Email is required") elif "@" not in email: errors.append("Email must contain @")โ # Validate subject subject = body.get("subject", "") if not subject: errors.append("Subject is required")โ # Validate message message = body.get("message", "") if not message: errors.append("Message is required") elif len(message) < 10: errors.append("Message must be at least 10 characters")โ # Validate urgency urgency = body.get("urgency", "medium") if urgency not in ("low", "medium", "high"): errors.append("Urgency must be one of: low, medium, high")โ if errors: return response.json({"errors": errors}, 400)โ submission = { "id": next_id, "name": name, "email": email, "subject": subject, "message": message, "urgency": urgency, "status": "new", "created_at": datetime.now().isoformat() } next_id += 1 submissions.append(submission)โ return response.json({ "message": "Contact form submitted successfully", "submission": submission }, 201)โโ@get("/api/contact/submissions")async def list_submissions(request, response): status = request.params.get("status")โ if status: filtered = [s for s in submissions if s["status"] == status] return response.json({"submissions": filtered, "count": len(filtered)})โ return response.json({"submissions": submissions, "count": len(submissions)})Valid submission output:
{ "message": "Contact form submitted successfully", "submission": { "id": 1, "name": "Alice", "email": "alice@example.com", "subject": "Question", "message": "I have a question about your product pricing.", "urgency": "high", "status": "new", "created_at": "2026-03-22T14:30:00.000000" }}(Status: 201 Created)
Invalid submission output:
{ "errors": [ "Name must be between 2 and 100 characters", "Email must contain @", "Subject is required", "Message must be at least 10 characters" ]}(Status: 400 Bad Request)
11. Gotchas#
1. request.body is None for GET requests#
Problem: Accessing request.body in a GET handler returns None.
Cause: GET requests carry no body. Browsers and curl send none by convention.
Fix: Use request.params for GET parameters. The body populates only for POST, PUT, and PATCH requests.
2. Forgetting the Content-Type header#
Problem: request.body is empty even though you sent JSON.
Cause: The Content-Type: application/json header is missing. Without it, Tina4 does not parse the body as JSON.
Fix: Include -H "Content-Type: application/json" in your curl command. In JavaScript fetch(), set headers: {"Content-Type": "application/json"}.
3. File upload with wrong field name#
Problem: request.files["photo"] raises a KeyError.
Cause: The form field name does not match the key you look up.
Fix: Check that the form uses <input type="file" name="photo"> and curl uses -F "photo=@file.jpg". The string after -F must match the key in request.files.
4. response.redirect() in AJAX calls#
Problem: Your JavaScript fetch() call receives a redirect response, but the browser does not navigate.
Cause: fetch() follows redirects silently. It returns the final response. It does not trigger browser navigation.
Fix: For AJAX calls, return JSON with a redirect URL. Handle navigation in JavaScript: window.location.href = data.redirect_url. Use response.redirect() only for traditional form submissions and browser navigation.
5. Cookie not being set#
Problem: You called response.cookie() but the cookie does not appear in the browser.
Cause: Setting secure=True restricts the cookie to HTTPS. On http://localhost, the browser drops it.
Fix: During development on HTTP, use secure=False (the default). Enable secure=True only in production with HTTPS configured.
6. Large file uploads fail#
Problem: Uploading a large file returns a 413 error or empty request.files.
Cause: The file exceeds TINA4_MAX_UPLOAD_SIZE. The default limit is 10 MB (10,485,760 bytes).
Fix: Set TINA4_MAX_UPLOAD_SIZE in your .env file. For 50 MB: TINA4_MAX_UPLOAD_SIZE=52428800. The check happens before your handler runs -- there is nothing to catch in your route code.
7. Chaining response methods in wrong order#
Problem: Headers or cookies set after calling response.json() have no effect.
Cause: response.json() finalizes and returns the response object. Calling methods on a separate line after the return is dead code.
Fix: Chain headers and cookies before the final response method:
# Correctreturn response.header("X-Custom", "value").cookie("token", "abc").json({"ok": True})โ# Wrong -- header is set after json() already returnedresult = response.json({"ok": True})response.header("X-Custom", "value") # Too latereturn result8. Header names are lowercase#
Problem: request.headers.get("Content-Type") returns None even though the header exists.
Cause: Tina4 normalizes all header names to lowercase during parsing. The ASGI protocol delivers them this way.
Fix: Use lowercase keys: request.headers.get("content-type"). This applies to all headers -- "authorization", "user-agent", "x-custom-header".