Tutorial de API de películas en Go

Crea una herramienta de línea de comandos en Go que lista las películas mejor valoradas de un género y un año de estreno. Decodifica el JSON en structs tipados, reintenta un 429 tras la espera de Retry-After y guarda las respuestas en disco. Usa solo la biblioteca estándar, requiere Go 1.22 o superior y lleva unos 20 minutos.

.go Go Actualizado el Ver en Markdown

Vas a construir topfilms, una herramienta de línea de comandos en Go. Ejecuta go run . -genre 878 -year 2010 -n 10 y mostrará las diez mejores películas de ciencia ficción estrenadas en 2010, con nota y número de votos. Usa structs tipados, solo la biblioteca estándar y Go 1.22 o superior. Calcula 20 minutos.

Paso 1: obtén una clave de API

Empieza en precios, donde eliges un periodo prepago y uno de los planes de 20, 40 o 120 solicitudes por minuto. Después crea una clave en el panel de tu cuenta y expórtala:

El programa la lee con os.Getenv("AMODB_KEY"), así que nunca queda en el código fuente.

Shell
export AMODB_KEY="YOUR_API_KEY"

Paso 2: crea el módulo

Crea una carpeta e inicializa un módulo. No hay nada que descargar.

go version debería mostrar 1.22 o superior. Pon todo el código siguiente en un archivo llamado main.go.

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

Paso 3: envía la primera solicitud

La herramienta llama a GET /3/discover/movie, que filtra y ordena el catálogo. El género 878 es ciencia ficción, y la lista completa de ids está en géneros. Este programa completo solo imprime la línea de estado.

Nunca uses http.DefaultClient para llamadas a la API, porque no tiene tiempo de espera.

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

Paso 4: decodifica en structs tipados

Declara solo los campos que leas. encoding/json ignora el resto, así que los campos nuevos en la respuesta no pueden romper la herramienta.

La consulta pide sort_by=vote_average.desc y vote_count.gte=500. Sin ese mínimo de votos, los títulos con un único voto de 10 estrellas encabezarían la lista. Cada filtro está documentado en 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"`
}

Paso 5: reintenta ante 429 e informa de los errores

Lee el cuerpo una vez y decide según el código de estado.

EstadoQué hace el cliente
200Escribe el archivo de caché y decodifica el cuerpo.
401Devuelve un error que nombra AMODB_KEY. Sin reintento, porque la siguiente llamada también fallaría.
429Espera Retry-After segundos (1 si falta, 30 como máximo) y lo intenta de nuevo, hasta 3 intentos.
Otros 4xx y 5xxDevuelve status_code y status_message del cuerpo de error de TMDB.
Error de transporteDevuelve network error envuelto con %w.

La función auxiliar de espera es pequeña:

Consulta errores para ver todos los códigos y límites de solicitudes para la ventana deslizante.

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
}

Paso 6: guarda las respuestas en disco

El caché perimetral mantiene las listas de discover durante 30 minutos. El cliente guarda cada cuerpo correcto en la carpeta de caché del usuario, con el nombre del SHA-256 de la URL completa, y lo vuelve a servir mientras el archivo sea más reciente que el TTL. Así, un comando repetido no hace ninguna solicitud.

Paso 7: el programa completo

Reemplaza main.go por esto y ejecuta go run . -genre 878 -year 2010 -n 10.

Prueba otros filtros con las opciones. go run . -genre 18 -year 1999 -n 5 lista los cinco dramas mejor valorados de 1999. Ejecuta el mismo comando dos veces y la segunda responde desde el archivo de caché sin tocar la red, hasta que pasen 30 minutos. Si ninguna película llega a 500 votos para tu combinación, la herramienta imprime No movies matched. y termina con normalidad.

El bucle de reintentos retorna desde dentro del switch en todos los casos salvo en un 429 al que aún le quedan intentos. Por eso la función termina con un error simple: el compilador necesita un return después del bucle, y el mensaje solo aparecería si se cambiara la lógica del bucle.

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

Próximos pasos

  • Añade una opción -page y pásala como parámetro page para recorrer la lista más abajo.
  • Resuelve cada id de película con películas y añade append_to_response=ratings para leer la nota de IMDb en ratings.imdb.value.
  • Ejecuta varios géneros a la vez con goroutines y mantenlos por debajo de las solicitudes por minuto de tu plan.
  • Lleva la misma idea a Java o lee el inicio rápido.