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!
Indice
- Parte 1 - Preparación
- Parte 2 - Modelos
- Parte 3 - Base de datos
- Parte 4 - Manejo de peticiones
- Parte 5 - Middlewares y JWT
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.
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.
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.
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.
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}.
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.
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.
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.
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.
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 😏.
Top comments (9)
Muchas gracias, muy buen tutorial me ayudo mucho, pero hasta el momento no he podido implementar swagger al proyecto. vi en tu repo en el readme.md que si lo implementaste. lo hiciste por fuera o has utilizado alguna libreria.
Muchas gracias, tu tutorial me sirvio de mucho, ya tengo mas claro como trabajar con Go, me parece un lenguaje muy potente e interesante para aplicar en mis proyectos. Tengo una duda como harias para regresar en el modelo de Post, los datos del usuario que creo el registro ?
Me alegro de que el tutorial te sea útil. Quieres decir que algo como esto regresa:
Si es así, puede agregar un campo más a la estructura Post que sea de tipo User, y usar el user_id que tiene el Post para obtener el Usuario correspondiente.
Hola Orlando, pero si agrega el User dentro del struct de Post, no habria que hacer un inner join? porque aunque ponga la estructura de User y en el scan trate de asignar los datos, solo se setea el Id
Muchas gracias Orlando, me ayudo mucho entender un poco las estructuras para terminar un proyecto en el que estoy trabajando, una API igualmente con conexión a base de datos Oracle y Elasticsearch. Te felicito, un saludo
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.
Muchas gracias Orlando, me llevé demasiada información valiosa y útil de este tuto.
Me alegra que lo encuentres útil. Gracias por tomarte el tiempo de escribir 😁️.