Este repositorio contiene un proyecto base para desarrollar una API con Go Fiber.El objetivo de este repositorio es estructurar un proyecto base con Clean Architecture. Se establece la estructura de carpetas necesaria para el dominio, casos de usos, repositorios, controladores y elementos transversales, de esta manera se simplifica la etapa de desarrollo para que se centre en lo realmente necesario.
- Estructura de carpetas
- Arquitectura limpia
- Crear entidades del dominio
- Puertos
- Adaptadores
- Schema
- API
- Elementos transversales
- Instalar dependencias
- Configurar las variables de entorno
- Correr aplicación
- Correr pruebas unitarias
Estructura base para el manejo de carpetas
.
├── /cmd
│ ├── /internal
| | ├── /config # Módulo con la configuración de la base de datos
| | ├── /http # Infraestructura
| | | ├── handlers # Módulo con los controladores para los endpoint
| | | ├── middlewares # Contiene los middleware personalizados para Fiber
| | | ├── routes # Rutas de los endpoint
| | | └── server.go # Módulo para iniciar la API
| └── main.go # Punto de entrada para la función main
├── /docs # Este módulo es autogenerado y contiene los archivos necesarios para documentación de swagger
├── /internal
| ├── /domain # Entidades del negocio
| ├── /http # Los objetos de transferencias de datos
| ├── /repository # Módulo con la implementaciones de los puertos secundarios
| └── /service # Casos de uso
├── /pkg # Elementos transversales
| ├── /errs # Módulo transversal para manejar los errores de la API
| ├── /logger # Módulo transversal para imprimir los logs generados en la aplicación
| └── /utils # Funciones transversales a la aplicación
├── .env # Archivo con las variables de entorno a usar
├── go.mod # Se define el path del módulo, además de las dependencias para el proceso de compilación
└── go.sum # Este archivo lista el checksum de las dependencia directa e indirecta, además de incluir la versiónLa base de este repositorio es la arquitectura de puertos y adaptadores, también conocida como la arquitectura hexagonal.
Todos los modelos de dominio se colocarán en el directorio internal/domain. Contiene la definición go struct de cada entidad que forma parte del dominio y que puede ser utilizada en toda la aplicación.
Nota: no todos los go struct son modelos de dominio. Sólo los structs que están involucrados en la lógica del negocio.
type Author struct {
ID uint `gorm:"id;primary_key"`
FullName string `gorm:"full_name;not null;unique"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Books []Book
}Como se puede observar la estructura contiene tags del ORM Gorm, el cual, permite hacer usar de los modelos para correr las migraciones del ORM
Los puertos son la interfaz que pertenece al núcleo y definen cómo se debe abordar la comunicación entre los actores y el núcleo.
Se definen los casos de uso que el núcleo implementara y se expone para ser consumido por los actores externos.
// AuthorService port secondary
type AuthorService interface {
CreateAuthor(schema.AuthorRequest) *errs.AppError
FindAllAuthor() ([]schema.AuthorResponse, *errs.AppError)
FindAuthorById(uint) (*schema.AuthorResponse, *errs.AppError)
UpdateAuthor(*schema.AuthorRequest) (*schema.AuthorResponse, *errs.AppError)
DeleteAuthor(uint) *errs.AppError
}Definen las acciones que la capa de datos debe implementar.
// AuthorRepository port interface
type AuthorRepository interface {
SaveAuthor(*Author) *errs.AppError
FindAllAuthor() ([]Author, *errs.AppError)
FindAuthorById(uint) (*Author, *errs.AppError)
UpdateAuthor(*Author) (*Author, *errs.AppError)
DeleteAuthor(id uint) *errs.AppError
}Esta capa que sirve para transformar la comunicación entre actores externos y la lógica de la aplicación de forma que ambas dos quedan independientes.
Es la implementación de los casos de uso definidos en el puerto primario.
// internal/service/author.go
package service
import (
"github.com/karlbehrensg/go-fiber-template/internal/domain"
"github.com/karlbehrensg/go-fiber-template/internal/http/requests"
"github.com/karlbehrensg/go-fiber-template/internal/http/responses"
"github.com/karlbehrensg/go-fiber-template/pkg/errs"
)
// AuthorService port primary
type AuthorService interface {
CreateAuthor(requests.AuthorRequest) *errs.AppError
FindAllAuthor() ([]responses.AuthorResponse, *errs.AppError)
FindAuthorById(uint) (*responses.AuthorResponse, *errs.AppError)
UpdateAuthor(*requests.AuthorRequest) (*responses.AuthorResponse, *errs.AppError)
DeleteAuthor(uint) *errs.AppError
}
type DefaultAuthorService struct {
repo domain.AuthorRepository
}
// NewAuthorService create a new instance of DefaultAuthorService
func NewAuthorService(repository domain.AuthorRepository) DefaultAuthorService {
return DefaultAuthorService{repository}
}
// CreateAuthor use case for create author
func (s DefaultAuthorService) CreateAuthor(request requests.AuthorRequest) *errs.AppError {
author := &domain.Author{
FullName: request.FullName,
}
// calls repository to save author
if err := s.repo.SaveAuthor(author); err != nil {
return err
}
return nil
}
// FindAllAuthor use case for find all author
func (s DefaultAuthorService) FindAllAuthor() ([]responses.AuthorResponse, *errs.AppError) {
var authors []domain.Author
var err *errs.AppError
// calls repository to find all author
if authors, err = s.repo.FindAllAuthor(); err != nil {
return nil, err
}
response := make([]responses.AuthorResponse, 0)
for _, author := range authors {
response = append(response, *author.ToNewAuthorResponse())
}
return response, nil
}
// FindAuthorById use case for find author by ID
func (s DefaultAuthorService) FindAuthorById(id uint) (*responses.AuthorResponse, *errs.AppError) {
var author *domain.Author
var err *errs.AppError
// calls repository to find author by ID
if author, err = s.repo.FindAuthorById(id); err != nil {
return nil, err
}
response := *author.ToNewAuthorResponse()
return &response, nil
}
// UpdateAuthor use case for update author
func (s DefaultAuthorService) UpdateAuthor(request *requests.AuthorRequest) (*responses.AuthorResponse, *errs.AppError) {
author := &domain.Author{
ID: request.Id,
FullName: request.FullName,
}
var err *errs.AppError
// calls repository to update author
if author, err = s.repo.UpdateAuthor(author); err != nil {
return nil, err
}
response := *author.ToNewAuthorResponse()
return &response, nil
}
// DeleteAuthor use case for delete author
func (s DefaultAuthorService) DeleteAuthor(id uint) *errs.AppError {
// calls repository to delete author
if err := s.repo.DeleteAuthor(id); err != nil {
return err
}
return nil
}Es la implementación de los puertos secundarios relacionados con la capa de datos.
// internal/repository/authorGorm.go
package repository
import (
"fmt"
"strings"
"github.com/karlbehrensg/go-fiber-template/internal/domain"
"github.com/karlbehrensg/go-fiber-template/pkg/errs"
"github.com/karlbehrensg/go-fiber-template/pkg/logger"
"gorm.io/gorm"
)
type AuthorRepositoryGorm struct {
client *gorm.DB
}
// NewAuthorRepositoryGorm create a new instance of AuthorRepositoryGorm
func NewAuthorRepositoryGorm(dbClient *gorm.DB) AuthorRepositoryGorm {
return AuthorRepositoryGorm{dbClient}
}
// SaveAuthor save author in database
func (r AuthorRepositoryGorm) SaveAuthor(author *domain.Author) *errs.AppError {
if err := r.client.Create(author).Error; err != nil {
logger.Error(err.Error())
if strings.Contains(err.Error(), "ERROR: duplicate key value violates unique constraint ") {
return errs.NewUnexpectedError("key full_name duplicate value")
}
return errs.NewUnexpectedError("Unexpected error from database")
}
return nil
}
// FindAllAuthor find all author in database
func (r AuthorRepositoryGorm) FindAllAuthor() ([]domain.Author, *errs.AppError) {
authors := []domain.Author{}
if err := r.client.Find(&authors).Error; err != nil {
logger.Error(err.Error())
return nil, errs.NewUnexpectedError("Unexpected error from database")
}
return authors, nil
}
// FindAuthorById find author by ID in database
func (r AuthorRepositoryGorm) FindAuthorById(id uint) (*domain.Author, *errs.AppError) {
var author *domain.Author
if err := r.client.Where("id = ?", id).First(&author).Error; err != nil {
logger.Error(err.Error())
if strings.Contains(err.Error(), "record not found") {
return nil, errs.NewNotFoundError(err.Error())
}
return nil, errs.NewUnexpectedError("Unexpected error from database")
}
return author, nil
}
// UpdateAuthor update author in database
func (r AuthorRepositoryGorm) UpdateAuthor(author *domain.Author) (*domain.Author, *errs.AppError) {
var result *gorm.DB
if result = r.client.Where("id = ?", author.ID).Updates(&author); result.Error != nil {
logger.Error(result.Error.Error())
if strings.Contains(result.Error.Error(), "ERROR: duplicate key value violates unique constraint ") {
return nil, errs.NewUnexpectedError("key full_name duplicate value")
}
return nil, errs.NewUnexpectedError("Unexpected error from database")
}
// validates if the rows have changed
if result.RowsAffected < 1 {
logger.Info(fmt.Sprintf("Row with id=%d cannot be updated because it doesn't exist", author.ID))
return nil, errs.NewNotFoundError("Author not found")
}
return author, nil
}
// DeleteAuthor delete author in database
func (r AuthorRepositoryGorm) DeleteAuthor(id uint) *errs.AppError {
var author *domain.Author
var result *gorm.DB
if result = r.client.Where("id = ?", id).Delete(&author); result.Error != nil {
logger.Error(result.Error.Error())
return errs.NewUnexpectedError("Unexpected error from database")
}
// validates if the rows have changed
if result.RowsAffected < 1 {
logger.Info(fmt.Sprintf("Row with id=%d cannot be deleted because it doesn't exist", id))
return errs.NewNotFoundError("Author not found")
}
return nil
}Son un tipo de estructura que sirven únicamente para transportar datos, estas estructuras contienen las propiedades de la entidad. Las estructuras pueden tener su origen en una o más entidades.
Las estructuras del request se pueden usar para la validación de los datos de entrada.
// internal/http/requests/auth.go
package requests
type LoginRequest struct {
Email string `form:"email" validate:"required,email" example:"edwyn.rangel.externo@zeleri.com"`
Password string `form:"password" validate:"required,min=7" example:"1234567"`
}// internal/http/responses/auth.go
package responses
type LoginResponse struct {
Token string `json:"token"`
}La validación se hace llamando la función GetValidator() que se encuentra en la ruta utils/validator.go.
package utils
import "github.com/go-playground/validator/v10"
var validate *validator.Validate
// GetValidator Initiatilize validator in singleton way
func GetValidator() *validator.Validate {
if validate == nil {
validate = validator.New()
}
return validate
}Esta sección permite exponer los puntos de entrada de la aplicación a través del framework Fiber.
Son los elementos que contiene la lógica de los punto de entrada, en estas funciones se recibe la petición de los clientes y se llaman los casos de uso para el procesamiento de los datos, luego del que core procesa los datos y se encarga de retornar una respuesta a los clientes.
// cmd/api/internal/http/handlers/author.go
...
type AuthorHandler struct {
Service service.AuthorService
}
// CreateAuthor godoc
// @Summary create author.
// @Description endpoint for create authors.
// @Tags Author
// @Accept json
// @Produce json
// @Param Body body requests.AuthorRequest true "The body to author"
// @Success 201 {object} responses.AuthorResponse
// @Failure 400 {object} responses.ErrorResponse
// @Failure 401 {object} responses.ErrorResponse
// @Failure 500 {object} responses.ErrorResponse
// @Security Bearer
// @Router /author [post]
// CreateAuthor controller to create author
func (h AuthorHandler) CreateAuthor(c *fiber.Ctx) error {
// Convert the request data to the structure
data := &requests.AuthorRequest{}
if err := c.BodyParser(&data); err != nil {
logger.Error("Error decode json")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Invalid data",
})
}
// validates the structure
if err := utils.GetValidator().Struct(data); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": err.Error(),
})
}
// calls use case to create author
if err := h.Service.CreateAuthor(*data); err != nil {
return c.Status(err.Code).JSON(err.AsMessage())
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Author created",
})
}
...Este módulo contiene los middleware personalizados para Fiber.
// cmd/api/internal/http/middlewares/jwt.go
...
// ValidateJWT middleware to validate JWT
func ValidateJWT() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get token from header
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"message": "Missing or malformed token",
})
}
token := strings.TrimSpace(strings.Split(authHeader, " ")[1])
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"message": "Missing or malformed token",
})
}
// Validate token
claims := &utils.JWTClaims{}
if err := claims.ValidateToken(token); err != nil {
logger.Error(err.Error())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"message": "Invalid or expired token",
})
}
return c.Next()
}
}Este módulo contiene las rutas de la puntos de entradas, además se hace la implementación de las intancias de los servicios y los repositorios.
// cmd/api/internal/http/routes/author.go
...
// AuthorRoutes endpoints for the author section
func AuthorRoutes(router *fiber.App, dbClient *gorm.DB) {
c := controller.AuthorController{Service: service.NewAuthorService(repository.NewAuthorRepositoryGorm(dbClient))}
api := router.Group("/author")
api.Use(middleware.ValidateJWT())
api.Post("", c.CreateAuthor)
api.Get("", c.GetAllAuthor)
api.Get("/:id", c.GetAuthorById)
api.Put("/:id", c.UpdateAuthor)
api.Delete("/:id", c.DeleteAuthor)
}El llamado de las rutas se hace en el archivo cmd/api/internal/http/server.go desde la función start()
// cmd/api/internal/http/server.go
...
// instantiating fiber
app := fiber.New()
// added middleware
app.Use(recover.New())
app.Use(fiberLogger.New())
// define routes
routes.SwaggerRoutes(app)
routes.AuthRoutes(app, dbClient)
routes.AuthorRoutes(app, dbClient)
routes.BookRoutes(app, dbClient)
routes.NotFoundRoute(app)
// run server
app.Listen(":" + os.Getenv("APP_PORT"))Esta compuesto por aquellos módulos que son transversales a la apliación.
Permite imprimir log estructurados y nivelados haciendo uso de la librería ZAP.
Son un conjunto de funciones que permite manejar los errores para la API.
Son todas las funciones genéricas que se pueden usar en la aplicación.
Para correr el proyecto sin problemas es necesario hacer la instalación de un conjunto de dependencias, entre las cuales se tiene las globales, que son un conjunto de paquetes que son implementadas para correr proceso de automatización como el swagger o mock para las pruebas unitarias. Y las dependencia de compilación, que son todas aquellas librerías necesarias para hacer que la aplicación funcione.
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/golang/mock/mockgen@v1.6.0
Para instalar las dependencia de compilación, es necesario que primero se generen los mocks y la documentación de Swagger poruqe de lo contrario saldrán unas series de error por falta de esos archivos.
go mod tidy
El archivo .env es opcional, en el se puede almacenar las variables de entorno necesarias a cargar. Este contiene la variable con el secreto para realizar el cifrado de JWT, conectar al servicio de base de datos y configuración de la aplicación.
# App
ENV=development
APP_PORT=8080
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=library
DB_SCHEMA=public
DB_SSL_MODE=disable
DB_TIME_ZONE=UTC
# JWT
JWT_SECRET=secretEsta condición en el archivo app/app.go, permite cargar las variables de entorno desde el archivo .env cuando se este en el ambiente de desarrollo
if os.Getenv("ENV") != "production" {
if err := godotenv.Load(); err != nil {
logger.Error(fmt.Sprintf("Error godotenv %s", err.Error()))
}
}Posterior a esta condición se llama la función
utils.CheckEnv()// utils/utils.go
// CheckEnv validate env required
func CheckEnv() {
envProps := []string{
"ENV",
"DB_HOST",
"DB_USER",
"DB_PASSWORD",
"DB_NAME",
"DB_PORT",
"DB_SSL_MODE",
"DB_TIME_ZONE",
"APP_PORT",
}
for _, k := range envProps {
if os.Getenv(k) == "" {
logger.Fatal(fmt.Sprintf("Environment variable %s not defined. Terminating application...", k))
}
}
}Para validar la existencia de la variables de entorno necesarias para el funcionamiento de la aplicación, en caso de no existir alguna de estas variables se imprimirá un log indicando la variable de entorno faltante y se detendrá la aplicación.
Para correr las migraciones primero se debe crear el cliente que hace la conexión hacia la base de datos
// config/database/postgres.go
// GetDbClient generates the client for the database
func GetDbClient() *gorm.DB {
dataSource := os.ExpandEnv("host=${DB_HOST} user=${DB_USER} password=${DB_PASSWORD} dbname=${DB_NAME} port=${DB_PORT} sslmode=${DB_SSL_MODE} TimeZone=${DB_TIME_ZONE}")
client, err := gorm.Open(postgres.Open(dataSource), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: os.ExpandEnv("${DB_SCHEMA}."),
SingularTable: false,
}})
if err != nil {
logger.Fatal(err.Error())
}
logger.Info("Database connected")
return client
}En el archivo config/database/postgres.go también contiene la función que corre el proceso de migración.
// Migrate create the tables in the database
func Migrate(client *gorm.DB, models ...interface{}) {
client.AutoMigrate(models...)
}La creación del cliente y el llamado de la migración se hacen desde el archivo app/app.go
// app/app.go
...
// get client db
dbClient := database.GetDbClient()
// run migration
database.Migrate(dbClient, &domain.Author{}, &domain.Book{}, &domain.User{})Nota: es necesario tener instalado postgres con una base de datos llamada igual que el valor de la env DB_NAME.
swag init -g cmd/api/main.go
Nota: cuando se genera la documentación de swagger en el directorio docs, el archivo docs.go importa la siguiente github.com/swaggo/swag y esta se puede marcar como una dependencia faltate, para corregir el problema de dependencia es necesario correr nuevamente el comando $ go mod tidy
go run cmd/api/main.go
open url http://localhost:8080/swagger/
Para generar la documentación Swagger se agregó bloque de texto comentando en el archivo main.go y en los archivos para los handler.
Lo primero que se debe hacer es generar los mocks
go generate ./...Luego de generar los mock se pueden correr las pruebas de la siguiente manera
go test -coverprofile=cover.out ./...
go tool cover -func=cover.outPara visualizar la cobertura en el navegador se debe correr el siguiente comando
go tool cover -html=cover.out