Tina4

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.

ruby
Tina4::Router.get("/echo") do |request, response|  response.json({    method: request.method,    path: request.path,    your_ip: request.ip  })end
bash
curl http://localhost:7147/echo
json
{"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:

ruby
Tina4::Router.get("/api/check") do |request, response|  response.json({ method: request.method })end
bash
curl http://localhost:7147/api/check
json
{"method":"GET"}

path#

The URL path strips query parameters:

ruby
# 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:

ruby
# 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:

ruby
# 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:

ruby
# POST with {"name": "Widget", "price": 9.99}request.body["name"]  # "Widget"request.body["price"] # 9.99

headers#

Request headers as a hash. Header names keep their original casing:

ruby
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:

ruby
request.ip # "127.0.0.1"

cookies#

A hash of cookies the client sent:

ruby
request.cookies["session_id"]  # "abc123"request.cookies["preferences"] # "dark-mode"

files#

Uploaded files (section 7 covers the details):

ruby
request.files["avatar"] # File object with name, type, size, tmp_path

Inspecting the Full Request#

A route that dumps everything:

ruby
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  })end
bash
curl -X POST "http://localhost:7147/debug/request?page=1" \  -H "Content-Type: application/json" \  -H "X-Custom: hello" \  -d '{"name": "test"}'
json
{  "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:

ruby
response.json({ name: "Alice", age: 30 })
json
{"name":"Alice","age":30}

Pass a status code as the second argument:

ruby
response.json({ id: 7, name: "Widget" }, 201)

Returns 201 Created with the JSON body.

html -- Raw HTML Response#

Return an HTML string:

ruby
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:

ruby
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):

ruby
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:

ruby
response.redirect("/login")

Sends a 302 Found by default. Pass a different status for permanent redirects:

ruby
response.redirect("/new-location", 301)

file -- File Download Response#

Send a file for download:

ruby
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:

ruby
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:

ruby
Tina4::Router.get("/api/feed") do |request, response|  response.xml("<feed><entry><title>Hello</title></entry></feed>")end

Sets Content-Type: application/xml; charset=utf-8.

error -- Error Envelope#

Return a structured error response:

ruby
Tina4::Router.post("/api/things") do |request, response|  response.error("VALIDATION_FAILED", "Name is required", 400)end

This produces:

json
{"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:

CodeMeaningWhen to Use
200OKDefault. Successful GET, PUT, PATCH.
201CreatedSuccessful POST that created a resource.
204No ContentSuccessful DELETE. No body needed.
301Moved PermanentlyURL has permanently changed.
302FoundTemporary redirect.
400Bad RequestInvalid input from the client.
401UnauthorizedMissing or invalid authentication.
403ForbiddenAuthenticated but not allowed.
404Not FoundResource does not exist.
409ConflictDuplicate or conflicting data. Two users claim the same username.
413Payload Too LargeBody exceeds TINA4_MAX_UPLOAD_SIZE.
422Unprocessable EntityValid JSON but fails business rules. The data parses but the logic rejects it.
500Internal Server ErrorSomething broke on the server.

Set the status with chaining:

ruby
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:

ruby
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] })end
bash
curl -v http://localhost:7147/api/data 2>&1 | grep "< X-"
< X-Request-Id: 65f3a7b8c1234567< X-Rate-Limit-Remaining: 57

Convention: 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:

bash
CORS_ORIGINS=https://myapp.com,https://admin.myapp.com

Manual override when needed:

ruby
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:

ruby
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" })end

Cookie options:

OptionTypeDefaultDescription
http_onlyboolfalseJavaScript cannot access it
secureboolfalseOnly sent over HTTPS
same_sitestring"Lax""Strict", "Lax", or "None"
max_ageintsessionLifetime in seconds
pathstring"/"URL path scope
domainstringcurrentDomain scope

Read cookies from the request:

ruby
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 })end

Delete a cookie by setting max_age to 0:

ruby
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#

ruby
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  })end

Test with curl:

bash
curl -X POST http://localhost:7147/api/upload \  -F "image=@/path/to/photo.jpg"
json
{  "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:

ruby
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)end
bash
curl -X POST http://localhost:7147/api/upload \  -F "image=@/path/to/photo.jpg"
json
{  "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:

ruby
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)end

8. File Downloads#

Send files with response.file:

ruby
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)end

The browser downloads the file. Tina4 detects the MIME type from the extension and sets the right headers.

Force a specific download filename:

ruby
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:

ruby
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
bash
# JSON (default)curl http://localhost:7147/api/products/1
json
{"id":1,"name":"Wireless Keyboard","price":79.99}
bash
# Plain textcurl http://localhost:7147/api/products/1 -H "Accept: text/plain"
Product #1: Wireless Keyboard - $79.99
bash
# HTML (renders the template)curl http://localhost:7147/api/products/1 -H "Accept: text/html"
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#

ruby
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 dataend

The Validator accepts the request body (a hash) and provides chainable methods:

MethodDescription
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:

ruby
response.error("VALIDATION_FAILED", "Name is required", 400)

This produces:

json
{"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).

bash
TINA4_MAX_UPLOAD_SIZE=10485760

When 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:

bash
TINA4_MAX_UPLOAD_SIZE=52428800

Calculate 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#

MethodPathDescription
POST/api/imagesUpload 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:

  1. Accept JPEG, PNG, and WebP only
  2. Maximum file size: 2MB
  3. Save files to src/public/uploads/ with a unique filename
  4. Return original filename, saved filename, file size in KB, and the URL
  5. The GET endpoint serves the file (not JSON)

Test with:#

bash
# 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.jpg

12. Solution#

Create src/routes/images.rb:

ruby
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)end

Expected output for upload:

json
{  "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:

json
{  "error": "Invalid file type",  "received": "application/pdf",  "allowed": ["image/jpeg", "image/png", "image/webp"]}

(Status: 400 Bad Request)

Expected output for file too large:

json
{  "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.

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:

bash
TINA4_MAX_UPLOAD_SIZE=50mb

9. 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:

ruby
# 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