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,
}
}
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,
}
}
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
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,
}
}
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,
}
}
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
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, "")
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")
with the following in both test files.
mock.AnythingOfType("*context.emptyCtx")
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
}
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:
- Pass the
ACCOUNT_API_URL
environment variable to the handler config. - 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
}
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)
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,
})
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
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,
}
}
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,
})
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
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)
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
}
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)
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
}
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,
}
}
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!
Top comments (2)
Thank you Jacob for such a great tutorials!
I am glad you are finding them useful! ๐๐