# Tutoriel API de films en Go

> Créez un outil Go qui liste les films les mieux notés d'un genre et d'une année de sortie. Il décode le JSON dans des structs typées, réessaie un 429 après le délai Retry-After et met les réponses en cache sur disque. Il n'utilise que la bibliothèque standard, demande Go 1.22 ou plus récent et prend environ 20 minutes.

Vous allez construire `topfilms`, un outil Go en ligne de commande. Lancez `go run . -genre 878 -year 2010 -n 10` et il affiche les dix films de science-fiction les mieux notés sortis en 2010, avec leur note et leur nombre de votes. Il utilise des structs typées, uniquement la bibliothèque standard et Go 1.22 ou plus récent. Prévoyez 20 minutes.

## Étape 1 : obtenir une clé d'API

Commencez sur les [tarifs](https://amodb.dev/fr/pricing/), où vous choisissez une période prépayée et l'une des offres à 20, 40 ou 120 requêtes par minute. Créez ensuite une clé dans le tableau de bord de votre compte et exportez-la :

```bash
export AMODB_KEY="YOUR_API_KEY"
```

Le programme la lit avec `os.Getenv("AMODB_KEY")`, elle ne se trouve donc jamais dans le code source.

## Étape 2 : créer le module

Créez un dossier et initialisez un module. Il n'y a rien à télécharger.

```bash
mkdir topfilms && cd topfilms
go mod init topfilms
go version
```

`go version` doit afficher 1.22 ou plus. Placez tout le code ci-dessous dans un fichier nommé `main.go`.

## Étape 3 : envoyer la première requête

L'outil appelle `GET /3/discover/movie`, qui filtre et trie le catalogue. Le genre `878` est la science-fiction, et la liste complète des identifiants est sur [genres](https://amodb.dev/fr/genres/). Ce programme complet n'affiche que la ligne de statut.

```go
package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	url := "https://api.amodb.dev/3/discover/movie?with_genres=878"
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AMODB_KEY"))
	res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

N'utilisez jamais `http.DefaultClient` pour les appels d'API, car il n'a pas de délai d'expiration.

## Étape 4 : décoder dans des structs typées

Ne déclarez que les champs que vous lisez. `encoding/json` ignore le reste : de nouveaux champs dans la réponse ne peuvent donc pas casser l'outil.

```go
type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}
```

La requête demande `sort_by=vote_average.desc` et `vote_count.gte=500`. Sans ce plancher de votes, des titres avec un seul vote de 10 étoiles seraient en tête de liste. Chaque filtre est documenté sur [découverte](https://amodb.dev/fr/discover/).

## Étape 5 : relancer sur 429 et signaler les erreurs

Lisez le corps une seule fois, puis décidez d'après le code de statut.

| Statut              | Ce que fait le client                                                                                      |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| 200                 | Écrit le fichier de cache et décode le corps.                                                              |
| 401                 | Renvoie une erreur qui nomme `AMODB_KEY`. Pas de nouvelle tentative, car l'appel suivant échouerait aussi. |
| 429                 | Attend `Retry-After` secondes (1 si absent, 30 au plus) et réessaie, jusqu'à 3 tentatives.                 |
| Autres 4xx et 5xx   | Renvoie le `status_code` et le `status_message` du corps d'erreur TMDB.                                    |
| Erreur de transport | Renvoie `network error` enveloppée avec `%w`.                                                              |

La fonction d'aide pour le délai est courte :

```go
func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}
```

Voir [erreurs](https://amodb.dev/fr/errors/) pour tous les codes et [limites de débit](https://amodb.dev/fr/rate-limits/) pour la fenêtre glissante.

## Étape 6 : mettre les réponses en cache sur disque

La périphérie garde les listes discover 30 minutes. Le client enregistre chaque corps réussi dans le dossier de cache de l'utilisateur, nommé d'après le SHA-256 de l'URL complète, et le ressert tant que le fichier est plus récent que la durée de vie. Une commande répétée ne fait alors aucune requête.

## Étape 7 : le programme complet

Remplacez `main.go` par ceci et lancez `go run . -genre 878 -year 2010 -n 10`.

```go
package main

