Gin Basics #2 Routing and Handlers
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.
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.
r.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"userID": id})
})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.
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.
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.
r.GET("/files/*filepath", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"path": c.Param("filepath")})
})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.
r.GET("/search", func(c *gin.Context) {
keyword := c.Query("q")
c.JSON(http.StatusOK, gin.H{"keyword": keyword})
})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.
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.
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.
// /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.
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.
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.
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.
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
:nameand read them withc.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 withc.DefaultQuery, arrays withc.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.