Chapter 2: Routing#
1. How Routing Works in Tina4#
Every web application maps URLs to code. You type /products in your browser. The framework finds the handler for /products, runs it, sends back the result. That mapping is routing.
In Tina4, routes live in Ruby files inside src/routes/. Every .rb file in that directory (and its subdirectories) is auto-loaded at startup. No registration. No central config. Drop a file in. It works.
The simplest possible route:
Tina4::Router.get("/hello") do |request, response| response.json({ message: "Hello, World!" })endSave that as src/routes/hello.rb, start the server, visit http://localhost:7147/hello:
{"message":"Hello, World!"}One line registers the route. One block handles the request. Done.
2. HTTP Methods#
Tina4 supports all five standard HTTP methods. Each one lives on Tina4::Router:
Tina4::Router.get("/products") do |request, response| response.json({ action: "list all products" })endโTina4::Router.post("/products") do |request, response| response.json({ action: "create a product" }, 201)endโTina4::Router.put("/products/{id}") do |request, response| id = request.params[:id] response.json({ action: "replace product #{id}" })endโTina4::Router.patch("/products/{id}") do |request, response| id = request.params[:id] response.json({ action: "update product #{id}" })endโTina4::Router.delete("/products/{id}") do |request, response| id = request.params[:id] response.json({ action: "delete product #{id}" })endTest each one:
curl http://localhost:7147/products{"action":"list all products"}curl -X POST http://localhost:7147/products \ -H "Content-Type: application/json" \ -d '{"name": "Widget"}'{"action":"create a product"}curl -X PUT http://localhost:7147/products/42{"action":"replace product 42"}curl -X PATCH http://localhost:7147/products/42{"action":"update product 42"}curl -X DELETE http://localhost:7147/products/42{"action":"delete product 42"}GET reads. POST creates. PUT replaces. PATCH patches. DELETE removes. REST convention. Predictable API.
HEAD and OPTIONS - automatic, no boilerplate#
Tina4 handles HEAD and OPTIONS for you. You don't register them. They follow from your Tina4::Router.get(...) / Tina4::Router.post(...) / etc. routes:
HEADis identical toGETexcept the response carries no body (RFC 9110 ยง9.3.2). EveryGETroute automatically responds toHEAD: the framework strips the response body on the way out and preservesContent-Lengthpointing at the byte count the equivalentGETwould have sent. Cache validators, link checkers, and uptime probes work without you writing anything.OPTIONSon any registered path returns204 No Contentwith anAllow:header listing every method the path supports (RFC 9110 ยง9.3.7).- Wrong method on an existing path returns
405 Method Not Allowedwith the sameAllow:header (RFC 9110 ยง15.5.6 + ยง10.2.1). SendingPUTto aGET-only route used to return404, semantically wrong and confusing for link checkers. Now you get a real405. TRACEandCONNECTare rejected with405(security default for origin servers).
Note: real CORS preflight (requests with Origin + Access-Control-Request-Method) is still handled by the CORS middleware as before. Plain RFC 9110 OPTIONS introspection hits the router's Allow-header response.
# HEAD on a GET route - same headers, empty bodycurl -sI http://localhost:7147/products# HTTP/1.1 200 OK# Content-Type: application/json# Content-Length: 33โ# OPTIONS - discover what the path supportscurl -sI -X OPTIONS http://localhost:7147/products# HTTP/1.1 204 No Content# Allow: GET, POST, HEAD, OPTIONSโ# Wrong method - clear 405 with Allow headercurl -sI -X PUT http://localhost:7147/products# HTTP/1.1 405 Method Not Allowed# Allow: GET, POST, HEAD, OPTIONSExplicit Tina4::Router.head and Tina4::Router.options registration#
The automatic behaviour is enough for 95% of apps. When you need custom HEAD or OPTIONS handlers, register them explicitly:
# HEAD handler that doesn't run the full GET body - useful for# expensive endpoints where the client only needs to check existence# or read validators (ETag, Last-Modified).Tina4::Router.head("/expensive/{id}") do |request, response| response.header("ETag", compute_etag(request.params[:id])) response.call(nil, 200)endโ# OPTIONS handler that returns more than just Allow - for example# a discovery endpoint.Tina4::Router.options("/api/discovery") do |_req, response| response.json({ version: "v1", endpoints: [] })endThe framework still strips the response body for HEAD handlers (RFC 9110 ยง9.3.2 is a MUST), so you can't accidentally leak content even if your handler returns a body.
3. Path Parameters#
Path parameters capture values from the URL. Wrap the name in curly braces:
Tina4::Router.get("/users/{id}/posts/{post_id}") do |request, response| user_id = request.params[:id] post_id = request.params[:post_id]โ response.json({ user_id: user_id, post_id: post_id })endcurl http://localhost:7147/users/5/posts/99{"user_id":"5","post_id":"99"}Notice: user_id came back as the string "5", not the integer 5. Path parameters are strings by default.
Auto-casting: Tina4 automatically casts path parameter values that are purely numeric to integers. For example, requesting
/users/42/posts/99will give yourequest.params[:id]as the integer42andrequest.params[:post_id]as the integer99-- no explicit:inttype hint required. The:inttype hint adds validation (rejecting non-numeric values with a 404), but the auto-casting happens regardless.
Typed Parameters#
Enforce a type with a colon after the parameter name:
Tina4::Router.get("/orders/{id:int}") do |request, response| id = request.params[:id] response.json({ order_id: id, type: id.class.name })endcurl http://localhost:7147/orders/42{"order_id":42,"type":"Integer"}Pass a non-integer? The route refuses to match. You get a 404:
curl http://localhost:7147/orders/abc{"error":"Not found","path":"/orders/abc","status":404}Supported types:
| Type | Matches | Auto-cast | Example |
|---|---|---|---|
int | Digits only | Integer | {id:int} matches 42 but not abc |
float | Decimal numbers | Float | {price:float} matches 19.99 |
path | All remaining path segments (catch-all) | String | {slug:path} matches docs/api/auth |
The {name} form (no type) matches any single path segment and returns it as a string.
Typed Parameters in Action#
Here is a complete example showing the most commonly used typed parameters together:
# Integer parameter -- only digits match, auto-cast to IntegerTina4::Router.get("/products/{id:int}") do |request, response| id = request.params[:id] # Integer, e.g. 42 response.json({ product_id: id, type: id.class.name })endโ# Float parameter -- decimal numbers, auto-cast to FloatTina4::Router.get("/products/{id:int}/price/{price:float}") do |request, response| id = request.params[:id] price = request.params[:price] response.json({ product_id: id, price: price, type: price.class.name })endโ# Path parameter -- catch-all, captures remaining segments as a stringTina4::Router.get("/files/{filepath:path}") do |request, response| filepath = request.params[:filepath] # filepath could be "images/photos/cat.jpg" response.json({ filepath: filepath, type: filepath.class.name })end# Integer route -- matches digits, returns an Integercurl http://localhost:7147/products/42{"product_id":42,"type":"Integer"}# Integer route -- non-integer gives a 404curl http://localhost:7147/products/abc{"error":"Not found","path":"/products/abc","status":404}# Path catch-all -- captures everything after /files/curl http://localhost:7147/files/images/photos/cat.jpg{"filepath":"images/photos/cat.jpg","type":"String"}The :int and :float types act as both a constraint and a converter. If the URL segment does not match the expected pattern, the route is skipped entirely and Tina4 moves on to the next registered route (or returns 404 if nothing matches). The :path type is greedy -- it consumes all remaining segments, making it ideal for file paths and documentation URLs.
4. Query Parameters#
Query parameters are key-value pairs after the ? in a URL. Access them through request.params:
Tina4::Router.get("/search") do |request, response| q = request.params["q"] || "" page = (request.params["page"] || 1).to_i limit = (request.params["limit"] || 10).to_iโ response.json({ query: q, page: page, limit: limit, offset: (page - 1) * limit })endcurl "http://localhost:7147/search?q=keyboard&page=2&limit=20"{"query":"keyboard","page":2,"limit":20,"offset":20}Missing query parameter? request.params["key"] returns nil. Use || to provide defaults.
5. Route Groups#
A set of routes shares a common prefix. Tina4::Router.group eliminates repetition:
Tina4::Router.group("/api/v1") doโ Tina4::Router.get("/users") do |request, response| response.json({ users: [] }) endโ Tina4::Router.get("/users/{id:int}") do |request, response| id = request.params[:id] response.json({ user: { id: id, name: "Alice" } }) endโ Tina4::Router.post("/users") do |request, response| response.json({ created: true }, 201) endโ Tina4::Router.get("/products") do |request, response| response.json({ products: [] }) endโendThese routes register as /api/v1/users, /api/v1/users/{id}, and /api/v1/products. Short paths inside the group. Tina4 prepends the prefix.
curl http://localhost:7147/api/v1/users{"users":[]}curl http://localhost:7147/api/v1/products{"products":[]}Groups nest:
Tina4::Router.group("/api") do Tina4::Router.group("/v1") do Tina4::Router.get("/status") do |request, response| response.json({ version: "1.0" }) end endโ Tina4::Router.group("/v2") do Tina4::Router.get("/status") do |request, response| response.json({ version: "2.0" }) end endendcurl http://localhost:7147/api/v1/status{"version":"1.0"}curl http://localhost:7147/api/v2/status{"version":"2.0"}6. Middleware#
Middleware is code that runs before or after your route handler. Authentication. Logging. Rate limiting. Input validation. Anything that belongs on multiple routes but not in every handler.
Middleware on a Single Route#
Pass middleware as the third argument:
log_request = lambda do |request, response, next_handler| start = Process.clock_gettime(Process::CLOCK_MONOTONIC) $stderr.puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{request.method} #{request.path}"โ result = next_handler.call(request, response)โ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2) $stderr.puts " Completed in #{duration}ms"โ resultendโTina4::Router.get("/api/data", middleware: "log_request") do |request, response| response.json({ data: [1, 2, 3] })endThe middleware receives request, response, and next_handler. Call next_handler.call(request, response) to proceed to the route handler. Skip the call and the handler never runs -- a gatekeeper that blocks unauthorized requests.
Blocking Middleware#
Middleware that checks for an API key:
require_api_key = lambda do |request, response, next_handler| api_key = request.headers["X-API-Key"] || ""โ if api_key != "my-secret-key" return response.json({ error: "Invalid API key" }, 401) endโ next_handler.call(request, response)endโTina4::Router.get("/api/secret", middleware: "require_api_key") do |request, response| response.json({ secret: "The answer is 42" })endcurl http://localhost:7147/api/secret{"error":"Invalid API key"}Status: 401 Unauthorized.
curl http://localhost:7147/api/secret -H "X-API-Key: my-secret-key"{"secret":"The answer is 42"}Middleware on a Group#
Apply middleware to every route inside a group:
Tina4::Router.group("/api/admin", middleware: "require_auth") doโ Tina4::Router.get("/dashboard") do |request, response| response.json({ page: "admin dashboard" }) endโ Tina4::Router.get("/users") do |request, response| response.json({ page: "user management" }) endโendEvery route in the group now demands the Authorization header. No per-route repetition.
Multiple Middleware#
Chain them with an array:
Tina4::Router.get("/api/important", middleware: ["log_request", "require_api_key", "require_auth"]) do |request, response| response.json({ data: "important stuff" })endMiddleware runs in order: log_request first, then require_api_key, then require_auth, then the route handler. If any middleware skips next_handler, the chain stops there.
7. Route Decorators: @noauth and @secured#
Tina4 provides two decorators for controlling authentication at the route level.
@noauth -- Public Routes#
When your application has global authentication middleware, @noauth marks specific routes as public:
# @noauthTina4::Router.get("/api/public/info") do |request, response| response.json({ app: "My Store", version: "1.0.0" })endThe @noauth comment tells Tina4 to skip authentication for this route, even if global auth middleware guards the parent group.
@secured -- Protected GET Routes#
@secured marks a GET route as requiring authentication:
# @securedTina4::Router.get("/api/profile") do |request, response| # request.user is populated by the auth middleware response.json({ user: request.user })endBy default, POST, PUT, PATCH, and DELETE routes are secured. GET routes are public unless you add @secured. Reading is open. Writing demands credentials.
8. Route Chaining: .secure and .cache#
Routes return a chainable object. Two methods you can call on any route: .secure and .cache.
.secure#
.secure requires a valid bearer token in the Authorization header. If the token is missing or invalid, the route returns 401 Unauthorized without ever reaching your handler:
Tina4::Router.get("/api/account") do |request, response| response.json({ account: request.user })end.securecurl http://localhost:7147/api/account# 401 Unauthorizedโcurl http://localhost:7147/api/account -H "Authorization: Bearer eyJhbGci..."# 200 OK.cache#
.cache enables response caching for the route. Once the handler runs and produces a response, subsequent requests to the same URL return the cached result without re-executing the handler:
Tina4::Router.get("/api/catalog") do |request, response| # Expensive database query response.json({ products: products })end.cacheChaining Both#
Chain .secure and .cache together:
Tina4::Router.get("/api/data") do |request, response| response.json({ data: data })end.secure.cacheThis route requires a bearer token and caches the response. Order does not matter -- .cache.secure produces the same result.
9. Wildcard and Catch-All Routes#
Wildcard Routes#
Use * at the end of a path to match everything after it:
Tina4::Router.get("/docs/*") do |request, response| path = request.params["*"] || "" response.json({ section: "docs", path: path })endcurl http://localhost:7147/docs/getting-started{"section":"docs","path":"getting-started"}curl http://localhost:7147/docs/api/authentication/jwt{"section":"docs","path":"api/authentication/jwt"}Catch-All Route (Custom 404)#
Handle any unmatched URL:
Tina4::Router.get("/*") do |request, response| response.json({ error: "Page not found", path: request.path }, 404)endDefine this route last (or in a file that sorts alphabetically after your other route files). Tina4 matches routes in registration order. First match wins.
Or create a custom 404 page at src/templates/errors/404.html:
{% extends "base.html" %} {% block title %}Not Found{% endblock %} {% block content %} <h1>404 - Page Not Found</h1> <p>The page you are looking for does not exist.</p> <a href="/">Go back home</a>{% endblock %}Tina4 uses this template for unmatched routes when the file exists.
10. Route Listing via CLI#
As your application grows, you need to see all registered routes at a glance:
tina4 routesMethod Path Middleware Auth------ ---- ---------- ----GET /hello - publicGET /products - publicPOST /products - securedPUT /products/{id} - securedPATCH /products/{id} - securedDELETE /products/{id} - securedGET /api/v1/users - publicGET /api/v1/users/{id:int} - publicPOST /api/v1/users - securedGET /api/admin/dashboard require_auth publicGET /api/admin/users require_auth publicGET /api/public/info - @noauthGET /api/profile - @securedGET /search - publicGET /docs/* - publicFilter by method:
tina4 routes --method POSTMethod Path Middleware Auth------ ---- ---------- ----POST /products - securedPOST /api/v1/users - securedSearch for a path pattern:
tina4 routes --filter usersMethod Path Middleware Auth------ ---- ---------- ----GET /api/v1/users - publicGET /api/v1/users/{id:int} - publicPOST /api/v1/users - securedGET /api/admin/users require_auth public11. Organizing Route Files#
Organize route files however you want. Tina4 loads every .rb file in src/routes/ recursively. Two common patterns:
Pattern 1: One File Per Resource#
src/routes/โโโ products.rb # All product routesโโโ users.rb # All user routesโโโ orders.rb # All order routesโโโ pages.rb # HTML page routesPattern 2: Subdirectories by Feature#
src/routes/โโโ api/โ โโโ products.rbโ โโโ users.rbโ โโโ orders.rbโโโ admin/โ โโโ dashboard.rbโ โโโ settings.rbโโโ pages/ โโโ home.rb โโโ about.rbBoth work the same. The directory structure has no effect on URL paths -- only the route definitions inside the files matter. Pick whichever pattern keeps your project navigable.
12. Exercise: Build a Full CRUD API for Products#
Build a complete REST API for managing products. Data lives in a Ruby array (no database yet -- Chapter 5 handles that).
Requirements#
Create src/routes/product_api.rb with these routes:
| Method | Path | Description |
|---|---|---|
GET | /api/products | List all products. Support ?category= filter. |
GET | /api/products/{id:int} | Get a single product by ID. Return 404 if not found. |
POST | /api/products | Create a new product. Return 201. |
PUT | /api/products/{id:int} | Replace a product. Return 404 if not found. |
DELETE | /api/products/{id:int} | Delete a product. Return 204 with no body. |
Each product has: id (int), name (string), category (string), price (float), in_stock (bool).
Seed data:
products = [ { id: 1, name: "Wireless Keyboard", category: "Electronics", price: 79.99, in_stock: true }, { id: 2, name: "Yoga Mat", category: "Fitness", price: 29.99, in_stock: true }, { id: 3, name: "Coffee Grinder", category: "Kitchen", price: 49.99, in_stock: false }, { id: 4, name: "Standing Desk", category: "Office", price: 549.99, in_stock: true }, { id: 5, name: "Running Shoes", category: "Fitness", price: 119.99, in_stock: true }]Test with:
# List allcurl http://localhost:7147/api/productsโ# Filter by categorycurl "http://localhost:7147/api/products?category=Fitness"โ# Get onecurl http://localhost:7147/api/products/3โ# Createcurl -X POST http://localhost:7147/api/products \ -H "Content-Type: application/json" \ -d '{"name": "Desk Lamp", "category": "Office", "price": 39.99, "in_stock": true}'โ# Updatecurl -X PUT http://localhost:7147/api/products/3 \ -H "Content-Type: application/json" \ -d '{"name": "Burr Coffee Grinder", "category": "Kitchen", "price": 59.99, "in_stock": true}'โ# Deletecurl -X DELETE http://localhost:7147/api/products/3โ# Not foundcurl http://localhost:7147/api/products/99913. Solution#
Create src/routes/product_api.rb:
# In-memory product store (resets on server restart)$products = [ { id: 1, name: "Wireless Keyboard", category: "Electronics", price: 79.99, in_stock: true }, { id: 2, name: "Yoga Mat", category: "Fitness", price: 29.99, in_stock: true }, { id: 3, name: "Coffee Grinder", category: "Kitchen", price: 49.99, in_stock: false }, { id: 4, name: "Standing Desk", category: "Office", price: 549.99, in_stock: true }, { id: 5, name: "Running Shoes", category: "Fitness", price: 119.99, in_stock: true }]โ$next_id = 6โ# List all products, optionally filter by categoryTina4::Router.get("/api/products") do |request, response| category = request.params["category"]โ if category filtered = $products.select { |p| p[:category].downcase == category.downcase } response.json({ products: filtered, count: filtered.length }) else response.json({ products: $products, count: $products.length }) endendโ# Get a single product by IDTina4::Router.get("/api/products/{id:int}") do |request, response| id = request.params[:id]โ product = $products.find { |p| p[:id] == id }โ if product response.json(product) else response.json({ error: "Product not found", id: id }, 404) endendโ# Create a new productTina4::Router.post("/api/products") do |request, response| body = request.bodyโ if body["name"].nil? || body["name"].empty? return response.json({ error: "Name is required" }, 400) endโ product = { id: $next_id, name: body["name"], category: body["category"] || "Uncategorized", price: (body["price"] || 0).to_f, in_stock: body["in_stock"] != false }โ $next_id += 1 $products << productโ response.json(product, 201)endโ# Replace a productTina4::Router.put("/api/products/{id:int}") do |request, response| id = request.params[:id] body = request.bodyโ index = $products.index { |p| p[:id] == id }โ if index.nil? response.json({ error: "Product not found", id: id }, 404) else $products[index] = { id: id, name: body["name"] || $products[index][:name], category: body["category"] || $products[index][:category], price: (body["price"] || $products[index][:price]).to_f, in_stock: body.key?("in_stock") ? body["in_stock"] : $products[index][:in_stock] } response.json($products[index]) endendโ# Delete a productTina4::Router.delete("/api/products/{id:int}") do |request, response| id = request.params[:id]โ index = $products.index { |p| p[:id] == id }โ if index.nil? response.json({ error: "Product not found", id: id }, 404) else $products.delete_at(index) response.json(nil, 204) endendExpected output for the test commands:
List all:
{"products":[{"id":1,"name":"Wireless Keyboard","category":"Electronics","price":79.99,"in_stock":true},{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"in_stock":true},{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"in_stock":false},{"id":4,"name":"Standing Desk","category":"Office","price":549.99,"in_stock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"in_stock":true}],"count":5}Filter by category:
{"products":[{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"in_stock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"in_stock":true}],"count":2}Get one:
{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"in_stock":false}Create:
{"id":6,"name":"Desk Lamp","category":"Office","price":39.99,"in_stock":true}(Status: 201 Created)
Update:
{"id":3,"name":"Burr Coffee Grinder","category":"Kitchen","price":59.99,"in_stock":true}Delete: empty response with status 204 No Content.
Not found:
{"error":"Product not found","id":999}(Status: 404 Not Found)
14. Gotchas#
1. Trailing Slashes Matter#
Problem: /products works but /products/ returns 404.
Cause: Tina4 treats /products and /products/ as different routes by default.
Fix: Pick one convention. Stick with it. Set TINA4_TRAILING_SLASH_REDIRECT=true in .env and Tina4 redirects /products/ to /products.
2. Parameter Names Must Be Unique in a Path#
Problem: /users/{id}/posts/{id} behaves wrong -- both parameters share the same name.
Cause: The second {id} overwrites the first in request.params.
Fix: Use distinct names: /users/{user_id}/posts/{post_id}.
3. Method Conflicts#
Problem: You defined Tina4::Router.get("/items/{id}", ...) and Tina4::Router.get("/items/{action}", ...) and the wrong handler runs.
Cause: Both patterns match /items/42. First registered wins.
Fix: Use typed parameters: Tina4::Router.get("/items/{id:int}", ...) matches integers only, leaving /items/export free. Or restructure: /items/{id:int} and /items/actions/{action}.
4. Route Handler Must Return a Response#
Problem: Handler runs but the browser shows an empty page or 500 error.
Cause: No call to response.json, response.render, or another response method. Without a return value from the response object, Tina4 has nothing to send.
Fix: Every handler must end with response.json(...), response.html(...), or response.render(...).
5. Block Syntax Matters#
Problem: Route handler raises a syntax error about unexpected blocks.
Cause: Ruby blocks with do...end and {...} have different precedence.
Fix: Use do |request, response| ... end for route blocks. The curly brace form works for single-line handlers but causes parsing issues with method arguments.
6. Middleware Must Be a Named Function or String#
Problem: Passing an inline lambda as middleware causes unexpected behavior.
Cause: Tina4 expects middleware referenced by name (a string), resolved at runtime.
Fix: Define middleware as a named method or lambda. Pass the name as a string: "my_middleware".
7. Group Prefix Must Start with a Slash#
Problem: Tina4::Router.group("api/v1") produces routes that do not match.
Cause: The group prefix needs a leading /.
Fix: Start group prefixes with /: Tina4::Router.group("/api/v1").