DEV Community

loading...
Cover image for 06 - Creating Signup Handler in Gin - Binding Data

06 - Creating Signup Handler in Gin - Binding Data

jacobsngoodwin profile image Jacob Goodwin ・10 min read

In this tutorial we'll get started on a handler for signing up a user. More specifically, we'll work on binding the JSON request body to a struct. In the process, we'll create reusable code for sending the API consumer validation errors.

We'll also add a new method to our UserService for signing up a user. We'll make sure to include this method in our mock as well so that we can begin adding our first tests for this method.

In the next part, we'll create a TokenService for creating tokens when a user successfully signs up or signs in.

If at any point you are confusion about file structure or code, go to Github repository and checkout the branch for the previous lesson to be in sync with me!

If you prefer video, check out the video version below!

And for an overview of what the app we're building, see this.

Diagram

The diagram below shows the handler for the POST endpoint we'll be working on. As previously mentioned, this handler will interact with a UserService and TokenService, as shown.

Signup Handler

Fixes from last time

If you look at the go.mod file, we currently have the following name for our module: module github.com/jacobsngoodwin/memrizr. However, I would like to append the path with account, to correspond to the path of our current golang application. The result will be module github.com/jacobsngoodwin/memrizr/account.

You'll just need to make sure you update the import paths in all files to include the addition of account to the module path.

I'd also recommend re-running go test ./... inside of the account folder to make sure all tests are passing.

Create Signup Handler

Let's add a file for our signup handler, ~/handler/signup.go. The Signup handler method is currently contained inside of ~/handler/handler.go. Cut Signup from this file and add it to our newly created file. Upon completion, we should have the following in our signup.go. Also make sure to include package handler and relevant imports at the top of the file.

// Signup handler
func (h *Handler) Signup(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "hello": "it's signup",
    })
}
Enter fullscreen mode Exit fullscreen mode

Alright, now it's time for the fun. I highly recommend you find your favorite caffeinated beverage and settle down for the long haul!

Incoming Request Body Definition

Inside of signup.go, we add the following.

// signupReq is not exported, hence the lowercase name
// it is used for validation and json marshalling
type signupReq struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required,gte=6,lte=30"`
}

// Signup handler
func (h *Handler) Signup(c *gin.Context) {
    // define a variable to which we'll bind incoming
    // json body, {email, password}
    var req signupReq

    //
}
Enter fullscreen mode Exit fullscreen mode

The signupReq struct defines the fields we expect to receive on the incoming request. We'll eventually bind this data to an instance of this struct, which is defined inside of the handler by var req signupReq.

Inside of the struct, you'll see struct "tags" defined inside of back-ticks.

The "json" struct tags bind the name of our struct parameters (e.g. Email) to values received in the request body as JSON (e.g. email). Because we've included these JSON struct tags, the gin framework will be able to handle binding the incoming serialized request body to our struct instance.

The "binding" struct tags are used to set validation rules. Gin comes preloaded with the go-playground/validator library which includes several pre-defined validators for incoming request bodies, and for things like headers as well. In our case, we require both email and password to be non-empty, and the password must be between 6 and 30 characters, inclusive.

The documentation lists built-in validators and also describes things like validation errors and custom validators. Note that in the documentation, the struct tag validate is used, whereas in Gin we must use the struct tag binding.

Make sure throughout this project to import go-playground/validator/10. I had some issues with auto-import importing v9

Binding Data

The gin documentation has excellent documentation on how to bind incoming request data to a struct. The framework provides various methods on its context (gin.Context) which allow us to bind data to structs like the signupReq which we just created.

There are specific methods for binding different incoming HTTP request bodies such as JSON, XML, or form data. We're going to use a method called "shouldBind" which infers which binder to use from the Content-Type header of the incoming request. Since we only have struct tags for "json," our shouldBind will only know how to work with JSON, even if we were to send a request with a different Content-Type header.

We'll now create a helper function, bindData which we'll use along with gin and go-playground/validator to bind data to structs and respond with any validation or data-binding error.

Since we'll be reusing this function, let's create a new file for this function, ~/handler/bind_data.go, making sure to include the package name and imports corresponding to your module path.

// used to help extract validation errors
type invalidArgument struct {
    Field string `json:"field"`
    Value string `json:"value"`
    Tag   string `json:"tag"`
    Param string `json:"param"`
}

// bindData is helper function, returns false if data is not bound
func bindData(c *gin.Context, req interface{}) bool {
    // Bind incoming json to struct and check for validation errors
    if err := c.ShouldBind(req); err != nil {
        log.Printf("Error binding data: %+v\n", err)

        if errs, ok := err.(validator.ValidationErrors); ok {
            // could probably extract this, it is also in middleware_auth_user
            var invalidArgs []invalidArgument

            for _, err := range errs {
                invalidArgs = append(invalidArgs, invalidArgument{
                    err.Field(),
                    err.Value().(string),
                    err.Tag(),
                    err.Param(),
                })
            }

            err := apperrors.NewBadRequest("Invalid request parameters. See invalidArgs")

            c.JSON(err.Status(), gin.H{
                "error":       err,
                "invalidArgs": invalidArgs,
            })
            return false
        }

        // later we'll add code for validating max body size here!

        // if we aren't able to properly extract validation errors,
        // we'll fallback and return an internal server error
        fallBack := apperrors.NewInternal()

        c.JSON(fallBack.Status(), gin.H{"error": fallBack})
        return false
    }

    return true
}

