[go: up one dir, main page]

DEV Community

Cover image for Routing the Easy Way in Go
Abhik Banerjee
Abhik Banerjee

Posted on • Originally published at abhik.hashnode.dev

Routing the Easy Way in Go

I have come across many developers who feel overwhelmed when trying to design a backend server in a language or a framework outside their comfort zone. Considering I was a fresher once who had similar problems, it is somewhat easy to empathize. One of my mentors Chirag Nayyar always said that if your fundamentals are clear, you will never be fazed. It is a belief I have come to share and it is precisely because of that belief that I will discuss the routing in web development when it comes to the backend.
We will be continuing with our little mini-project of Passwordless Auth in Go. In this article, we will discuss the essence of routing and then frame our routes and controller in Golang using Gorilla Mux. Before we start, I guess a few words of wisdom.

We will be using the utils package in this article. This Go package was discussed in the 3rd article.

and

We will be using the data package in this article. This Go package was discussed in the 4th article.

The code is hosted in the following GitHub Repo:
Embed GitHub Repo.

GitHub logo abhik-99 / Passwordless-Login-in-Go

This repo contains code for my article series on creating a Passwordless Login system in Go from scratch.

Fair Warning: This article will be a bit long.

Crash Course into Routing

So, before we had the whole REST paradigm, we used SOAP. At the time of writing, REST is the most widely accepted and used paradigm with a portion of GraphQL and a minor portion of tech arising out of HTTP 2.1.

REST comes with a few “verbs”. This is meant to convey meta-information about the HTTP request. These verbs are GET, POST, PUT, PATCH, and DELETE (with a few others that are not that often used by web developers most of the time). These verbs are attached as METHOD on the HTTP request. GET means you want to fetch some data from the web server. POST means you want to create a resource on the server. PUT and PATCH are for updating the data stored on the server. PUT is when you are sending the whole of the data object while PATCH is when you want to send only the portion of the data object on the server which needs a change. DELETE is obviously for deleting a resource on the server.

We also use a series of codes as responses from the server to denote the status of the request made to the server. These are registered with an entity called the Internet Assigned Numbers Authority. You can browse through the complete list here.

Now, there are other information encoded in the URL itself. The first one is the URL parameter. These are essentially identifiers that come between the front slashes. Next are the URL queries. These come after the parameters. They come in a key:value pair separated by “=” and are written after a “?” in the URL. For example, in the URL http://www.example.com/items/1?color=grey&size=6&unit=45” we have 1 as the URL parameter while color, size and unit are URL queries. These URL queries are separated by an &.

When we place a request to a web server, regardless of the tech stack used behind the scenes, the flow remains the same. The web server intercepts the request -> the request goes through a middleware or a series of middleware -> request is at last handled by a handler function or a controller.

What is a middleware? A middleware is a piece of code that runs after a process is started but before the actual handler for the process is used. It is an easy way to encapsulate logic which might be common in a group of functions. All frameworks in all languages will have a way to denote that middleware has completed execution and the request can be passed to the next middleware in the series or to the handler function. This comes in the form of a ”next” function. If the middleware does not complete successfully, it is meant to send back an error response to the client.

This more or less covers everything you need to know as a web developer to get down and dirty with development.

The routes package

Refer back to the 2nd article in the series for the file structure. We will place our routes in the routes package. Routes are separated by their concern. In our case, we have 2 concerns – authentication and user profile fetching. So, we will have 2 Go files in the package – auth_routes.go and user_routes.go in the project.

Authentication Routes

package routes

import (
    "github.com/abhik-99/passwordless-login/pkg/controllers"

    "github.com/gorilla/mux"
)

func RegisterAuthRoutes(r *mux.Router) {

    r.HandleFunc("/signup", controllers.Signup).Methods("POST")

    //This initiates the Email-based authentication,
    r.HandleFunc("/email/{emailId}", controllers.OTPViaEmail).Methods("GET")
    //the Email & OTP can be passed via REQ Body
    r.HandleFunc("/email", controllers.LoginViaEmail).Methods("POST")

    //This initiates the Phone-based authentication,
    r.HandleFunc("/phone/{phoneNo}", controllers.OTPViaPhone).Methods("GET")
    // the Phone number & OTP can be passed via REQ Body
    r.HandleFunc("/phone", controllers.LoginViaPhone).Methods("POST")

}

Enter fullscreen mode Exit fullscreen mode

