In Chapter 3, we built a clean router that could handle exact path matches. But real APIs need dynamic routes - when your frontend calls /api/users/123
, you don't want to pre-register a route for every possible user ID!
Today, we'll implement dynamic routing with URL parameters, just like Express's /users/:id
or Gin's /users/:id
.
What We've Built So Far
From previous chapters:
- ✅ Clean HTTP server foundation
- ✅ Router with method-aware route registration
- ✅ Exact path matching (
/users
,/products
) - ❌ Dynamic paths (
/users/:id
,/products/:category/:id
)
The Problem with Static Routes
Our current router only handles exact matches. For a real user API, you'd need:
// This doesn't scale!
router.GET("/users/1", getUser1Handler)
router.GET("/users/2", getUser2Handler)
router.GET("/users/3", getUser3Handler)
// ... for every possible user ID?
Frontend developers expect to call /users/123
, /users/456
, or any ID dynamically. We need route patterns like /users/:id
.
Starting with a Static Route Problem
Let's follow the notes and start with a static route to see the problem firsthand.
First, let's create some sample data. Create data.go
:
package main
type User struct {
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
var users = []User{
{
Id: 1,
Name: "John Doe",
Email: "johndoe@gmail.com",
},
{
Id: 2,
Name: "Jane Doe",
Email: "janedoe@gmail.com",
},
{
Id: 3,
Name: "Alex Jones",
Email: "alexjones@gmail.com",
},
{
Id: 4,
Name: "Ada Lovelace",
Email: "adalovelace@gmail.com",
},
}
Now let's create a handler for a specific user. Update main.go
:
package main
import (
"encoding/json"
"net/http"
)
func main() {
server := NewServer(":3000")
setupRoutes(server)
server.Start()
}
func setupRoutes(s *Server) {
// Static route - only works for user ID 1
s.Router.GET("/users/1", getUser1)
}
func getUser1(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Hardcoded to return user with ID 1
var user User
for _, u := range users {
if u.Id == 1 {
user = u
break
}
}
response := map[string]any{
"data": user,
}
jsonBody, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
w.Write(jsonBody)
}
Test this static approach:
go run .
curl http://localhost:3000/users/1
# Works: Returns John Doe
curl http://localhost:3000/users/2
# 404: route not found
curl http://localhost:3000/users/123
# 404: route not found
The Problem: We can only handle user ID 1. Every other ID returns 404, even though we have data for users 2, 3, and 4.
Implementing Pattern Matching First
Now let's solve this step by step. First, we'll implement pattern matching to recognize /users/:id
routes.
Update the resolveRoute
method in router.go
:
func (r *Router) resolveRoute(req *http.Request) (HandlerFunc, error) {
method := req.Method
path := req.URL.Path
log.Printf("%s %s", method, path)
methodRoutes, ok := r.routes[method]
if !ok {
return nil, fmt.Errorf("method not supported")
}
// Try exact match first
if handler, ok := methodRoutes[path]; ok {
return handler, nil
}
// If no exact match, try pattern matching
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
for routePattern, handler := range methodRoutes {
routeSegments := strings.Split(strings.Trim(routePattern, "/"), "/")
if len(pathSegments) == len(routeSegments) && matchesPattern(pathSegments, routeSegments) {
return handler, nil
}
}
return nil, fmt.Errorf("path does not exist")
}
Add the required imports at the top of router.go
:
import (
"fmt"
"log"
"net/http"
"strings" // Add this
)
Now we need the pattern matching function:
func matchesPattern(pathSegments, routeSegments []string) bool {
for i, segment := range routeSegments {
if strings.HasPrefix(segment, ":") {
// This is a parameter segment - always matches
continue
}
if segment != pathSegments[i] {
return false
}
}
return true
}
Understanding the Pattern Matching Loop in Detail
Let me break down that crucial pattern matching loop that we just added:
for routePattern, handler := range methodRoutes {
routeSegments := strings.Split(strings.Trim(routePattern, "/"), "/")
if len(pathSegments) == len(routeSegments) &&
matchesPattern(pathSegments, routeSegments) {
return handler, nil
}
}
This loop is doing several important things. Let's trace through an example where:
-
Request:
/users/123
-
Registered routes:
/users/:id
,/products/:id
,/hello
Step 1: Iterate Through All Registered Routes
for routePattern, handler := range methodRoutes {
The methodRoutes
map contains all routes registered for this HTTP method (GET, POST, etc.). For our example:
methodRoutes = {
"/users/:id": getUserHandler,
"/products/:id": getProductHandler,
"/hello": helloHandler
}
So our loop will check each of these patterns one by one:
-
Iteration 1:
routePattern = "/users/:id"
,handler = getUserHandler
-
Iteration 2:
routePattern = "/products/:id"
,handler = getProductHandler
-
Iteration 3:
routePattern = "/hello"
,handler = helloHandler
Step 2: Split the Route Pattern into Segments
routeSegments := strings.Split(strings.Trim(routePattern, "/"), "/")
This line is doing two operations:
-
strings.Trim(routePattern, "/")
: Removes leading/trailing slashes
-
/users/:id
becomesusers/:id
- This handles cases like
users/:id
or/users/:id/
consistently
-
strings.Split(..., "/")
: Splits on forward slashes to get individual path segments
Let's see this in action for each iteration:
Iteration 1 (routePattern = "/users/:id"
):
strings.Trim("/users/:id", "/") // → "users/:id"
strings.Split("users/:id", "/") // → ["users", ":id"]
// So: routeSegments = ["users", ":id"]
Iteration 2 (routePattern = "/products/:id"
):
strings.Trim("/products/:id", "/") // → "products/:id"
strings.Split("products/:id", "/") // → ["products", ":id"]
// So: routeSegments = ["products", ":id"]
Iteration 3 (routePattern = "/hello"
):
strings.Trim("/hello", "/") // → "hello"
strings.Split("hello", "/") // → ["hello"]
// So: routeSegments = ["hello"]
Remember, we already split the incoming request path earlier:
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
// For "/users/123" this gives us: ["users", "123"]
Step 3: Check Length and Pattern Match
if len(pathSegments) == len(routeSegments) &&
matchesPattern(pathSegments, routeSegments) {
return handler, nil
}
This condition has two parts that BOTH must be true:
Part 1: Length Check (len(pathSegments) == len(routeSegments)
)
This ensures the number of segments match. For our request /users/123
(2 segments):
-
Iteration 1:
len(["users", "123"]) == len(["users", ":id"])
→2 == 2
✅ -
Iteration 2:
len(["users", "123"]) == len(["products", ":id"])
→2 == 2
✅ -
Iteration 3:
len(["users", "123"]) == len(["hello"])
→2 == 1
❌
The length check immediately eliminates routes that can't possibly match.
Part 2: Pattern Match (matchesPattern(pathSegments, routeSegments)
)
This is where the actual pattern matching logic happens. The function compares each segment:
For the iterations that passed the length check:
Iteration 1: matchesPattern(["users", "123"], ["users", ":id"])
-
i=0
:segment = "users"
(doesn't start with:
)- Check:
"users" != "users"
? NO → Continue
- Check:
-
i=1
:segment = ":id"
(starts with:
)- This is a parameter →
continue
(always matches)
- This is a parameter →
-
Result:
true
✅
Iteration 2: matchesPattern(["users", "123"], ["products", ":id"])
-
i=0
:segment = "products"
(doesn't start with:
)- Check:
"products" != "users"
? YES →return false
- Check:
-
Result:
false
❌
So for /users/123
:
- Route
/users/:id
matches! (length ✅ + pattern ✅) - Route
/products/:id
doesn't match (length ✅ + pattern ❌) - Route
/hello
doesn't match (length ❌)
The loop returns the handler for /users/:id
.
Why This Design Works
This approach is elegant because:
- Fast Length Check: Eliminates impossible matches immediately
-
Flexible Parameters:
:id
matches any value (123, abc, anything) - Exact Matching: Non-parameter segments must match exactly
- First Match Wins: Returns as soon as a pattern matches
Now let's update our route registration to use a pattern. Update main.go
:
func setupRoutes(s *Server) {
// Dynamic route pattern - but handler still doesn't know the ID
s.Router.GET("/users/:id", getUsers)
}
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// We can match the pattern but can't access the ID yet!
// For now, let's just return the first user
user := users[0]
response := map[string]any{
"data": user,
"note": "Pattern matching works, but we can't access the ID parameter yet!",
}
jsonBody, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
w.Write(jsonBody)
}
Test the pattern matching:
go run .
curl http://localhost:3000/users/1
curl http://localhost:3000/users/2
curl http://localhost:3000/users/123
curl http://localhost:3000/users/anything
All of these now return a response! The pattern matching is working - /users/:id
matches any /users/something
request. But notice we're always returning the same user because we can't access the ID parameter yet.
The Current Problem: We Can't Extract URL Values
Our pattern matching works, but we have a new problem: how do we access the actual ID that was requested? The handler needs to know whether the user requested /users/1
or /users/123
.
This is exactly what we need to solve next - extracting parameter values from the URL.
Implementing Parameter Extraction
Now let's extend our router to extract the parameter values and make them available to handlers.
First, we need to modify our resolveRoute
method to extract parameters and store them:
func (r *Router) resolveRoute(req *http.Request) (HandlerFunc, error) {
method := req.Method
path := req.URL.Path
log.Printf("%s %s", method, path)
methodRoutes, ok := r.routes[method]
if !ok {
return nil, fmt.Errorf("method not supported")
}
// Try exact match first
if handler, ok := methodRoutes[path]; ok {
return handler, nil
}
// If no exact match, try pattern matching
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
for routePattern, handler := range methodRoutes {
routeSegments := strings.Split(strings.Trim(routePattern, "/"), "/")
if len(pathSegments) == len(routeSegments) && matchesPattern(pathSegments, routeSegments) {
// NEW: Extract parameters and add to request context
params := extractParams(pathSegments, routeSegments)
if len(params) > 0 {
ctx := context.WithValue(req.Context(), "params", params)
*req = *req.WithContext(ctx)
}
return handler, nil
}
}
return nil, fmt.Errorf("path does not exist")
}
Add the context import:
import (
"context" // Add this
"fmt"
"log"
"net/http"
"strings"
)
Now let's add the parameter extraction function:
func extractParams(pathSegments, routeSegments []string) map[string]string {
params := make(map[string]string)
for i, segment := range routeSegments {
if strings.HasPrefix(segment, ":") {
paramName := segment[1:] // Remove the ":"
params[paramName] = pathSegments[i]
}
}
return params
}
How Parameter Extraction Works:
For /users/:id
matching /users/123
:
routeSegments = ["users", ":id"]
pathSegments = ["users", "123"]
- When we see
:id
at position 1, we extractpathSegments[1]
which is"123"
- Result:
{"id": "123"}
Adding a Helper Function to Access Parameters
We need a way for handlers to access the extracted parameters:
// Helper function to get path parameters from request context
func GetPathValue(r *http.Request, key string) string {
if params, ok := r.Context().Value("params").(map[string]string); ok {
return params[key]
}
return ""
}
Updating the Handler to Use Parameters
Now let's update our handler to actually use the extracted ID parameter:
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// NOW we can access the ID parameter from the URL!
userIdStr := GetPathValue(r, "id")
userId, err := strconv.ParseInt(userIdStr, 10, 32)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
response := map[string]any{
"error": "Invalid user ID",
}
jsonBody, _ := json.Marshal(response)
w.Write(jsonBody)
return
}
// Find the user with this ID
var user User
for _, u := range users {
if u.Id == int(userId) {
user = u
break
}
}
if user.Id == 0 {
w.WriteHeader(http.StatusNotFound)
response := map[string]any{
"error": "user not found",
}
jsonBody, _ := json.Marshal(response)
w.Write(jsonBody)
return
}
response := map[string]any{
"data": user,
}
jsonBody, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
w.Write(jsonBody)
}
Add the import for strconv
:
import (
"encoding/json"
"net/http"
"strconv" // Add this
)
Testing the Complete Solution
Now let's test our complete dynamic routing solution:
go run .
Try different user IDs:
curl http://localhost:3000/users/1
# Returns: {"data":{"id":1,"name":"John Doe","email":"johndoe@gmail.com"}}
curl http://localhost:3000/users/2
# Returns: {"data":{"id":2,"name":"Jane Doe","email":"janedoe@gmail.com"}}
curl http://localhost:3000/users/4
# Returns: {"data":{"id":4,"name":"Ada Lovelace","email":"adalovelace@gmail.com"}}
curl http://localhost:3000/users/999
# Returns: {"error":"user not found"}
curl http://localhost:3000/users/abc
# Returns: {"error":"Invalid user ID"}
Perfect! Our router now:
- ✅ Matches patterns like
/users/:id
- ✅ Extracts parameters from the URL
- ✅ Makes parameters accessible to handlers
- ✅ Handles different user IDs dynamically
- ✅ Provides proper error handling for invalid/missing data
What We've Accomplished
We now have:
- ✅ Dynamic route patterns (
/users/:id
) - ✅ Pattern matching (recognizes parameterized routes)
- ✅ Parameter extraction (gets values from URLs)
- ✅ Parameter access (
GetPathValue(r, "id")
) - ✅ Flexible routing (works with any ID)
- ✅ Proper error handling (invalid IDs, missing users)
- ✅ JSON responses (real API behavior)
What's Next?
In Chapter 5, we'll focus getting data from the request. Right now, our only source of information from the request is the url but there are multiple ways the client can pass data to our web server
Challenge: Try creating a route pattern like /products/:category/:id
and a handler that uses both parameters. See how the pattern matching handles nested parameters!
Next: Chapter 5: Query Params
Top comments (0)