Skip to content

Codebase

The API for this project is created mainly using Go. Some middlewares is created using Node.js with Express.js frameworks.

API

Get Started

The API is built using Go. The API is divided into services. Each services consist of few files. First being main.go as the entrypoint of the service API. To differ the main and routes, each service has a package called routes. This package is used to declare the routes of the service. This route need to follow the endpoint format of:

/{version}/{service_name}/{endpoint}

So, it's mandatory to use a subrouter for ease of use. Below is a snippet on how to declare the subrouter:
1. Create a subrouter with the version in main.go before setting the route

main.go
// Preview the library section for more info on 
// the wrapper routerX
r := router.CreateRouterx() 
// This will create a router with /v1/ prefix
routes.SetServiceRoute(r.PathPrefix("/v1").Subrouter())
2. Create a subrouter with the service name as the prefix from the router input
service.go
func SetServiceRoute(r *mux.Router) {
    // This will create a router with prev_prefix/service/
    // prefix
    subrouter := r.PathPrefix("/service").Subrouter()
    /* Set route handler /*
}

Besides routes and the entrypoint, each service has it's own config.go files that are placed on config package. This files declared the configuration of the server, such as host, database, and other service that this service depend on. config.go will parse configuration from json files called app.config.json that are placed within the same folder as the config.go files.

Below is an example on how to create config.go files for api service

package config

type Config struct {
    ServiceName string `json:"service_name"`
    /*Other Fields*/
}


func ParseConfig(path string) {
    // Read the other configuration from app.config.json
    exe, err := os.Executable()
    if err != nil {
        panic(err)
    }

    exePath := filepath.Dir(exe)

    configFile, err := os.Open(filepath.Join(exePath, "", path))

    if err != nil {
        return nil, err
    }

    defer configFile.Close()

    err = jsonutil.DecodeJSON(configFile, &config)

    if err != nil {
        return nil, err
    }

    /* Do other things */

    return config, err
}

Warning

For ease of deployment, any secret key or other sensitive configuration, place them inside an environment variables. REFRAIN FROM PLACING THEM ON THE CONFIG FILE.

Custom Library

This codebase use a custom http library which are an extensions of Golang http library.

Custom Handler Type

In this library, there are few types that are declared, that is

type Handler struct {
    Metadata *Metadata
    Handler HandlerLogic
}

This type is the http.Handler equivalent, and used to create a handler for a route. This struct implement the http.Handler interface.

Metadata

This handler two field, that is, metadata, which is a struct type

type Metadata struct {
    DB *sql.DB
    Cache *cache.RedisClient
    Config interface{}
}

This struct hold data/struct that may be needed on the handler, such as database and cache, and configuration files.

HandlerLogic

As for HandlerLogic, it's a function that act as the main logic of the handler. The function has a signature as follows:

type HandlerLogic func(metadata *Metadata, w http.ResponseWriter, r *http.Request) responseerror.HTTPCustomError

Custom HTTP Error

For convenience, each handler must return a custom error type called HTTPCustomError.

type ResponseError struct {
    Code    int
    Message string
    Name    string
}

The field is straightforward. Code is the return code of the error, such as 404 for Not Found, 400 for Bad Request, etc. Name is the name of the error, which must be an ENUM. When creating a new name of the error, you must add them into the list of all error type in the correct error group (Bad Request, Not Found, etc.).

For message, it's a brief description of the errors that were thrown. Same as errorType, you must declare the message on the correct group as a template. This makes the message dynamic. You can add {{.dynamicFields}} into your string. responseerror package has a function named ParseMessage that can replace the placeholder with the value on a map with the same key.

type errorMessageTemplate string
const (
    template errorMessageTemplate "Hello, {{.name}}. {{.MOTD}}"
)

s := responseerror.ParseMessage(tmp errorMessageTemplate, map[string]string{
    "name": "John Smith",
    "MOTD": "Good Morning!",
})

