# Падручнік па 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/be/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/be/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 зорак. Кожны фільтр апісаны на старонцы [пошук па фільтрах](https://amodb.dev/be/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/be/errors/), а слізгальнае акно на старонцы [ліміты запытаў](https://amodb.dev/be/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/be/movies/) і дадайце `append_to_response=ratings`, каб прачытаць ацэнку IMDb з `ratings.imdb.value`.
- Запускайце некалькі жанраў адначасова праз goroutine, трымаючыся ніжэй за запыты ў хвіліну вашага плана.
- Перанясіце тую ж ідэю на [Java](https://amodb.dev/be/java-movie-api/) або прачытайце [хуткі старт](https://amodb.dev/be/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 спробы ўсяго. Пасля гэтага вяртае выклікаючаму паведамленне пра статус.

### Ці можна адпраўляць запыты з некалькіх goroutine?

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

### Дзе ўтыліта трымае свой кэш?

У тэчцы amodb-top-films унутры os.UserCacheDir, па адным файле на запыт, названым па хэшы SHA-256 ад URL. Файл выкарыстоўваецца паўторна 30 хвілін, столькі ж, колькі edge трымае спісы discover.

## Звязаныя старонкі

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

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/go-movie-api/
