DEV Community

Cover image for Guía API CRUD con Go y PostgreSQL
Marlos Rodriguez
Marlos Rodriguez

Posted on

Guía API CRUD con Go y PostgreSQL

¿Porque escoger Go para crear una API? 🤔

Go es un lenguaje fantástico creado por Google, fuertemente tipado, fácil de leer y con multi-hilo y asincronismo por defecto.
En este post vamos a crear una API CRUD de lista de Videojuegos (Porque el típico TO DO es muy aburrido 😘) con Go Fiber y GORM con una base de datos PostgreSQL, el proyecto se llamara gamelist.

Requisitos Previos 🛑

Como punto de partido tendremos un proyecto de Go usando Go Modules, tener una instancia de GORM con PostgreSQL y un servidor básico con Fiber. Si no sabes como, en posts anteriores cubro estos temas, combínalos y sigue el tutorial.


La Estructura del Proyecto

Si has seguido los tutoriales previos, tendras dentro de tu proyecto un archivo main.go con un servidor de Fiber y una carpeta con el archivo /storage/connectDB.go con la conexión a la base de datos. Creamos 3 carpetas mas:

  • handlers: Aquí crearemos las funciones HTTP.
  • models: Nuestros modelos para la bases de datos.
  • utils: Y aquí nuestras funciones y herramientas para nuestro proyecto.

¿Que son las variables de Entorno? Y porque tienes que usarlas 🤗

Las Variables de Entorno (O ENV por "Environment variables") son variables de valor dinámico que afectan el funcionamiento de la App. En proyectos de programación es información importante que afecta su funcionamiento y que debería estar oculto al publicarlo (Por ejemplo al subirlo al GitHub), en estos archivos deben estar cosas como los datos de conexión a la base datos.

Para continuar con el proyecto vamos a usar ENV.

Usar ENV en nuestro proyecto 🤫

Para usar ENV agregamos en la ruta base de nuestro proyecto un archivo .env, la sintaxis de los ENV es key=value. Vamos a tomar los datos de nuestra Base de datos, y ponerla en nuestro ENV:

DB_HOST=localhost
DB_USER=postgres
DB_PORT=5432
DB_PASSWORD=password
DB_NAME=db
Enter fullscreen mode Exit fullscreen mode

Los nombres de las variables suelen estar en mayúsculas y separados por "_".

Para acceder a nuestras variables utilizares el paquete os y su función Getenv os.Getenv(key) esta función buscara la variable que le pasemos y si existe retornara el valor.

¡Alto! Importante Esto por si solo no nos servira a nosotros ¿Por que? Porque esto busca en las ENV por defecto de Go, solo con agregar el archivo .env el programa no sabra que existe y no cargara nuestras variables.

Para que nuestra aplicación carga las variables y podamos usarlas, usaremos el paquete GoDotEnv que cargara nuestro archivo .env permitiendo usarlas. lo agregamos con el comando go get:

go get github.com/joho/godotenv
Enter fullscreen mode Exit fullscreen mode

Para usarlo agregamos en el archivo donde usemos las ENV, en los import el paquete GoDotEnv:

import (
    //Autoload the env
    _ "github.com/joho/godotenv/autoload"
)
Enter fullscreen mode Exit fullscreen mode

Al importar el autoload como su nombre indica solo al importarlo cargara nuestro archivo .env. Otra forma de hacerlo, pero que puede darle problemas en el deploy, es:

import (
    "github.com/joho/godotenv"
    "log"
    "os"
)

func main() {
  err := godotenv.Load()
  if err != nil {
    log.Fatal("Error loading .env file")
  }

  val:= os.Getenv("key")
}
Enter fullscreen mode Exit fullscreen mode

Implementar ENV y mejores practicas 🤓

