# Tutorial de API de filmes em Go

> Crie uma ferramenta de linha de comando em Go que lista filmes mais bem avaliados de um gênero e ano de lançamento. Decodifica o JSON em structs tipadas, repete um 429 após o atraso de Retry-After e guarda respostas em disco. Usa só a biblioteca padrão, exige Go 1.22 ou mais recente e leva cerca de 20 minutos.

Você vai construir `topfilms`, uma ferramenta de linha de comando em Go. Execute `go run . -genre 878 -year 2010 -n 10` e ela imprime os dez filmes de ficção científica mais bem avaliados lançados em 2010, com nota e número de votos. Ela usa structs tipadas, apenas a biblioteca padrão e Go 1.22 ou mais recente. Conte com 20 minutos.

## Passo 1: obtenha uma chave de API

Comece em [preços](https://amodb.dev/pt/pricing/), onde você escolhe um período pré-pago e um dos planos de 20, 40 ou 120 requisições por minuto. Depois crie uma chave no painel da sua conta e exporte-a:

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

O programa a lê com `os.Getenv("AMODB_KEY")`, então ela nunca fica no código-fonte.

## Passo 2: crie o módulo

Crie uma pasta e inicialize um módulo. Não há nada para baixar.

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

`go version` deve mostrar 1.22 ou superior. Coloque todo o código abaixo em um arquivo chamado `main.go`.

## Passo 3: envie a primeira requisição

A ferramenta chama `GET /3/discover/movie`, que filtra e ordena o catálogo. O gênero `878` é ficção científica, e a lista completa de ids está em [gêneros](https://amodb.dev/pt/genres/). Este programa completo apenas imprime a linha de status.

```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 use `http.DefaultClient` em chamadas de API, porque ele não tem timeout.

## Passo 4: decodifique em structs tipadas

Declare só os campos que você lê. O `encoding/json` ignora o resto, então novos campos na resposta não quebram a ferramenta.

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

A consulta pede `sort_by=vote_average.desc` e `vote_count.gte=500`. Sem o piso de votos, títulos com um único voto de 10 estrelas liderariam a lista. Todo filtro está documentado em [discover](https://amodb.dev/pt/discover/).

## Passo 5: repita em 429 e informe os erros

Leia o corpo uma vez e depois decida pelo código de status.

| Status             | O que o cliente faz                                                                              |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| 200                | Grava o arquivo de cache e decodifica o corpo.                                                   |
| 401                | Retorna um erro que cita `AMODB_KEY`. Sem repetição, porque a próxima chamada também falharia.   |
| 429                | Dorme por `Retry-After` segundos (1 se ausente, 30 no máximo) e tenta de novo, até 3 tentativas. |
| Outros 4xx e 5xx   | Retorna o `status_code` e o `status_message` do corpo de erro do TMDB.                           |
| Erro de transporte | Retorna `network error` envolvido com `%w`.                                                      |

O auxiliar de atraso é pequeno:

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

Veja [erros](https://amodb.dev/pt/errors/) para todos os códigos e [limites de requisições](https://amodb.dev/pt/rate-limits/) para a janela deslizante.

## Passo 6: guarde as respostas em cache no disco

A borda guarda as listas do discover por 30 minutos. O cliente salva cada corpo bem-sucedido na pasta de cache do usuário, nomeado pelo SHA-256 da URL completa, e o serve de novo enquanto o arquivo for mais novo que o TTL. Um comando repetido então não faz nenhuma requisição.

## Passo 7: o programa completo

Substitua `main.go` por isto e execute `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)
	}
}
```

Teste outros filtros com as flags. `go run . -genre 18 -year 1999 -n 5` lista os cinco dramas mais bem avaliados de 1999. Execute o mesmo comando duas vezes e a segunda execução responde pelo arquivo de cache sem tocar na rede, até passarem 30 minutos. Se nenhum filme tiver 500 votos para a sua combinação, a ferramenta imprime `No movies matched.` e termina normalmente.

O loop de repetição retorna de dentro do `switch` em todos os casos, exceto um 429 que ainda tem tentativas sobrando. Por isso a função termina com um erro simples: o compilador exige um return depois do loop, e a mensagem só apareceria se a lógica do loop fosse alterada.

## Próximos passos

- Adicione uma flag `-page` e passe-a como o parâmetro `page` para avançar mais na lista.
- Resolva cada id de filme com [filmes](https://amodb.dev/pt/movies/) e adicione `append_to_response=ratings` para ler a nota do IMDb em `ratings.imdb.value`.
- Rode vários gêneros ao mesmo tempo com goroutines e mantenha-se abaixo das requisições por minuto do seu plano.
- Porte a mesma ideia para [Java](https://amodb.dev/pt/java-movie-api/) ou leia o [início rápido](https://amodb.dev/pt/quickstart/).

## Perguntas e respostas

### Qual versão do Go o código exige?

Go 1.22 ou mais recente. Ele usa a função min embutida do Go 1.21, o alias any e só a biblioteca padrão, então o go.mod não lista dependências e go run . funciona logo depois de go mod init.

### Por que usar structs tipadas em vez de map[string]any?

Structs dão nomes de campo verificados na compilação e tipos numéricos reais, e o encoding/json ignora os campos que você não declarou. Isso combina com uma API em que novos campos podem surgir com o tempo, porque o código antigo continua funcionando.

### Como o cliente espera depois de um 429?

Ele lê o cabeçalho Retry-After como segundos inteiros, usa 1 quando o cabeçalho não existe, limita a espera a 30 e faz no máximo 3 tentativas no total. Depois disso, devolve a mensagem de status ao chamador.

### Posso enviar requisições de várias goroutines?

Sim, mas cada chamada ainda conta nas 20, 40 ou 120 requisições por minuto da sua chave. Distribua as chamadas com um time.Ticker ou um canal com buffer e deixe a repetição em 429 absorver o excesso ocasional.

### Onde a ferramenta guarda o cache?

Em uma pasta chamada amodb-top-films dentro de os.UserCacheDir, com um arquivo por requisição, nomeado a partir de um hash SHA-256 da URL. Um arquivo é reaproveitado por 30 minutos, o mesmo tempo em que a borda mantém as listas do discover.

## Páginas relacionadas

- [API Discover](https://amodb.dev/pt/discover/): API Discover com os mesmos filtros do TMDB v3. Liste filmes e séries por gênero, elenco, produtora, palavra-chave, ano, nota, duração e provedor, com ordenação.
- [Gêneros e listas de consulta](https://amodb.dev/pt/genres/): API de gêneros com caminhos do TMDB v3: ids de gênero de filmes e TV com nomes traduzidos, mais listas de países, idiomas, funções, classificações e provedores.
- [Limites de requisições](https://amodb.dev/pt/rate-limits/): O amodb limita cada chave por requisições por minuto em uma janela de 60 segundos: 20, 40 ou 120 por plano. Leia os cabeçalhos e trate o 429 com Retry-After.
- [Erros](https://amodb.dev/pt/errors/): Todos os erros do amodb com status HTTP, status_code e mensagem do TMDB, mais o formato de erro do OMDb. O que cada um significa e o que seu cliente deve fazer.
- [Tutorial de API de filmes em Java](https://amodb.dev/pt/java-movie-api/): Crie um guia de episódios em Java com HttpClient do JDK e records do Jackson: busque uma temporada, respeite Retry-After em 429 e use cache em arquivos.
- [Tutorial de API de filmes em PHP](https://amodb.dev/pt/php-movie-api/): Crie uma função PHP e uma página de filme que chamam a API do amodb com cURL, tentam de novo no 429 e guardam cada resposta em arquivo. PHP 8.2 ou mais novo.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/go-movie-api/
