Building A Custom Payroll System: MongoDB, Go, and Yellowcard API Series - Part 3
Complete Integration of Routes, Payment Client, Webhooks, and Helpers Explained
Table of contents
Prerequisite - check out the second article here
Listening to "Strangers" by Lewis Capaldi ... I guess it provides a bit of inspiration ๐ซ .
As mentioned in the second article of this series, we will connect the dots with helpers, routes, payment provider clients, and controllers. So, without further ado, let's get started.
Helpers ๐ง
One funny thing about helpers or utility functions is that they are often reusable for other applications. Just a simple copy and paste will get the job done ๐ซฃ.
In our case, we need a function to encrypt the user's plain password. Bcrypt is a well-known algorithm we can use. So, create a utils/password.go
file and add this code.
package utils
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
type Hasher struct {
Cost int
}
func NewHasher(cost int) Hasher {
return Hasher{
Cost: cost,
}
}
func (h Hasher) HashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), h.Cost)
if err != nil {
return "", fmt.Errorf("failed to successfully hash password : [%w] ", err)
}
return string(hashed), nil
}
func (h Hasher) CheckPassword(password, hashedPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
When we provide our plain text password to the function above, it returns a hashed password and allows us to verify it against the plain text password.
Another important helper we need is for managing our JWT token. This token will be used to sign our payload and verify it during user authorization. We provide the necessary signer configuration, pass in our payload, and a JWT token is generated for us. Make sure to run go get github.com/square/go-jose/v3
to install the JWT library we will be using.
package utils
import (
"time"
"<project-name>/internals"
"github.com/square/go-jose/v3"
"github.com/square/go-jose/v3/jwt"
)
var logger internals.Logger
func init() {
logger = internals.GetLogger()
}
type SigningPayload struct {
Payload any
Secret string
Algorithm jose.SignatureAlgorithm
Issuer, Subject, Audience string
Expiry time.Duration
}
type VerificationPayload struct {
Token string
Secret string
Issuer, Audience string
}
func Sign(payload SigningPayload) (string, error) {
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: payload.Algorithm, Key: []byte(payload.Secret)}, nil)
if err != nil {
logger.Errorf("Failed to create signer: %v\n", err)
return "", err
}
claims := jwt.Claims{
Issuer: payload.Issuer,
Subject: payload.Subject,
Audience: jwt.Audience{payload.Audience},
Expiry: jwt.NewNumericDate(time.Now().Add(payload.Expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
data := struct {
jwt.Claims
Payload any `json:"payload"`
}{
Claims: claims,
Payload: payload.Payload,
}
rawJwt, err := jwt.Signed(signer).Claims(data).CompactSerialize()
if err != nil {
logger.Errorf("Failed to create JWT: %v\n", err)
return "", err
}
return rawJwt, nil
}
func Verify(payload VerificationPayload) (any, error) {
parsedToken, err := jwt.ParseSigned(payload.Token)
if err != nil {
logger.Errorf("Failed to parse JWT: %v\n", err)
return nil, err
}
claims := struct {
jwt.Claims
Payload any `json:"payload"`
}{}
err = parsedToken.Claims([]byte(payload.Secret), &claims)
if err != nil {
logger.Errorf("Failed to verify JWT: %v\n", err)
return nil, err
}
err = claims.Validate(jwt.Expected{
Issuer: payload.Issuer,
Audience: jwt.Audience{payload.Audience},
Time: time.Now(),
})
if err != nil {
logger.Errorf("JWT claims validation failed: %v\n", err)
return nil, err
}
return claims.Payload, nil
}
You can check out the other utility functions here. I won't bore you with the details.
Payment Facilitator ๐จ
A payment facilitator is an organization that helps merchants or users accept payments, usually by credit card, debit card, or other electronic methods. We will use Yellow Card, a company that focuses on Pan-African cryptocurrency exchange with popular, affordable, and localized payment methods, including Mobile Money and bank transfers.
Integrating a payment provider can be as simple as creating an HTTP client with all the necessary configurations, such as the authorization token, API key, and secret key.
Create a pkg/yc-client.go
file. Here, we will build a client that allows us to communicate over HTTP with the Yellow Card payment server so we can use it to make transfers to employees.
package pkg
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"<project-name>/utils"
"github.com/samber/lo"
)
type YellowClient struct {
client *http.Client
baseUrl, apiKey, apiSecret string
}
// NewYellowClient constructor
func NewYellowClient(baseUrl, apiKey, apiSecret string) *YellowClient {
return &YellowClient{
client: &http.Client{},
baseUrl: baseUrl,
apiKey: apiKey,
apiSecret: apiSecret,
}
}
// httpAuth method to generate authorization headers
func (yc *YellowClient) httpAuth(path, method string, body map[string]interface{}) (map[string]string, error) {
yc.client.Timeout = time.Second * 10
yc.client.Transport = &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
}
yc.client.Jar = nil
yc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }
date := time.Now().UTC().Format(time.RFC3339)
h := hmac.New(sha256.New, []byte(yc.apiSecret))
h.Write([]byte(date))
h.Write([]byte(path))
h.Write([]byte(method))
if body != nil && lo.Contains([]string{http.MethodPost, http.MethodPut}, method) {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyHmac := sha256.Sum256(bodyBytes)
bodyB64 := base64.StdEncoding.EncodeToString(bodyHmac[:])
h.Write([]byte(bodyB64))
}
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return map[string]string{
"X-YC-Timestamp": date,
"Authorization": fmt.Sprintf("YcHmacV1 %s:%s", yc.apiKey, signature),
"Content-Type": "application/json",
}, nil
}
// MakeRequest method to make an authorized request
func (yc *YellowClient) MakeRequest(method string, path string, body map[string]interface{}) (*http.Response, error) {
headers, err := yc.httpAuth(path, method, body)
if err != nil {
return nil, err
}
log.Printf("header = %v", headers)
url := yc.baseUrl + path
var bodyBytes []byte
if body != nil && lo.Contains([]string{http.MethodPost, http.MethodPut}, method) {
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
http.DefaultClient = yc.client
req, err := http.NewRequest(method, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
utils.LoopOverMap(headers, func(k, v string) { req.Header.Set(k, v) })
resp, err := yc.client.Do(req)
if err != nil {
return nil, err
}
if !lo.Contains([]int{http.StatusCreated, http.StatusOK, http.StatusNoContent}, resp.StatusCode) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, err
}
return resp, fmt.Errorf("failed to submit payment = %v , code = %v", string(body), resp.Status)
}
return resp, nil
}
Building this client allows us to add extra methods to communicate with specific endpoints provided by Yellow Card.
We used some utility function here you can check them out in the utils folder of the attached code repository.
For more details on the client, check out https://github.com/caleberi/EDB/blob/main/pkg/yc-client.go.
Routes
Routes are pathways to controllers that handle requests and return responses. Based on our previous code, we need to create the following routes to support requests made to the application.
Create a engine/routes.go
file and add the following code:
package engine
import (
"<project-name>/common"
"<project-name>/controllers"
)
func (srv *Application) RegisterRoute() *Application {
r := srv.mux
r.RemoveExtraSlash = true
r.RedirectFixedPath = false
r.RedirectTrailingSlash = false
r.POST("/webhook/yellow-card", controllers.YellowCardWebHook)
authorizedRouter := r.Group("/auth")
{
authorizedRouter.POST("/register", controllers.RegisterUser)
authorizedRouter.POST("/login", controllers.LoginUser)
authorizedRouter.Use(common.AuthorizeUser()).
POST("/logout", controllers.LogoutUser)
}
managementRouter := r.Group("/employee")
managementRouter.Use(common.AuthorizeUser())
{
managementRouter.POST("/", (controllers.AddEmployee))
managementRouter.PUT("/:employeeId", (controllers.UpdateEmployee))
managementRouter.DELETE("/:employeeId", (controllers.DeleteEmployee))
}
// include admin route check here
disbursementRouter := r.Group("/disbursements")
disbursementRouter.Use(common.AuthorizeUser())
{
disbursementRouter.POST("/:employeeId", (controllers.MakeDisbursmentToEmployee))
}
return srv
}
The routes are now attached to our application. In our main.go
file, we can set up our database and call the function to register our routes as shown below:
func main() {
config, err := common.LoadConfiguration(common.ConfEnvSetting{YamlFilePath: []string{"./dev.yml"}}) //"./dev.example.yml"
if err != nil {
log.Fatal(err)
}
logger := internals.GetLogger()
serverCtx, serverStopCtx := context.WithCancel(context.Background())
defer serverStopCtx()
if config.LogLevel == "debug" {
logger.Infof("[Gin-Debug] SET gin.forceConsoleLog")
gin.ForceConsoleColor()
}
clientOpts := options.Client().
ApplyURI(config.MongoDB.DBUri).
SetMaxPoolSize(20).
SetMinPoolSize(5)
client := setupDatabase(clientOpts)
app := &engine.Application{
DB: client,
Config: config,
Logger: logger,
Context: serverCtx,
}
app.Setup().
RegisterRoute().
GracefulShutdown()
if err := app.ListenAndServe(); err != nil {
log.Panic(err)
}
}
We have configured our application to use the MongoDB client and created an instance of the Application
struct, which is our server. By calling the register route function, we can now implement our controllers. If you run the application now, it will fail to build because we haven't implemented our controllers yet. I feel this quite long enough. Long articles can be boring sometimes ๐ค.
Therefore, I will extend the final puzzle piece to the next article to avoid making this one too long. A short break can give you time to reflect on what we've covered so far.
I am Caleb and you can reach me on Linkedin or follow me on Twitter. @Soundboax