Ahora con el paquete GoDotEnv, podemos usar ENV en todo el proyecto pero hay ciertas cosas en tener en cuenta.

  • Orden de uso: Esto es algo importante, como dije podemos usar GoDotEnv en todo el proyecto, pero el orden es importante, deberías usar este paquete cargar el .env en una etapa temprana del programa, porque puede que intente cargar una variable importante y no lo encuentra.
  • Rendimiento: Acceder al ENV es un poco lento, ten esto en cuenta y no abuses de esto.

Para evitar esto, me gusta crear en mis proyecto la utilidad accessENV.go el cual se encargara de acceder a todas nuestras variables y las guardara en una cache para que cuando se necesite otra vez podrá accederse rápidamente.

Creamos el archivo accessENV.go dentro de la carpeta utils, importamos los paquetes os sync y GoDotEnv crearemos una función con el mismo nombre accessENV y fuera de la función crearemos nuestra "cache" que sera un map de Go con un RWMutex del paquete sync, este sera el resultado de la función:

package utils

import (
    "os"
    "sync"

    //Autoload the env
    _ "github.com/joho/godotenv/autoload"
)

var (
    environment      = map[string]string{}
    environmentMutex = sync.RWMutex{}
)

//AccessENV Return the ENV if exits
func AccessENV(key string) string {
        //Obtiene el valor del map, si existe regresa el valor
    environmentMutex.RLock()
        val := environment[key]
    environmentMutex.RUnlock()

    if environment[key] != "" {
        return val
    }

        //Si el valor no existe, lo obtiene de ENV
    val := os.Getenv(key)

    if val == "" || len(val) <= 0 {
        return ""
    }

        //Si existe en ENV, lo asigna al map
    environmentMutex.Lock()
    environment[key] = val
    environmentMutex.Unlock()

    return val
}
Enter fullscreen mode Exit fullscreen mode

¿Que es el RWMutex y porque no uso solo el map?

RWMutex is a reader/writer mutual exclusion lock

Esto quiere decir que bloquea la lectura y escritura de los map, el cual es necesario si se trabaja con gorutines, porque puede dar un fatal error debido que si no se bloquea, dos gorutines pueden intentar leer o escribir al mismo tiempo, corrompiendo los datos y deteniendo la aplicación.

La forma es que como funciona es que:

  • Lock Unlock para bloquear y desbloquear la escritura
  • RLock RUnlock para bloquear y desbloquear la lectura El uso de maps es seguro dentro de las funciones y no dará ningún error.

Ahora en podemos usar esto y acceder a nuestras variables de manera segura y rápida. Con nuestra función vamos a cambiar la función de ConnectDB() para acceder a los datos de la base de datos desde el archivo .env, cambiamos el codigo de esta manera:

package storage 

import ( 
  "fmt" 
  "log" 
  "strconv"
  "github.com/jinzhu/gorm"  
  //Postgres Driver imported 
  _ "github.com/lib/pq"
  "gamelist/utils"
)
func ConnectDB() *gorm.DB {
   var (
        host     = utils.AccessENV("DB_HOST")
        user     = utils.AccessENV("DB_USER")
        port     = utils.AccessENV("DB_PORT")
        password = utils.AccessENV("DB_PASSWORD")
        name     = utils.AccessENV("DB_NAME")
    )
    if host == "" {
        log.Fatalln("Error loading ENV")
        return nil
    }

    portInt, err := strconv.Atoi(port)

    if err != nil {
        log.Fatalln("Error en convertir el port : " + err.Error())
        return nil
    }
}
Enter fullscreen mode Exit fullscreen mode

El resto del código es igual, ahora comprobamos si las variables están vacías, en cuyo caso ocurrió un error al obtener los valores de ENV. Luego se convierte el port de string a int, formato que la función necesita.

Crear el modelo de Juego

Para poder trabajar con GORM necesitas crear un modelo. Dentro de la carpeta models creamos el archivo juego.go, para este proyecto solo usaremos un modelo, el cual sera Juego que tendra Nombre, Desarrollador y precio, usaremos el Model de GORM que nos provee con todo lo necesario para manipular la información:

