DEV Community

Cover image for Connecting to a Database
Andres Court
Andres Court

Posted on

Connecting to a Database

If you've been following this series, your directory structure should look something like this:

Handling Errors

In order to standarize the error response, it is recommended to create a helper function to handle it. If you see the Get function, one of its parameters is a handler function, so lets create one

file: internal/router/common.go

package router

import "net/http"

type ErrorResponse func(w http.ResponseWriter, r *http.Request) error

func HandleErrors(h ErrorResponse) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := h(w, r); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
        }
    }
}

func HomeRoute(w http.ResponseWriter, r *http.Request) error {
    _, err := w.Write([]byte("Hello world"))
    return err
}
Enter fullscreen mode Exit fullscreen mode

Now lets wrap the Get function in our error response

file: internal/router/router.go

...
func (s *service) Router() error {
    r := chi.NewRouter()

    r.Get("/", HandleErrors(HomeRoute))

    port := fmt.Sprintf(":%d", s.port)
    slog.Info("Starting server", "port", port)
    return http.ListenAndServe(port, r)
}
Enter fullscreen mode Exit fullscreen mode

Now we have a centralized way to handle any error that we find in our project.

Returning JSON response

It is common that an API returns a JSON response, this will give the frontend a standarize way to receive and process the information provided.

file: internal/router/common.go

package router

import (
    "encoding/json"
    "net/http"
)

...

func HomeRoute(w http.ResponseWriter, r *http.Request) error {
    return JSONResponse(w, http.StatusOK, map[string]any{"message": "Hello world"})
}

func JSONResponse(w http.ResponseWriter, code int, data map[string]any) error {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    return json.NewEncoder(w).Encode(data)
}
Enter fullscreen mode Exit fullscreen mode

Health endpoint

Now lets create a health endpoint, which will be in charge of checking that all the dependencies of our project are working correctly

file: internal/router/health.go

package router

import "net/http"

func HealthRoute(w http.ResponseWriter, r *http.Request) error {
    return JSONResponse(w, http.StatusOK, map[string]any{"status": "ok"})
}
Enter fullscreen mode Exit fullscreen mode

file: internal/router/router.go

...
func (s *service) Router() error {
    ...
    r.Get("/health", HandleErrors(HealthRoute))
    ...
}
Enter fullscreen mode Exit fullscreen mode

Database connection

Now we have all the components so we can connect to a database and check if everything is correctly working, first we need to install the package to connect to a PostgreSQL database

go get github.com/jackc/pgx/v5
Enter fullscreen mode Exit fullscreen mode

Next we will create a database package where we will keep all the database interaction function

file: internal/database/database.go

package database

import (
    "database/sql"
    "os"

    _ "github.com/jackc/pgx/v5"
)

type Service interface{}

type service struct {
    DB *sql.DB
}