Enter fullscreen mode Exit fullscreen mode

bindData Explanation

In this helper function we do the following:

  1. Create an invalidArgument type with "json" struct tags. This will enable us to send the selected validation information in a JSON response.
  2. Attempt to bind the data to the req argument which we passed into the function. This req will be passed by reference.
  3. We determine if the binding error can be cast as validator.ValidationErrors from go-playground/validate.
  4. If the error is a validator.ValidationErrors, we extract 4 fields of interest: Field, Value, Tag, and Parameter using methods from the library.
  5. We create a NewBadRequest error from our custom apperrors package and send it, along with the invalidArguments we extracted, as JSON.
  6. If the binding error is something other than validator.ValidationErrors, we send a fallback 500 error.
  7. In either error case, we return false from the function so that the calling handler will know that a JSON response has already been sent, and that binding failed.
  8. If there are no errors, we return true to let the calling function know binding was successful.

Call bindData In Signup Handler

Finally, let's call this in signup.go. Note that in the cases of an error, or !ok, we return to prevent overwriting the JSON response in bindData.

// Bind incoming json to struct and check for validation errors
    if ok := bindData(c, &req); !ok {
        return
    }
Enter fullscreen mode Exit fullscreen mode

Add Signup to UserService

Interface Method Definition

The service layer will eventually need to implement the logic for signing up a user. So let's think about what we expect our Signup method to do. We've already made part of that decision by only accepting an email and password in the signup handler. We could have also accepted more information like name and imageURL, but we'll handle updating user information in a separate method.

So here's what we'll add to our interface in ~/model/interfaces.go.

type UserService interface {
    Get(ctx context.Context, uid uuid.UUID) (*User, error)
    Signup(ctx context.Context, u *User) error
}
Enter fullscreen mode Exit fullscreen mode

We'll require the method to accept a *model.User, though the user will only have email and password assigned when called. The method possibly returns an error from down the call chain (service or repository layer).

You might ask why we don't return a model.User. We make the choice here to modify the *User reference passed as a parameter. In this case, we'll modify the user by adding an ID down the call chain.

Call Signup in Handler

Back in signup.go, let's add the following below the call to bindData.

u := &model.User{
        Email:    req.Email,
        Password: req.Password,
    }

    err := h.UserService.Signup(c, u)

    if err != nil {
        log.Printf("Failed to sign up user: %v\n", err.Error())
        c.JSON(apperrors.Status(err), gin.H{
            "error": err,
        })
        return
    }
Enter fullscreen mode Exit fullscreen mode

This does the following:

  1. Creates a new reference to a user by assigning the user Email and Password from the successfully bound request.
  2. Calls our recently defined Signup method. If we get an error from the user service, we'll return that error as JSON. Note that we're using our apperrors.Status function to determine if the error can be cast to one of our custom errors, and then returning the appropriate status code. I'll leave it to you to review that method if you want!

At this point you may be getting some warnings from your editor. This is because both our concrete and mock implementations of the UserService do not yet implement the Signup method.

Let's fix that.

Implement UserService.Signup Method

Application Implementation

Let's first add the Signup method to the concrete implementation in ~/service/user_service.go. We'll add some real code in this method down the line, but for now, just panic if the method is called.

// SignUp reaches our to a UserRepository to verify the
// email address is available and signs up the user if this is the case
func (s *UserService) Signup(ctx context.Context, u *model.User) error {
    panic("Method not implemented")
}
Enter fullscreen mode Exit fullscreen mode

Mock Implementation

I'll include the mock implementation here without explanation. See the previous two tutorials or the testify documentation for more information!

// Signup is a mock of UserService.Signup
func (m *MockUserService) Signup(ctx context.Context, u *model.User) error {
    ret := m.Called(ctx, u)

    var r0 error
    if ret.Get(0) != nil {
        r0 = ret.Get(0).(error)
    }

    return r0
}
Enter fullscreen mode Exit fullscreen mode

Hopefully by now, any editor errors (at least regarding UserService implementations) have been eliminated.

Signup Test

Create a file ~/handler/signup_test.go. Inside, add the outer function for the test and configure gin in test mode.

func TestSignup(t *testing.T) {
    // Setup
    gin.SetMode(gin.TestMode)
}
Enter fullscreen mode Exit fullscreen mode

So what should we test with our partially implemented Signup handler?

For now, we'll simply test that we get a proper status code of 400 for a few validation error cases. We'll also test that we get the proper response in the case of an error calling the UserService.

Test Missing Email and Password

