# Урок за 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/bg/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/bg/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, защото няма timeout.

## Стъпка 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/bg/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/bg/errors/) за всеки код и [лимити на заявките](https://amodb.dev/bg/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/bg/movies/) и добавете `append_to_response=ratings`, за да прочетете оценката от IMDb от `ratings.imdb.value`.
- Пускайте няколко жанра наведнъж с goroutine, като останете под заявките в минута на плана си.
- Пренесете същата идея към [Java](https://amodb.dev/bg/java-movie-api/) или прочетете [бърз старт](https://amodb.dev/bg/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/bg/discover/): Discover API със същите филтри като TMDB v3. Списък с филми и сериали по жанр, актьори, компания, ключова дума, година, рейтинг и продължителност със сортиране.
- [Жанрове и справочни списъци](https://amodb.dev/bg/genres/): API за списък с жанрове с пътища на TMDB v3: id на жанрове за филми и сериали с преведени имена, списъци с държави, езици, длъжности, рейтинги и доставчици.
- [Лимити на заявките](https://amodb.dev/bg/rate-limits/): amodb ограничава всеки ключ по заявки в минута в плъзгащ се прозорец от 60 секунди: 20, 40 или 120. Заглавки за лимита и обработка на 429 с Retry-After.
- [Грешки](https://amodb.dev/bg/errors/): Всяка грешка на amodb с HTTP статус, status_code на TMDB и съобщение, плюс формата на грешките на OMDb. Какво означава всяка и какво да направи клиентът ви.
- [Урок за Java и API за филми](https://amodb.dev/bg/java-movie-api/): Java справочник по епизоди с JDK HttpClient и Jackson: вземете сезон, спазвайте Retry-After при 429 и кеширайте отговорите във файлове. Java 17 или по-нова.
- [Урок за PHP и API за филми](https://amodb.dev/bg/php-movie-api/): Създайте PHP функция и страница за филм, които викат amodb с cURL, повтарят при 429, екранират изхода и кешират отговорите във файл. PHP 8.2 или по-нов.

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