import (
	"crypto/sha256"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const baseURL = "https://api.amodb.dev/3"

type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}

type apiError struct {
	StatusCode    int    `json:"status_code"`
	StatusMessage string `json:"status_message"`
}

type Client struct {
	key      string
	hc       *http.Client
	cacheDir string
}

func NewClient(key string) (*Client, error) {
	base, err := os.UserCacheDir()
	if err != nil {
		base = os.TempDir()
	}
	dir := filepath.Join(base, "amodb-top-films")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	return &Client{key: key, hc: &http.Client{Timeout: 10 * time.Second}, cacheDir: dir}, nil
}

func (c *Client) Get(path string, query url.Values, ttl time.Duration, out any) error {
	endpoint := baseURL + path + "?" + query.Encode()
	cacheFile := filepath.Join(c.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte(endpoint))))
	if info, err := os.Stat(cacheFile); err == nil && time.Since(info.ModTime()) < ttl {
		if body, err := os.ReadFile(cacheFile); err == nil {
			return json.Unmarshal(body, out)
		}
	}

	for attempt := 1; attempt <= 3; attempt++ {
		req, err := http.NewRequest(http.MethodGet, endpoint, nil)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.key)
		res, err := c.hc.Do(req)
		if err != nil {
			return fmt.Errorf("network error: %w", err)
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		if err != nil {
			return fmt.Errorf("reading reply: %w", err)
		}

		switch {
		case res.StatusCode == http.StatusOK:
			_ = os.WriteFile(cacheFile, body, 0o644)
			return json.Unmarshal(body, out)
		case res.StatusCode == http.StatusTooManyRequests && attempt < 3:
			time.Sleep(retryDelay(res.Header.Get("Retry-After")))
		default:
			return statusError(res.StatusCode, body)
		}
	}
	return errors.New("retries exhausted")
}

func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}

func statusError(status int, body []byte) error {
	if status == http.StatusUnauthorized {
		return errors.New("401: the key is invalid or suspended, check AMODB_KEY")
	}
	var e apiError
	if json.Unmarshal(body, &e) == nil && e.StatusMessage != "" {
		return fmt.Errorf("amodb returned %d (status_code %d): %s", status, e.StatusCode, e.StatusMessage)
	}
	return fmt.Errorf("amodb returned %d", status)
}

func yearOf(date string) string {
	if len(date) >= 4 {
		return date[:4]
	}
	return "n/a"
}

