DEV Community

loading...
Cover image for 11 - Account API Cleanup & Fixes

11 - Account API Cleanup & Fixes

jacobsngoodwin profile image Jacob Goodwin ・9 min read

Today we're going to clean up some of the naming, variables, and parameters passed to a few methods. Nothing will be terribly intellectually challenging, but we will modify a lot of files.

If you find this boring or are uncertain about the changes made to the codebase, please check out the branch for this tutorial from the Github repository!

You might also find the video useful to make sure you don't miss any changes.

I also want to thank everybody who has subscribed to me feed and actually reads! Thanks for subjecting yourself to some seriously dense material. I hope you find a useful snippet here or there! 😊

Make Service and Repository Implementations Private

Our service and repository factories return interfaces. As an example, let's look at the NewUserService in ~/service/user_service.go

// UserService acts as a struct for injecting an implementation of UserRepository
// for use in service methods
type UserService struct {
    UserRepository model.UserRepository
}

// USConfig will hold repositories that will eventually be injected into this
// this service layer
type USConfig struct {
    UserRepository model.UserRepository
}

// NewUserService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewUserService(c *USConfig) model.UserService {
    return &UserService{
        UserRepository: c.UserRepository,
    }
}
Enter fullscreen mode Exit fullscreen mode

We want to force external packages to access the service by means of the factory and its config struct (as opposed to instantiating UserService directly). Therefore, we will make the actual service and repository structs package private.

Update UserService

To get started with this, let's make the UserService implementation package private. We do this by making the service name lowercase, i.e. userService.

// userService acts as a struct for injecting an implementation of UserRepository
// for use in service methods
type userService struct {
    UserRepository model.UserRepository
}

// USConfig will hold repositories that will eventually be injected into this
// this service layer
type USConfig struct {
    UserRepository model.UserRepository
}

// NewUserService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewUserService(c *USConfig) model.UserService {
    return &userService{
        UserRepository: c.UserRepository,
    }
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, you will need to return this lower-case userService inside of NewUserService and to update all of the receiver methods to use userService.

As an example (you'll need to do this to all of the methods):

// Signup reaches our to a UserRepository to sign up the user.
// UserRepository Create should handle checking for user exists conflicts
func (s *userService) Signup(ctx context.Context, u *model.User) error {
    // omitting body...
}
// ... subsequent code omitted
Enter fullscreen mode Exit fullscreen mode

Update TokenService

Let's do the same in TokenService, located in ~/service/token_service.go.

// tokenService used for injecting an implementation of TokenRepository
// for use in service methods along with keys and secrets for
// signing JWTs
type tokenService struct {
    // TokenRepository model.TokenRepository
    PrivKey       *rsa.PrivateKey
    PubKey        *rsa.PublicKey
    RefreshSecret string
}

// TSConfig will hold repositories that will eventually be injected into this
// this service layer
type TSConfig struct {
    // TokenRepository model.TokenRepository
    PrivKey       *rsa.PrivateKey
    PubKey        *rsa.PublicKey
    RefreshSecret string
}

// NewTokenService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewTokenService(c *TSConfig) model.TokenService {
    return &tokenService{
        PrivKey:       c.PrivKey,
        PubKey:        c.PubKey,
        RefreshSecret: c.RefreshSecret,
    }
}
Enter fullscreen mode Exit fullscreen mode

Also remember to update the factory and receiver methods to use tokenService.

Update PGUserRepository

Let's do the same for our PGUserRepository.

// pgUserRepository is data/repository implementation
// of service layer UserRepository
type pgUserRepository struct {
    DB *sqlx.DB
}