func New() (Service, error) {
    conn, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        return nil, err
    }

    return &service{
        DB: conn,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

file: internal/router/router.go

...
import (
    ...
    "github.com/alcb1310/bookstore/internal/database"
    ...
)

type service struct {
    port uint16
    db   database.Service
}

func New(port uint16, db database.Service) *service {
    return &service{
        port: port,
        db:   db,
    }
}
...
Enter fullscreen mode Exit fullscreen mode

file: cmd/api/main.go

...
import (
    ...
    "github.com/alcb1310/bookstore/internal/database"
    ...
)

func main() {
    ...
    db, err := database.New()
    if err != nil {
        slog.Error("Error connecting to database", "error", err)
        panic(err)
    }

    s := router.New(port, db)
    ...
}
Enter fullscreen mode Exit fullscreen mode

Updating the Health endpoint

Now that we are connected to the database we need to have the capacity to check if the database is still available

file: internal/database/database.go

...
type Service interface {
    HealthCheck() error
}
...
func (s *service) HealthCheck() error {
    return s.DB.Ping()
}
Enter fullscreen mode Exit fullscreen mode

file: internal/router/health.go

package router

import "net/http"

func (s *service) HealthRoute(w http.ResponseWriter, r *http.Request) error {
    if err := s.db.HealthCheck(); err != nil {
        return err
    }

    return JSONResponse(w, http.StatusOK, map[string]any{"status": "ok"})
}
Enter fullscreen mode Exit fullscreen mode

file: internal/router/router.go

...
func (s *service) Router() error {
    r := chi.NewRouter()

    r.Get("/", HandleErrors(HomeRoute))
    r.Get("/health", HandleErrors(s.HealthRoute))

    port := fmt.Sprintf(":%d", s.port)
    slog.Info("Starting server", "port", port)
    return http.ListenAndServe(port, r)
}
Enter fullscreen mode Exit fullscreen mode

Handling errors

At the moment, we are returning to much information to the user, information that should be logged, but not something to show the final user. First we will need to implement an error structure for the api

file internal/interfaces/apierror.go

package interfaces

type APIError struct {
    Code          int
    Msg           string
    OriginalError error
}

func (e *APIError) Error() string {
    return e.Msg
}
Enter fullscreen mode Exit fullscreen mode

file internal/router/common.go

package router

import (
    ...
    "errors"
    "log/slog"

    "github.com/alcb1310/bookstore/internal/interfaces"
)

type ErrorResponse func(w http.ResponseWriter, r *http.Request) error

func HandleErrors(h ErrorResponse) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := h(w, r); err != nil {
            if e, ok := errors.AsType[*interfaces.APIError](err); ok {
                slog.Error("API error", "code", e.Code, "error", e.OriginalError)
                _ = JSONResponse(w, e.Code, map[string]any{"error": e.Msg})
                return

            }

            slog.Error("Internal server error", "error", err)
            _ = JSONResponse(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
        }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

Finally, errors should be coded where it occurs, so we edit the health check function

file: internal/database/database.go

package database

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "net/http"
    "os"

    "github.com/alcb1310/bookstore/internal/interfaces"
    "github.com/jackc/pgx/v5/pgconn"
    _ "github.com/jackc/pgx/v5/stdlib"
)

...

func (s *service) HealthCheck() error {
    if err := s.DB.Ping(); err != nil {
        if e, ok := errors.AsType[*pgconn.PgError](err); ok {
            switch e.Code {
            case "3D000":
                return &interfaces.APIError{
                    Code:          http.StatusGatewayTimeout,
                    Msg:           "Database is not available",
                    OriginalError: e,
                }
            case "28000":
                return &interfaces.APIError{
                    Code:          http.StatusBadGateway,
                    Msg:           "Database is not available",
                    OriginalError: e,
                }
            default:
                return &interfaces.APIError{
                    Code:          http.StatusInternalServerError,
                    Msg:           "Database is not available",
                    OriginalError: e,
                }
            }
        }
        if _, ok := errors.AsType[*pgconn.ConnectError](err); ok {
            return &interfaces.APIError{
                Code:          http.StatusServiceUnavailable,
                Msg:           "Database is not available",
                OriginalError: err,
            }
        }

        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

With this we are returning proper errors to the user, with specific Http Error Codes according on what have happened to the database connection, but, if we loose network connection, then the application hangs up. To solve this we have the context api and the PingContext function where we can establish a time limit to a process, so we will wait at most for 5 seconds before an error happens

package database

import (
    ...
    "time"
    ...
)

...

func (s *service) HealthCheck() error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := s.DB.PingContext(ctx); err != nil {
        ...
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Adding middleware

Middleware are functions we run before the route itself runs, there are plenty of things we can use a middleware for, such as authentication and authorizatin, logging, among others, so lets add some to our application

What every of the middleware we add here does can be found in the Go chi middleware documentation page

To enable the httprate middleware, we will need to add its package

go get github.com/go/chi/httprate
Enter fullscreen mode Exit fullscreen mode

file internal/router/router.go

package router

import (
    ...
    "time"

    ...
    "github.com/go-chi/httprate"
)

...

func (s *service) Router() error {
    r := chi.NewRouter()

    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.CleanPath)
    r.Use(middleware.Recoverer)
    r.Use(httprate.LimitByIP(100, 1*time.Minute))

    ...
}
Enter fullscreen mode Exit fullscreen mode

To test the rate limiter, I've created a short script that is part of the project's repo

file: cmd/scripts/rate

!# /bin/bash

source .env
start=$EPOCHREALTIME

for i in {1..103}
do
    echo "Request $i"
    curl http://localhost:$PORT
done

end=$EPOCHREALTIME

echo "Elapsed: $(echo "$end - $start" | bc) seconds"
Enter fullscreen mode Exit fullscreen mode

Top comments (0)