En este artículo de programación en el lenguaje Go (también conocido como Golang) vamos a ver cómo crear una API REST que se comunica a través de JSON, guardando y mostrando los datos a partir de una base de datos de MySQL / MariaDB.
Al final vamos a tener una API REST con Go usando los 4 métodos: POST, PUT, DELETE y GET usando el enrutador Mux. Además vamos a implementar las 4 operaciones de una base de datos: insert, update, delete y select.
Te dejaré al final el código completo y además una explicación en mi canal de YouTube.
Base de datos
Vamos a consumir una base de datos de videojuegos. Por lo tanto recuerda crear una base de datos con el nombre de tu preferencia (yo la he llamado video_games
) y colocar dentro la siguiente tabla:
CREATE TABLE video_games
(
id bigint unsigned not null primary key auto_increment,
name VARCHAR(255) NOT NULL,
genre VARCHAR(255) NOT NULL,
year INTEGER NOT NULL
);
Los datos que vamos a manejar son id
como bigint que se convertirá en un int64 al mapearlo para Go. El nombre y género serán de tipo varchar, convertidos a string en Go. Y finalmente el año será un entero igualmente de tipo int64 para golang.
Configurando credenciales de acceso a base de datos
Lo primero que tienes que hacer es configurar las credenciales de acceso a la base de datos en el archivo llamado .env
. Si no existe, debes crearlo basándote en el archivo .env.example. En mi caso se ve así:
user="root"
pass=""
host="localhost"
port="3306"
db_name="video_games"
Del archivo podemos notar varias cosas; todas relacionadas con las credenciales de acceso a la base de datos de MySQL.
En este caso mi usuario es root
, no cuento con contraseña; el host es localhost
en el puerto 3306
y el nombre de la base de datos es video_games
(recuerda crearla desde la consola de MySQL o phpmyadmin).
El archivo de entorno se va a leer y cargar en las variables. En el archivo de variables además definimos el dominio permitido para CORS, pues de este modo podemos consumir esta API desde otro dominio:
package main
import (
"fmt"
"github.com/joho/godotenv"
"os"
)
var _ = godotenv.Load(".env") // Cargar del archivo llamado ".env"
var (
ConnectionString = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
os.Getenv("user"),
os.Getenv("pass"),
os.Getenv("host"),
os.Getenv("port"),
os.Getenv("db_name"))
)
const AllowedCORSDomain = "http://localhost"
Como lo puedes notar, en la línea 10 estamos definiendo la cadena de conexión a la base de datos. Finalmente definimos la función que nos ayudará a conectar a MySQL desde Go:
package main
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func getDB() (*sql.DB, error) {
return sql.Open("mysql", ConnectionString)
}
Recuerda que anteriormente ya te mostré cómo conectar MySQL con Go solo que el ejemplo era por consola.
Structs y funciones útiles
En toda esta API que estamos creando vamos a manejar videojuegos para ejemplificar de una manera sencilla.
Para ello definimos el struct
de Videojuego que tiene los mismos campos que definimos en la base de datos. Lo que está entre backticks es para indicar el nombre que tendrá la propiedad al leerse y decodificarse de JSON.
type VideoGame struct {
Id int64 `json:"id"`
Name string `json:"name"`
Genre string `json:"genre"`
Year int64 `json:"year"`
}
Adicional al struct definimos algunos útiles, por ejemplo, una función para convertir de cadena a int64 que nos servirá para leer variables de la URL y convertirlas a entero.
package main
import "strconv"
func stringToInt64(s string) (int64, error) {
numero, err := strconv.ParseInt(s, 0, 64)
if err != nil {
return 0, err
}
return numero, err
}
Controlador
No planeo utilizar ninguna metodología ni cosas de esas; pero he colocado todas las operaciones del modelo (el CRUD) en un mismo archivo. Queda así:
package main
func createVideoGame(videoGame VideoGame) error {
bd, err := getDB()
if err != nil {
return err
}
_, err = bd.Exec("INSERT INTO video_games (name, genre, year) VALUES (?, ?, ?)", videoGame.Name, videoGame.Genre, videoGame.Year)
return err
}
func deleteVideoGame(id int64) error {
bd, err := getDB()
if err != nil {
return err
}
_, err = bd.Exec("DELETE FROM video_games WHERE id = ?", id)
return err
}
// It takes the ID to make the update
func updateVideoGame(videoGame VideoGame) error {
bd, err := getDB()
if err != nil {
return err
}
_, err = bd.Exec("UPDATE video_games SET name = ?, genre = ?, year = ? WHERE id = ?", videoGame.Name, videoGame.Genre, videoGame.Year, videoGame.Id)
return err
}
func getVideoGames() ([]VideoGame, error) {
//Declare an array because if there's error, we return it empty
videoGames := []VideoGame{}
bd, err := getDB()
if err != nil {
return videoGames, err
}
// Get rows so we can iterate them
rows, err := bd.Query("SELECT id, name, genre, year FROM video_games")
if err != nil {
return videoGames, err
}
// Iterate rows...
for rows.Next() {
// In each step, scan one row
var videoGame VideoGame
err = rows.Scan(&videoGame.Id, &videoGame.Name, &videoGame.Genre, &videoGame.Year)
if err != nil {
return videoGames, err
}
// and append it to the array
videoGames = append(videoGames, videoGame)
}
return videoGames, nil
}
func getVideoGameById(id int64) (VideoGame, error) {
var videoGame VideoGame
bd, err := getDB()
if err != nil {
return videoGame, err
}
row := bd.QueryRow("SELECT id, name, genre, year FROM video_games WHERE id = ?", id)
err = row.Scan(&videoGame.Id, &videoGame.Name, &videoGame.Genre, &videoGame.Year)
if err != nil {
return videoGame, err
}
// Success!
return videoGame, nil
}
Todas las funciones devuelven al menos un argumento que es de tipo error, pues así se puede manejar las posibles fallas que se encuentren en la ejecución. Además, estamos evitando inyecciones SQL pues si te fijas usamos placeholders en lugar de concatenar cadenas.
Por cierto, todas las funciones reciben o devuelven el tipo de dato VideoGame
que vimos anteriormente. Las funciones son:
- createVideoGame – Insertar un videojuego en la base de datos de MySQL
- deleteVideoGame – Eliminar un videojuego a partir de su id usando Go
- updateVideoGame – Actualizar un videojuego en la base de datos
- getVideoGames – Obtener todos los videojuegos existentes como un arreglo de tipo VideoGame
- getVideoGameById – Obtener un videojuego por id
Estas funciones serán expuestas con el enrutador, ahí es en donde vamos a conectar a MySQL con Go para exponer o traer los datos.
Definiendo rutas de la API con Go
Llegamos al punto en donde vamos a exponer nuestro controlador y base de datos a través de una API usando Go. Las definimos dentro de una función que recibe el enrutador, de este modo hacemos las cosas más simples y separamos conceptos.
package main
import (
"encoding/json"
"github.com/gorilla/mux"
"net/http"
)
func setupRoutesForVideoGames(router *mux.Router) {
// First enable CORS. If you don't need cors, comment the next line
enableCORS(router)
router.HandleFunc("/videogames", func(w http.ResponseWriter, r *http.Request) {
videoGames, err := getVideoGames()
if err == nil {
respondWithSuccess(videoGames, w)
} else {
respondWithError(err, w)
}
}).Methods(http.MethodGet)
router.HandleFunc("/videogame/{id}", func(w http.ResponseWriter, r *http.Request) {
idAsString := mux.Vars(r)["id"]
id, err := stringToInt64(idAsString)
if err != nil {
respondWithError(err, w)
// We return, so we stop the function flow
return
}
videogame, err := getVideoGameById(id)
if err != nil {
respondWithError(err, w)
} else {
respondWithSuccess(videogame, w)
}
}).Methods(http.MethodGet)
router.HandleFunc("/videogame", func(w http.ResponseWriter, r *http.Request) {
// Declare a var so we can decode json into it
var videoGame VideoGame
err := json.NewDecoder(r.Body).Decode(&videoGame)
if err != nil {
respondWithError(err, w)
} else {
err := createVideoGame(videoGame)
if err != nil {
respondWithError(err, w)
} else {
respondWithSuccess(true, w)
}
}
}).Methods(http.MethodPost)
router.HandleFunc("/videogame", func(w http.ResponseWriter, r *http.Request) {
// Declare a var so we can decode json into it
var videoGame VideoGame
err := json.NewDecoder(r.Body).Decode(&videoGame)
if err != nil {
respondWithError(err, w)
} else {
err := updateVideoGame(videoGame)
if err != nil {
respondWithError(err, w)
} else {
respondWithSuccess(true, w)
}
}
}).Methods(http.MethodPut)
router.HandleFunc("/videogame/{id}", func(w http.ResponseWriter, r *http.Request) {
idAsString := mux.Vars(r)["id"]
id, err := stringToInt64(idAsString)
if err != nil {
respondWithError(err, w)
// We return, so we stop the function flow
return
}
err = deleteVideoGame(id)
if err != nil {
respondWithError(err, w)
} else {
respondWithSuccess(true, w)
}
}).Methods(http.MethodDelete)
}
Básicamente es configurar los endpoint de la API creada con Go, invocando a las funciones del controlador. Veamos a continuación las funciones enableCORS
, respondWithSuccess
y respondWithError
.
Una cosa importante es la parte en donde se decodifica todo el cuerpo de la petición desde JSON al struct, lo cual puedes ver en la línea 40, leemos la petición desde r.Body
y luego la decodificamos dentro de la variable creada anteriormente.
Hacemos lo mismo (decodificar JSON de la petición) para hacer el update, eso en la línea 56.
Middleware de CORS
CORS permite compartir recursos en dominios distintos. Es decir, permite que consumas localhost:8000 desde localhost:80 o similares; algo así como si fuera posible consumir facebook.com desde google.com
Si tú no usarás esta característica, simplemente comenta el código que la usa en el enrutador. Lo que vamos a hacer es agregar encabezados que van a permitir CORS para el dominio configurado previamente en las variables.
Para lograrlo, vamos a interceptar todas las peticiones para agregar estos encabezados.
func enableCORS(router *mux.Router) {
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", AllowedCORSDomain)
}).Methods(http.MethodOptions)
router.Use(middlewareCors)
}
func middlewareCors(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, req *http.Request) {
// Just put some headers to allow CORS...
w.Header().Set("Access-Control-Allow-Origin", AllowedCORSDomain)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
// and call next handler!
next.ServeHTTP(w, req)
})
}
Responder al cliente
También tenemos dos funciones que nos van a ayudar a responderle al cliente que consuma la API; una es para responder con éxito (código HTTP 200) y otra para responder con error (código 500).
La que responde con error recibe el error y lo muestra como cadena. La función que responde con éxito recibe un parámetro de tipo interface
, que es algo así como un tipo de dato genérico en Go. Ambas funciones codifican la respuesta como JSON.
// Helper functions for respond with 200 or 500 code
func respondWithError(err error, w http.ResponseWriter) {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(err.Error())
}
func respondWithSuccess(data interface{}, w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
Poniendo todo junto
Para terminar este tutorial veamos cómo es el método main. Este método es el punto de entrada de los programas en Go, por lo tanto en esta función es en donde hacemos un ping a la base de datos, configuramos las rutas y encendemos el servidor en el puerto especificado.
package main
import (
"github.com/gorilla/mux"
"log"
"net/http"
"time"
)
func main() {
// Ping database
bd, err := getDB()
if err != nil {
log.Printf("Error with database" + err.Error())
return
} else {
err = bd.Ping()
if err != nil {
log.Printf("Error making connection to DB. Please check credentials. The error is: " + err.Error())
return
}
}
// Define routes
router := mux.NewRouter()
setupRoutesForVideoGames(router)
// .. here you can define more routes
// ...
// for example setupRoutesForGenres(router)
// Setup and start server
port := ":8000"
server := &http.Server{
Handler: router,
Addr: port,
// timeouts so the server never waits forever...
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Printf("Server started at %s", port)
log.Fatal(server.ListenAndServe())
}
Recuerda que para compilar debes haber instalado Go. Luego de eso, en el directorio en donde está el código, ejecuta go build
y ejecuta el archivo que aparezca. Si el firewall pide permisos, dáselos. De igual modo he escrito un poco de documentación en el readme del proyecto.
Puedes probar la API desde cualquier lenguaje de programación o usando Postman como yo lo he hecho en mi vídeo:
Aquí te dejo el código completo, como siempre, en mi GitHub. Te invito también a leer más sobre Go en mi blog.