loading...
Cover image for API Rest con Go (Golang) y PostgreSQL Parte 5

API Rest con Go (Golang) y PostgreSQL Parte 5

orlmonteverde profile image Orlando Monteverde Updated on ・15 min read

Requisitos:

  • Conocimientos básicos sobre el lenguaje go.
  • Conocimiento sobre HTTP y REST recomendable.
  • Go en su versión 1.11 o superior.
  • PostgreSQL en su versión 9.6 o superior.
  • Conocimientos básicos de SQL deseables.
  • Ganas de aprender.

Que vamos a desarrollar

Crearemos un API Rest para un microblog, estilo twitter en su versión mas básica. Con un modelo de usuarios y uno de publicaciones. Con un CRUD completo en cada caso, autenticación con JSON Web Tokens, creación, uso de middlewares y asegurar contraseña por con bcrypt. ¿Suena interesante? ¡Entonces vamos!

Meme

Indice

Código fuente hasta el momento.

Activando el API

Lo primero que debemos hacer es asegurarnos de que el API esta corriendo.

go build ./cmd/microblog && ./microblog

Realizando peticiones

Cuando desarrollamos un API REST nuestro interés, por lo general, es que sea consumida por algún cliente, bien sea una aplicación móvil, web, otro servidor, entre otros. Pero no podemos esperar hasta conectar el cliente para saber si todo funciona como esperamos, para eso existen las pruebas. Hay diferentes tipos de pruebas, y es un tema que excede el propósito de este articulo, en esta ocasión probaremos cada endpoint manualmente con un par de herramientas populares para dicho fin: curl y postman.

La intención es la siguiente: muestro como realizar la petición con postman por medio de un GIF y posteriormente les comparto como hacer la misma petición desde la terminal usando curl, no es necesario que utilicen ambos, el uso de estos programas no sera cubierto a detalle pero si lo suficiente para las necesidades de nuestro proyecto. Dicho esto, manos a la obra.

Keyboard

Creando un usuario

Si vamos a utilizar postman necesitamos tenerlo instalado en nuestro ordenador, si no es así podemos descargarlo desde aquí.

Lo primero que haremos sera crear una colección en postman, este paso no es obligatorio pero nos va a facilita reutilizar las peticiones luego.

new collection

Ahora podemos proceder a crear nuestro usuario, simplemente realizando una petición de tipo POST a la dirección http://localhost:9000/api/v1/users/ con el contenido correspondiente en formato JSON.

create user

O por medio de curl.

curl -X POST -H 'Content-Type: application/json' \
  -d '{"first_name": "orlando", "last_name": "monteverde", "username": "orlmonteverde", "email": "orlmonteverde@gmail.com", "password": "123456"}' \
  http://localhost:9000/api/v1/users/

En cualquier caso habremos registrado un nuevo usuario. Ahora que ya sabemos como se hace, los invito a insertar un par mas, como practica y para poder usarlos en las siguientes pruebas.

Obtener todos los usuarios

De manera similar a la caso anterior vamos a realizar una petición a la dirección http://localhost:9000/api/v1/users/ pero esta vez sera de tipo GET y no requiere contenido.

get users

O con curl.

curl http://localhost:9000/api/v1/users/

Obtener un usuario

En el caso anterior pudimos obtener todos los usuarios, pero esta vez queremos obtener uno en especifico, esto lo logramos repitiendo la petición pero esta vez agregando el id del usuario al final de la dirección http://localhost:9000/api/v1/users/{id}.

get user

O con curl.

curl http://localhost:9000/api/v1/users/11

Actualizar usuario

Ahora que ya podemos crear y obtener usuarios, veamos como actualizar un usuario existente. Esta vez realizamos una petición PUT a la dirección http://localhost:9000/api/v1/users/{id}, donde el id debe corresponder con el del usuario que deseamos actualizar. Y, finalmente, el contenido que reemplazara al anterior en formato JSON.

update user

O con curl.

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"email": "orlmonteverde@gmail.com", "first_name": "carlos", "last_name": "monteverde"}' \
  http://localhost:9000/api/v1/users/11

Eliminar usuario

Para completa nuestro CRUD solo faltaria la operación de borrar. Realizamos una petición DELETE a la dirección http://localhost:9000/api/v1/users/{id}, donde el id debe corresponder con el del usuario que deseamos borrar y con eso es suficiente.

delete user

O con curl.

curl -X DELETE http://localhost:9000/api/v1/users/11

Llegados a este punto, ya tenemos experiencia realizando peticiones a nuestra API así que, como es costumbre, para el caso de las publicaciones avanzaremos mas rápido ya que las operaciones son bastante parecidas.

