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.
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.
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.
mkdir topfilms && cd topfilms
go mod init topfilms
go versionPassaggio 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.
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_ 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.
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.
| Stato | Cosa fa il client |
|---|---|
| 200 | Scrive il file di cache e decodifica il corpo. |
| 401 | Restituisce un errore che nomina AMODB_KEY. Nessun nuovo tentativo, perché anche la chiamata successiva fallirebbe. |
| 429 | Dorme per Retry-After secondi (1 se manca, 30 al massimo) e riprova, fino a 3 tentativi. |
| Altri 4xx e 5xx | Restituisce status_code e status_message dal corpo di errore TMDB. |
| Errore di trasporto | Restituisce 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.
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.
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
-pagee passalo come parametropageper scendere più in basso nell'elenco. - Risolvi ogni ID di film con film e aggiungi
append_per leggere il punteggio IMDb dato_ response= ratings 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.