Chapter 3: Request & 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 shows 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.
Tina4::Router.get("/echo") do |request, response| response.json({ method: request.method, path: request.path, your_ip: request.ip })endcurl http://localhost:7147/echo{"method":"GET","path":"/echo","your_ip":"127.0.0.1"}The pattern holds for every route: inspect the request, build the response, return it.
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.
method#
The HTTP method arrives as an uppercase string:
Tina4::Router.get("/api/check") do |request, response| response.json({ method: request.method })endcurl http://localhost:7147/api/check{"method":"GET"}path#
The URL path strips query parameters:
# Request to /api/users?page=2request.path # "/api/users"params#
Path parameters from the URL pattern (see Chapter 2). The parameter names match the {name} placeholders in the route:
# Route: /users/{id}/posts/{post_id}# Request: /users/5/posts/42request.params["id"] # "5" (or 5 if typed as {id:int})request.params["post_id"] # "42"query#
Query string parameters live in the same params hash:
# Request: /search?q=keyboard&page=2&sort=pricerequest.params["q"] # "keyboard"request.params["page"] # "2"request.params["sort"] # "price"body#
The parsed request body. JSON requests become a hash. Form submissions contain the form fields. GET requests produce nil:
# POST with {"name": "Widget", "price": 9.99}request.body["name"] # "Widget"request.body["price"] # 9.99headers#
Request headers as a hash. Header names keep their original casing:
request.headers["Content-Type"] # "application/json"request.headers["Authorization"] # "Bearer eyJhbGci..."request.headers["X-Custom"] # "my-value"ip#
The client's IP address. Tina4 respects X-Forwarded-For and X-Real-IP headers behind a reverse proxy:
request.ip # "127.0.0.1"cookies#
A hash of cookies the client sent:
request.cookies["session_id"] # "abc123"request.cookies["preferences"] # "dark-mode"files#
Uploaded files (section 7 covers the details):
request.files["avatar"] # File object with name, type, size, tmp_pathInspecting the Full Request#
A route that dumps everything:
Tina4::Router.post("/debug/request") do |request, response| response.json({ method: request.method, path: request.path, params: request.params, query: request.params, body: request.body, headers: request.headers, ip: request.ip, cookies: request.cookies })endcurl -X POST "http://localhost:7147/debug/request?page=1" \ -H "Content-Type: application/json" \ -H "X-Custom: hello" \ -d '{"name": "test"}'{ "method": "POST", "path": "/debug/request", "params": {}, "query": {"page": "1"}, "body": {"name": "test"}, "headers": { "Content-Type": "application/json", "X-Custom": "hello", "Host": "localhost:7147", "User-Agent": "curl/8.4.0", "Accept": "*/*", "Content-Length": "16" }, "ip": "127.0.0.1", "cookies": {}}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.
json -- JSON Response#
The workhorse for APIs. Pass any hash or value and it becomes JSON:
response.json({ name: "Alice", age: 30 }){"name":"Alice","age":30}Pass a status code as the second argument:
response.json({ id: 7, name: "Widget" }, 201)Returns 201 Created with the JSON body.
html -- Raw HTML Response#
Return an HTML string:
response.html("<h1>Hello</h1><p>This is HTML.</p>")Sets Content-Type: text/html; charset=utf-8.
text -- Plain Text Response#
Return plain text:
response.text("Just a plain string.")Sets Content-Type: text/plain; charset=utf-8.
render -- Template Response#
Render a Frond template with data (Chapter 4: Templates goes deep):
response.render("products.html", { products: products, title: "Our Products"})Tina4 finds the template in src/templates/, renders it, returns the HTML.
redirect -- Redirect Response#
Send the client elsewhere:
response.redirect("/login")Sends a 302 Found by default. Pass a different status for permanent redirects:
response.redirect("/new-location", 301)file -- File Download Response#
Send a file for download:
response.file("/path/to/report.pdf")Tina4 sets the right Content-Type from the file extension and adds Content-Disposition so the browser downloads instead of displaying.
Custom filename:
response.file("/path/to/report.pdf", "monthly-report-march-2026.pdf")This sets the Content-Disposition: attachment header with your chosen filename.
xml -- XML Response#
Return XML content:
Tina4::Router.get("/api/feed") do |request, response| response.xml("<feed><entry><title>Hello</title></entry></feed>")endSets Content-Type: application/xml; charset=utf-8.
error -- Error Envelope#
Return a structured error response:
Tina4::Router.post("/api/things") do |request, response| response.error("VALIDATION_FAILED", "Name is required", 400)endThis 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. Use this pattern across your API for consistent error handling.
4. Status Codes#
Every response method accepts a status code. The ones 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 permanently changed. |
302 | Found | Temporary redirect. |
400 | Bad Request | Invalid input from the client. |
401 | Unauthorized | Missing or invalid authentication. |
403 | Forbidden | Authenticated but not allowed. |
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. |
Set the status with chaining:
response.status(201).json({ id: 7, created: true })Equivalent to response.json({ id: 7, created: true }, 201). Some developers prefer the chained form.
5. Custom Headers#
Add custom headers with the header method. Chain calls before the final response:
Tina4::Router.get("/api/data") do |request, response| response .header("X-Request-Id", SecureRandom.hex(8)) .header("X-Rate-Limit-Remaining", "57") .header("Cache-Control", "no-cache") .json({ data: [1, 2, 3] })endcurl -v http://localhost:7147/api/data 2>&1 | grep "< X-"< X-Request-Id: 65f3a7b8c1234567< X-Rate-Limit-Remaining: 57Convention: Title-Case for custom headers, prefixed with X-.
CORS Headers#
Tina4 handles CORS based on the CORS_ORIGINS setting in .env. The default * allows all origins. For production, restrict:
CORS_ORIGINS=https://myapp.com,https://admin.myapp.comManual override when needed:
response .header("Access-Control-Allow-Origin", "https://myapp.com") .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") .header("Access-Control-Allow-Headers", "Content-Type, Authorization") .json({ data: "value" })6. Cookies#
Set cookies on the response:
Tina4::Router.post("/login") do |request, response| # After validating credentials... response .cookie("session_id", "abc123xyz", { http_only: true, secure: true, same_site: "Strict", max_age: 3600, # 1 hour in seconds path: "/" }) .json({ message: "Logged in" })endCookie options:
| Option | Type | Default | Description |
|---|---|---|---|
http_only | bool | false | JavaScript cannot access it |
secure | bool | false | Only sent over HTTPS |
same_site | string | "Lax" | "Strict", "Lax", or "None" |
max_age | int | session | Lifetime in seconds |
path | string | "/" | URL path scope |
domain | string | current | Domain scope |
Read cookies from the request:
Tina4::Router.get("/profile") do |request, response| session_id = request.cookies["session_id"]โ if session_id.nil? return response.json({ error: "Not logged in" }, 401) endโ response.json({ session: session_id })endDelete a cookie by setting max_age to 0:
response .cookie("session_id", "", { max_age: 0, path: "/" }) .json({ message: "Logged out" })7. File Uploads#
Uploaded files live in request.files. Each file object holds metadata and a temporary path.
Handling a Single File Upload#
Tina4::Router.post("/api/upload") do |request, response| if request.files["image"].nil? return response.json({ error: "No file uploaded" }, 400) endโ file = request.files["image"]โ response.json({ name: file.name, # "photo.jpg" type: file.type, # "image/jpeg" size: file.size, # 245760 (bytes) tmp_path: file.tmp_path # Temporary file location })endTest with curl:
curl -X POST http://localhost:7147/api/upload \ -F "image=@/path/to/photo.jpg"{ "name": "photo.jpg", "type": "image/jpeg", "size": 245760, "tmp_path": "/tmp/tina4_upload_abc123"}Saving the Uploaded File#
The uploaded file sits in a temporary location. Move it to a permanent path:
Tina4::Router.post("/api/upload") do |request, response| if request.files["image"].nil? return response.json({ error: "No file uploaded" }, 400) endโ file = request.files["image"]โ # Validate file type allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] unless allowed_types.include?(file.type) return response.json({ error: "Invalid file type. Allowed: JPEG, PNG, GIF, WebP" }, 400) endโ # Validate file size (max 5MB) max_size = 5 * 1024 * 1024 if file.size > max_size return response.json({ error: "File too large. Maximum size: 5MB" }, 400) endโ # Generate a unique filename extension = File.extname(file.name) filename = "img_#{SecureRandom.hex(8)}#{extension}" upload_dir = File.join(__dir__, "../../public/uploads") destination = File.join(upload_dir, filename)โ # Ensure the uploads directory exists FileUtils.mkdir_p(upload_dir) unless Dir.exist?(upload_dir)โ # Move the file FileUtils.mv(file.tmp_path, destination)โ response.json({ message: "File uploaded successfully", filename: filename, url: "/uploads/#{filename}", size: file.size }, 201)endcurl -X POST http://localhost:7147/api/upload \ -F "image=@/path/to/photo.jpg"{ "message": "File uploaded successfully", "filename": "img_a1b2c3d4e5f6a7b8.jpg", "url": "/uploads/img_a1b2c3d4e5f6a7b8.jpg", "size": 245760}The file now lives at http://localhost:7147/uploads/img_a1b2c3d4e5f6a7b8.jpg.
Handling Multiple Files#
When the HTML form uses multiple or you have multiple file inputs:
Tina4::Router.post("/api/upload-many") do |request, response| results = []โ request.files.each do |key, file| extension = File.extname(file.name) filename = "file_#{SecureRandom.hex(8)}#{extension}" upload_dir = File.join(__dir__, "../../public/uploads") destination = File.join(upload_dir, filename)โ FileUtils.mkdir_p(upload_dir) unless Dir.exist?(upload_dir) FileUtils.mv(file.tmp_path, destination)โ results << { original_name: file.name, saved_as: filename, url: "/uploads/#{filename}" } endโ response.json({ uploaded: results, count: results.length }, 201)end8. File Downloads#
Send files with response.file:
Tina4::Router.get("/api/reports/{filename}") do |request, response| filename = request.params["filename"] filepath = File.join(__dir__, "../../data/reports", filename)โ unless File.exist?(filepath) return response.json({ error: "Report not found" }, 404) endโ response.file(filepath)endThe browser downloads the file. Tina4 detects the MIME type from the extension and sets the right headers.
Force a specific download filename:
response.file(filepath, "Q1-2026-Sales-Report.pdf")9. Content Negotiation#
The same endpoint can return different formats based on what the client asks for. Check the Accept header:
Tina4::Router.get("/api/products/{id:int}") do |request, response| id = request.params["id"] product = { id: id, name: "Wireless Keyboard", price: 79.99 }โ accept = request.headers["Accept"] || "application/json"โ if accept.include?("text/html") response.render("product-detail.html", { product: product }) elsif accept.include?("text/plain") text = "Product ##{id}: #{product[:name]} - $#{product[:price]}" response.text(text) else # Default to JSON response.json(product) endend# JSON (default)curl http://localhost:7147/api/products/1{"id":1,"name":"Wireless Keyboard","price":79.99}# Plain textcurl http://localhost:7147/api/products/1 -H "Accept: text/plain"Product #1: Wireless Keyboard - $79.99# HTML (renders the template)curl http://localhost:7147/api/products/1 -H "Accept: text/html"<!DOCTYPE html><html>...rendered template...</html>10. Input Validation#
Tina4 ships a Validator class for declarative input validation. Chain rules together, then check the result. If validation fails, response.error returns a structured error envelope (see section 3).
The Validator Class#
Tina4::Router.post "/api/users" do |request, response| v = Tina4::Validator.new(request.body) v.required("name").required("email").email("email").min_length("name", 2)โ unless v.valid? return response.error("VALIDATION_FAILED", v.errors.first[:message], 400) endโ # proceed with valid dataendThe Validator accepts the request body (a hash) and provides chainable methods:
| Method | Description |
|---|---|
required(field) | Field 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 |
numeric(field) | Field must be a number |
in_list(field, values) | Field must be one of the allowed values |
Call v.valid? to check all rules. Call v.errors to get the list of failures, each with a :field and :message key.
The Error Response Envelope#
response.error returns a consistent JSON error envelope:
response.error("VALIDATION_FAILED", "Name is required", 400)This produces:
{"error": true, "code": "VALIDATION_FAILED", "message": "Name is required", "status": 400}The three arguments are: an error code string, a human-readable message, and the HTTP status code. Use this pattern across your API for consistent error handling.
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 returns a 413 Payload Too Large response before your handler runs. The check fires before body parsing begins. Your route code never executes.
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.
11. Exercise: Build an Image Upload API#
Build an API that handles image uploads and serves them back.
Requirements#
| Method | Path | Description |
|---|---|---|
POST | /api/images | Upload an image. Validate type and size. Return the image URL. |
GET | /api/images/{filename} | Return the uploaded image file. Return 404 if not found. |
Rules:
- Accept JPEG, PNG, and WebP only
- Maximum file size: 2MB
- Save files to
src/public/uploads/with a unique filename - Return original filename, saved filename, file size in KB, and the URL
- The GET endpoint serves the file (not JSON)
Test with:#
# Uploadcurl -X POST http://localhost:7147/api/images \ -F "image=@/path/to/photo.jpg"โ# Downloadcurl http://localhost:7147/api/images/img_a1b2c3d4e5f6a7b8.jpg --output downloaded.jpg12. Solution#
Create src/routes/images.rb:
require "securerandom"require "fileutils"โTina4::Router.post("/api/images") do |request, response| # Check if a file was uploaded if request.files["image"].nil? return response.json({ error: "No image file provided. Use field name 'image'." }, 400) endโ file = request.files["image"]โ # Validate file type allowed_types = ["image/jpeg", "image/png", "image/webp"] unless allowed_types.include?(file.type) return response.json({ error: "Invalid file type", received: file.type, allowed: allowed_types }, 400) endโ # Validate file size (max 2MB) max_size = 2 * 1024 * 1024 if file.size > max_size return response.json({ error: "File too large", size_bytes: file.size, max_bytes: max_size }, 400) endโ # Generate unique filename preserving extension extension = File.extname(file.name).downcase saved_name = "img_#{SecureRandom.hex(8)}#{extension}" upload_dir = File.join(__dir__, "../../public/uploads") destination = File.join(upload_dir, saved_name)โ # Create uploads directory if it does not exist FileUtils.mkdir_p(upload_dir) unless Dir.exist?(upload_dir)โ # Move the uploaded file FileUtils.mv(file.tmp_path, destination)โ response.json({ message: "Image uploaded successfully", original_name: file.name, saved_name: saved_name, size_kb: (file.size / 1024.0).round(1), type: file.type, url: "/uploads/#{saved_name}" }, 201)endโTina4::Router.get("/api/images/{filename}") do |request, response| filename = request.params["filename"]โ # Prevent directory traversal if filename.include?("..") || filename.include?("/") return response.json({ error: "Invalid filename" }, 400) endโ filepath = File.join(__dir__, "../../public/uploads", filename)โ unless File.exist?(filepath) return response.json({ error: "Image not found", filename: filename }, 404) endโ response.file(filepath)endExpected output for upload:
{ "message": "Image uploaded successfully", "original_name": "photo.jpg", "saved_name": "img_a1b2c3d4e5f6a7b8.jpg", "size_kb": 240.0, "type": "image/jpeg", "url": "/uploads/img_a1b2c3d4e5f6a7b8.jpg"}(Status: 201 Created)
Expected output for invalid type:
{ "error": "Invalid file type", "received": "application/pdf", "allowed": ["image/jpeg", "image/png", "image/webp"]}(Status: 400 Bad Request)
Expected output for file too large:
{ "error": "File too large", "size_bytes": 5242880, "max_bytes": 2097152}(Status: 400 Bad Request)
The GET endpoint returns the raw image file with the correct Content-Type header. The browser displays it. Curl with --output saves it to disk.
13. Gotchas#
1. Forgetting the response method#
Problem: Handler runs (log output confirms it) but the browser gets an empty response or 500 error.
Cause: No call to response.json, response.html, or another response method. Ruby returns the last expression in a block, but if it is not a response, Tina4 has nothing to send.
Fix: End every handler with a response method call.
2. request.body is nil for GET requests#
Problem: Accessing request.body in a GET handler returns nil.
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.
3. Body is nil for JSON requests#
Problem: request.body is nil or empty even though you sent JSON.
Cause: Missing Content-Type: application/json header. Without it, Tina4 does not parse the body as JSON.
Fix: Include -H "Content-Type: application/json" with curl. In frontend JavaScript, fetch() with JSON.stringify() needs headers: {"Content-Type": "application/json"}.
4. Content-Type Mismatch#
Problem: response.json sends HTML, or response.html sends plain text.
Cause: A middleware or error handler is overwriting the response. Or you used puts instead of a response method.
Fix: Use response.json(...), response.html(...), or another response method. puts bypasses the response object entirely.
5. File Uploads Return Empty#
Problem: request.files is empty despite uploading a file.
Cause: The form lacks enctype="multipart/form-data", or curl uses -d instead of -F.
Fix: HTML forms need <form enctype="multipart/form-data">. Curl needs -F "field=@file.jpg" (with @), not -d.
6. Redirect Loops#
Problem: The browser shows "too many redirects" or hangs.
Cause: A route redirects to another route, which redirects back. Example: /login redirects to /dashboard, and /dashboard redirects to /login because the user is not authenticated.
Fix: Trace the redirect chain with the browser's network inspector. Make sure auth checks do not redirect authenticated users away from pages they can access.
7. Cookie Not Set#
Problem: response.cookie(...) was called but the browser shows no cookie.
Cause: secure: true means the cookie travels over HTTPS only. Local development uses http://localhost, so the cookie is dropped.
Fix: Set secure: false during development, or use secure: ENV["TINA4_DEBUG"] != "true" to auto-switch.
8. Large Request Body Rejected#
Problem: POST requests with large bodies return 413.
Cause: The body exceeds the configured maximum size.
Fix: Increase TINA4_MAX_UPLOAD_SIZE in .env. Default is 10mb. For file uploads, you may need 50mb or more:
TINA4_MAX_UPLOAD_SIZE=50mb9. 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:
# Correctresponse .header("X-Custom", "value") .cookie("token", "abc", { http_only: true }) .json({ ok: true })โ# Wrong -- header is set after json already returnedresult = response.json({ ok: true })response.header("X-Custom", "value") # Too lateresult