Votre première API REST avec GO et CockroachDB

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:

  1. Une orientation Marketplace, ce qui veut dire que nous avons besoin d'une gestion des vendeurs et des produits qu'ils vendent
  2. Une gestion des stocks par vendeur
  3. Un prix par vendeur

Pour stocker ces informations, nous utiliserons donc le schéma de données suivant:

schéma

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:

  1. GET /products
  2. GET /products/{id}
  3. POST /products
  4. PUT /products/{id}
  5. 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.

Ne pas oublier de faire un go mod tidy avant de lancer le code pour importer les librairies ajoutées.

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.

La création nous retourne l'ID pour l'appel en GET, PUT, DELETE
Le GET avec notre ID

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

  1. Supprimer la référence au produit pour le vendeur
  2. 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

Les chats c'est dangereux

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

Il reste toujours une référence pour le Cocker

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.