package models

import (
    "time"

    "github.com/jinzhu/gorm"
) 

//Juego estructura 
type Juego struct {
        gorm.Model
        Nombre         string `gorm:"not null" json:"nombre"`
        Desarrollador  string `gorm:"not null" json:"desarrollador"`
        Precio         int    `gorm:"not null" json:"precio"`
}
Enter fullscreen mode Exit fullscreen mode

El gorm.Model nos agrega esto a nuestro Juego:

// gorm.Model definition
type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}
Enter fullscreen mode Exit fullscreen mode

Ahorrándonos de agregarlos nosotros, por ultimo agregamos un Hook que nos permite GORM. Este se llamara AfterUpdate que se ejecuta cada que actualizamos algo y la usaremos para actualizar el dato UpdatedAt automáticamente, este es el código:

// Updating data in same transaction
func (u *User) AfterUpdate(tx *gorm.DB) (err error) {
  u.UpdatedAt = time.Now()
  return
}
Enter fullscreen mode Exit fullscreen mode

Por ultimo para poder usarlo, tenemos que hacer la migración en la base de datos. Dentro del ConnectDB() antes del return agregamos DB.AutoMigrate(&models.User{}) esto hara la migración en la base de datos si no existe, y si existe simplemente la ignorara evitando cualquier tipo de problemas.

Ya cree el modelo ¿Ahora como lo uso? Creando el CRUD de la DB 👨‍💻

Esto constara de 5 funciones que interactuarán con nuestra base de datos y sera la base de nuestro CRUD. Dentro de la carpeta storage creamos juegos.go donde crearemos nuestro CRUD.

importaremos estos paquetes:


import (
    "log"
    "time"
    "github.com/jinzhu/gorm"

    "gamelist/models"
)
Enter fullscreen mode Exit fullscreen mode

Existe diferentes maneras de trabajar con la instancia de la base de datos, en este tutorial crearemos una estructura con la base de datos y agregaremos los métodos de nuestro CRUD.

//JuegoDB struct
type JuegoDB struct {
    db  *gorm.DB
}
Enter fullscreen mode Exit fullscreen mode

esto creara una estructura con una instancia de GORM como parámetro, podremos crear tantas como queramos, importante que nombre de la instancia de GORM este en minuscula y asi no se exporte. Para poder usarlo tenemos que crear una nueva con una instancia de GORM conectada a nuestra base de datos, para ellos crearemos una función que la cree:

//NuevoJuegoDB Create a new storage user service
func NuevoJuegoDB () JuegoDB {
        nuevaDB := ConnectDB()
    nuevoServicio := JuegoDB{db: nuevaDB}

    return nuevoServicio 
}
Enter fullscreen mode Exit fullscreen mode

Esto creara una nueva instancia y sera lo que usemos después en nuestros handlers. Como estamos en la misma carpeta que la función ConnectDB() podremos usarla sin necesidad de ninguna importación.

Crear los métodos del CRUD

Ahora que podemos crear una nueva instancia JuegoDB necesitamos agregar los métodos a nuestra estructura, esto hara que al crear una nueva instancia en NuevoJuegoDB() venga con funciones que podremos usar, para agregarlo como metodo tenemos que crear una función normal pero con (j *JuegoDB) antes del nombre, así que vamos a hacerlo.

  • Obtener un juego:
//ObtenerJuego del juego con el ID
func (j *JuegoDB) ObtenerJuego(id int) *models.Juego, error {
        var juego *models.Juego = new(models.Juego)     
        // SELECT * FROM juegos WHERE id = 10;
        if err := j.db.First(&juego, id).Error; err != nil {
        return nil, err
    }

        return juego, nil
}
Enter fullscreen mode Exit fullscreen mode

