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

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

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

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

## Paso 2: crea el módulo

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

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

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

## 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](https://amodb.dev/es/genres/). Este programa completo solo imprime la línea de estado.

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

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

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

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

## Paso 5: reintenta ante 429 e informa de los errores

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

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

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

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

Consulta [errores](https://amodb.dev/es/errors/) para ver todos los códigos y [límites de solicitudes](https://amodb.dev/es/rate-limits/) para la ventana deslizante.

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

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

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.

## 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](https://amodb.dev/es/movies/) 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](https://amodb.dev/es/java-movie-api/) o lee el [inicio rápido](https://amodb.dev/es/quickstart/).

## Preguntas y respuestas

### ¿Qué versión de Go necesita el código?

Go 1.22 o superior. Usa la función integrada min de Go 1.21, el alias any y solo la biblioteca estándar, así que go.mod no lista dependencias y go run . funciona justo después de go mod init.

### ¿Por qué usar structs tipados en lugar de map[string]any?

Los structs te dan nombres de campo comprobados al compilar y tipos numéricos reales, y encoding/json omite los campos que no declaraste. Eso encaja con una API donde pueden aparecer campos nuevos con el tiempo, porque el código antiguo sigue funcionando.

### ¿Cómo espera el cliente ante un 429?

Lee el encabezado Retry-After como segundos enteros, usa 1 si el encabezado falta, limita la espera a 30 y hace como máximo 3 intentos en total. Después devuelve el mensaje de estado a quien lo llamó.

### ¿Puedo enviar solicitudes desde varias goroutines?

Sí, pero cada llamada sigue contando para las 20, 40 o 120 solicitudes por minuto de tu clave. Reparte las llamadas con un time.Ticker o un canal con búfer y deja que el reintento ante 429 absorba el desborde ocasional.

### ¿Dónde guarda la herramienta su caché?

En una carpeta llamada amodb-top-films dentro de os.UserCacheDir, con un archivo por solicitud nombrado a partir de un hash SHA-256 de la URL. Un archivo se reutiliza durante 30 minutos, el mismo tiempo que el caché perimetral mantiene las listas de discover.

## Páginas relacionadas

- [API Discover](https://amodb.dev/es/discover/): API Discover con los mismos filtros que TMDB v3. Lista películas y series por género, reparto, productora, palabra clave, año, nota, duración y plataforma.
- [Géneros y listas de consulta](https://amodb.dev/es/genres/): API de géneros con rutas TMDB v3: ids de género de películas y TV con nombres traducidos, más listas de países, idiomas, oficios, clasificaciones y proveedores.
- [Límites de solicitudes](https://amodb.dev/es/rate-limits/): amodb limita cada clave por solicitudes por minuto en una ventana deslizante de 60 segundos: 20, 40 o 120 según el plan. Lee los encabezados y gestiona el 429.
- [Errores](https://amodb.dev/es/errors/): Cada error de amodb con su estado HTTP, su status_code de TMDB y su mensaje, más la forma de error de OMDb. Qué significa cada uno y qué debe hacer tu cliente.
- [Tutorial de API de películas en Java](https://amodb.dev/es/java-movie-api/): Crea una guía de episodios en Java con HttpClient y Jackson: lee una temporada, respeta Retry-After ante un 429 y usa caché de archivos. Java 17 o superior.
- [Tutorial de API de películas en PHP](https://amodb.dev/es/php-movie-api/): Crea una función PHP y una página de película que llaman a amodb con cURL, reintentan ante 429 y guardan cada respuesta en un archivo. PHP 8.2 o superior.

Actualizado: 2026-10-18. Source: https://amodb.dev/es/go-movie-api/