fmt.Println(s) // "Hello, John Smith. Good Morning!"
All Errors
  1. Bad Request

    const (
        MissingParameter     errorType = "missing_parameter"
        HeaderValueMistmatch errorType = "header_value_mismatch"
        UsernameExists       errorType = "username_exists"
        EmailExists          errorType = "email_exists"
        UsernameInvalid      errorType = "username_invalid"
        PasswordWeak         errorType = "password_weak"
        EmailInvalid         errorType = "invalid_email"
        PayloadInvalid       errorType = "invalid_payload"
        OTPInvalid           errorType = "invalid_otp"
        OTPExpired           errorType = "otp_expired"
        CallsStatusInvalid   errorType = "invalid_status_value"
        SteamNotLinked       errorType = "steam_not_linked"
        SteamAlreadyLinked   errorType = "steam_has_been_linked"
        MalformedSessionID   errorType = "malformed_uuidv7_id"
        InvalidPIN           errorType = "invalid_pin"
    )
    
    const (
        MissingParameterMessage     errorMessageTemplate = "required field {{.field}} is missing"
        HeaderValueMistmatchMessage errorMessageTemplate = "mismatch value in header {{.name}}"
        UsernameExistMessage        errorMessageTemplate = "email already taken"
        EmailsExistMessage          errorMessageTemplate = "username already taken"
        UsernameInvalidMessage      errorMessageTemplate = "username is invalid"
        PasswordWeakMessage         errorMessageTemplate = "password is weak"
        EmailInvalidMessage         errorMessageTemplate = "email is invalid"
        PayloadInvalidMessage       errorMessageTemplate = "payload is invalid"
        OTPInvalidMessage           errorMessageTemplate = "otp is invalid"
        OTPExpiredMessage           errorMessageTemplate = "otp is expired. please resend a new otp to your email"
        StatusInvalidMessage        errorMessageTemplate = "trying to update status to {{.reqStatus}} when user status is {{.status}}"
        SteamAlreadyLinkedMessage   errorMessageTemplate = "this account has been linked to steam"
        SteamNotLinkedMessage       errorMessageTemplate = "account has not been linked to steam yet"
        MalformedSessionIDMessage   errorMessageTemplate = "{{.id}} cannot be parsed as a valid uuidv7 id "
        InvalidPINMessage           errorMessageTemplate = "{{.pin}} is invalid"
    )
    

  2. Unauthenticated

    const (
        UserMarkedInActive errorType = "user_marked_inactive"
        InvalidCredentials errorType = "invalid_credentials"
    )
    
    const (
        UserMarkedInActiveMessage errorMessageTemplate = "your account hasn't been verified and currently marked inactive"
        InvalidCredentialsMessage errorMessageTemplate = "email or password is wrong"
    )
    

  3. Unauthorized

    const (
        InvalidAuthHeader errorType = "invalid_auth_header"
        EmptyAuthHeader   errorType = "empty_auth_header"
        InvalidToken      errorType = "invalid_token"
        TokenExpired      errorType = "token_expired"
        RefreshDenied     errorType = "refresh_denied"
        ClaimsMismatch    errorType = "claims_mismatch"
        AccessDenied      errorType = "access_denied"
    )
    
    const (
        InvalidAuthHeaderMessage errorMessageTemplate = "Not accepted authorization of type {{.authType}}"
        EmptyAuthHeaderMessage   errorMessageTemplate = "Required authorization header in request header"
        InvalidTokenMessage      errorMessageTemplate = "Invalid Token.{{.description}}"
        TokenExpiredMessage      errorMessageTemplate = "your token has expired"
        RefreshDeniedMessage     errorMessageTemplate = "cannot get new access token when the previous one still active"
        ClaimsMismatchMessage    errorMessageTemplate = "refresh claims and username claims don't share the same credentials"
        MTLSFailureMessage       errorMessageTemplate = "cannot establish trust. certificate is either invalid, revoked, or empty"
        AccessDeniedMesage       errorMessageTemplate = "you don't have permission to access this route"
    )
    

  4. Internal Service Error

    const (
        InternalServiceErr errorType = "internal_service_error"
    )
    
    const (
        InternalServiceErrorTemplate errorMessageTemplate = "sorry, we cannot proceed with your request at the moment. please try again later!. "
    )
    
    type InternalServiceError struct {
        *ResponseError
        Description string
    }
    

Info

Internal Service Error is a unique one. It add a new field to the struct. That is the description. When a Internal Service Error is thrown, the service will print the error description.

  1. Too Many Request
    const (
        ResendIntervalNotReachedErr errorType = "otp_resend_interval_not_reached"
    )
    
    const (
        ResendIntervalNotReachedMessage errorMessageTemplate = "Mail has already been sent to your registered email"
    )
    