// NewUserRepository is a factory for initializing User Repositories
func NewUserRepository(db *sqlx.DB) model.UserRepository {
    return &pgUserRepository{
        DB: db,
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, remember to update the factory and receiver methods to use pGUserRepository!

Context Fixes

I want to forward our Context down the call chain (handler -> service -> repository -> data sources), so that we can timeout any handler, along with calls all the way to the data sources, after a lengthy amount of time. To do this, though, we need to extract the Request().Context from off of the gin context.

We do this by passing the request context to service method calls inside of handler layer methods.

We need to update our current 2 handlers, Signup and Me.

Handler Updates

In ~/handler/me.go:


// ... code omitted

// use the Request Context
    ctx := c.Request.Context()
    u, err := h.UserService.Get(ctx, uid)

// ... code omitted

Enter fullscreen mode Exit fullscreen mode

And in ~/handler/signup.go, update the calls to Signup and NewPairFromUser methods with the Context from the Request. We'll use the same context for both calls, though I can definitely see cases where this would not be prudent.

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

    // error checking here

    // create token pair as strings
    tokens, err := h.TokenService.NewPairFromUser(ctx, u, "")
Enter fullscreen mode Exit fullscreen mode

Fix Handler Layer Tests

Now that we're using a plain Golang context to call service methods, we need to fix our signup_test.go and me_test.go.

You merely need to replace mock call arguments using:

mock.AnythingOfType("*gin.Context")
Enter fullscreen mode Exit fullscreen mode

with the following in both test files.

mock.AnythingOfType("*context.emptyCtx")
Enter fullscreen mode Exit fullscreen mode

We use *context.emptyCtx here, which is an alias for context.Background(), a non-nil, empty context (we don't have any context fields in the unit tests).

SQLX

I also want to use methods in SQLX that can receive the context. To do this, we'll update method calls on DB in the PGUserRepository to use "Context" versions of each method. As an example, let's look at the Create method and change r.DB.Get to r.DB.GetContext, remembering to pass in the context.

// Create reaches out to database SQLX api
func (r *pgUserRepository) Create(ctx context.Context, u *model.User) error {
    query := "INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *"

  // update this method to "GetContext"
    if err := r.DB.GetContext(ctx, u, query, u.Email, u.Password); err != nil {
        // ... code omitted
    }
    // ... code omitted
}
Enter fullscreen mode Exit fullscreen mode

Also make sure to update the Get call in FindByID to GetContext.

Update Environment Variables Usage

To make our app more "configurable," I want to make the following updates:

  1. Pass the ACCOUNT_API_URL environment variable to the handler config.
  2. Set the expiration duration for both JWTs as environment variables so a user can easily set them per their requirements or per environment.

Pass ACCOUNT_API_URL to Handler Config

Where possible, environment variables should be read in from the main package so the app can terminate early if there is any configuration error.

Let's add BaseURL as a field to our Config inside of ~/handler/handler.go. This way we can instantiate the handler with this BaseURL inside of the main package.

// Config will hold services that will eventually be injected into this
// handler layer on handler initialization
type Config struct {
    R            *gin.Engine
    UserService  model.UserService
    TokenService model.TokenService
    BaseURL      string
}
Enter fullscreen mode Exit fullscreen mode

Then inside of the NewHandler function, we'll use this value to group our handlers.

// Create a group, or base url for all routes
    g := c.R.Group(c.BaseURL)
Enter fullscreen mode Exit fullscreen mode

Let's read and pass this environment variable to NewHandler inside of injection.go.

// read in ACCOUNT_API_URL
    baseURL := os.Getenv("ACCOUNT_API_URL")

    handler.NewHandler(&handler.Config{
        R:            router,
        UserService:  userService,
        TokenService: tokenService,
        BaseURL:      baseURL,
    })
Enter fullscreen mode Exit fullscreen mode

We have also instantiated our handler inside of unit tests, but we do not need to make any changes as the tests work with an empty string for the BaseURL. Omitted string fields in a struct will default to an empty string in Go.

JWT Expiration

We currently set the JWT expiration with hard-coded values inside of the token utility methods found in ~/service/tokens.go. I want to make these functions configurable. Let's add environment variables for expiration time in seconds in .env.dev.

ID_TOKEN_EXP=900 #15 mins in seconds
REFRESH_TOKEN_EXP=259200 #3 days in seconds
Enter fullscreen mode Exit fullscreen mode

We'll read in and parse these environment variables the injection file in package main, as we did with the BaseURL. But first, we need to add these timeouts to our tokenService, TSConfig, and NewTokenService in ~/service/token_service.go. We'll add them as int64 type.

// tokenService used for injecting an implementation of TokenRepository
// for use in service methods along with keys and secrets for
// signing JWTs
type tokenService struct {
    // TokenRepository model.TokenRepository
    PrivKey               *rsa.PrivateKey
    PubKey                *rsa.PublicKey
    RefreshSecret         string
    IDExpirationSecs      int64
    RefreshExpirationSecs int64
}
// TSConfig will hold repositories that will eventually be injected into this
// this service layer
type TSConfig struct {
    // TokenRepository model.TokenRepository
    PrivKey               *rsa.PrivateKey
    PubKey                *rsa.PublicKey
    RefreshSecret         string
    IDExpirationSecs      int64
    RefreshExpirationSecs int64
}

// NewTokenService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewTokenService(c *TSConfig) model.TokenService {
    return &tokenService{
        PrivKey:               c.PrivKey,
        PubKey:                c.PubKey,
        RefreshSecret:         c.RefreshSecret,
        IDExpirationSecs:      c.IDExpirationSecs,
        RefreshExpirationSecs: c.RefreshExpirationSecs,
    }
}
Enter fullscreen mode Exit fullscreen mode

Then inside of injection.go, we'll parse the environment variables and call NewTokenService with them:

// load expiration lengths from env variables and parse as int
    idTokenExp := os.Getenv("ID_TOKEN_EXP")
    refreshTokenExp := os.Getenv("REFRESH_TOKEN_EXP")

    idExp, err := strconv.ParseInt(idTokenExp, 0, 64)
    if err != nil {
        return nil, fmt.Errorf("could not parse ID_TOKEN_EXP as int: %w", err)
    }

    refreshExp, err := strconv.ParseInt(refreshTokenExp, 0, 64)
    if err != nil {
        return nil, fmt.Errorf("could not parse REFRESH_TOKEN_EXP as int: %w", err)
    }

    tokenService := service.NewTokenService(&service.TSConfig{
        PrivKey:               privKey,
        PubKey:                pubKey,
        RefreshSecret:         refreshSecret,
        IDExpirationSecs:      idExp,
        RefreshExpirationSecs: refreshExp,
    })
Enter fullscreen mode Exit fullscreen mode

We use int64 as this is the type of int used golang's time package.

The final thing we need to do is accept these values in our utility functions, found in ~/service/token.go, for generating tokens.

Note that in generateRefreshToken we are working with time and time.Duration so we have to do some casting of our int64.

// generateIDToken generates an IDToken which is a jwt with myCustomClaims
// Could call this GenerateIDTokenString, but the signature makes this fairly clear
func generateIDToken(u *model.User, key *rsa.PrivateKey, exp int64) (string, error) {
    unixTime := time.Now().Unix()
  tokenExp := unixTime + exp
  // ... code omitted
}

// generateRefreshToken creates a refresh token
// The refresh token stores only the user's ID, a string
func generateRefreshToken(uid uuid.UUID, key string, exp int64) (*RefreshToken, error) {
    currentTime := time.Now()
    tokenExp := currentTime.Add(time.Duration(exp) * time.Second)
    tokenID, err := uuid.NewRandom() // v4 uuid in the google uuid lib
Enter fullscreen mode Exit fullscreen mode

Let's now make sure to call these functions inside of token_service.go by passing in the expiration times!

// ... code omitted

  idToken, err := generateIDToken(u, s.PrivKey, s.IDExpirationSecs)

  // not showing code here

  refreshToken, err := generateRefreshToken(u.UID, s.RefreshSecret, s.RefreshExpirationSecs)

Enter fullscreen mode Exit fullscreen mode

Update Token Service Test

Let's update our tests in ~/service/token_service_test.go to take in these new expiration parameters. We'll hard code the expiration durations in seconds ad the start of the test.

func TestNewPairFromUser(t *testing.T) {
    var idExp int64 = 15 * 60
    var refreshExp int64 = 3 * 24 * 2600

    priv, _ := ioutil.ReadFile("../rsa_private_test.pem")
    privKey, _ := jwt.ParseRSAPrivateKeyFromPEM(priv)
    pub, _ := ioutil.ReadFile("../rsa_public_test.pem")
    pubKey, _ := jwt.ParseRSAPublicKeyFromPEM(pub)
    secret := "anotsorandomtestsecret"

    // ... code omitted
}
Enter fullscreen mode Exit fullscreen mode

We also need to update the expectations in this test to use the expiration values passed to NewTokenService. We do this by updating the expectedExpiresAt... statement for each token.

    // for idToken
    expiresAt := time.Unix(idTokenClaims.StandardClaims.ExpiresAt, 0)
        expectedExpiresAt := time.Now().Add(time.Duration(idExp) * time.Second)
    assert.WithinDuration(t, expectedExpiresAt, expiresAt, 5*time.Second)

    // for refreshToken
    expiresAt = time.Unix(refreshTokenClaims.StandardClaims.ExpiresAt, 0)
        expectedExpiresAt = time.Now().Add(time.Duration(refreshExp) * time.Second)
        assert.WithinDuration(t, expectedExpiresAt, expiresAt, 5*time.Second)
Enter fullscreen mode Exit fullscreen mode

Accept Only JSON and multipart/form

Our struct tags for our model.User and model.TokenPair only support JSON. Currently, when we bind data with our bindData function in ~/handler/bind_data.go, we might receive form or XML data in the HTTP body. I want to make sure to send a clear error if the user sends anything other than application/json.

Let's add some code in ~/handler/bind_data at the top of the bindData function.

// send error if Content-Type != application/json
    if c.ContentType() != "application/json" {
        msg := fmt.Sprintf("%s only accepts Content-Type application/json", c.FullPath())

        err := apperrors.NewUnsupportedMediaType(msg)

        c.JSON(err.Status(), gin.H{
            "error": err,
        })
        return false
    }
Enter fullscreen mode Exit fullscreen mode

Note that I added a new custom error type called UnsupportedMediaType. You can see this added in our ~/model/apperrors/apperrors.go.

// "Set" of valid errorTypes
const (
    Authorization        Type = "AUTHORIZATION"        // Authentication Failures -
    BadRequest           Type = "BADREQUEST"           // Validation errors / BadInput
    Conflict             Type = "CONFLICT"             // Already exists (eg, create account with existent email) - 409
    Internal             Type = "INTERNAL"             // Server (500) and fallback errors
    NotFound             Type = "NOTFOUND"             // For not finding resource
    PayloadTooLarge      Type = "PAYLOADTOOLARGE"      // for uploading tons of JSON, or an image over the limit - 413
    UnsupportedMediaType Type = "UNSUPPORTEDMEDIATYPE" // for http 415
)

// ... code omitted

// Status is a mapping errors to status codes
// Of course, this is somewhat redundant since
// our errors already map http status codes
func (e *Error) Status() int {
    switch e.Type {
    case Authorization:
        return http.StatusUnauthorized
    case BadRequest:
        return http.StatusBadRequest
    case Conflict:
        return http.StatusConflict
    case Internal:
        return http.StatusInternalServerError
    case NotFound:
        return http.StatusNotFound
    case PayloadTooLarge:
        return http.StatusRequestEntityTooLarge
    case UnsupportedMediaType:
        return http.StatusUnsupportedMediaType
    default:
        return http.StatusInternalServerError
    }
}

// ... code omitted

// NewUnsupportedMediaType to create an error for 415
func NewUnsupportedMediaType(reason string) *Error {
    return &Error{
        Type:    UnsupportedMediaType,
        Message: reason,
    }
}
Enter fullscreen mode Exit fullscreen mode

Run Server

Having made the above fixes, you should now be able to run the application. If you would like to see examples of sending HTTP requests (including bodies in formats other than application/json), please check out the video!

docker-compose up

Run All Unit Tests

Let's change into the account folder, and make sure all of our tests pass.

cd account

go test -v ./...

Conclusion

I hope you're now in sync with me! Again, you can also go ahead and checkout the code for the lesson-11 branch on Github to have these updates.

Next time we'll get working on creating a TokenRepository for storing valid refresh tokens in Redis.

Hasta entonces, chau!

Discussion (2)

pic
Editor guide
Collapse
leexikang profile image
Min San

Thank you Jacob for such a great tutorials!

Collapse
jacobsngoodwin profile image
Jacob Goodwin Author

I am glad you are finding them useful! 👍😁