Esta función da por hecho que el id existe y no esta vacío, comprobaremos el id en los handlers. Esta función crea una variable vacia, que se usara en la DB, GORM buscara automaticamente en función del modelo de la variable y le pasaremos el id como parametro. En caso de error regresara un nil y el error, si todo salio bien, regresara el juego y un error nil.

  • Obtener todos los Juegos:
//ObtenerJuegos Obtener todos los Juegos
func (j *JuegoDB) ObtenerJuegos () []*models.Juego, error {
        var juego []*models.Juego = []*models.Juego{new(models.Juego)     
        // SELECT * FROM juegos;
        if err := j.db.Find(&juego).Error; err != nil {
        return nil, err
    }

        return juego, nil
}
Enter fullscreen mode Exit fullscreen mode
  • Crear un Juego:
//CrearJuego crea un Juego
func (j *JuegoDB) CrearJuego(nuevoJuego *models.Juego) *models.Juego, bool, error {
        if err := j.db.Create(&nuevoJuego).Error; err != nil {
        return nil, false, err
    }

        return *nuevoJuego, true, nil
}
Enter fullscreen mode Exit fullscreen mode
  • Modificar un Juego:
//ModificarJuego Modifica el Juego
func (j *JuegoDB) ModificarJuego(nuevoJuego *models.Juego) bool, error {
        if err := j.db.Model(&juego).Updates(&nuevoJuego).Error; err != nil {
        return false, err
    }

        return true, nil
}
Enter fullscreen mode Exit fullscreen mode

En esta función se utiliza Updates que actualiza todos los campos que no estén vacíos. GORM, encadena las funciones, al agregar Model() le decimos donde buscar, luego la función Updates buscara con el id del objeto y actualizara en la base de datos.

  • Eliminar un Juego:
//EliminarJuego Elimina un Juego
func (j *JuegoDB) EliminarJuego(id int) bool, error {
        if err := j.db.Delete(&nuevoJuego, id).Error; err != nil {
        return false, err
    }

        return true, nil
}
Enter fullscreen mode Exit fullscreen mode

Estas son todas las funciones de nuestro CRUD, Podremos acceder a los juegos, crearlo, modificarlo y eliminarlo.

Ahora con las funciones de la DB, Los Handlers 😎

Como ya dije, usaremos Go Fiber para nuestros Handlers y administrar las peticiones HTTP. Usaremos la misma estructura que las funciones de la Base de datos. Crearemos una estructura y le agregaremos las funciones de nuestro handler.

import (
     "strconv"
     "strings"
     "time"

      "github.com/go-redis/redis/v8"
      "github.com/gofiber/fiber/v2"
      "github.com/jinzhu/gorm"

      "gamelist/storage"
      "gamelist/models"
)

type juegosHandler struct {
     BaseDatos storage.JuegoDB
}
Enter fullscreen mode Exit fullscreen mode

Y crearemos la función para crear un nuevo handler:

//NuevoJuegosHandler Crear un nuevo handler
func NuevoJuegosHandler() *juegosHandler {
    //Regresa nuevo Handler
    return &juegosHandler{
        BaseDatos: storage.NuevoJuegoDB(),
    }
}
Enter fullscreen mode Exit fullscreen mode

Ahora crearemos nuestros handlers usando Go Fiber, la forma de un handler de Fiber es: func Nombre(c *fiber.Ctx) error {...}, son todas las mismas, toma como metodo el Context o contexto de Fiber y regresa un error si todo sale bien sera nil. Ahora vamos a crear nuestras funciones:

  • Obtener un Juego por ID:
//ObtenerJuego Obtener un Juego por ID
func (j *juegosHandler) ObtenerJuego(c *fiber.Ctx) error {
        //Obtener el ID desde los parametros, es uno de los metodos de Fiber
    ID := c.Params("id")

        //Si el ID esta vacio, no se envio asi que regresa un error
    if len(ID) < 0 {
                //Esta es la forma de regresar errores en fiber
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Review your input"})
    }
        ...
}
Enter fullscreen mode Exit fullscreen mode

Para enviar errores en Fiber tienes que enviar un numero de Status o Estatus, que indique que ha pasado con la petición, Fiber viene con codigos por defecto que son los estandares en la Web. Y opcionalmente puedes mandar información en formato JSON con la información del servidor, en este caso el error. Ahora convertiremos el ID, que es un string a un int para ser usado en la Base de datos:

//ObtenerJuego Obtener un Juego por ID
func (j *juegosHandler) ObtenerJuego(c *fiber.Ctx) error {
        ...
        //Convertir ID a int
        IntID, err := strconv.Atoi(ID)

    if err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error converting in Integer", "data": err.Error()})
    }
        //Obtener datos del Juego
    juego, err := j.BaseDatos.ObtenerJuego(ID)

    if err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
    }

        //Regresar los datos del juego, Go lo convertira en JSON
    return c.Status(fiber.StatusAccepted).JSON(juego)
}
Enter fullscreen mode Exit fullscreen mode
  • Obtener todos los juegos: Esta es mas simple que el anterior, no tenemos que hacer ninguna comprobación por parte del usuario, solo asegurarnos de obtener la lista de Juegos.