Querynator

This API don't use ORM library to make the query. Querynator is the library that were used to create query to the database. Querynator is built on PostgreSQL on mind, so the functionality may break when using other driver.

Querynator has three mode, normal, batch insert, and join.

As for now, querynator support this operation: 1. Insert (Normal Mode)
2. Batch Insert (Filter Batch Insert Mode
3. Filter Batch Insert (Filter Batch Insert Mode)
4. Update (Normal Mode)
5. Delete (Normal Mode)
6. Find (Normal Mode)
7. FindOne (Normal Mode)
8. IsExist (Normal Mode)
9. Find (Join Mode)
10. Transaction (Begin, Commit, and Rollback)

Querynator use sqlx for some advanced functionality such as parse multiple SQL rows into a struct. This is used heavily on Find. Other than that, it used plain SQL interface from the standard library.

Normal Mode


1. Insert

func (q *Querynator) Insert(v interface{}, db QueryOperation, tableName string, returnField string) (interface{}, error)
This function will insert data specified in v to the database with table equal to tableName. v must be of type struct and has db tag on the fields to specify the column name of the fields. If returnField is not an empty string, interface{} will has the return value from column specified in returnField

Important

Insert will ignore field that aren't initialized (equal to the zero value for basic type and length is zero for a slice)

  1. Delete

    func (q *Querynator) Delete(v interface{}, db QueryOperation, tableName string) error {
    
    This function will delete v from the database from table equal to tableName.

  2. Update

    func (q *Querynator) Update(v interface{}, conditionNames []string, conditionValues []any, db QueryOperation, tableName string) error {
    
    This function will update data in the database with non empty value from v. Data that fit the condition specified will be updated.

  3. IsExists

    func (q *Querynator) IsExists(v interface{}, db *sql.DB, tableName string) (bool, error) {
    
    Check if v exist in the database

  4. FindOne

    func (q *Querynator) FindOne(v interface{}, dest interface{}, db *sql.DB, tableName string, returnFieldsName ...string) error {
    

Find one row that has the same value as v and load the result to dest.

  1. Find
    func (q *Querynator) Find(v interface{}, dest interface{}, limit int, db *sql.DB, tableName string, returnFieldsName ...string) error {
    
    Find all occurences in the database that has the same value as v and load them into dest
Join

To change mode to Join Mode, call the initiate join operation methods of querynator.

func (q *Querynator) PrepareJoinOperation() *JoinQueryExecutor
This will create a JoinQueryExecutor instance.
1. Add Join Table This will attach a table into the join operation
func (e *JoinQueryExecutor) AddJoinTable(joinTableName string, joinKeyName string, receiverTableName string, receiverForeignKeyName string) {

joinTableName is the new table that are going to be added to the operation. receiverTableName is the name of the table that are matched against the new table, and must have already been declared/added to the operation (either via AddJoinTable or the name that are placed on the Find method)
2. UseExplicitCast

func (e *JoinQueryExecutor) UseExplicitCast()
This will explicitly cast into the type of the conditions that are specified in Find method.
3. SetLimit
func (e *JoinQueryExecutor) SetLimit(limit int)
This will limit the amout of result the find method will return.
4. OrderBy
const (
    ASCENDING  OrderType = "ASC"
    DESCENDING OrderType = "DESC"
)

func (e *JoinQueryExecutor) OrderBy(orderByField string, orderType OrderType) {
This will order the result based on the field, either ASCENDING or DESCENDING.
5. Find
func (e *JoinQueryExecutor) Find(db *sql.DB, condition []QueryCondition, dest interface{}, tableName string, joinClause JoinClause, returnFields map[string][]string) error {
Executor the join operation and return value based on returnFields that matched the conditions specified. If the returnFields is happened to have a null value, the query will change the value into a zero value of the appropriate type.

Filter Operation

To create filter operation, one must first call PrepareFilterOperation methods of querynator.

func (q *Querynator) PrepareFilterOperation() *FilteredQueryExecutor {

This will return a FilteredQueryExecutor.

Filter query is an operation where the operation is conducted by looking at other table values. If the data fit the criteria, the operations is executed, else it was ignored.

Info

Filter query currently only support batch insert.

  1. AddTableSource

    func (f *FilteredQueryExecutor) AddTableSource(tableSource string, columnSource string, columnReceiver string) {
    
    This will add table that will be used for comparison for the data

  2. UseTransaction

    func (f *FilteredQueryExecutor) UseTransaction(db *sql.DB) error
    

This will begin a transcation. The transaction instance will be placed on field called Tx.

  1. BatchInsert
    func (f *FilteredQueryExecutor) BatchInsert(data interface{}, db *sql.DB, tableName string) error {
    

Insert data in batch.
4. Rollback

func (f *FilteredQueryExecutor) Rollback() error

Rollback the operation.
6. Commit

func (f *FilteredQueryExecutor) Commit() error

Commit the operation.

Routerx and Routex

This is an extensions to mux.Router

type Routerx struct {
    Router   *mux.Router
    Metadata *httpx.Metadata
}

To create a new routerx, call the function CreateRouterx

func CreateRouterx(metadata *httpx.Metadata) *Routerx

Routerx only implements a handful of mux.Router methods, that includes, Handle, PathPrefix, and Use. Other functionality can be called via Router field.

Routerx also not implement http.Handler interface, so to attach router to http server, use Router field.

Equivalent to mux.Router. Handle methods attach handler into a route instance called routex, that are a wrapper to mux.Route.

routex, add one new methods, that is UseMiddleware. This will attach a middleware chain to the handler of the route.

Middleware

Middleware is a http.Handler that can called other http.Handler specified in next. In this codebase, middleware is declared as a type

type Middleware func(next http.Handler) http.Handler

This signature follows the mux.Middlewarefnc type.

A few useful middleware has been implemented, that is
1. Payload Check Middleware
2. Auth Middleware
3. Certificate Check Middleware
4. Route Getter Middleware

Middleware 1-3 need to be initialized. Middleware 4 does not.

Here is a snippet on auth middleware factory

func AuthMiddleware(authAPI ServiceAPI, tlsConfig *tls.Config, metadata *httpx.Metadata) Middleware {
    return func(next http.Handler) http.Handler {
        fn := func(metadata *httpx.Metadata, w http.ResponseWriter, r *http.Request) responseerror.HTTPCustomError {
            var token string

            endpoint := r.Context().Value(EndpointKey).(string)

            auth := r.Header.Get("Authorization")

            if auth == "" {
                return responseerror.CreateBadRequestError(
                    responseerror.EmptyAuthHeader,
                    responseerror.EmptyAuthHeaderMessage,
                    nil,
                )
            }

            if authType, authValue, _ := strings.Cut(auth, " "); authType != "Bearer" {
                return responseerror.CreateUnauthorizedError(
                    responseerror.InvalidAuthHeader,
                    responseerror.InvalidAuthHeaderMessage,
                    map[string]string{
                        "authType": authType,
                    },
                )

            } else {
                token = authValue
            }

            req := &httpx.HTTPRequest{}
            req, err := req.CreateRequest(
                authAPI.Scheme,
                authAPI.Host,
                authAPI.Port,
                AUTH_VERIFY_TOKEN_ENDPOINT,
                http.MethodPost,
                http.StatusOK,
                struct {
                    Token    string `json:"token"`
                    Endpoint string `json:"endpoint"`
                }{
                    Token:    token,
                    Endpoint: endpoint,
                },
                tlsConfig,
            )

            if err != nil {
                return err
            }

            claims := &jwtutil.Claims{}
            err = req.Send(claims)

            if err != nil {
                return err
            }

            // send token and claims into the next middleware chain
            ctx := context.WithValue(r.Context(), ClaimsKey, claims)
            ctx = context.WithValue(ctx, TokenKey, token)

            next.ServeHTTP(w, r.WithContext(ctx))
            return nil
        }

        return &httpx.Handler{
            Metadata: metadata,
            Handler:  fn,
        }
    }
}

Other middleware follows a similar pattern.

Warning

Auth middleware required you to use route getter middleware before this middleware. Omitting that middleware will create a panic. Beware!

Info

data is being passed through middleware chain via context. Use the ContextKey to retrieve any relevant information.