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, 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 :
Le programme la lit avec os.Getenv("AMODB_KEY"), elle ne se trouve donc jamais dans le code source.
export AMODB_KEY="YOUR_API_KEY"Étape 2 : créer le module
Créez un dossier et initialisez un module. Il n'y a rien à télécharger.
go version doit afficher 1.22 ou plus. Placez tout le code ci-dessous dans un fichier nommé main.go.
mkdir topfilms && cd topfilms
go mod init topfilms
go versionÉ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. Ce programme complet n'affiche que la ligne de statut.
N'utilisez jamais http.DefaultClient pour les appels d'API, car il n'a pas de délai d'expiration.
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)
}É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.
La requête demande sort_ 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.
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"`
}É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 :
Voir erreurs pour tous les codes et limites de débit pour la fenêtre glissante.
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
}É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.
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.
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)
}
}Pour aller plus loin
- Ajoutez une option
-pageet passez-la comme paramètrepagepour descendre plus loin dans la liste. - Résolvez chaque identifiant de film avec films et ajoutez
append_pour lire la note IMDb dansto_ response= ratings 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, ou lisez le démarrage rapide.