# Посібник з API фільмів на Go

> Створіть консольну утиліту на 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/uk/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` це наукова фантастика, а повний список id є на сторінці [жанри](https://amodb.dev/uk/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 зірок. Кожен фільтр задокументовано на сторінці [discover](https://amodb.dev/uk/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/uk/errors/), а про ковзне вікно на сторінці [ліміти запитів](https://amodb.dev/uk/rate-limits/).

## Крок 6: кешуйте відповіді на диску

Edge тримає списки 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/uk/movies/) і додайте `append_to_response=ratings`, щоб прочитати оцінку IMDb з `ratings.imdb.value`.
- Запускайте кілька жанрів одночасно через горутини, тримаючись у межах запитів на хвилину вашого тарифу.
- Перенесіть ту саму ідею на [Java](https://amodb.dev/uk/java-movie-api/) або прочитайте [швидкий старт](https://amodb.dev/uk/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 хвилин, стільки ж, скільки edge тримає списки discover.

## Пов'язані сторінки

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

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