# Руководство по Go API фильмов

> Соберите утилиту командной строки на Go, которая выводит лучшие по рейтингу фильмы заданного жанра и года выхода. Она разбирает JSON в типизированные структуры, повторяет запрос при 429 после паузы Retry-After и кэширует ответы на диске. Нужна только стандартная библиотека, Go 1.22 или новее и около 20 минут.

Вы построите `topfilms`, утилиту командной строки на Go. Выполните `go run . -genre 878 -year 2010 -n 10`, и она выведет десять лучших по рейтингу научно-фантастических фильмов 2010 года с оценкой и числом голосов. Она использует типизированные структуры, только стандартную библиотеку и Go 1.22 или новее. Рассчитывайте на 20 минут.

## Шаг 1: получите ключ API

Начните со страницы [цены](https://amodb.dev/ru/pricing/), где вы выбираете оплаченный период и один из тарифов на 20, 40 или 120 запросов в минуту. Затем создайте ключ в панели аккаунта и экспортируйте его:

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

Программа читает его через `os.Getenv("AMODB_KEY")`, поэтому в исходном коде его нет.

## Шаг 2: создайте модуль

Создайте папку и инициализируйте модуль. Скачивать ничего не нужно.

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

`go version` должен вывести 1.22 или выше. Весь код ниже помещайте в файл `main.go`.

## Шаг 3: отправьте первый запрос

Утилита вызывает `GET /3/discover/movie`, который фильтрует и сортирует каталог. Жанр `878` это Science Fiction, а полный список ID есть на странице [жанры](https://amodb.dev/ru/genres/). Эта полная программа только печатает строку статуса.

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

Никогда не используйте `http.DefaultClient` для вызовов API, потому что у него нет таймаута.

## Шаг 4: разбор в типизированные структуры

Объявляйте только те поля, которые читаете. `encoding/json` игнорирует остальные, поэтому новые поля в ответе не сломают утилиту.

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

Запрос просит `sort_by=vote_average.desc` и `vote_count.gte=500`. Без нижнего порога голосов в начале списка оказались бы названия с единственным голосом 10 из 10. Все фильтры описаны на странице [discover](https://amodb.dev/ru/discover/).

## Шаг 5: повтор при 429 и отчет об ошибках

Прочитайте тело один раз, затем решайте по коду статуса.

| Статус            | Что делает клиент                                                                                             |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| 200               | Записывает файл кэша и разбирает тело.                                                                        |
| 401               | Возвращает ошибку с упоминанием `AMODB_KEY`. Без повтора, потому что следующий вызов тоже завершится ошибкой. |
| 429               | Ждет `Retry-After` секунд (1, если заголовка нет, не больше 30) и пробует снова, всего до 3 попыток.          |
| Другие 4xx и 5xx  | Возвращает `status_code` и `status_message` из тела ошибки TMDB.                                              |
| Ошибка транспорта | Возвращает `network error`, обернутую через `%w`.                                                             |

Вспомогательная функция задержки небольшая:

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

Все коды описаны на странице [ошибки](https://amodb.dev/ru/errors/), а скользящее окно на странице [лимиты запросов](https://amodb.dev/ru/rate-limits/).

## Шаг 6: кэшируйте ответы на диске

Граница сети хранит списки discover 30 минут. Клиент сохраняет каждое успешное тело в пользовательской папке кэша под именем SHA-256 от полного URL и отдает его снова, пока файл моложе TTL. Повторная команда тогда не делает запросов.

## Шаг 7: полная программа

Замените `main.go` этим кодом и выполните `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)
	}
}
```

Пробуйте другие фильтры через флаги. `go run . -genre 18 -year 1999 -n 5` выводит пять лучших по рейтингу драм 1999 года. Выполните ту же команду дважды, и второй запуск ответит из файла кэша, не обращаясь к сети, пока не пройдет 30 минут. Если для вашей комбинации нет фильма с 500 голосами, утилита печатает `No movies matched.` и завершается как обычно.

Цикл повторов возвращает результат из `switch` во всех случаях, кроме 429, когда еще остались попытки. Поэтому функция заканчивается обычной ошибкой: после цикла компилятору нужен return, а сообщение появилось бы, только если изменить логику цикла.

## Дальнейшие шаги

- Добавьте флаг `-page` и передайте его как параметр `page`, чтобы пройти дальше по списку.
- Получите каждый ID фильма через [фильмы](https://amodb.dev/ru/movies/) и добавьте `append_to_response=ratings`, чтобы прочитать оценку IMDb из `ratings.imdb.value`.
- Запускайте несколько жанров одновременно через горутины, оставаясь в пределах запросов в минуту вашего тарифа.
- Перенесите ту же идею на [Java](https://amodb.dev/ru/java-movie-api/) или прочитайте [быстрый старт](https://amodb.dev/ru/quickstart/).

## Вопросы и ответы

### Какая версия Go нужна для кода?

Go 1.22 или новее. Код использует встроенную функцию min из Go 1.21, псевдоним any и только стандартную библиотеку, поэтому в go.mod нет зависимостей, а go run . работает сразу после go mod init.

### Зачем типизированные структуры вместо map[string]any?

Структуры дают имена полей на этапе компиляции и настоящие числовые типы, а encoding/json пропускает необъявленные поля. Это подходит API, где со временем могут появляться новые поля, ведь старый код продолжает работать.

### Как клиент ждет при 429?

Он читает заголовок Retry-After как целое число секунд, подставляет 1, если заголовка нет, ограничивает ожидание 30 секундами и делает не больше 3 попыток всего. После этого он возвращает вызывающему сообщение о статусе.

### Можно ли отправлять запросы из нескольких горутин?

Да, но каждый вызов все равно идет в счет 20, 40 или 120 запросов в минуту вашего ключа. Распределяйте вызовы через time.Ticker или буферизованный канал и позвольте повтору при 429 перехватывать редкие превышения.

### Где утилита хранит кэш?

В папке amodb-top-films внутри os.UserCacheDir, по одному файлу на запрос, с именем из хэша SHA-256 от URL. Файл используется повторно 30 минут, столько же граница сети хранит списки discover.

## Связанные страницы

- [Discover API](https://amodb.dev/ru/discover/): Discover API с фильтрами TMDB v3. Фильмы и сериалы по жанру, актеру, студии, ключевому слову, году, рейтингу, длительности и провайдеру, с сортировкой.
- [Жанры и справочники](https://amodb.dev/ru/genres/): API списка жанров с путями TMDB v3: ID жанров фильмов и сериалов с названиями, а также списки стран, языков, должностей, рейтингов и провайдеров.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.
- [Ошибки](https://amodb.dev/ru/errors/): Все ошибки amodb: HTTP-статус, status_code и сообщение TMDB, а также формат ошибки OMDb. Что означает каждая и что должен делать ваш клиент дальше.
- [Руководство по Java movie API](https://amodb.dev/ru/java-movie-api/): Создайте гид по сериям на Java с JDK HttpClient и Jackson: загрузка сезона, пауза по Retry-After при 429 и кэш ответов в файлах. Нужна Java 17 или новее.
- [Руководство по PHP movie API](https://amodb.dev/ru/php-movie-api/): Создайте функцию PHP и страницу фильма с вызовом API amodb через cURL, повтором при 429, экранированием вывода и файловым кэшем. Нужен PHP 8.2 или новее.

Обновлено: 2026-10-18. Source: https://amodb.dev/ru/go-movie-api/