Given above is the code we will put inside the auth_routes.go file. As you can see, we are defining a function in the above code called RegisterAuthRoutes(). This function takes in a reference to a Gorilla Mux Router (or Subrouter) and then attaches to its controllers (also called handler functions) for each of the routes.
In our main.go we had created an authentication subrouter using Gorilla Mux. The subrouter was defined in such a way that any route that is defined upon it will have the /auth prefix. This allows for easy grouping of routes based on concern as discussed in previous articles in this series.

The HandleFunc() method on the subrouter takes in the path and the controller for that path. We also define the HTTP Method which needs to be used for the route for the controller to be invoked as shown in the code above. In our case, the authentication paths are as follows:

Route Name HTTP Method Controller Name Concern
/signup POST Signup Creates a User Profile
/email/{emailId} GET OTPViaEmail Initiates Auth by sending OTP to email if the user with that email has signed up prior.
/email POST LoginViaEmail Takes in the OTP and email and then returns JWT token if auth is successful.
/phone/{phoneNo} GET OTPViaPhone Initiates Auth by sending OTP to phone if the user with that mobile has signed up prior.
/phone POST LoginViaPhone Takes in the OTP and phone and then returns JWT token if auth is successful.

User Routes

Next, we have the user_routes.go file. This is where we will define all the routes relating to user action. The code below shows the contents of the user_routes.go file.

package routes

import (
    "github.com/abhik-99/passwordless-login/pkg/controllers"
    "github.com/abhik-99/passwordless-login/pkg/middleware"

    "github.com/gorilla/mux"
)

// Protected Routes
func RegisterUserRoutes(r *mux.Router) {
    r.Use(middleware.ValidateTokenMiddleware)
    r.HandleFunc("", controllers.GetPublicUsers).Methods("GET")
    r.HandleFunc("/{id}", controllers.GetPublicUserProfile).Methods("GET")
    r.HandleFunc("/profile", controllers.GetUserProfile).Methods("GET")
}


Enter fullscreen mode Exit fullscreen mode

Like the previous file, we have a function that takes in a reference to a Gorilla Mux router (or subrouter) and then registers a few routes on it. However, these routes are slightly different from the above ones. Can you guess how? There are two points to consider here:

  1. Firstly, in line number 12, we have also attached a middleware. We say that every time a request is made to these routes, run the ValidateTokenMiddleware() middleware function before actually passing the request to the handler functions. We will discuss what this middle does in the section below.
  2. There is a route without any name at line 13. This essentially means that if the subrouter has a prefix of /user then the Router will consider the controller mentioned for any request coming to only the prefix /user. In our case, whenever someone makes a GET request to /user then the GetPublicUser handler function is used to resolve it.

We have two other routes that we attach to the subrouter passed to the RegisterUserRoutes() function. These are:

Route Name HTTP Method Controller Name Concern
/ GET GetPublicUsers Fetches all the public profiles of users.
/{id} GET GetPublicUserProfile Fetches a specific user profile whose ID is passed in as a parameter.
/profile GET GetUserProfile Fetches the user profile of the user logged in (basically who JWT is passed in the auth header).

The controllers package

The controllers package is where the code for the handler functions will be kept. Keeping in line with our convention of dividing the files into packages based on their concern, we will be segregating the controllers into two files - auth_controllers.go and user_controllers.go. Needless to say, the former will host the code for authentication routes while the latter will have the code for the user-oriented protected routes

Thanks to our discussions of the utils package in the previous articles, the explanation in this section can be kept brief. Most of the heavy work is being done by the authentication-related route handlers so we will focus on them with a brief discussion on one of the more important user routes.

Authentication-oriented Controllers

The following is the code in the auth_controllers.go file.

package controllers

import (
    "fmt"
    "net/http"
    "net/mail"

    "github.com/abhik-99/passwordless-login/pkg/data"
    "github.com/abhik-99/passwordless-login/pkg/utils"
    "github.com/gorilla/mux"
)

func Signup(w http.ResponseWriter, r *http.Request) {
    var newUser data.CreateUserDTO
    err := utils.DecodeJSONRequest(r, &newUser)
    if err != nil {
        utils.EncodeJSONResponse(w, http.StatusBadRequest, struct {
            utils.GenericJsonResponseDTO
            Err string `json:"err"`
        }{
            GenericJsonResponseDTO: utils.GenericJsonResponseDTO{
                Message: "Invalid Request Body",
            },
            Err: err.Error(),
        })
        return
    }
    if _, err := data.CreateNewUser(newUser); err != nil {
        http.Error(w, "Error Occurred while Creating user", http.StatusInternalServerError)
        return
    } else {
        utils.EncodeJSONResponse(w, http.StatusOK, utils.GenericJsonResponseDTO{Message: "User Created Successfully"})
    }

}

