Tutorial API film in Go

Crea un tool CLI in Go che elenca i film più votati di un genere e di un anno di uscita. Decodifica il JSON in struct tipizzate, ritenta un 429 dopo il ritardo di Retry-After e mette in cache le risposte su disco. Usa solo la libreria standard, richiede Go 1.22 o successivo e dura circa 20 minuti.

.go Go Aggiornato il Vedi in Markdown

Costruirai topfilms, un tool da riga di comando in Go. Esegui go run . -genre 878 -year 2010 -n 10 e stampa i dieci film di fantascienza più votati usciti nel 2010, con punteggio e numero di voti. Usa struct tipizzate, solo la libreria standard e Go 1.22 o successivo. Calcola 20 minuti.

Passaggio 1: ottieni una chiave API

Inizia dalla pagina dei prezzi, dove scegli un periodo prepagato e uno dei piani da 20, 40 o 120 richieste al minuto. Poi crea una chiave nella dashboard del tuo account ed esportala:

Il programma la legge con os.Getenv("AMODB_KEY"), quindi non sta mai nel codice sorgente.

Shell
export AMODB_KEY="YOUR_API_KEY"

Passaggio 2: crea il modulo

Crea una cartella e inizializza un modulo. Non c'è nulla da scaricare.

go version dovrebbe stampare 1.22 o superiore. Metti tutto il codice qui sotto in un file chiamato main.go.

Shell
mkdir topfilms && cd topfilms
go mod init topfilms
go version

Passaggio 3: invia la prima richiesta

Il tool chiama GET /3/discover/movie, che filtra e ordina il catalogo. Il genere 878 è la fantascienza, e l'elenco completo degli ID è nella pagina generi. Questo programma completo stampa solo la riga di stato.

Non usare mai http.DefaultClient per le chiamate API, perché non ha un timeout.

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

Passaggio 4: decodifica in struct tipizzate

Dichiara solo i campi che leggi. encoding/json ignora il resto, quindi i nuovi campi nella risposta non possono rompere il tool.

La query chiede sort_by=vote_average.desc e vote_count.gte=500. Senza quella soglia di voti, i titoli con un solo voto da 10 stelle guiderebbero l'elenco. Ogni filtro è documentato nella pagina discover.

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

Passaggio 5: ritenta su 429 e segnala gli errori

Leggi il corpo una sola volta, poi decidi in base al codice di stato.

StatoCosa fa il client
200Scrive il file di cache e decodifica il corpo.
401Restituisce un errore che nomina AMODB_KEY. Nessun nuovo tentativo, perché anche la chiamata successiva fallirebbe.
429Dorme per Retry-After secondi (1 se manca, 30 al massimo) e riprova, fino a 3 tentativi.
Altri 4xx e 5xxRestituisce status_code e status_message dal corpo di errore TMDB.
Errore di trasportoRestituisce network error avvolto con %w.

La funzione di aiuto per il ritardo è piccola:

Vedi errori per tutti i codici e limiti di richieste per la finestra scorrevole.

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
}

Passaggio 6: metti le risposte in cache su disco

L'edge tiene le liste discover per 30 minuti. Il client salva ogni corpo riuscito nella cartella di cache dell'utente, con il nome dato dall'SHA-256 dell'URL completo, e lo serve di nuovo finché il file è più giovane del TTL. Un comando ripetuto allora non fa alcuna richiesta.

Passaggio 7: il programma completo

Sostituisci main.go con questo ed esegui go run . -genre 878 -year 2010 -n 10.

Prova altri filtri con i flag. go run . -genre 18 -year 1999 -n 5 elenca i cinque drammi più votati del 1999. Esegui lo stesso comando due volte e la seconda risponde dal file di cache senza toccare la rete, finché non passano 30 minuti. Se nessun film ha 500 voti per la tua combinazione, il tool stampa No movies matched. ed esce normalmente.

Il ciclo di nuovi tentativi ritorna dall'interno dello switch in ogni caso tranne un 429 che ha ancora tentativi disponibili. Per questo la funzione termina con un semplice errore: il compilatore richiede un return dopo il ciclo, e il messaggio apparirebbe solo se la logica del ciclo fosse modificata.

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

Prossimi passi

  • Aggiungi un flag -page e passalo come parametro page per scendere più in basso nell'elenco.
  • Risolvi ogni ID di film con film e aggiungi append_to_response=ratings per leggere il punteggio IMDb da ratings.imdb.value.
  • Esegui più generi insieme con le goroutine, e restando sotto le richieste al minuto del tuo piano.
  • Porta la stessa idea in Java, oppure leggi l'avvio rapido.