Crear publicación

curl -X POST -H 'Content-Type: application/json' \
  -d '{"body": "my first post", "user_id": 14}' \
  http://localhost:9000/api/v1/posts/

Es importante recordar que el user_id debe coincidir con el id de un usuario existente u obtendremos un error debido a la restricción que agregamos al momento de crear la tabla en la parte 3.

Obtener todas las publicaciones

curl http://localhost:9000/api/v1/posts/

Obtener una publicación

curl http://localhost:9000/api/v1/posts/11

Actualizar publicación

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"body": "my first post updated"}' \
  http://localhost:9000/api/v1/posts/1

Borrar una publicación

curl -X DELETE http://localhost:9000/api/v1/posts/10

Obtener publicaciones por usuario

curl -X GET http://localhost:9000/api/v1/posts/user/14

Middleware

Sin complicarlo demasiado, un middleware en nuestro caso es solo una función, esta ejecutara alguna acción antes (o después) de que se ejecute una función handler, esto nos permite reutilizar código de una manera bastante practica, por ejemplo, obtener logs por consola en cada petición, recuperar el programa después de que ocurra un panic para evitar que nuestro servidor caiga y, prácticamente, cualquier cosa que se nos ocurra. Conociendo la firma que deben tener estas funciones, el proceso que tenga lugar en el cuerpo de la función lo definimos nosotros.

Los middlewares son tan usados que el paquete chi (y similares) que estamos ocupando para manejar las rutas incluye varios métodos para trabajar con ellos, ademas de incluir sus propios middlewares, vamos a utilizar dos que son bastante utilizados.

Middlewares con Chi

Regresamos al paquete server y realizamos unos cambios en el archivo server.go.

// internal/server/server.go
package server

import (
    "log"
    "net/http"
    "time"

    "github.com/go-chi/chi"
    "github.com/go-chi/chi/middleware"
    v1 "github.com/orlmonteverde/go-postgres-microblog/internal/server/v1"
)

// [...]

Importando el paquete middleware, que es un subpaquete de chi y procedemos a usarlo.

// internal/server/server.go
// [...]
func New(port string) (*Server, error) {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    // [...]
}

// [...]

Ahora, gracias al método Use del puntero a chi.Mux, podemos agregar middlewares a las rutas hijas de r, en este caso Logger, que nos muestra logs por consola de las peticiones a nuestra API, los invito a realizar y petición y observar la terminal donde tienen corriendo el API, por ejemplo, esta:

curl http://localhost:9000/api/v1/users/

De igual forma, agregamos el middleware Recoverer, que permite a nuestro servidor recuperarse de posibles panics. El paquete chi nos permite agregar middlewares a rutas especificas por medio del método With del puntero a chi.Mux de a siguiente manera:

r.With(middleware).Get("/", handler)

Esto lo vamos a utilizar mas adelante cuando desarrollemos nuestro middleware para autorización con JWT.

JSON Web Token

Hasta este punto tenemos un API funcional con las operaciones mas comunes, pero se encuentra totalmente expuesta, normalmente deseamos que solo un grupo determinado pueda acceder a cierta información y aun mas si hablamos de editar o borrar.

Para solucionar esto normalmente recurrimos a la autorización, donde tenemos varias opciones. Una bastante utilizada con las API REST es JWT, les aseguro que la menciono por eso y no porque tengo como cuatro artículos diciendo que lo usaríamos 😅.

JWT es un estándar abierto basado en JSON propuesto por IETF (RFC 7519) para la creación de tokens de acceso que permiten la propagación de identidad y privilegios o claims en inglés. Por ejemplo, un servidor podría generar un token indicando que el usuario tiene privilegios de administrador y proporcionarlo a un cliente. El cliente entonces podría utilizar el token para probar que está actuando como un administrador en el cliente o en otro sistema. El token está firmado por la clave del servidor, así que el cliente y el servidor son ambos capaz de verificar que el token es legítimo.
wikipedia

Lo primero que necesitamos es descargar un paquete de terceros que nos facilitara el trabajo con JWT, existen varios, pero personalmente me gusta jwt-go, así que procederemos con la descarga.

go get github.com/dgrijalva/jwt-go

Paquete Claim

La información contenida dentro de nuestro token es lo que llamamos claims, aquí podemos agregar información útil, algunas estandarizadas como la fecha de expiración del token y también datos personalizados que nos interese incluir, por ejemplo si tiene permisos de administrador. No debemos agregar aquí información sensible/secreta como contraseñas o números de tarjeta de crédito porque esta información sera compartida en base64, por lo que es fácilmente decodificable, en un momento lo veremos.

