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.

.go Go Mis à jour le Voir en Markdown

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.

Shell
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.

Shell
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.

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)
}

É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_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.

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"`
}

É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.

StatutCe que fait le client
200Écrit le fichier de cache et décode le corps.
401Renvoie une erreur qui nomme AMODB_KEY. Pas de nouvelle tentative, car l'appel suivant échouerait aussi.
429Attend Retry-After secondes (1 si absent, 30 au plus) et réessaie, jusqu'à 3 tentatives.
Autres 4xx et 5xxRenvoie le status_code et le status_message du corps d'erreur TMDB.
Erreur de transportRenvoie 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.

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
}

É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.

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)
	}
}

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 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, ou lisez le démarrage rapide.