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 answers 413 Content Too Large and your handler never runs. A declared Content-Length over the limit is refused before a single body byte is read. Anything else is counted as it arrives, chunked bodies included, and stopped the moment it crosses the line. That holds under uvicorn and on the built-in server alike (ADR-0068).
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]}A header name has to be a valid HTTP token, and a value can't contain a carriage return, a line feed or a NUL. If it does, header() will raise ValueError naming the header, for example Invalid character in header content ["X-Custom-Header"]. The same check runs in redirect() for the location. Nothing gets stripped quietly, so validate user input before it reaches a header.
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" |
The cookie name has to be a valid HTTP token. If the value or any attribute contains a carriage return, a line feed, a NUL or ;, cookie() will raise ValueError with Invalid character in cookie content ["session_id"]. Encode structured data (base64 works) before you store it in a cookie.
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".