golangtutorial

Handle Integer IDs in Go Chi Path Params

Picture this: you're building a sleek Go API using the awesome go-chi router. You've got endpoints like /users/{userID}, /products/{productID}, /orders/{orderID}... and suddenly, you're drowning in a sea of strconv.Atoi calls, each one checking for errors. It's the digital equivalent of death by a thousand paper cuts!

Handling integer IDs embedded directly in your URL paths can quickly become tedious, repetitive, and frankly, a bit ugly, especially as your application grows. But fear not, fellow Gopher! There's a much cleaner, more elegant solution: leveraging Chi's middleware capabilities.

Middleware in go-chi (and other Go HTTP routers) is like a helpful bouncer that processes requests before they reach your final handler. We can use this to our advantage: intercept the request, perform that pesky string-to-integer conversion once, handle any errors there, and then pass the clean, purified integer ID down the chain in the request context. Your final handlers will thank you!

The Hero: Our ID-Parsing Middleware

Our middleware will grab a named path parameter (like {itemID} from /items/{itemID}), attempt to parse it into an integer, and if successful, tuck that integer into the request's context.Context. If the parsing fails (e.g., someone tries to hit /items/abc), we'll gracefully return a 400 Bad Request.

First, a tiny utility to ensure our context keys are unique and safe:

// ctxKey is a custom type to prevent context key collisions.
// It's good practice not to use basic types (like string) directly as context keys.
type ctxKey string

// String makes ctxKey implement fmt.Stringer, which helps with debugging.
func (ck ctxKey) String() string {
	return string(ck)
}

Now, for the main event: our URLIDMiddleware!

// URLIDMiddleware creates a middleware that parses a named URL parameter
// into an integer and stores it in the request context.
// `name` is the name of the URL parameter (e.g., "itemID" for /{itemID}).
func URLIDMiddleware(name string) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// 1. Extract the named URL parameter as a string
			idStr := chi.URLParam(r, name)

			// 2. Attempt to convert the string to an integer
			id, err := strconv.Atoi(idStr)
			if err != nil {
				// 3. Oh no, it's not a valid integer! Log the error and send a 400.
				log.Printf("Error converting ID '%s' to integer: %+v\n", idStr, err)
				http.Error(w, "Invalid ID format. Must be an integer.", http.StatusBadRequest)
				// Important: return here to stop the request from proceeding
				return
			}

			// 4. Success! Put the parsed integer into the request context.
			// We use our custom ctxKey type for safety.
			newCtx := context.WithValue(r.Context(), ctxKey(name), id)

			// 5. Hand off the request to the next handler in the chain,
			// but with our new context containing the parsed ID!
			next.ServeHTTP(w, r.WithContext(newCtx))
		})
	}
}

Grabbing Your ID: The Easy Button Utility

Once the middleware has done its job and stored the ID in the context, how do you get it back in your final handler? With another tiny helper function, of course!

// GetURLID retrieves a named integer ID from the request context.
// It assumes URLIDMiddleware has already been run for the given 'name'.
func GetURLID(r *http.Request, name string) int {
	// We confidently cast to 'int' here because our middleware ensures it.
	// If this function is called without the middleware, it will panic.
	return r.Context().Value(ctxKey(name)).(int)
}

This is your "easy button" for retrieving the pre-parsed integer, keeping your actual handler logic clean and focused.

Putting It All Together: A Chi Example

Now, let's see how beautifully this integrates with a go-chi router:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"strconv" // Needed for strconv.Atoi
	"time"    // Needed for time.Now in the log

	"github.com/go-chi/chi/v5" // Our awesome router
)

// Our context key and middleware code (copy-paste from above or put in separate file)
type ctxKey string
func (ck ctxKey) String() string { return string(ck) }

func URLIDMiddleware(name string) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			idStr := chi.URLParam(r, name)
			id, err := strconv.Atoi(idStr)
			if err != nil {
				log.Printf("%s [ERROR] Invalid ID '%s' for parameter '%s': %v\n", time.Now().Format("15:04:05"), idStr, name, err)
				http.Error(w, fmt.Sprintf("Invalid ID '%s'. Must be an integer.", idStr), http.StatusBadRequest)
				return
			}
			newCtx := context.WithValue(r.Context(), ctxKey(name), id)
			next.ServeHTTP(w, r.WithContext(newCtx))
		})
	}
}

func GetURLID(r *http.Request, name string) int {
	// A small safeguard: if the middleware wasn't used, this will panic.
	// In production, you might return (int, bool) to handle missing values gracefully.
	val, ok := r.Context().Value(ctxKey(name)).(int)
	if !ok {
		log.Printf("%s [WARN] GetURLID called for '%s' but ID not found in context. Check middleware setup.", time.Now().Format("15:04:05"), name)
		return 0 // Or panic, or return an error depending on desired behavior
	}
	return val
}


func main() {
	r := chi.NewRouter()

	// Route group for items, where itemID is expected as an integer
	r.Route("/items/{itemID}", func (r chi.Router) {
		// Apply our ID parsing middleware *before* the handlers in this route group.
		// It will parse "itemID" and put the int into the context under key "itemID".
		r.Use(URLIDMiddleware("itemID"))

		// Our actual handler for /items/{itemID}
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			// Get the itemID directly from the context, no more strconv.Atoi here!
			itemID := GetURLID(r, "itemID")

			// Now you can safely use itemID as an integer!
			fmt.Fprintf(w, "You requested item ID: %d\n", itemID)
			// e.g., fetch item from database: db.GetItem(itemID)
		})
	})

	log.Printf("Server starting on http://localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", r))
}

Run this Go server (go run your_file.go) and try these URLs:

  • http://localhost:8080/items/123 -> You should see "You requested item ID: 123"
  • http://localhost:8080/items/hello -> You should get a 400 Bad Request with a clear message!

Why This Rocks (and a Quick Security Nudge)

This middleware approach offers several advantages:

  1. Cleaner Handlers: Your actual route handlers are now free from repetitive strconv.Atoi calls and error checking. They can just assume GetURLID will return a valid integer.
  2. Centralized Validation: All ID parsing and validation logic is in one place. Need to change how IDs are handled? One spot to update!
  3. Readability: The code becomes more expressive. itemID := GetURLID(r, "itemID") is much clearer than a block of string conversion.

Of course, simplifying integer ID retrieval is great, but always remember: if you're using integer IDs directly in your URLs (especially for sensitive resources), you must ensure your endpoints are properly protected with authentication and authorization. Just because you can access /users/1 doesn't mean you should be able to see user 1's private data! But that, my friend, is a topic for another exciting article.

Happy routing!