Gin Basics #2 Routing and Handlers

5 min read

In the previous post we got our first Gin server up and running. This post covers the fundamentals of routing — registering routes per HTTP method, reading path parameters and query strings, and grouping endpoints with router groups.

  • #1 Getting started and your first server
  • #2 Routing and handlers ← this post
  • #3 Request binding and validation
  • #4 Responses — JSON, status codes, errors
  • #5 Middleware
  • #6 Database integration (GORM)
  • #7 Project structure and a mini REST API

Registering routes per HTTP method #

Gin provides a function for each method. Even for the same path, you can register different handlers when the method differs.

Routes per method
r := gin.Default()

r.GET("/users", listUsers)
r.POST("/users", createUser)
r.GET("/users/:id", getUser)
r.PUT("/users/:id", updateUser)
r.PATCH("/users/:id", patchUser)
r.DELETE("/users/:id", deleteUser)

You define endpoints by combining a method with a path. The basic skeleton of a REST API shows through directly. Notice that the handlers are split out as named functions rather than anonymous functions. As the number of endpoints grows, pulling handlers out into functions reads much better.

Path parameters — :name #

To capture part of the path as a variable, prefix it with a colon. Read the value with c.Param.

Path parameter
r.GET("/users/:id", func(c *gin.Context) {
	id := c.Param("id")
	c.JSON(http.StatusOK, gin.H{"userID": id})
})
Request
curl http://localhost:8080/users/42
# {"userID":"42"}

The value c.Param returns is always a string. If you need to treat it as a number, you have to convert it yourself with something like strconv.Atoi.

Number conversion
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
	c.JSON(http.StatusBadRequest, gin.H{"error": "id must be a number"})
	return
}

You can also use multiple parameters.

Multiple parameters
r.GET("/users/:id/posts/:postID", func(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"userID": c.Param("id"),
		"postID": c.Param("postID"),
	})
})

Wildcards — *name #

When you want to capture the entire rest of the path, use an asterisk. This is useful for capturing values with multiple slashes, such as file paths.

Wildcard
r.GET("/files/*filepath", func(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{"path": c.Param("filepath")})
})
Request
curl http://localhost:8080/files/images/logo.png
# {"path":"/images/logo.png"}

A value captured by a wildcard has a leading slash.

Query strings — ?key=value #

Read the query string appended to a URL with c.Query.

Reading a query
r.GET("/search", func(c *gin.Context) {
	keyword := c.Query("q")
	c.JSON(http.StatusOK, gin.H{"keyword": keyword})
})
Request
curl "http://localhost:8080/search?q=golang"
# {"keyword":"golang"}

When you want to provide a default value if none is present, use c.DefaultQuery. This is a common pattern for pagination parameters.

Query with a default
page := c.DefaultQuery("page", "1")
size := c.DefaultQuery("size", "20")

c.Query returns an empty string when the value is absent. If you need to know whether the value actually exists, use c.GetQuery.

Checking presence
value, ok := c.GetQuery("q")
if !ok {
	// the q parameter itself is missing
}

When the same key appears multiple times, use c.QueryArray to receive an array.

Array query
// /filter?tag=go&tag=web
tags := c.QueryArray("tag") // ["go", "web"]

Router groups — bundling a common prefix #

API versions or per-resource routes repeat the leading part of the path. With router groups you write the common prefix only once.

Router group
r := gin.Default()

v1 := r.Group("/api/v1")
{
	v1.GET("/users", listUsers)
	v1.POST("/users", createUser)
	v1.GET("/users/:id", getUser)
}

The code above produces paths like /api/v1/users. The braces wrapping the group are not syntactically required, but they are a convention for visually grouping which routes belong to the group.

Groups can also be nested.

Nested group
v1 := r.Group("/api/v1")
{
	users := v1.Group("/users")
	{
		users.GET("", listUsers)       // /api/v1/users
		users.GET("/:id", getUser)     // /api/v1/users/:id
	}
}

Groups don’t stop at bundling paths. You can apply middleware per group, so for example you can gather only the routes that require authentication into a single group. We cover this in #5 Middleware.

Splitting handlers into functions #

If you write all your handlers inline in the route registration, the main function quickly bloats. It’s better to split handlers out into named functions.

Splitting handlers
func getUser(c *gin.Context) {
	id := c.Param("id")
	c.JSON(http.StatusOK, gin.H{"userID": id})
}

func main() {
	r := gin.Default()
	r.GET("/users/:id", getUser)
	r.Run()
}

The handler’s type is gin.HandlerFunc, that is, func(c *gin.Context). As long as you keep this shape, you can register it on a route no matter where it’s defined. We cover the full-blown structure of splitting across files in #7 Project structure.

Unmatched paths — NoRoute #

When a request comes in for an unregistered path, Gin returns a default 404. If you want to define the response format yourself, register a NoRoute handler.

Custom 404
r.NoRoute(func(c *gin.Context) {
	c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
})

This is useful when you want the entire API to have a consistent JSON error format.

Wrapping up #

What this post covered:

  • Register routes per method with r.GET, r.POST, r.PUT, r.DELETE, and so on
  • Declare path parameters with :name and read them with c.Param; the value is always a string
  • Capture the entire rest of the path with the wildcard *name
  • Read query strings with c.Query, defaults with c.DefaultQuery, arrays with c.QueryArray
  • Bundle a common prefix with r.Group; nesting and per-group middleware are possible
  • Split handlers out as named functions of the form func(c *gin.Context)
  • Control the response format for unmatched paths with NoRoute

In the next post (#3 Request binding and validation) we cover binding the request body into a struct, and validating input with validator tags.

X