# מדריך 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/he/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` הוא מדע בדיוני, ורשימת המזהים המלאה נמצאת בעמוד [ז'אנרים](https://amodb.dev/he/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/he/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/he/errors/), ואת החלון הנע בעמוד [מגבלות קצב](https://amodb.dev/he/rate-limits/).

## שלב 6: שמירת תגובות במטמון בדיסק

הקצה מחזיק רשימות discover 30 דקות. הלקוח שומר כל גוף מוצלח בתיקיית המטמון של המשתמש, בשם שנגזר מ-SHA-256 של הכתובת המלאה, ומגיש אותו שוב כל עוד הקובץ צעיר מה-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` כדי להתקדם בעומק הרשימה.
- פתרו כל מזהה סרט עם [סרטים](https://amodb.dev/he/movies/) והוסיפו `append_to_response=ratings` כדי לקרוא את ציון IMDb מ-`ratings.imdb.value`.
- הריצו כמה ז'אנרים בבת אחת עם goroutines, ושמרו אותם מתחת לבקשות לדקה של המסלול שלכם.
- העבירו את אותו רעיון ל-[Java](https://amodb.dev/he/java-movie-api/), או קראו את [המדריך למתחילים](https://amodb.dev/he/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 ניסיונות בסך הכול. אחר כך הוא מחזיר את הודעת הסטטוס לקורא.

### אפשר לשלוח בקשות מכמה goroutines?

כן, אבל כל קריאה עדיין נספרת ב-20, 40 או 120 הבקשות לדקה של המפתח שלכם. פזרו קריאות עם time.Ticker או ערוץ עם חוצץ, ותנו לניסיון החוזר ב-429 לתפוס גלישה מזדמנת.

### איפה הכלי שומר את המטמון?

בתיקייה בשם amodb-top-films תחת os.UserCacheDir, עם קובץ אחד לכל בקשה בשם שנגזר מגיבוב SHA-256 של הכתובת. קובץ נשמר לשימוש חוזר 30 דקות, כמו הזמן שבו הקצה מחזיק רשימות discover.

## עמודים קשורים

- [API גילוי](https://amodb.dev/he/discover/): Discover API עם אותם מסננים כמו TMDB v3. הציגו סרטים וסדרות לפי ז'אנר, שחקנים, חברה, מילת מפתח, שנה, דירוג, משך וספק סטרימינג, עם מיון ודפדוף בתוצאות.
- [ז'אנרים ורשימות חיפוש](https://amodb.dev/he/genres/): API ז'אנרים עם נתיבי TMDB v3: מזהי ז'אנרים של סרטים וסדרות עם שמות מתורגמים, ובנוסף רשימות של מדינות, שפות, תפקידים, דירוגי גיל וספקי סטרימינג.
- [מגבלות קצב](https://amodb.dev/he/rate-limits/): amodb מגביל כל מפתח לפי בקשות לדקה בחלון נע של 60 שניות: 20, 40 או 120 בהתאם למסלול שלכם. קראו את כותרות מגבלת הקצב וטפלו ב-429 עם Retry-After.
- [שגיאות](https://amodb.dev/he/errors/): כל שגיאה של amodb עם סטטוס HTTP, status_code והודעה של TMDB, וגם מבנה השגיאה של OMDb. מה כל אחת מהן אומרת ומה הלקוח שלכם צריך לעשות כשהיא מופיעה.
- [מדריך API לסרטים ב-Java](https://amodb.dev/he/java-movie-api/): בנו מדריך פרקים ב-Java עם HttpClient של ה-JDK ורשומות Jackson: שלפו עונה, כבדו את Retry-After ב-429 ושמרו תגובות בקבצים. Java 17 ומעלה ו-Maven 3.9.
- [מדריך API לסרטים ב-PHP](https://amodb.dev/he/php-movie-api/): בנו פונקציית PHP ועמוד סרט קטן שקוראים ל-API של amodb עם cURL, מנסים שוב ב-429, מנקים פלט ושומרים כל תגובה בקובץ מטמון ל-6 שעות. PHP 8.2 ומעלה.

עודכן: 2026-10-18. Source: https://amodb.dev/he/go-movie-api/