Vamos a colocar toda la lógica de los claims en un paquete para facilitar el trabajo con los mismos.

// pkg/claim/claim.go
package claim

import jwt "github.com/dgrijalva/jwt-go"

type Claim struct {
    jwt.StandardClaims
    ID string `json:"id"`
}

Creamos un estructura a la que llamamos Claim y le agregamos el tipo StandardClaims que nos proporciona el paquete jwt-go, este tipo incluye los campos mas comunes para el token, adicionalmente incluimos el campo ID que utilizaremos para agregar el id del usuario, aunque podemos agregar todos los campos que necesitemos es mejor no excederse ya que mientras mas contenido incluyamos mayor sera el tamaño del token.

Ahora agreguemos un método al nuestro tipo Claim que nos permita generar un token a partir de este.

// pkg/claim/claim.go
func (c *Claim) GetToken(signingString string) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
    return token.SignedString([]byte(signingString))
}

Esta es una tarea bastante sencilla gracias al paquete jwt-go, utilizando su función NewWithClaims podemos obtener un token que incluya un claim, ademas debemos pasar el método que queremos utilizar para firmar el token, en este caso HS256 que es bastante utilizado pero existen mas, los invito a revisar la documentación del paquete jwt-go y la de JWT.

Finalmente firmamos el token con el método SignedString del puntero a jwt.Token, este método requiere la clave que utilizaremos para firmar el token y, aunque el tipo esperado por esta es una interfaz vacía (interface{}) no podemos utilizar una clave de tipo string directamente, sino que debemos pasarlo como un slice de bytes.

Agreguemos una función mas, que nos permita hacer la operación inversa, recibir un token, validarlo y retornar un Claim a partir de este.

// pkg/claim/claim.go
func GetFromToken(tokenString, signingString string) (*Claim, error) {
    token, err := jwt.Parse(tokenString, func(*jwt.Token) (interface{}, error) {
        return []byte(signingString), nil
    })
    if err != nil {
        return nil, err
    }

    if !token.Valid {
        return nil, errors.New("invalid token")
    }

    claim, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return nil, errors.New("invalid claim")
    }

    iID, ok := claim["id"]
    if !ok {
        return nil, errors.New("user id not found")
    }

        d, ok := iID.(float64)
    if !ok {
        return nil, errors.New("invalid user id")
    }

    return &Claim{ID: int(id)}, nil
}

La función GetFromToken es un poco extensa pero no muy compleja, lo mas importante es el comienzo, donde utilizamos la función Parse del paquete jwt-go para validar el token y retornar un puntero a jwt.Token por medio de una función de tipo jwt.Keyfunc con la firma func(*jwt.Token) (interface{}, error) que debe retornar la clave para descifrar el token que, en este caso, debe ser la misma que usamos para firmarlo anteriormente porque estamos utilizando criptografía simétrica.

Hecho esto, realizamos algunas validaciones: si el método Parse retorno un error, si el token obtenido es valido, realizamos un type assertion al Claim dentro del token para convertirlo en un jwt.MapClaims que internamente es solo un map[string]interface{}, confirmamos si contiene la clave id, correspondiente al campo ID en nuestro tipo Claim, realizamos un type assertion para obtener el id como un float64 y retornar un puntero al tipo Claim con el id obtenido.

Middleware de autorización

Con esto terminamos el paquete claim, es momento de crear nuestro middleware. Comencemos por crear un nuevo directorio al que llamaremos middleware dentro del directorio internal.

mkdir internal/middleware

Dentro de este directorio crearemos un archivo al que llamaremos auth.go (o el nombre que prefieran) y agregamos el siguiente contenido.

// internal/middleware/auth.go
package middleware

import (
    "errors"
    "strings"
)

func tokenFromAuthorization(authorization string) (string, error) {
    if authorization == "" {
        return "", errors.New("autorization is required")
    }

    if !strings.HasPrefix(authorization, "Bearer") {
        return "", errors.New("invalid autorization format")
    }

    l := strings.Split(authorization, " ")
    if len(l) != 2 {
        return "", errors.New("invalid autorization format")
    }

    return l[1], nil
}

La función tokenFromAuthorization no es obligatoria, pero nos permite extraer parte de la lógica del middleware, que crearemos a continuación, dejando el código mas limpio. Esta función espera recibir una cadena de texto a la que llamamos authorization con una forma similar a esta Bearer <TOKEN>, validamos que el formato sea correcto: que no sea una cadena de texto vaciá, que contenga el prefijo Bearer gracias a la función HasPrefix del paquete strings, separamos la cadena de texto con la función Split también del paquete strings utilizando como separador el espacio que divide el prefijo Bearer del token, confirmamos que el slice obtenido tenga exactamente dos elementos y, si todo marcha como esperamos, retornamos el segundo elemento del slice, el cual debería corresponder al token.

