Votre première API REST avec GO et CockroachDB
Pour appréhender une nouvelle technologie, quoi de mieux qu'un projet simple et rapide à mettre en place?
On a tous en tête un hello world ou une application todo, mais je vais faire un peu plus original! Pour s'approprier Cockroach, je vais vous proposer de suivre un projet de création d'un catalogue produit.
ATTENTION - jeu concours à la fin!
Présentation du projet
Pour ce catalogue produit, nous voulons mettre en avant 3 éléments principaux:
- Une orientation Marketplace, ce qui veut dire que nous avons besoin d'une gestion des vendeurs et des produits qu'ils vendent
- Une gestion des stocks par vendeur
- Un prix par vendeur
Pour stocker ces informations, nous utiliserons donc le schéma de données suivant:
Le code sera une simple API permettant d'ajouter un produit, de le modifier en fonction du vendeur. Il sera aussi possible de modifier les catégories.
Pour les endpoints, nous retrouverons donc:
- GET /products
- GET /products/{id}
- POST /products
- PUT /products/{id}
- DELETE /products
Préparation du code
Pour démarrez la partie base de données, le plus simple reste de se lancer avec CockroachDB Serverless. Vous aurez accès à un cluster en quelques minutes et pourrez créer votre base de données facilement. Toutes les informations sur la connection seront accessibles dès la fin de l'installation.
Concernant le code, nous allons démarrez avec go mod init ainsi que go mod tidy pour préparer le projet. Ensuite nous pourrons créer un fichier main.go.
Pour ce projet nous utiliserons Gin, et nous allons initialiser le fichier main avec le code de base pour s'assurer que tout fonctionne.
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
Si tout se passe normalement, le code devrait s'exécuter sans souci et vous devriez pouvoir obtenir une console similaire.
Ensuite on ajoute godotenv pour facilement gérer les informations de connexions et autres sur les différents environnements.
Une fois fait, il suffit de lire le fichier, établir la connexion et valider que tout est ok. On va donc ajouter les librairies utiles pour la connexion à CockroachDB. La librairie retenue ici est PGX et nous utiliserons le pool via l'import de github.com/jackc/pgx/v4/pgxpool
Le fichier main devrait être comme suit:
package main
import (
"context"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/jackc/pgx/v4/pgxpool"
)
var (
config *pgxpool.Config
pool *pgxpool.Pool
)
func main() {
//Load the .env file
err := godotenv.Load()
if err != nil {
panic("Error loading .env file")
}
// Set up database connection pool
config, err = pgxpool.ParseConfig(os.Getenv("DB"))
if err != nil {
panic(err)
}
pool, err = pgxpool.ConnectConfig(context.Background(), config)
if err != nil {
panic(err)
}
defer pool.Close()
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
Définition des routes et d'un produit
Nous commençons par ajouter un type pour gérer nos produits. Pour gérer le type UUID, nous importons la librairie Google "github.com/google/uuid"
type Product struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Vendor uuid.UUID `json:"vendor"`
Price int `json:"price"`
Quantity int `json:"quantity"`
}
Puis nous ajoutons les routes. Pour les routes, nous utilisons la mécanique de Groupe de Gin
products := r.Group("/products")
{
products.GET("", getProduct)
products.GET("/:id", getProductByID)
products.POST("", createProduct)
products.PUT("/:id", putProduct)
products.DELETE("", deleteProduct)
}
La fonction getProduct
func getProduct(c *gin.Context) {
var products []Product
rows, err := pool.Query(context.Background(),
`SELECT p.id, p.name, p.description, c.name, v.name, pv.price, pv.quantity
FROM products p
INNER JOIN categories c ON p.category_id = c.id
INNER JOIN product_vendors pv ON p.id = pv.product_id
INNER JOIN vendors v ON pv.vendor_id = v.id`)
if err != nil {
log.Println("query error: ", err)
}
defer rows.Close()
for rows.Next() {
var product Product
err = rows.Scan(&product.ID, &product.Name, &product.Description, &product.Category, &product.Vendor, &product.Price, &product.Quantity)
if err != nil {
log.Println("parsing error: ", err)
}
products = append(products, product)
}
c.JSON(http.StatusOK, products)
}
De la même manière, la fonction getProductByID modifiera simplement la requête
func getProductByID(c *gin.Context) {
var products []Product
id := c.Param("id")
rows, err := pool.Query(context.Background(),
`SELECT p.name, p.description, c.name, v.name, pv.price, pv.quantity
FROM products p
INNER JOIN categories c ON p.category_id = c.id
INNER JOIN product_vendors pv ON p.id = pv.product_id
INNER JOIN vendors v ON pv.vendor_id = v.id
WHERE p.id = $1`, id)
if err != nil {
log.Println("query error: ", err)
}
defer rows.Close()
for rows.Next() {
var product Product
err = rows.Scan(&product.Name, &product.Description, &product.Category, &product.Vendor, &product.Price, &product.Quantity)
if err != nil {
log.Println("parsing error: ", err)
}
product.ID = uuid.MustParse(id)
products = append(products, product)
}
c.JSON(http.StatusOK, products)
}
Simplement on ne remonte plus l'ID par la requête, puisqu'il est un paramètre de l'URL.
Pour créer un produit, il faut d'abord pouvoir retrouver le vendeur ainsi que la catégorie via le nom qui sera fourni dans le payload. Nous ajoutons donc deux méthodes outils.
func getVendorByName(name string) (uuid.UUID, error) {
var id uuid.UUID
err := pool.QueryRow(context.Background(),
`SELECT id FROM vendors WHERE name = $1`, name).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func getCategoryByName(name string) (uuid.UUID, error) {
var id uuid.UUID
err := pool.QueryRow(context.Background(),
`SELECT id FROM categories WHERE name = $1`, name).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
La création d'un produit
Pour créer un produit, il faut donc l'ajouter dans la table produit, mais il faut aussi mettre à jour les références dans la table de lien pour les vendeurs. Les fonctions utilitaires permettent d'avoir l'ensemble des informations nécessaires. Il pourrait être envisagé de les encadrer dans une transaction afin de garantir que les valeurs soient cohérentes, mais pour notre exemple, notre transaction n'encapsule que l'ajout du produit et la table de référence pour les quantités et prix par vendeurs.
Pour gérer une transaction il suffit de valider que la librairie github.com/jackc/pgx/v4 soit présente dans l'import. Nous utiliserons aussi la librairie github.com/cockroachdb/cockroach-go/v2/crdb/crdbpgx qui permet de gérer automatiquement l'encapsulation de la transaction et surtout les erreurs de contention de la sérialisation des transactions.
Il faudra aussi ajouter les context depuis la librairie standard.
L'import devrait donc ressembler à ceci
import (
"context"
"log"
"net/http"
"os"
"github.com/cockroachdb/cockroach-go/v2/crdb/crdbpgx"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
Pour la fonction de création, nous allons la décomposer en 2 parties. L'une pour gérer la partie response/request HTTP et l'autre pour la partie transaction. Voici le code pour la partie HTTP
func createProduct(c *gin.Context) {
var product Product
c.BindJSON(&product)
vendorID, err := getVendorByName(product.Vendor)
if err != nil {
log.Println("get vendor error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "vendor not found"})
return
}
categoryID, err := getCategoryByName(product.Category)
if err != nil {
log.Println("get category error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "category not found"})
return
}
err = crdbpgx.ExecuteTx(context.Background(), pool, pgx.TxOptions{}, func(tx pgx.Tx) error {
return insertProduct(context.Background(), tx, &product, vendorID, categoryID)
})
if err != nil {
log.Println("error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "can't create product"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "product created",
"uri": "/products/" + product.ID.String(),
})
}
Nous démarrons par l'appel aux méthodes outil et retournons une erreur si nous ne pouvons identifier la catégorie ou le vendeur. Une fois que tout est ok, nous pouvons faire appel à CockroachDB pour stocker notre nouveau produit. Pour ce faire nous utilisons la mécanique prévue dans la librairie proposée par Cockroach Labs pour Go.
err = crdbpgx.ExecuteTx(context.Background(), pool, pgx.TxOptions{}, func(tx pgx.Tx) error {
return insertProduct(context.Background(), tx, &product, vendorID, categoryID)
})
Ce code encapsule la gestion de la transaction et les retry éventuels sur les transactions de manière transparente. Nous lui fournissons un contexte afin de pouvoir réaliser le commit ou le rollback ainsi qu'une fonction de gestion de la transaction.
func insertProduct(ctx context.Context, tx pgx.Tx, product *Product, vendorID uuid.UUID, categoryID uuid.UUID) error {
//insert the product in the table
if err := tx.QueryRow(context.Background(),
`INSERT INTO products (name, description, category_id) VALUES ($1, $2, $3) RETURNING id`,
product.Name, product.Description, categoryID).Scan(&product.ID); err != nil {
return err
}
//create the reference link between the product and the vendor
if _, err := tx.Exec(context.Background(),
`INSERT INTO product_vendors (product_id, vendor_id, price, quantity) VALUES ($1, $2, $3, $4)`,
product.ID, vendorID, product.Price, product.Quantity); err != nil {
return err
}
return nil
}
Ici nous ajoutons tout d'abord le produit. Le produit soumis dans la payload est passé via une référence afin de pouvoir récupérer l'ID qui sera généré automatiquement. Pour ce faire, nous utilisons l'instruction RETURNING et un Scan.
Toutes les instructions sont lancées depuis la transaction via tx.
Vous devriez obtenir un résultat comme ce qui suit.
Modification du produit
Sans grande surprise, nous utilisons la même mécanique.
On gère la couche HTTP avec cette fonction
func putProduct(c *gin.Context) {
var product Product
c.BindJSON(&product)
vendorID, err := getVendorByName(product.Vendor)
if err != nil {
log.Println("get vendor error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "vendor not found"})
return
}
categoryID, err := getCategoryByName(product.Category)
if err != nil {
log.Println("get category error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "category not found"})
return
}
err = crdbpgx.ExecuteTx(context.Background(), pool, pgx.TxOptions{}, func(tx pgx.Tx) error {
return updateProduct(context.Background(), tx, product, vendorID, categoryID)
})
if err != nil {
log.Println("error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "can't create product"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "product updated",
"uri": "/products/" + product.ID.String(),
})
}
et la partie CockroachDB
func updateProduct(ctx context.Context, tx pgx.Tx, product Product, vendorID uuid.UUID, categoryID uuid.UUID) error {
//insert the product in the table
if _, err := tx.Exec(context.Background(),
`UPDATE products SET name = $1, description = $2, category_id = $3 WHERE id = $4)`,
product.Name, product.Description, categoryID, product.ID); err != nil {
return err
}
//create the reference link between the product and the vendor
if _, err := tx.Exec(context.Background(),
`UPDATE product_vendors SET price = $1, quantity = $2 WHERE product_id = $3 AND vendor_id = $4`,
product.Price, product.Quantity, product.ID, vendorID); err != nil {
return err
}
return nil
}
Rien de bien particulier, on reproduit la logique du create en utilisant tx pour notre transaction, et nous mettons à jour les tables via 2 instructions.
Supprimer un produit
Ici la suppression d'un produit est particulière. Vous l'aurez remarqué le post ne prend pas un ID en paramètre de l'appel. Dans ce cas, nous allons passer un payload complet (qui peut être issu d'un GET) afin de fournir les informations utiles pour la suppression.
Nous n'avons pas utiliser de contrainte sur les références du modèle afin de gérer la logique de suppression via notre code. Notre logique sera donc de
- Supprimer la référence au produit pour le vendeur
- Supprimer le produit si et seulement aucun vendeur ne le propose
Encore une fois, la logique restera la même avec une méthode pour la couche HTTP et une autre pour la partie données.
Voici les fonctions
func deleteProduct(c *gin.Context) {
var product Product
c.BindJSON(&product)
vendorID, err := getVendorByName(product.Vendor)
if err != nil {
log.Println("get vendor error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "vendor not found"})
return
}
err = crdbpgx.ExecuteTx(context.Background(), pool, pgx.TxOptions{}, func(tx pgx.Tx) error {
return manageDeleteProduct(context.Background(), tx, product, vendorID)
})
if err != nil {
log.Println("error: ", err)
c.JSON(http.StatusInternalServerError, gin.H{"message": "can't delete product"})
return
}
c.JSON(http.StatusNoContent, gin.H{
"message": "product deleted",
"uri": "/products/" + product.ID.String(),
})
}
func manageDeleteProduct(ctx context.Context, tx pgx.Tx, product Product, vendorID uuid.UUID) error {
//delete the reference link between the product and the vendor
if _, err := tx.Exec(context.Background(),
`DELETE FROM product_vendors WHERE product_id = $1 AND vendor_id = $2`,
product.ID, vendorID); err != nil {
return err
}
rows, err := tx.Query(context.Background(),
`SELECT COUNT(*) FROM product_vendors WHERE product_id = $1`,
product.ID)
if err != nil {
return err
}
defer rows.Close()
var count int
for rows.Next() {
if err := rows.Scan(&count); err != nil {
return err
}
}
if count == 0 {
//delete the product in the table
if _, err := tx.Exec(context.Background(),
`DELETE FROM products WHERE id = $1`,
product.ID); err != nil {
return err
}
}
return nil
}
Dans la suppression on ajoute simplement un COUNT qui permettra de valider si la suppression du produit est nécessaire ou non.
Si par exemple j'ai ce jeu de données
On pourra supprimer les chats des rues car ils ne sont présents qu'une seule fois.
Si maintenant nous souhaitons supprimer les Cocker, ce GET nous montre qu'ils sont présents chez 2 vendeurs
Si l'on utilise le payload suivant, on ne pourra pas supprimer le produit, mais uniquement la référence du vendeur
{
"id": "07400102-4ffd-4c87-817a-8de7f89bf08f",
"name": "Cocker",
"description": "Petit chien",
"category": "Dog",
"vendor": "Animaute",
"price": 600,
"quantity": 5
}
Voici l'appel
et le résultat
Conclusion
Comme vous avez pu le constater, faire une API REST s'appuyant sur CockroachDB n'a rien de particulier. Ce qui sera particulier c'est les capacités de tolérance aux pannes et de scalabilité que Cockroach offrent qui seront les mêmes que celles de votre API:
plus de noeuds = plus de puissance et plus de résilience
Pour le jeu concours, et bien... oui il y en a un!
Si vous avez été attentif, vous aurez constaté une anomalie dans le code proposé. Les 5 premiers à l'identifier correctement et à me l'expliquer sur mon email nico@cockroachlabs.com aurons le droit à une surprise que je me charge de vous faire parvenir via nos équipes marketing. Pas de folie, un petit T-Shirt, une paire de chaussette ou quelque chose du genre.