# 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](https://amodb.dev/it/pricing/), 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:

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

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

## Passaggio 2: crea il modulo

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

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

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

## 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](https://amodb.dev/it/genres/). Questo programma completo stampa solo la riga di stato.

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

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

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

```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 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](https://amodb.dev/it/discover/).

## 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:

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

Vedi [errori](https://amodb.dev/it/errors/) per tutti i codici e [limiti di richieste](https://amodb.dev/it/rate-limits/) per la finestra scorrevole.

## 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`.

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

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.

## 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](https://amodb.dev/it/movies/) 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](https://amodb.dev/it/java-movie-api/), oppure leggi l'[avvio rapido](https://amodb.dev/it/quickstart/).

## Domande e risposte

### Quale versione di Go serve al codice?

Go 1.22 o successivo. Usa la funzione integrata min di Go 1.21, l'alias any e solo la libreria standard, quindi go.mod non elenca dipendenze e go run . funziona subito dopo go mod init.

### Perché usare struct tipizzate invece di map[string]any?

Le struct danno nomi di campo verificati in compilazione e veri tipi numerici, e encoding/json salta i campi che non hai dichiarato. Va bene per un'API in cui col tempo possono comparire nuovi campi, perché il codice più vecchio continua a funzionare.

### Come attende il client su un 429?

Legge l'header Retry-After in secondi interi, ripiega su 1 quando l'header manca, limita l'attesa a 30 e fa al massimo 3 tentativi in tutto. Dopo restituisce al chiamante il messaggio di stato.

### Posso inviare richieste da più goroutine?

Sì, ma ogni chiamata conta comunque nelle 20, 40 o 120 richieste al minuto della tua chiave. Distribuisci le chiamate con un time.Ticker o un canale con buffer e lascia che il retry su 429 assorba gli sforamenti occasionali.

### Dove tiene la cache lo strumento?

In una cartella chiamata amodb-top-films sotto os.UserCacheDir, con un file per richiesta nominato da un hash SHA-256 dell'URL. Un file viene riusato per 30 minuti, lo stesso tempo per cui l'edge tiene le liste discover.

## Pagine correlate

- [API Discover](https://amodb.dev/it/discover/): API Discover con gli stessi filtri di TMDB v3. Elenca film e serie TV per genere, cast, società, parola chiave, anno, voto, durata e piattaforma.
- [Generi e liste di riferimento](https://amodb.dev/it/genres/): API generi con percorsi TMDB v3: ID dei generi di film e serie TV con nomi tradotti, più liste di paesi, lingue, mestieri, classificazioni e piattaforme.
- [Limiti di richieste](https://amodb.dev/it/rate-limits/): amodb limita ogni chiave per richieste al minuto in una finestra scorrevole di 60 secondi: 20, 40 o 120 in base al piano. Leggi gli header, gestisci il 429.
- [Errori](https://amodb.dev/it/errors/): Ogni errore di amodb con stato HTTP, status_code e messaggio TMDB, più la forma dell'errore OMDb. Cosa significa ciascuno e cosa deve fare il tuo client.
- [Tutorial API film in Java](https://amodb.dev/it/java-movie-api/): Guida agli episodi in Java con l'HttpClient del JDK e i record Jackson: leggi una stagione, gestisci Retry-After su 429, cache su file. Java 17 o successivo.
- [Tutorial API film in PHP](https://amodb.dev/it/php-movie-api/): Una funzione PHP e una pagina film che chiamano l'API amodb con cURL, ritentano su 429, fanno l'escape dell'output e salvano ogni risposta in cache. PHP 8.2+.

Aggiornato: 2026-10-18. Source: https://amodb.dev/it/go-movie-api/
