Article écrit par Etienne R.
Pour créer une API Rest en Go, il existe de nombreux frameworks dit “http” dédiés. C’est un framework qui gère les routes HTTP (Get, Post, Update, Patch, Delete, etc…) et qui retourne des données au format JSON, XML, HTML, etc… Il en existe plusieurs dans l’éco-système de Go tels que Gorilla, Beego, Iris, Echo, Fiber, etc… De notre coté, nous allons partir sur Gin. Qui dit API, dit base de données. Pour effectuer les requêtes SQL, nous allons utiliser un ORM afin de faciliter la communication avec la base de données. Pour cela, nous allons utiliser Gorm avec SQLite (pour les besoins de la démo car il est conseillé d’utiliser une base de données comme Postgres ou MariaDB en production).
Pré-requis
Avoir Go installé et configuré sur votre environnement de développement avec la commande go version.
Remarque : cet article a été réalisé avec la version de Go 1.22.
Optionnel : pour le développement, utilisez la librairie Air qui permet de reconstruire l’application à la volée lors de la modification d’un fichier et évite de le faire manuellement (à l’image de Nodemon sur Node).
Préparatifs
Dans le répertoire de votre future application, générer le fichier de package “go.mod” avec la commande ci-dessous.
$ go mod init demo-api-gin
go: creating new go.mod: module demo-api-gin
Puis les 3 paquets mentionnés précédemment.
go get github.com/gin-gonic/gin
go get "gorm.io/gorm"
go get "gorm.io/driver/sqlite"
Modèle
Créez un dossier models
avec un nouveau fichier articles.go
.
Structure
// models/articles.go
package models
import (
"gorm.io/gorm"
)
// Structure de la table
type Articles struct {
Id uint `gorm:"AUTO_INCREMENT" json:"id"`
Title string `gorm:"not null" json:"title" binding:"required"`
Description string `gorm:"not null" json:"description" binding:"required"`
}
On déclare la structure de notre modèle d’article avec 3 champs Id
, Title
et Description
. On déclare également que les 2 derniers champs sont obligatoires.
Remarque : Gin utilise la librairie Go Validator avec le mot clef binding
dans les options de Struct
.
Ajouter ou modifier un article
func (article *Articles) SaveOrUpdateArticle(db *gorm.DB) error {
return db.Model(&article).Save(article).Error
}
Avec la fonction Save
de Gorm, on peut ajouter ou mettre à jour une ligne d’article.
Trouver tous les articles
func (article *Articles) FindArticles(db *gorm.DB) (*[]Articles, error) {
var articles []Articles
err := db.Find(&articles).Error
return &articles, err
}
Avec la fonction Find
de Gorm, on peut obtenir la liste de tous les articles.
Trouver un article via son id
func (articles *Articles) FindArticle(db *gorm.DB, id string) (*Articles, error) {
var article Articles
err := db.Take(&article, id).Error
return &article, err
}
Avec la fonction Take
de Gorm, on peut obtenir la ligne d’un article en fonction de son id.
Supprimer un article
func (article *Articles) RemoveArticle(db *gorm.DB, id string) error {
return db.Delete(&article).Error
}
Avec la fonction Delete
de Gorm, on peut supprimer une ligne d’article en fonction de son id.
Contrôleurs CRUD (Create Read Update Delete)
Créez un dossier controllers
avec un nouveau fichier articles.go
.
// controllers/articles.go
package controllers
import (
"demo-api-gin/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ArticlesRepo struct {
Db *gorm.DB
}
func error500(c *gin.Context) {
c.JSON(500, gin.H{"error": "Something wrong"})
}
func error404(c *gin.Context) {
c.JSON(404, gin.H{"error": "Article not found"})
}
// Ajouter un article
func (repository *ArticlesRepo) PostArticles(c *gin.Context) {
}
// Obtenir la liste de tous les articles
func (repository *ArticlesRepo) GetArticles(c *gin.Context) {
}
// Obtenir un seul article via son id
func (repository *ArticlesRepo) GetArticle(c *gin.Context) {
}
// Modifier un article via son id
func (repository *ArticlesRepo) EditArticle(c *gin.Context) {
}
// Supprimer un article
func (repository *ArticlesRepo) DeleteArticle(c *gin.Context) {
}
On appel la structure de Gorm et les 2 messages génériques pour les erreurs 500 et 404 (afin d’éviter de la duplication de code). Et on met en place nos 5 contrôleurs que nous allons remplir.
Ajouter un article
func (repository *ArticlesRepo) PostArticles(c *gin.Context) {
var articleModel models.Articles
if err := c.ShouldBindJSON(&articleModel); err != nil {
c.JSON(422, gin.H{"error": err.Error()})
return
}
if err := articleModel.SaveOrUpdateArticle(repository.Db); err != nil {
error500(c)
return
}
c.JSON(201, gin.H{"success": articleModel})
}
On déclare une variable nommée articleModel
au format de la structure Articles
.
On vérifie que les valeurs des champs title
et description
ne sont pas vides avec la fonction ShouldBindJSON
. Si les 2 champs sont mal renseignés, on retourne une erreur HTTP 422.
On appel la fonction dédiée dans le modèle, c’est-à-dire SaveOrUpdateArticle
. Si tout va bien, on ajoute les valeurs en BDD et on retourne le code HTTP 201 avec un message de succès sinon on retourne une erreur générique avec le code HTTP 500.
Obtenir la liste de tous les articles
func (repository *ArticlesRepo) GetArticles(c *gin.Context) {
var articleModel models.Articles
articles, err := articleModel.FindArticles(repository.Db)
if err != nil {
error500(c)
return
}
c.JSON(200, articles)
}
On déclare une variable nommée articleModel
au format de la structure Articles
.
On appel la fonction dédiée dans le modèle, c’est-à-dire FindArticles
. Si tout va bien, on ajoute les valeurs en BDD et on retourne le code HTTP 200 avec le JSON contenant la liste des articles sinon on retourne une erreur générique avec le code HTTP 500.
Obtenir un seul article via son id
func (repository *ArticlesRepo) GetArticle(c *gin.Context) {
id := c.Params.ByName("id")
var articleModel models.Articles
article, err := articleModel.FindArticle(repository.Db, id)
if article.Id == 0 {
error404(c)
return
}
if err != nil {
error500(c)
return
}
c.JSON(200, article)
}
On récupère l’id dans une variable du même nom.
On définit l’article dans une variable articleModel
au format de la structure Articles
.
On appel la fonction dédiée dans le modèle, c’est-à-dire FindArticle
avec l’id désiré en paramètre. Dans un premier temps, on vérifie que si l’article n’existe pas ou plus, on retourne une erreur avec code HTTP 404. Puis on vérifie que si il y a une erreur avec la requête SQL, on retourne une erreur générique avec le code HTTP 500. Si tout va bien, on le JSON de l’article en 200.
Modifier un article via son id
func (repository *ArticlesRepo) EditArticle(c *gin.Context) {
id := c.Params.ByName("id")
var articleModel models.Articles
article, err := articleModel.FindArticle(repository.Db, id)
if article.Id == 0 {
error404(c)
return
}
if err := c.ShouldBindJSON(&article); err != nil {
c.JSON(422, gin.H{"error": err.Error()})
return
}
if err != nil {
error500(c)
return
}
if err = articleModel.SaveOrUpdateArticle(repository.Db); err != nil {
error500(c)
return
}
c.JSON(200, gin.H{"success": article})
}
On fait comme dans la requête précédente sauf que l’on appel par la suite, la fonction dédiée dans le modèle, c’est-à-dire SaveOrUpdateArticle
et on reprend le même principe que l’ajout d’un article (sauf pour le code HTTP qui est en 200).
Supprimer un article via son id
func (repository *ArticlesRepo) DeleteArticle(c *gin.Context) {
id := c.Params.ByName("id")
var articleModel models.Articles
_, err := articleModel.FindArticle(repository.Db, id)
if err != nil {
error404(c)
return
}
if err := articleModel.RemoveArticle(repository.Db, id); err == nil {
error500(c)
return
}
c.JSON(200, gin.H{"success": "Article #" + id + " deleted"})
}
On récupère l’id dans une variable du même nom.
On définit l’article dans une variable articleModel
au format de la structure “Articles.
On appel la fonction dédiée dans le modèle, c’est-à-dire FindArticle
avec l’id en paramètre. Dans un premier temps, on vérifie que si l’article n’existe pas, on retourne une erreur avec code HTTP 404. Puis on vérifie que si y a une erreur avec la requête SQL, on retourne une erreur générique avec le code HTTP 500. Si tout va bien, on retourne une 200 avec le JSON de l’article.
Routes
Créez un dossier routes
avec un nouveau fichier articles.go
.
// routes/articles.go
package routes
import (
"demo-api-gin/controllers"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func ArticlesRoutes(r *gin.Engine, db *gorm.DB) *gin.Engine {
articlesRepo := controllers.ArticlesRepo{
Db: db,
}
v1Articles := r.Group("api/v1/articles")
{
v1Articles.POST("", articlesRepo.PostArticles)
v1Articles.GET("", articlesRepo.GetArticles)
v1Articles.GET(":id", articlesRepo.GetArticle)
v1Articles.PUT(":id", articlesRepo.EditArticle)
v1Articles.DELETE(":id", articlesRepo.DeleteArticle)
}
return r
}
On regroupe nos 5 requêtes dans une même variable via la fonction Group
de Gin. afin de pouvoir préfixer le chemin de chaque endpoint.
Connexion à la base de données
Créez un dossier db
avec un nouveau fichier db.go
.
// db/db.go
package db
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"demo-api-gin/models"
)
func InitDb() *gorm.DB {
db, err := gorm.Open(sqlite.Open("api.db"), &gorm.Config{})
if err != nil {
panic("Failed to connect database")
}
db.AutoMigrate(&models.Articles{})
return db
}
Dans la fonction InitDb
, on instancie une connexion avec le fichier SQLite api.db
. En cas d’erreur, le programme sera interrompu par un panic
. L’ORM créé tout seul la table Articles
via la fonction AutoMigrate
.
Point d’entrée
A la racine du projet, ajoutez le fichier d’entrée, main.go
avec le code ci-dessous.
package main
import "demo-api-gin/api"
func main() {
err := http.ListenAndServe(":3000", api.Handlers())
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
La fonction principale main
, va chercher la fonction Handlers()
présente dans le fichier api.go
et instancier le serveur de Gin sur le port 3000.
Tests et taux de couverture
Tester manuellement les routes, c’est assez chronophage. Pour la prospérité, nous allons les tester. Pour ce faire, nous allons utiliser la librairie officielle net/http/httptest
ainsi que testify
pour les assertions. Ci-dessous, le tableau récapitulatif des scénarios à tester.
Type de requète | URL | Code attendu |
---|---|---|
GET | /articles | 200 |
GET | /articles/42 | 404 |
GET | /articles/1 | 200 |
POST | /articles | 422 |
POST | /articles | 201 |
PUT | /articles/42 | 404 |
PUT | /articles/1 | 422 |
PUT | /articles/1 | 200 |
DELETE | /article/42 | 404 |
DELETE | /articles/1 | 200 |
Préparatifs
Installez la librairie testify pour retourner des assertions go get github.com/stretchr/testify/assert
.
Dans le répertoire controllers
, créez un nouveau fichier articles_test.go
.
// controllers/articles_test.go
package controllers_test
import (
"demo-api-gin/api"
"demo-api-gin/db"
"demo-api-gin/models"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var baseUrl = "/api/v1/articles"
var messageNotFound = `{"error":"Article not found"}`
var messageMissingFields = `{"error":"invalid request"}`
func init() {
db := db.InitDb()
var article models.Articles
db.Migrator().DropTable(article)
db.Migrator().CreateTable(article)
articles := []models.Articles{{
Title: "Titre 1", Description: "Description 1",
}, {
Title: "Titre 2", Description: "Description 2",
}}
db.Save(articles)
}
On importe les librairies puis on déclare 3 variables dont la première pour avoir la base de l’URL et les autres pour afficher des messages d’erreur génériques. Le plus important se situe dans la fonction “init” car c’est le déroulement de chaque test. Une base de données est instanciée (un fichier api.db
sera instancié dans le dossier api
). Par défaut, la table articles
sera supprimée afin d’éviter tout conflit avec la fonction de Gorm DropTable
. Puis la création de la table articles
avec la fonction de Gorm CreateTable
. Cette table est alimentée de 2 lignes d’aticles avec la fonction de Gorm Save
. Avant de commencer nos tests, on met également en place une fonction pour appeler le routeur.
func setRouter(method string, uri string, body *strings.Reader) *httptest.ResponseRecorder {
router := api.Handlers()
w := httptest.NewRecorder()
if body == nil {
req, _ := http.NewRequest(method, uri, nil)
router.ServeHTTP(w, req)
return w
}
req, _ := http.NewRequest(method, uri, body)
router.ServeHTTP(w, req)
return w
}
Tester GET en 200
func TestGetArticles(t *testing.T) {
t.Run("GET articles - 200", func(t *testing.T) {
w := setRouter("GET", baseUrl, nil)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `[{"id":1,"title":"Titre 1","description":"Description 1"},{"id":2,"title":"Titre 2","description":"Description 2"}]`, w.Body.String())
})
}
- On fait un appel de type GET sur l’url
/api/v1/articles
; - On s’attend à recevoir un code HTTP avec la valeur 200 ;
- On s’attend à recevoir un tableau de JSON avec la liste des articles mockés dans la fonction
init
.
Tester GET /id en 200 et 404
func TestGetArticle(t *testing.T) {
t.Run("GET article - 404", func(t *testing.T) {
w := setRouter("GET", baseUrl+"/42", nil)
assert.Equal(t, 404, w.Code)
assert.Equal(t, messageNotFound, w.Body.String())
})
t.Run("GET article - 200", func(t *testing.T) {
w := setRouter("GET", baseUrl+"/1", nil)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `{"id":1,"title":"Titre 1","description":"Description 1"}`, w.Body.String())
})
}
Premier test :
- On fait un appel de type GET sur l’url
/api/v1/articles/42
; - On s’attend à recevoir un code HTTP avec la valeur 400 ;
- On s’attend à recevoir le message d’erreur générique dédié.
Second test :
- On fait un appel de type GET sur l’url
/api/v1/articles/1
; - On s’attend à recevoir un code HTTP avec la valeur 200 ;
- On s’attend à recevoir une ligne de JSON mockée dans la fonction
init
.
Tester POST en 422 et en 200
func TestPostArticle(t *testing.T) {
t.Run("POST article - 422", func(t *testing.T) {
newArticle := strings.NewReader(`{"title":"Titre 3","description":""}`)
w := setRouter("POST", baseUrl, newArticle)
assert.Equal(t, 422, w.Code)
assert.Equal(t, `{"error":"Key: 'Articles.Description' Error:Field validation for 'Description' failed on the 'required' tag"}`, w.Body.String())
})
t.Run("POST article - 201", func(t *testing.T) {
newArticle := strings.NewReader(`{"title":"Titre 3","description":"Description 3"}`)
w := setRouter("POST", baseUrl, newArticle)
assert.Equal(t, 201, w.Code)
assert.Equal(t, `{"success":{"id":3,"title":"Titre 3","description":"Description 3"}}`, w.Body.String())
})
}
Premier test :
- On fait un appel de type POST sur l’url
/api/v1/articles
avec un contenu en JSON erroné; - On s’attend à recevoir un code HTTP avec la valeur 422 ;
- On s’attend à recevoir le message d’erreur générique dédié.
Second test :
- On fait un appel de type POST sur l’url
/api/v1/articles
; - On s’attend à recevoir un code HTTP avec la valeur 200 ;
- On s’attend à recevoir le message de succès attendu.
Tester PUT en 404, 422 et 200
func TestEditArticle(t *testing.T) {
article := strings.NewReader(`{"title":"Titre test","description":"Description test"}`)
t.Run("PUT article - 404", func(t *testing.T) {
w := setRouter("PUT", baseUrl+"/42", article)
assert.Equal(t, 404, w.Code)
assert.Equal(t, messageNotFound, w.Body.String())
})
t.Run("PUT article - 422", func(t *testing.T) {
w := setRouter("PUT", baseUrl+"/1", nil)
assert.Equal(t, 422, w.Code)
assert.Equal(t, messageMissingFields, w.Body.String())
})
t.Run("PUT article - 200", func(t *testing.T) {
w := setRouter("PUT", baseUrl+"/1", article)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `{"success":{"id":1,"title":"Titre test","description":"Description test"}}`, w.Body.String())
})
}
Premier test :
- On fait un appel de type PUT sur l’url
/api/v1/articles/42
- On s’attend à recevoir un code HTTP avec la valeur 404 ;
- On s’attend à recevoir le message d’erreur générique dédié.
Second test :
- On fait un appel de type PUT sur l’url
/api/v1/articles1
avec du contenu erroné ; - On s’attend à recevoir un code HTTP avec la valeur 422 ;
- On s’attend à recevoir le message d’erreur générique dédié.
Troisième test :
- On fait un appel de type PUT sur l’url
/api/v1/articles1
avec du contenu erroné ; - On s’attend à recevoir un code HTTP avec la valeur 200 ;
- On s’attend à recevoir le message de succès attendu.
Tester DELETE en 404 et 200
func TestDeleteArticle(t *testing.T) {
t.Run("DELETE article - 404", func(t *testing.T) {
w := setRouter("DELETE", baseUrl+"/42", nil)
assert.Equal(t, 404, w.Code)
assert.Equal(t, messageNotFound, w.Body.String())
})
t.Run("DELETE article - 200", func(t *testing.T) {
w := setRouter("DELETE", baseUrl+"/1", nil)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `{"success":"Article #1 deleted"}`, w.Body.String())
})
}
Premier test :
- On fait un appel de type DELETE sur l’url
/api/v1/articles/42
; - On s’attend à recevoir un code HTTP avec la valeur 400 ;
- On s’attend à recevoir le message d’erreur générique dédié.
Second test :
- On fait un appel de type DELETE sur l’url
/api/v1/articles/1
; - On s’attend à recevoir un code HTTP avec la valeur 200 ;
- On s’attend à recevoir le message de succès attendu.
Exécution des tests
Pour exécuter les tests hors de votre IDE c’est avec la commande go test ./controllers
(ou go test ./controllers -v
pour le mode verbeux).
Taux de couverture
Pour avoir le taux de couverture, il faut ajouter un paramètre à la commande.
$ go test ./controllers -coverprofile=cover.cov
ok demo-api-gin/api 0.038s coverage: 98.1% of statements
Ou en global avec “go tool”.
go tool cover -func profile.cov
demo-api-gin/api coverage: 0.0% of statements
demo-api-gin coverage: 0.0% of statements
demo-api-gin/db coverage: 0.0% of statements
demo-api-gin/models coverage: 0.0% of statements
demo-api-gin/routes coverage: 0.0% of statements
ok demo-api-gin/controllers 0.039s coverage: 80.9% of statements
Remarque: si vous utilisez Sonarqube, c’est ce fichier qu’il faut renseigner comme étant le fichier de coverage sonar.go.coverage.reportPaths=cover.cov
.
Le fichier cover.cov
est lisible en langage “humain” en générant le rapport en HTML avec la commande go tool cover -html=cover.cov -o cover.html
.
Bonus : CORS
Si vous souhaitez communiquer avec l’API (hors requêtes GET) depuis le navigateur Internet, vous aurez ce genre d’erreur dans votre navigateur web
Blocage d’une requête multiorigines (Cross-Origin Request) : la politique « Same Origin » ne permet pas de consulter la ressource distante. Raison : l’en-tête CORS « Access-Control-Allow-Origin » est manquant. Code d’état : 201.
Pour parer à ce genre d’erreur, il faut modifier les en-têtes des requêtes HTTP.
Création du midlleware
A la racine du projet, créez un dossier middlewares
avec un fichier cors.go
.
// middlewares/cors
package middlewares
import (
"github.com/gin-gonic/gin"
)
// Activation du CORS
func Cors() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, PUT, DELETE, OPTIONS")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
Cette fonction est appelée comme middleware dans Gin dans la fonction Handlers
du fichier api.go
.
func Handlers() *gin.Engine {
// Code inchangé
r := gin.Default()
r.Use(middlewares.Cors())
// Code inchangé
}
Tester le middleware
Sans oublier les tests dans le fichier cors_test.go
.
package middlewares_test
import (
"demo-api-gin/api"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCors(t *testing.T) {
t.Run("TestCors Headers", func(t *testing.T) {
router := api.Handlers()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/articles", nil)
router.ServeHTTP(w, req)
assert.Equal(t, w.Header().Get("Access-Control-Allow-Origin"), "*")
assert.Equal(t, w.Header().Get("Access-Control-Allow-Credentials"), "true")
assert.Equal(t, w.Header().Get("Access-Control-Allow-Headers"), "Content-Type, Content-Length, Accept-Encoding, accept, origin, Cache-Control, X-Requested-With")
assert.Equal(t, w.Header().Get("Access-Control-Allow-Methods"), "POST, PUT, DELETE, OPTIONS")
})
t.Run("TestCors Options", func(t *testing.T) {
router := api.Handlers()
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/api/v1/articles", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 204, w.Code)
})
}
Premier test :
- On fait un appel de type GET sur l’url
/api/v1/articles
; - On s’attend à recevoir les 4 en-tètes liés au CORS.
Second test :
- On fait un appel de type OPTIONS sur l’url
/api/v1/articles/1
; - On s’attend à recevoir un code HTTP avec la valeur 204.
Ainsi, on peut utiliser la fonction native de JavaScript fetch
avec l’exemple ci-dessous.
async function addArticle(title, description) {
if (title && description) {
const body = JSON.stringify({ title, description });
const response = await fetch(url, {
method: "POST",
body,
});
const post = await response.json();
console.log(post)
}
}
addArticle("ici le titre", "ici la description");
Ressources
- Gin ;
- Gorm ;
- Gorm SQLite ;
- Air ;
- Go Validator ;
- Testify.