Suficiente preparación, es momento crear nuestro middleware de autorización.

// internal/middleware/auth.go
package middleware

import (
    "context"
    "errors"
    "net/http"
    "os"
    "strings"

    "github.com/orlmonteverde/go-postgres-microblog/pkg/claim"
    "github.com/orlmonteverde/go-postgres-microblog/pkg/response"
)

func Authorizator(next http.Handler) http.Handler {
    signingString := os.Getenv("SIGNING_STRING")
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authorization := r.Header.Get("Authorization")
        tokenString, err := tokenFromAuthorization(authorization)
        if err != nil {
            response.HTTPError(w, r, http.StatusUnauthorized, err.Error())
            return
        }

        c, err := claim.GetFromToken(tokenString, signingString)
        if err != nil {
            response.HTTPError(w, r, http.StatusUnauthorized, err.Error())
            return
        }

        ctx := r.Context()
        ctx = context.WithValue(ctx, "id", c.ID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// [...]

Vamos a recorrer la función paso a paso. Al inicio estamos obteniendo el valor de la variable de entorno SIGNING_STRING por medio de la función Getenv del paquete os y posteriormente estamos retornando una función, esta función tiene la misma estructura de las funciones handler que hemos utilizado anteriormente, pero no podemos retornarla como una función literal, sino que la convertimos al tipo HandlerFunc del paquete http, el cual satisface la interface Handler.

Dentro de esta función realizamos todo el proceso de validación del token, obtenemos el contenido del Header Authorization, que es la manera en la que esperamos recibir el token, y utilizamos la función tokenFromAuthorization que creamos recientemente para extraer el token, comprobamos si existe un error y utilizamos la función HTTPError del paquete response para responder con un mensaje de error de igual forma a como lo hacemos en las funciones handler seguido de un return para que la función no se continúe ejecutando.

Ahora utilizamos la función GetFromToken que agregamos antes al paquete claim, y obtenemos un nuevo Claim a partir del token, confirmamos si existe un error de manera similar al caso anterior. Superado este punto podemos asumir que todo ha marchado bien y el token es valido, así que permitiremos que la solicitud pase al handler correspondiente, representado en el middleware como el argumento next, llamando a su método ServeHTTP y pasando el http.ResponseWriter y el puntero a http.Request.

Pero si son observadores notaran que estamos haciendo un par de cosas antes, a partir del contexto del puntero a http.Request, creamos un nuevo contexto con la función WithValue del paquete context al que pasamos el ID del usuario utilizando como clave la cadena de texto id y pasamos al método ServeHTTP el puntero a http.Request con este nuevo contexto. Esto ultimo no es obligatorio y no usaremos ese valor en este caso, es principalmente para mostrar que pueden pasar valores al handler utilizando el contexto, cosa que puede resultar bastante útil.

Utilizando el Middleware de autorización

Una vez que tenemos listo nuestro middleware de autorización es momento de utilizarlo, así que vamos a regresar al archivo user_router.go para hacer algunos cambios.

// internal/server/v1/user_router.go
package v1

import (
    // [...]
    "github.com/orlmonteverde/go-postgres-microblog/internal/middleware"
    // [...]
)

// […]

func (ur *UserRouter) Routes() http.Handler {
    r := chi.NewRouter()

    r.
        With(middleware.Authorizator).
        Get("/", ur.GetAllHandler)

    // [...]

    return r
}

Utilizamos el método With del tipo puntero a chi.Mux para pasar un middleware en linea a una ruta especifica y es suficiente, nuestro endpoint para obtener usuarios se encuentra protegida, vamos a probarlo.

curl http://localhost:9000/api/v1/users/

Si todo ha salido bien, deberíamos obtener una respuesta como la siguiente.

{
  "message": "autorization is required"
}

Perfecto, ahora nuestra ruta esta protegida con JWT y podemos agregar nuestro nuevo middleware a todas las rutas que nos interese proteger utiliozando cualquiera de las opciones que nos ofrece chi para manejar middlewares.

Login

Hasta este punto nuestra API funciona bastante bien, pero no tenemos forma de acceder a las rutas protegidas por el middleware Authorizator, necesitamos una manera de confirmar la identidad del usuario y darle permiso de acceder a esa información, así que vamos a crear un nuevo endpoint, para permitir al usuario hacer login.

Regresaremos al archivo user_router.go para realizar algunas modificaciones y agregar nuestro nuevo handler.

// internal/server/v1/user_router.go
package v1

// [...]
func (ur *UserRouter) LoginHandler(w http.ResponseWriter, r *http.Request) {
    var u user.User
    err := json.NewDecoder(r.Body).Decode(&u)
    if err != nil {
        response.HTTPError(w, r, http.StatusBadRequest, err.Error())
        return
    }

    defer r.Body.Close()

    ctx := r.Context()
    storedUser, err := ur.Repository.GetByUsername(ctx, u.Username)
    if err != nil {
        response.HTTPError(w, r, http.StatusNotFound, err.Error())
        return
    }

    if !storedUser.PasswordMatch(u.Password) {
        response.HTTPError(w, r, http.StatusBadRequest, "password don't match")
        return
    }

    c := claim.Claim{ID: int(storedUser.ID)}
    token, err := c.GetToken(os.Getenv("SIGNING_STRING"))
    if err != nil {
        response.HTTPError(w, r, http.StatusInternalServerError, err.Error())
        return
    }

    response.JSON(w, r, http.StatusOK, response.Map{"token": token})
}

// […]

Como en los pasos anteriores hemos preparado el terreno para este endpoint el resultado es bastante sencillo y no creo que requiera demasiada explicación, pero igual le daremos un repaso rápido.

Obtenemos el usuario que debe ser enviando en el cuerpo de la petición, el cual debe contener el username y la password, buscamos un usuario en nuestra base de datos con el username enviado, utilizando el método GetByUsername del Repository.

Si el usuario existe procedemos a confirmar que la contraseña enviada coincida con la que tenemos almacenada en nuestra base de datos gracias al método PasswordMatch que agregamos a nuestro tipo User.

Si la contraseña coincide generamos un nuevo Claim, obtenemos un nuevo token a partir de este, utilizando su método GetToken, con el misma firma que utilizamos para el desencriptado del token en el middleware.

Si todo ha sucedido correctamente retornamos el nuevo token y podemos usar este para acceder a la información protegida por el middleware Authorizator. Vamos a probar el nuevo endpoint.

Ahora debemos agregar nuestro nuevo handler a las rutas para poder utilizarlo.

// internal/server/v1/user_router.go
package v1

// […]

func (ur *UserRouter) Routes() http.Handler {
    r := chi.NewRouter()

    // [...]

    r.Post("/login/", ur.LoginHandler)

    return r
}

Y ahora podemos realizar peticiones para probar nuestro handler.

curl -X POST http://localhost:9000/api/v1/users/login/ \
    -H 'Content-Type: application/json' \
    -d '{"username": "orlmonteverde", "password": "123456"}'

Con esto obtendremos un nuevo token y podremos usarlo en las peticiones protegidas. Por cierto, no se lo digan a nadie, pero esas son mis credenciales en facebook 😏.

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTV9.Dd0Ej44-4P2xwZAf9sXKUUInyRJ71QNVYRJaHCxj1FE"
}

Ahora tenemos nuestro token, como mencione antes, la información contenida en este es fácilmente decodificable, podemos comprobar esto si vamos a jwt.io y pegamos nuestro token.

JWT

Para finalizar, vamos a probar nuestro token contra el endpoint protegido con el middleware.

curl http://localhost:9000/api/v1/users/ \
    -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTV9.Dd0Ej44-4P2xwZAf9sXKUUInyRJ71QNVYRJaHCxj1FE'

Ahora deberíamos poder acceder a la información como antes de agregar el middleware. Si tienes algun problema o deseas ver el codigo completo, se encuentra disponible en este repositorio.

Gracias por acompañarme en este tutorial y por la paciencia, me tarde un montón, lo siento por eso.

Congratulations

Realmente espero que el contenido les sea de utilidad, y cualquier duda o sugerencia, por favor, déjenla en los comentarios y les responderé tan pronto como me resulte posible. Saludos y gracias por leer 😏.

konosuba

Posted on by:

orlmonteverde profile

Orlando Monteverde

@orlmonteverde

Web developer, gopher, blogger, open source enthusiast and professional coffee drinker ☕️.

Discussion

pic
Editor guide
 

Excelente muchas gracias por compartir, esperando próximos posts, ojala puedas realizar lo mismo usando fiber y gorm

 

Me alegra que te resulte útil, así que gracias a ti por tomarte el tiempo de escribir. Fiber ha estado ganando mucha popularidad, lo tengo pendiente para futuros artículos.