func main() {
	genre := flag.Int("genre", 878, "genre id, 878 is Science Fiction")
	year := flag.Int("year", 2010, "primary release year")
	limit := flag.Int("n", 10, "how many movies to print, 1 to 20")
	flag.Parse()

	key := os.Getenv("AMODB_KEY")
	if key == "" {
		fmt.Fprintln(os.Stderr, "Set AMODB_KEY first.")
		os.Exit(1)
	}
	client, err := NewClient(key)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	query := url.Values{}
	query.Set("language", "en-US")
	query.Set("with_genres", strconv.Itoa(*genre))
	query.Set("primary_release_year", strconv.Itoa(*year))
	query.Set("sort_by", "vote_average.desc")
	query.Set("vote_count.gte", "500")

	var page DiscoverPage
	if err := client.Get("/discover/movie", query, 30*time.Minute, &page); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(page.Results) == 0 {
		fmt.Println("No movies matched.")
		return
	}
	fmt.Printf("%d movies match, best first:\n", page.TotalResults)
	for i, m := range page.Results {
		if i >= *limit {
			break
		}
		fmt.Printf("%2d. %s (%s) %.1f from %d votes\n", i+1, m.Title, yearOf(m.ReleaseDate), m.VoteAverage, m.VoteCount)
	}
}
```

Essayez d'autres filtres avec les options. `go run . -genre 18 -year 1999 -n 5` liste les cinq drames les mieux notés de 1999. Lancez la même commande deux fois : la seconde répond depuis le fichier de cache sans toucher au réseau, jusqu'à ce que 30 minutes soient écoulées. Si aucun film n'a 500 votes pour votre combinaison, l'outil affiche `No movies matched.` et se termine normalement.

La boucle de nouvelle tentative retourne depuis l'intérieur du `switch` dans tous les cas sauf un 429 qui a encore des tentatives. C'est pourquoi la fonction se termine par une erreur simple : le compilateur exige un retour après la boucle, et le message n'apparaîtrait que si la logique de la boucle était modifiée.

## Pour aller plus loin

- Ajoutez une option `-page` et passez-la comme paramètre `page` pour descendre plus loin dans la liste.
- Résolvez chaque identifiant de film avec [films](https://amodb.dev/fr/movies/) et ajoutez `append_to_response=ratings` pour lire la note IMDb dans `ratings.imdb.value`.
- Lancez plusieurs genres à la fois avec des goroutines, et restez sous les requêtes par minute de votre offre.
- Portez la même idée en [Java](https://amodb.dev/fr/java-movie-api/), ou lisez le [démarrage rapide](https://amodb.dev/fr/quickstart/).

## Questions et réponses

### De quelle version de Go le code a-t-il besoin ?

Go 1.22 ou plus récent. Il utilise la fonction intégrée min de Go 1.21, l'alias any et uniquement la bibliothèque standard : go.mod ne liste donc aucune dépendance et go run . fonctionne juste après go mod init.

### Pourquoi utiliser des structs typées plutôt que map[string]any ?

Les structs donnent des noms de champs vérifiés à la compilation et de vrais types numériques, et encoding/json ignore les champs que vous n'avez pas déclarés. Cela convient à une API où de nouveaux champs peuvent apparaître avec le temps, car le code plus ancien continue de fonctionner.

### Comment le client attend-il sur un 429 ?

Il lit l'en-tête Retry-After en secondes entières, retombe sur 1 quand l'en-tête manque, plafonne l'attente à 30 et fait au plus 3 tentatives au total. Ensuite il renvoie le message d'état à l'appelant.

### Puis-je envoyer des requêtes depuis plusieurs goroutines ?

Oui, mais chaque appel compte quand même dans les 20, 40 ou 120 requêtes par minute de votre clé. Répartissez les appels avec un time.Ticker ou un canal avec tampon et laissez la relance sur 429 absorber les débordements occasionnels.

### Où l'outil garde-t-il son cache ?

Dans un dossier nommé amodb-top-films sous os.UserCacheDir, avec un fichier par requête nommé d'après un hachage SHA-256 de l'URL. Un fichier est réutilisé pendant 30 minutes, la durée pendant laquelle la périphérie garde les listes discover.

## Pages liées

- [API Discover](https://amodb.dev/fr/discover/): API Discover avec les mêmes filtres que TMDB v3. Listez films et séries par genre, distribution, société, mot-clé, année, note, durée et plateforme.
- [Genres et listes de référence](https://amodb.dev/fr/genres/): API de genres aux chemins TMDB v3 : identifiants de genres films et séries avec noms traduits, plus pays, langues, métiers, classifications et plateformes.
- [Limites de débit](https://amodb.dev/fr/rate-limits/): amodb limite chaque clé en requêtes par minute sur une fenêtre glissante de 60 secondes : 20, 40 ou 120 selon l'offre. Lisez les en-têtes et gérez le 429.
- [Erreurs](https://amodb.dev/fr/errors/): Chaque erreur amodb : statut HTTP, status_code et message TMDB, plus la forme d'erreur OMDb. Ce que chacune signifie et ce que votre client doit faire.
- [Tutoriel API de films en Java](https://amodb.dev/fr/java-movie-api/): Guide d'épisodes Java avec le HttpClient du JDK et les records Jackson : lire une saison, gérer Retry-After sur 429, cache en fichiers. Java 17 ou plus.
- [Tutoriel API de films en PHP](https://amodb.dev/fr/php-movie-api/): Une fonction PHP et une page de film qui appellent l'API amodb avec cURL, relancent sur 429, échappent la sortie et mettent chaque réponse en cache. PHP 8.2+.

Mis à jour: 2026-10-18. Source: https://amodb.dev/fr/go-movie-api/