In this cases, we set up our mock UserService. We don't care what it returns as we expect bindData to return an error response before this method can be called. We merely need this mock so we can assert that it is not called.

We then instantiate our handler and a response recorder, rr, from the httptest package.

Next, we create a request body with an empty email address and missing password.

Then we serve this request to our handler, and assert that a status code of 400 is received, and that we do not reach the part of our function where UserService.Signup is called.

We could break this up into more tests, but this tutorial is already long enough.

t.Run("Email and Password Required", func(t *testing.T) {
        // We just want this to show that it's not called in this case
        mockUserService := new(mocks.MockUserService)
        mockUserService.On("Signup", mock.AnythingOfType("*gin.Context"), mock.AnythingOfType("*model.User")).Return(nil)

        // a response recorder for getting written http response
        rr := httptest.NewRecorder()

        // don't need a middleware as we don't yet have authorized user
        router := gin.Default()

        NewHandler(&Config{
            R:           router,
            UserService: mockUserService,
        })

        // create a request body with empty email and password
        reqBody, err := json.Marshal(gin.H{
            "email": "",
        })
        assert.NoError(t, err)

        // use bytes.NewBuffer to create a reader
        request, err := http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
        assert.NoError(t, err)

        request.Header.Set("Content-Type", "application/json")

        router.ServeHTTP(rr, request)

        assert.Equal(t, 400, rr.Code)
        mockUserService.AssertNotCalled(t, "Signup")
    })
Enter fullscreen mode Exit fullscreen mode

Invalid Email

This is very similar to above, but we change the request body to have an invalid email, but valid password.

t.Run("Invalid email", func(t *testing.T) {
  ...

        // create a request body with empty email and password
        reqBody, err := json.Marshal(gin.H{
            "email":    "bob@bo",
            "password": "avalidpassword123",
        })

  })

  ....
Enter fullscreen mode Exit fullscreen mode

Password to Short or Long

Again, we can duplicate the above tests, but alter the request body to have a valid email, but invalid passwords.

t.Run("Password too short", func(t *testing.T) {
    ...

        // create a request body with empty email and password
        reqBody, err := json.Marshal(gin.H{
            "email":    "bob@bob.com",
            "password": "inval",
        })

    ...
    })
Enter fullscreen mode Exit fullscreen mode

...And for a long password...

    t.Run("Password too long", func(t *testing.T) {
    ...

        // create a request body with empty email and password
        reqBody, err := json.Marshal(gin.H{
            "email":    "bob@bob.com",
            "password": "invalkadsfjasdfkj;askldfj;askldfj;asdfiuerueuuuuuudfjasdfasdkfjj",
        })

    ...
Enter fullscreen mode Exit fullscreen mode

Error Returned by UserService.Signup

In this case we create a model.User reference that will be used for calling the Signup method. We also tell the response to respond with a conflict error having Status Code 409, which would happen if the user already exists.

Make sure to change the return of the mock to return this NewConflict error.

t.Run("Error calling UserService", func(t *testing.T) {
        u := &model.User{
            Email:    "bob@bob.com",
            Password: "avalidpassword",
        }

        mockUserService := new(mocks.MockUserService)
        mockUserService.On("Signup", mock.AnythingOfType("*gin.Context"), u).Return(apperrors.NewConflict("User Already Exists", u.Email))

        // a response recorder for getting written http response
        rr := httptest.NewRecorder()

        // don't need a middleware as we don't yet have authorized user
        router := gin.Default()

        NewHandler(&Config{
            R:           router,
            UserService: mockUserService,
        })

        // create a request body with empty email and password
        reqBody, err := json.Marshal(gin.H{
            "email":    u.Email,
            "password": u.Password,
        })
        assert.NoError(t, err)

        // use bytes.NewBuffer to create a reader
        request, err := http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
        assert.NoError(t, err)

        request.Header.Set("Content-Type", "application/json")

        router.ServeHTTP(rr, request)

        assert.Equal(t, 409, rr.Code)
        mockUserService.AssertExpectations(t)
    })
Enter fullscreen mode Exit fullscreen mode

Run Package Tests

go test -v ./handler

If all goes well, you should receive the following (in addition to information for TestMe).

--- PASS: TestSignup (0.00s)
    --- PASS: TestSignup/Email_and_Password_Required (0.00s)
    --- PASS: TestSignup/Invalid_email (0.00s)
    --- PASS: TestSignup/Password_too_short (0.00s)
    --- PASS: TestSignup/Password_too_long (0.00s)
    --- PASS: TestSignup/Error_calling_UserService (0.00s)
Enter fullscreen mode Exit fullscreen mode

Before continuing, I recommend that you run the tests with different status codes, or request bodies, to make sure they fail properly.

Next Time

Thanks for sticking around for this somewhat tedious tutorial! I do believe this information is useful, and hope you can apply these principles in your daily work.

Next time we'll wrap up this handler by creating and calling a TokenService.

Discussion (0)

pic
Editor guide