func OTPViaEmail(w http.ResponseWriter, r *http.Request) {
    params := mux.Vars(r)
    if _, err := mail.ParseAddress(params["emailId"]); err != nil {
        http.Error(w, "Invalid Email", http.StatusBadRequest)
        return
    } else {
        if _, userID, lookUpErr := data.UserLookupViaEmail(params["emailId"]); lookUpErr != nil {
            http.Error(w, "User does not exist", http.StatusUnauthorized)
            return
        } else {
            otp, err := utils.OTPGenerator()
            if err != nil {
                http.Error(w, "Error Occurred while generating user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }
            a := data.Auth{UserId: userID, Otp: otp}
            if err := a.SetOTPForUser(); err != nil {
                http.Error(w, "Error Occurred while setting user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }
            if err := utils.SendOTPMail(params["emailId"], otp); err != nil {
                http.Error(w, "Error Occurred while setting user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }

            utils.EncodeJSONResponse(w, http.StatusOK, utils.GenericJsonResponseDTO{Message: "User OTP sent"})

        }
    }
}

func OTPViaPhone(w http.ResponseWriter, r *http.Request) {
    params := mux.Vars(r)
    phoneNo := params["phoneNo"]
    if !utils.IsValidPhoneNumber(phoneNo) {
        http.Error(w, "Invalid Phone number", http.StatusBadRequest)
        return
    } else {
        if result, userID, lookUpErr := data.UserLookupViaPhone(phoneNo); lookUpErr != nil {
            http.Error(w, "Error Ocurred while Lookup", http.StatusInternalServerError)
            fmt.Println("[ERROR] ", lookUpErr)
            return
        } else if !result {
            http.Error(w, "User Not Found", http.StatusNotFound)
            return
        } else {
            otp, err := utils.OTPGenerator()
            if err != nil {
                http.Error(w, "Error Occurred while generating user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }
            a := data.Auth{UserId: userID, Otp: otp}
            if err := a.SetOTPForUser(); err != nil {
                http.Error(w, "Error Occurred while setting user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }
            if err := utils.SendOTPSms(phoneNo, otp); err != nil {
                http.Error(w, "Error Occurred while setting user OTP", http.StatusInternalServerError)
                fmt.Println("[ERROR] ", err)
                return
            }

            utils.EncodeJSONResponse(w, http.StatusOK, utils.GenericJsonResponseDTO{Message: "User OTP sent"})

        }
    }
}

func LoginViaEmail(w http.ResponseWriter, r *http.Request) {
    var dto data.LoginWithEmailDTO
    err := utils.DecodeJSONRequest(r, &dto)
    if err != nil {
        utils.EncodeJSONResponse(w, http.StatusBadRequest, struct {
            utils.GenericJsonResponseDTO
            Err string `json:"err"`
        }{
            GenericJsonResponseDTO: utils.GenericJsonResponseDTO{
                Message: "Invalid Request Body",
            },
            Err: err.Error(),
        })
        return
    }
    result, userID, lookUpErr := data.UserLookupViaEmail(dto.Email)

    if !result {
        http.Error(w, "User Does not Exist", http.StatusNotFound)
        return
    }
    if lookUpErr != nil {
        http.Error(w, "Error Ocurred while Lookup", http.StatusInternalServerError)
        fmt.Println("[ERROR] ", lookUpErr)
        return
    }
    a := data.Auth{UserId: userID, Otp: dto.Otp}
    if result, err := a.CheckOTP(); err != nil {
        http.Error(w, "Error Ocurred while verifying OTP", http.StatusInternalServerError)
        fmt.Println("[ERROR] ", err)
        return
    } else if !result {
        http.Error(w, "OTP Does not Match", http.StatusBadRequest)
        return
    }

    if result, err := utils.GenerateJWT(userID); err != nil {
        http.Error(w, "Error Occured during access token generation", http.StatusBadRequest)
        fmt.Println("[ERROR] ", err)
        return
    } else {
        utils.EncodeJSONResponse(w, http.StatusOK, data.AccessTokenDTO{AccessToken: result})
    }

}

func LoginViaPhone(w http.ResponseWriter, r *http.Request) {
    var dto data.LoginWithPhoneDTO
    err := utils.DecodeJSONRequest(r, &dto)
    if err != nil {
        utils.EncodeJSONResponse(w, http.StatusBadRequest, struct {
            utils.GenericJsonResponseDTO
            Err string `json:"err"`
        }{
            GenericJsonResponseDTO: utils.GenericJsonResponseDTO{
                Message: "Invalid Request Body",
            },
            Err: err.Error(),
        })
        return
    }
    _, userID, lookUpErr := data.UserLookupViaPhone(dto.Phone)

    if lookUpErr != nil {
        http.Error(w, "User Does not Exist", http.StatusUnauthorized)
        return
    }
    a := data.Auth{UserId: userID, Otp: dto.Otp}
    if result, err := a.CheckOTP(); err != nil {
        http.Error(w, "Error Ocurred while verifying OTP", http.StatusInternalServerError)
        fmt.Println("[ERROR] ", err)
        return
    } else if !result {
        http.Error(w, "OTP Does not Match", http.StatusBadRequest)
        return
    }

    if result, err := utils.GenerateJWT(userID); err != nil {
        http.Error(w, "Error Occured during access token generation", http.StatusBadRequest)
        fmt.Println("[ERROR] ", err)
        return
    } else {
        utils.EncodeJSONResponse(w, http.StatusOK, data.AccessTokenDTO{AccessToken: result})
    }
}


Enter fullscreen mode Exit fullscreen mode

Let’s discuss the Signup() function first. In lines 14 and 15, we try to decode the request body into the CreateUserDTO. This simultaneously validates it. One of the upsides of using Go in backend web development as opposed to Express is that this validation comes super easy thanks to the Validator package. In case the request body cannot be decoded or unmarshaled into the DTO, we that the user is trying to be smart with us.

We check for any error during the JSON unmarshalling phase at line 16. Between lines 17 and 25, a few interesting things happen. First off, we use the EncodeJSONResponse() function to send in an HTTP Bad Request response to the client. We define an anonymous struct between lines 17 and 19 and then simultaneously initialize it in the next lines. Pay close attention to how we embed fields from GenericJsonResponseDTO. This syntax allows us to embed fields from other structs into new structs easily in Go. Notice the way we initialize it between lines 21 and 24.

Even though when initializing embedded struct fields in Go in such a way we need to initialize the embedded struct separately when encoded, the result has all the fields at the same level in a JSON and we won’t find a JSON property called GenericJsonResponseDTO in the response. Instead, we will find the message field at the same level as err. If there are no errors, we create the user and send back the successful response.

Next, we have the OTPViaEmail() function. Refer to the previous section to see the route it handles. You will notice a different syntax. We have used curly braces or second brackets. This signifies a route parameter. The name of the route parameter is defined within the curly braces. To retrieve the URL parameter, we need to mux.Vars() in Gorilla Mux. This returns a map. The value of the URL parameter will be stored with the name as the key in this map.

We retrieve the email from the URL parameter map and then verify if it is a valid email using the ParseAddress() of the mail module. The happy path here consists of looking up if any user with the email exists & retrieving the MongoDB ID of the collection at line 43. We then generate a pseudo-random OTP using the OTPGenerator() utility function, set the OTP in our Redis DB with the user ID as the key at line 54 with the SetOTPForUser() which we defined on the Auth struct (being used here as a model), send the mail at line 59 with the SendOTPMail() utility function, and tell the client that the OTP has been sent to the user’s email at line 65.

Let’s discuss LoginViaEmail(). It is the next logical step in the flow. In this function, we first decode the request body to the LoginWithEmailDTO struct having the Email and Otp fields. Again, the happy flow here will then consist of looking up a user with that Email at line 125 (kind of redundant but acts as another flimsy line of defence), retrieving the User’s MongoDB ID, and then verifying the OTP by querying our Redis DB at line 137 with the CheckOTP() function defined on the Auth data model struct. If the OTP checks out then at line 146 we generate a JWT for our user using the GenerateJWT() utility function. The JWT at line 151 by marshalling it using the AccessTokenDTO struct.

There are two other functions called OTPViaPhone() and LoginViaPhone() in the same file in the GitHub Repo. These have similar steps albeit with the user’s phone number.

User-oriented Controllers

Next, let’s head over to the user_controller.go file. We will discuss the GetUserProfile() controller here. In almost all web development scenarios, you will need one way or another to retrieve the authenticated user’s profile. That’s what we are doing in the code below.

package controllers

import (
    "log"
    "net/http"

    "github.com/abhik-99/passwordless-login/pkg/data"
    "github.com/abhik-99/passwordless-login/pkg/utils"
    "github.com/gorilla/mux"
)

func GetPublicUsers(w http.ResponseWriter, r *http.Request) {
    if userProfiles, err := data.GetAllPublicUserProfiles(); err != nil {
        http.Error(w, "Error Occurred while fetching users", http.StatusInternalServerError)
        log.Println("ERROR", err)
    } else {
        utils.EncodeJSONResponse(w, http.StatusOK, userProfiles)
    }
}

func GetPublicUserProfile(w http.ResponseWriter, r *http.Request) {
    params := mux.Vars(r)
    id := params["id"]
    if !utils.IsValidObjectID(id) {
        http.Error(w, "Invalid User ID", http.StatusBadRequest)
        return
    }
    user, err := data.GetUserProfileById(id)
    if err != nil {
        http.Error(w, "Invalid User ID", http.StatusBadRequest)
        log.Println("ERROR while user profile query", err)
        return
    }
    utils.EncodeJSONResponse(w, http.StatusOK, user)

}

func GetUserProfile(w http.ResponseWriter, r *http.Request) {
    userID := r.Header.Get("user")
    user, err := data.GetUserProfileById(userID)
    if err != nil {
        http.Error(w, "Invalid User ID", http.StatusBadRequest)
        log.Println("ERROR while user profile query", err)
        return
    }
    utils.EncodeJSONResponse(w, http.StatusOK, user)

}


Enter fullscreen mode Exit fullscreen mode

At line 39, we have the get the MongoDB Collection ID for the authenticated user by retrieving the value of the user header. But where did we define any step for this? Where is it coming from? That’s going to be revealed in the next section. We then make a simple User ID-based lookup in our MongoDB user collection at line 40 and return the full user profile at line 46.

The other functions are simple so we will skip discussing them as well. But do glance over the code and try to understand what they do.

The middlewares package

Lastly, we arrive at the middlewares package. In our case, we only have one middleware. This we will place in the auth_middleware.go file. The code for the file is given below. Discussion follows.

package middleware

import (
    "net/http"
    "strings"

    "github.com/abhik-99/passwordless-login/pkg/utils"
)

func ValidateTokenMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

        tokenString := r.Header.Get("Authorization")
        if tokenString == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        splitToken := strings.Split(tokenString, "Bearer ")
        if len(splitToken) != 2 {
            http.Error(w, "Invalid token format", http.StatusUnauthorized)
            return
        }

        tokenString = splitToken[1]

        tokenClaims, err := utils.ValidateJWT(tokenString)

        if err != nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        userId, _ := tokenClaims.GetSubject()
        r.Header.Set("user", userId)

        next.ServeHTTP(w, r)
    })
}


Enter fullscreen mode Exit fullscreen mode

Notice the special syntax of the middleware. Unlike the handler function, it does not take in a ResponseWriter and a reference to the Request. Instead, it takes in the “next” handler. As explained in an earlier section, this next function is invoked if the middleware has successfully completed the processing of the request.

Also, pay close attention to how the ValidateTokenMiddleware() returns a handler function in the form of a Golang closure. This is the syntax in Gorilla Mux in Go. In ExpressJS, you might need to follow such conventions. Inside the handler function, we split the auth header and then took the JWT. We check for its validity at line 27 with the ValidateJWT() utility. If the JWT is valid, then invoking the GetSubject() function on the returned claims will give us the user ID as that’s how we created the JWT in the first place.

At line 34 we mutate our HTTP request. Many new web developers miss this step, they might take an alternative approach. But we need to consider these two things:

  1. Our HTTP request is like a car containing people (people being analogous to data) that passes through a pathway. This pathway is essentially the web server’s request-handling process up to the request handler function. So, it is not exposed to the outside world.
  2. It will get discarded once the request is handled. So, we can easily use it as a carrier for some bytes of extra information to relieve us of extra computation. Here, setting the user header in the middleware helps us skip the JWT decoding step in the GetUserProfile handler function.

At last, the next.ServeHTTP(w, r) at line 36 passed this request to the next middleware/handler in the series or in our case, the router handler functions defined on the user route subrouter.

With this discussion, the whole mini-project discussion can be concluded as we have explored all the important aspects of it throughout 5 articles.

Conclusion

If you have stuck around till this part, then kudos to your patience and dedication. In this series, we explored the Go backend web development stack consisting of Gorilla Mux, MongoDB, and Redis by building a password-less authentication system in Go. While it is funny how the whole “passwordless” works in this paradigm of authentication, it is what it is. I guess I will go back to completing the Chainlink CCIP series which I had taken a hiatus from after this. So, until next time, keep building awesome things and WAGMI!

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.