//ObtenerTodosJuegos Obtener Todos los Juegos
func (j *juegosHandler) ObtenerTodosJuegos(c *fiber.Ctx) error {
        //Obtener datos del Juego
    juego, err := j.BaseDatos.ObtenerJuego(ID)

    if err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
    }

        //Regresar los datos del juego, Go lo convertira en JSON
    return c.Status(fiber.StatusAccepted).JSON(juego)
}
Enter fullscreen mode Exit fullscreen mode

Al igual que con el anterior, Go lo convertira en JSON automáticamente, no importa si es un array.

  • Crear un Juego:
//CrearJuego Crea un nuevo Juego
func (j *juegosHandler) CrearJuego(c *fiber.Ctx) error {
        //Crear el nuevo juego
    var nuevoJuego *models.Juego

        //Obtener los datos del body
        if err := c.BodyParser(&nuevoJuego); err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Revisa tu Body", "data": err.Error()})
    }

        //Comprobar los datos del juego
        //Nombre
    if len(strings.TrimSpace(nuevoJuego.Nombre)) <= 0 || strings.TrimSpace(nuevoJuego.Nombre) == "" {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El Nombre del juego es obligatorio")
    }

        //Desarrollador
    if len(strings.TrimSpace(nuevoJuego.Desarrollador)) <= 0 || strings.TrimSpace(nuevoJuego.Desarrollador) == "" {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El Desarrolladordel juego es obligatorio")
    }

        //Precio
    if len(nuevoJuego.Precio) < 0 {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "El precio no puede ser negativo")
    }

        //Crear el Juego
    juego, exitoso, err := j.BaseDatos.CrearJuego(nuevoJuego)

    if err != nil || !exitoso {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
    }

        //Regresar los datos del juego, Go lo convertira en JSON
    return c.Status(fiber.StatusAccepted).JSON(juego)
}
Enter fullscreen mode Exit fullscreen mode

En esta función obtendremos los datos del nuevo juego del body con los datos que el usuario deberia enviar, tendra que enviar un JSON como este:

{
        "nobmre": "Dark Souls",
        "desarrollador": "From Software",
        "precio": 20
}
Enter fullscreen mode Exit fullscreen mode
  • Modificar un Juego: Esta sera muy similar al anterior pero verificaremos los valores y si no lo envia lo tendra vacio y asi GORM lo ignorara:
//ModificarJuego Modificia un Juego
func (j *juegosHandler) ModificarJuego (c *fiber.Ctx) error {
        //Crear el nuevo juego
    var body *models.Juego

        //Obtener los datos del body
        if err := c.BodyParser(&body); err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Revisa tu Body", "data": err.Error()})
    }

        //Crear el nuevo juego
    var nuevoJuego *models.Juego

        //Comprobar los datos del juego
        //Nombre
    if len(strings.TrimSpace(body.Nombre)) <= 0 || strings.TrimSpace(body.Nombre) == "" {
        nuevoJuego.Nombre = body.Nombre
    }

        //Desarrollador
    if len(strings.TrimSpace(body.Desarrollador)) <= 0 || strings.TrimSpace(body.Desarrollador) == "" {
        nuevoJuego.Desarrollador= body.Desarrollador
    }

        //Precio
    if len(body.Precio) < 0 {
        nuevoJuego.Precio= body.Precio
    }

        //Modificar el Juego
    exitoso, err := j.BaseDatos.ModificarJuego(nuevoJuego)

    if err != nil || !exitoso {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
    }

        //Esto regresara el numero de estatus de que todo salio bien
    return c.SendStatus(fiber.StatusAccepted)
}
Enter fullscreen mode Exit fullscreen mode
  • Eliminar un Juego:
//EliminarJuego Elimina un Juego por ID
func (j *juegosHandler) EliminarJuego(c *fiber.Ctx) error {
        //Obtener el ID desde los parametros, es uno de los metodos de Fiber
    ID := c.Params("id")

        //Si el ID esta vacio, no se envio asi que regresa un error
    if len(ID) < 0 {
                //Esta es la forma de regresar errores en fiber
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Review your input"})
    }

        //Convertir ID a int
        IntID, err := strconv.Atoi(ID)

    if err != nil {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error converting in Integer", "data": err.Error()})
    }
        //Obtener datos del Juego
    exitoso, err := j.BaseDatos.EliminarJuego(ID)

    if err != nil || !exitoso {
        return c.Status(fiber.ErrBadRequest.Code).JSON(fiber.Map{"status": "error", "message": "Error in DB", "data": err.Error()})
    }

        //Regresar Estatus de aceptado, todo salio bien.
    return c.SendStatus(fiber.StatusAccepted)
}
Enter fullscreen mode Exit fullscreen mode

Con esto terminamos con el handler de nuestro CRUD, Lo unico que falta es configurar el servidor con los metodos necesarios.

Configurar el Servidor Fiber

En nuestro main.go deberiamos tener algo como esto:

func main() {
    //Crear nuestra aplicación de Fiber
    app := fiber.New()

    app.Get("/", func(c *fiber.Ctx) error {
        return c.SendString("Hello, World 👋!")
    })

    log.Fatal(app.Listen(":3000"))
}
Enter fullscreen mode Exit fullscreen mode

Para poder configurar con nuestros handlers, tendremos que agregar esto a nuestros import

import (
      "log"

      "github.com/gofiber/fiber/v2"

      "gamelist/handlers"
)
Enter fullscreen mode Exit fullscreen mode

Ahora crearemos un nuevo Handler y agregaremos las rutas de nuestro CRUD:

func main() {
    //Crear nuestra aplicación de Fiber
    app := fiber.New()

    nuevoHandler := handlers.NuevoJuegosHandler()

    app.Get("/:id", nuevoHandler.ObtenerJuego)
    app.Get("/", nuevoHandler.ObtenerTodosJuegos)
    app.Post("/", nuevoHandler.CrearJuego)
    app.Put("/", nuevoHandler.ModificarJuego)
    app.Delete("/:id", nuevoHandler.EliminarJuego)

    log.Fatal(app.Listen(":3000"))
}
Enter fullscreen mode Exit fullscreen mode

¡Listo! ¡Terminamos nuestro Proyecto! 🤩

Eso es todo en este tutorial, Los siguientes pasos serian agregar middlewares a nuestro proyecto y seguridad con JWT.

Gracias por leer, cualquier cosa no dudes en preguntar.

Top comments (0)