Tutorial API filme Go

Construiești un instrument Go în linia de comandă care listează cele mai bine cotate filme după gen și an de lansare. Decodează JSON în structuri tipizate, reîncearcă un 429 după întârzierea din Retry-After și ține răspunsurile în cache pe disc. Folosește doar biblioteca standard, cere Go 1.22 sau mai nou și durează cam 20 de minute.

.go Go Actualizat Vezi ca Markdown

Vei construi topfilms, un instrument Go în linia de comandă. Rulează go run . -genre 878 -year 2010 -n 10 și afișează cele mai bine cotate zece filme științifico-fantastice lansate în 2010, cu scor și număr de voturi. Folosește structuri tipizate, doar biblioteca standard și Go 1.22 sau mai nou. Socotește 20 de minute.

Pasul 1: obține o cheie API

Începe de la prețuri, unde alegi o perioadă plătită în avans și unul dintre planurile de 20, 40 sau 120 de cereri pe minut. Apoi creează o cheie în panoul contului tău și exportă-o:

Programul o citește cu os.Getenv("AMODB_KEY"), deci nu ajunge niciodată în codul sursă.

Shell
export AMODB_KEY="YOUR_API_KEY"

Pasul 2: creează modulul

Fă un folder și inițializează un modul. Nu e nimic de descărcat.

go version ar trebui să afișeze 1.22 sau mai mult. Pune tot codul de mai jos într-un fișier numit main.go.

Shell
mkdir topfilms && cd topfilms
go mod init topfilms
go version

Pasul 3: trimite prima cerere

Instrumentul apelează GET /3/discover/movie, care filtrează și sortează catalogul. Genul 878 este Science Fiction, iar lista completă de ID-uri este la genuri. Acest program complet doar afișează linia de stare.

Nu folosi niciodată http.DefaultClient pentru apeluri API, pentru că nu are timeout.

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

Pasul 4: decodează în structuri tipizate

Declară doar câmpurile pe care le citești. encoding/json le ignoră pe restul, așa că noile câmpuri din răspuns nu pot strica instrumentul.

Interogarea cere sort_by=vote_average.desc și vote_count.gte=500. Fără pragul de voturi, titlurile cu un singur vot de 10 stele ar conduce lista. Fiecare filtru este documentat la descoperire.

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

Pasul 5: reîncearcă la 429 și raportează erorile

Citește corpul o singură dată, apoi decide după codul de stare.

StareCe face clientul
200Scrie fișierul de cache și decodează corpul.
401Întoarce o eroare care numește AMODB_KEY. Fără reîncercare, pentru că și următorul apel ar eșua.
429Doarme Retry-After secunde (1 dacă lipsește, cel mult 30) și încearcă din nou, până la 3 încercări.
Alte 4xx și 5xxÎntoarce status_code și status_message din corpul de eroare TMDB.
Eroare de transportÎntoarce network error învelit cu %w.

Funcția ajutătoare pentru întârziere este mică:

Vezi erori pentru fiecare cod și limite de cereri pentru fereastra glisantă.

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
}

Pasul 6: păstrează răspunsurile în cache pe disc

Marginea ține listele discover 30 de minute. Clientul salvează fiecare corp reușit în folderul de cache al utilizatorului, denumit după SHA-256 al URL-ului complet, și îl servește din nou cât timp fișierul este mai tânăr decât TTL. O comandă repetată nu mai face atunci nicio cerere.

Pasul 7: programul complet

Înlocuiește main.go cu acesta și rulează go run . -genre 878 -year 2010 -n 10.

Încearcă alte filtre cu flag-urile. go run . -genre 18 -year 1999 -n 5 listează cele mai bine cotate cinci drame din 1999. Rulează aceeași comandă de două ori, iar a doua rulare răspunde din fișierul de cache fără să atingă rețeaua, până trec 30 de minute. Dacă niciun film nu are 500 de voturi pentru combinația ta, instrumentul afișează No movies matched. și iese normal.

Bucla de reîncercare returnează din interiorul lui switch în fiecare caz, cu excepția unui 429 care mai are încercări. De aceea funcția se încheie cu o eroare simplă: compilatorul cere un return după buclă, iar mesajul ar apărea doar dacă s-ar schimba logica buclei.

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

Pașii următori

  • Adaugă un flag -page și transmite-l ca parametru page ca să parcurgi mai adânc lista.
  • Rezolvă fiecare ID de film cu filme și adaugă append_to_response=ratings ca să citești scorul IMDb din ratings.imdb.value.
  • Rulează mai multe genuri deodată cu goroutine și ține-le sub cererile pe minut ale planului tău.
  • Portează aceeași idee în Java sau citește ghidul de pornire rapidă.