Merge branch 'tmdbconf'
This commit is contained in:
26
tmdb/api.go
Normal file
26
tmdb/api.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"git.haelnorr.com/h/golib/env"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
*Config
|
||||
token string // ENV TMDB_TOKEN: API token for TMDB (required)
|
||||
}
|
||||
|
||||
func NewAPIConnection() (*API, error) {
|
||||
token := env.String("TMDB_TOKEN", "")
|
||||
if token == "" {
|
||||
return nil, errors.New("No TMDB API Token provided")
|
||||
}
|
||||
api := &API{
|
||||
token: token,
|
||||
}
|
||||
err := api.getConfig()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "api.getConfig")
|
||||
}
|
||||
return api, nil
|
||||
}
|
||||
@@ -20,13 +20,17 @@ type Image struct {
|
||||
StillSizes []string `json:"still_sizes"`
|
||||
}
|
||||
|
||||
func GetConfig(token string) (*Config, error) {
|
||||
url := "https://api.themoviedb.org/3/configuration"
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) getConfig() error {
|
||||
url := requestURL("configuration")
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return errors.Wrap(err, "api.get")
|
||||
}
|
||||
config := Config{}
|
||||
json.Unmarshal(data, &config)
|
||||
return &config, nil
|
||||
err = json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "json.Unmarshal")
|
||||
}
|
||||
api.Config = &config
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -42,11 +42,12 @@ type Crew struct {
|
||||
Job string `json:"job"`
|
||||
}
|
||||
|
||||
func GetCredits(movieid int32, token string) (*Credits, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v/credits?language=en-US", movieid)
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) GetCredits(movieid int64) (*Credits, error) {
|
||||
path := []string{"movie", strconv.FormatInt(movieid, 10), "credits"}
|
||||
url := buildURL(path, nil)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
credits := Credits{}
|
||||
json.Unmarshal(data, &credits)
|
||||
|
||||
160
tmdb/doc.go
Normal file
160
tmdb/doc.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Package tmdb provides a client for The Movie Database (TMDB) API.
|
||||
//
|
||||
// This package offers a clean interface for interacting with TMDB's REST API,
|
||||
// including automatic rate limiting, retry logic, and convenient URL building utilities.
|
||||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// First, create an API connection using your TMDB API token:
|
||||
//
|
||||
// api, err := tmdb.NewAPIConnection()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// The token is read from the TMDB_TOKEN environment variable.
|
||||
//
|
||||
// # Making Requests
|
||||
//
|
||||
// The package provides clean URL building functions to construct API requests:
|
||||
//
|
||||
// // Simple endpoint
|
||||
// url := tmdb.requestURL("movie", "550")
|
||||
// // Result: "https://api.themoviedb.org/3/movie/550"
|
||||
//
|
||||
// // With query parameters
|
||||
// url := tmdb.buildURL([]string{"search", "movie"}, map[string]string{
|
||||
// "query": "Inception",
|
||||
// "page": "1",
|
||||
// })
|
||||
// // Result: "https://api.themoviedb.org/3/search/movie?language=en-US&page=1&query=Inception"
|
||||
//
|
||||
// All requests made with buildURL automatically include "language=en-US" by default.
|
||||
//
|
||||
// # Rate Limiting
|
||||
//
|
||||
// TMDB has rate limits around 40 requests per second. This package implements
|
||||
// automatic retry logic with exponential backoff:
|
||||
//
|
||||
// - Initial backoff: 1 second
|
||||
// - Exponential growth: 1s → 2s → 4s → 8s → 16s → 32s (max)
|
||||
// - Maximum retries: 3 attempts
|
||||
// - Respects Retry-After header when provided by the API
|
||||
//
|
||||
// Example of rate-limited request:
|
||||
//
|
||||
// data, err := api.get(url)
|
||||
// if err != nil {
|
||||
// // Will return error only after exhausting all retries
|
||||
// log.Printf("Request failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// # Searching for Movies
|
||||
//
|
||||
// Search for movies by title:
|
||||
//
|
||||
// results, err := tmdb.SearchMovies(token, "Fight Club", false, 1)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// for _, movie := range results.Results {
|
||||
// fmt.Printf("%s %s\n", movie.Title, movie.ReleaseYear())
|
||||
// fmt.Printf("Poster: %s\n", movie.GetPoster(&api.Image, "w500"))
|
||||
// }
|
||||
//
|
||||
// # Getting Movie Details
|
||||
//
|
||||
// Fetch detailed information about a specific movie:
|
||||
//
|
||||
// movie, err := tmdb.GetMovie(550, token)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Printf("Title: %s\n", movie.Title)
|
||||
// fmt.Printf("Overview: %s\n", movie.Overview)
|
||||
// fmt.Printf("Release Date: %s\n", movie.ReleaseDate)
|
||||
// fmt.Printf("IMDb ID: %s\n", movie.IMDbID)
|
||||
//
|
||||
// # Getting Credits
|
||||
//
|
||||
// Retrieve cast and crew information:
|
||||
//
|
||||
// credits, err := tmdb.GetCredits(550, token)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Println("Cast:")
|
||||
// for _, actor := range credits.Cast {
|
||||
// fmt.Printf(" %s as %s\n", actor.Name, actor.Character)
|
||||
// }
|
||||
//
|
||||
// fmt.Println("\nCrew:")
|
||||
// for _, member := range credits.Crew {
|
||||
// if member.Job == "Director" {
|
||||
// fmt.Printf(" Director: %s\n", member.Name)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Image URLs
|
||||
//
|
||||
// The API configuration includes base URLs for images. Use helper methods to
|
||||
// construct full image URLs:
|
||||
//
|
||||
// posterURL := movie.GetPoster(&api.Image, "w500")
|
||||
// // Available sizes: "w92", "w154", "w185", "w342", "w500", "w780", "original"
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// The package returns wrapped errors for easy debugging:
|
||||
//
|
||||
// data, err := api.get(url)
|
||||
// if err != nil {
|
||||
// if strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
// // Handle rate limiting
|
||||
// } else if strings.Contains(err.Error(), "unexpected status code") {
|
||||
// // Handle HTTP errors
|
||||
// } else {
|
||||
// // Handle network errors
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Common error scenarios:
|
||||
// - "rate limit exceeded: maximum retries reached" - All retry attempts exhausted
|
||||
// - "unexpected status code: 401" - Invalid API token
|
||||
// - "unexpected status code: 404" - Resource not found
|
||||
// - Network errors for connectivity issues
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The package uses the following environment variable:
|
||||
//
|
||||
// - TMDB_TOKEN: Your TMDB API access token (required)
|
||||
//
|
||||
// Obtain an API token from: https://www.themoviedb.org/settings/api
|
||||
//
|
||||
// # Best Practices
|
||||
//
|
||||
// 1. Reuse the API connection instead of creating new ones for each request
|
||||
// 2. Use buildURL for consistency and automatic language parameter injection
|
||||
// 3. Handle rate limit errors gracefully - they indicate temporary service issues
|
||||
// 4. Cache API responses when appropriate to reduce API calls
|
||||
// 5. Use specific image sizes instead of "original" to save bandwidth
|
||||
//
|
||||
// # API Documentation
|
||||
//
|
||||
// For complete TMDB API documentation, visit:
|
||||
// https://developer.themoviedb.org/docs
|
||||
//
|
||||
// # Rate Limiting Details
|
||||
//
|
||||
// From TMDB's documentation:
|
||||
// "While our legacy rate limits have been disabled for some time, we do still
|
||||
// have some upper limits to help mitigate needlessly high bulk scraping. They
|
||||
// sit somewhere in the 40 requests per second range."
|
||||
//
|
||||
// This package automatically handles rate limiting with exponential backoff to
|
||||
// ensure respectful API usage.
|
||||
package tmdb
|
||||
@@ -2,4 +2,7 @@ module git.haelnorr.com/h/golib/tmdb
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/pkg/errors v0.9.1
|
||||
require (
|
||||
git.haelnorr.com/h/golib/env v0.9.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
|
||||
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
|
||||
@@ -2,7 +2,7 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -33,11 +33,12 @@ type Movie struct {
|
||||
Video bool `json:"video"`
|
||||
}
|
||||
|
||||
func GetMovie(id int32, token string) (*Movie, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v?language=en-US", id)
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) GetMovie(movieid int64) (*Movie, error) {
|
||||
path := []string{"movie", strconv.FormatInt(movieid, 10)}
|
||||
url := buildURL(path, nil)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
movie := Movie{}
|
||||
json.Unmarshal(data, &movie)
|
||||
|
||||
122
tmdb/request.go
122
tmdb/request.go
@@ -4,25 +4,113 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func tmdbGet(url string, token string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.NewRequest")
|
||||
}
|
||||
req.Header.Add("accept", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.DefaultClient.Do")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "io.ReadAll")
|
||||
}
|
||||
return body, nil
|
||||
const baseURL string = "https://api.themoviedb.org"
|
||||
const apiVer string = "3"
|
||||
|
||||
const (
|
||||
maxRetries = 3 // Maximum number of retry attempts for 429 responses
|
||||
initialBackoff = 1 * time.Second // Initial backoff duration
|
||||
maxBackoff = 32 * time.Second // Maximum backoff duration
|
||||
)
|
||||
|
||||
// requestURL builds a clean API URL from path segments.
|
||||
// Example: requestURL("movie", "550") -> "https://api.themoviedb.org/3/movie/550"
|
||||
// Example: requestURL("search", "movie") -> "https://api.themoviedb.org/3/search/movie"
|
||||
func requestURL(pathSegments ...string) string {
|
||||
path := strings.Join(pathSegments, "/")
|
||||
return fmt.Sprintf("%s/%s/%s", baseURL, apiVer, path)
|
||||
}
|
||||
|
||||
// buildURL is a convenience function that builds a URL with query parameters.
|
||||
// Example: buildURL([]string{"search", "movie"}, map[string]string{"query": "Inception", "page": "1"})
|
||||
func buildURL(pathSegments []string, params map[string]string) string {
|
||||
baseURL := requestURL(pathSegments...)
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["language"] = "en-US"
|
||||
values := url.Values{}
|
||||
for key, val := range params {
|
||||
values.Add(key, val)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s?%s", baseURL, values.Encode())
|
||||
}
|
||||
|
||||
// get performs a GET request to the TMDB API with proper authentication headers
|
||||
// and automatic retry logic with exponential backoff for rate limiting (429 responses).
|
||||
//
|
||||
// The TMDB API has rate limits around 40 requests per second. This function
|
||||
// implements a courtesy backoff mechanism that:
|
||||
// - Retries up to maxRetries times on 429 responses
|
||||
// - Uses exponential backoff: 1s, 2s, 4s, 8s, etc. (up to maxBackoff)
|
||||
// - Returns an error if max retries are exceeded
|
||||
//
|
||||
// The url parameter should be the full URL (can be built using requestURL or buildURL).
|
||||
func (api *API) get(url string) ([]byte, error) {
|
||||
backoff := initialBackoff
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.NewRequest")
|
||||
}
|
||||
req.Header.Add("accept", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.token))
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.DefaultClient.Do")
|
||||
}
|
||||
|
||||
// Check for rate limiting (429 Too Many Requests)
|
||||
if res.StatusCode == http.StatusTooManyRequests {
|
||||
res.Body.Close()
|
||||
|
||||
// If we've exhausted retries, return an error
|
||||
if attempt >= maxRetries {
|
||||
return nil, errors.New("rate limit exceeded: maximum retries reached")
|
||||
}
|
||||
|
||||
// Check for Retry-After header first (respect server's guidance)
|
||||
if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
|
||||
if duration, err := time.ParseDuration(retryAfter + "s"); err == nil {
|
||||
backoff = duration
|
||||
}
|
||||
}
|
||||
|
||||
// Apply exponential backoff: 1s, 2s, 4s, 8s, etc.
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
|
||||
time.Sleep(backoff)
|
||||
|
||||
// Double the backoff for next iteration
|
||||
backoff *= 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// For other error status codes, return an error
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Success - read and return body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "io.ReadAll")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("max retries (%d) exceeded due to rate limiting (HTTP 429)", maxRetries)
|
||||
}
|
||||
|
||||
360
tmdb/request_test.go
Normal file
360
tmdb/request_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
segments []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single segment",
|
||||
segments: []string{"configuration"},
|
||||
want: "https://api.themoviedb.org/3/configuration",
|
||||
},
|
||||
{
|
||||
name: "two segments",
|
||||
segments: []string{"search", "movie"},
|
||||
want: "https://api.themoviedb.org/3/search/movie",
|
||||
},
|
||||
{
|
||||
name: "movie with id",
|
||||
segments: []string{"movie", "550"},
|
||||
want: "https://api.themoviedb.org/3/movie/550",
|
||||
},
|
||||
{
|
||||
name: "movie with id and credits",
|
||||
segments: []string{"movie", "550", "credits"},
|
||||
want: "https://api.themoviedb.org/3/movie/550/credits",
|
||||
},
|
||||
{
|
||||
name: "no segments",
|
||||
segments: []string{},
|
||||
want: "https://api.themoviedb.org/3/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := requestURL(tt.segments...)
|
||||
if got != tt.want {
|
||||
t.Errorf("requestURL() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
segments []string
|
||||
params map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no params",
|
||||
segments: []string{"movie", "550"},
|
||||
params: nil,
|
||||
want: "https://api.themoviedb.org/3/movie/550?language=en-US",
|
||||
},
|
||||
{
|
||||
name: "with query param",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "Inception",
|
||||
},
|
||||
want: "https://api.themoviedb.org/3/search/movie?language=en-US&query=Inception",
|
||||
},
|
||||
{
|
||||
name: "multiple params",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "Fight Club",
|
||||
"page": "2",
|
||||
"include_adult": "false",
|
||||
},
|
||||
// Note: URL params can be in any order, so we check contains instead
|
||||
want: "https://api.themoviedb.org/3/search/movie?",
|
||||
},
|
||||
{
|
||||
name: "params with special characters",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "The Matrix",
|
||||
},
|
||||
want: "https://api.themoviedb.org/3/search/movie?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildURL(tt.segments, tt.params)
|
||||
if !strings.HasPrefix(got, tt.want) {
|
||||
t.Errorf("buildURL() = %v, want prefix %v", got, tt.want)
|
||||
}
|
||||
// Check that all params are present (checking keys, values may be URL encoded)
|
||||
for key := range tt.params {
|
||||
if !strings.Contains(got, key+"=") {
|
||||
t.Errorf("buildURL() missing param key %s in %v", key, got)
|
||||
}
|
||||
}
|
||||
// Check that language is always added
|
||||
if !strings.Contains(got, "language=en-US") {
|
||||
t.Errorf("buildURL() missing default language param in %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_Success(t *testing.T) {
|
||||
// Create a test server that returns 200 OK
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify headers
|
||||
if r.Header.Get("accept") != "application/json" {
|
||||
t.Errorf("missing or incorrect accept header")
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
t.Errorf("missing or incorrect Authorization header")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
body, err := api.get(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RateLimitRetry(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that returns 429 twice, then 200
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
if attemptCount <= 2 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
start := time.Now()
|
||||
body, err := api.get(server.URL)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attemptCount != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attemptCount)
|
||||
}
|
||||
|
||||
// Should have waited at least 1s + 2s = 3s total
|
||||
if elapsed < 3*time.Second {
|
||||
t.Errorf("expected backoff delay, got %v", elapsed)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RateLimitExceeded(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that always returns 429
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
t.Errorf("get() expected rate limit error, got: %v", err)
|
||||
}
|
||||
|
||||
// Should have attempted maxRetries + 1 times (initial + retries)
|
||||
expectedAttempts := maxRetries + 1
|
||||
if attemptCount != expectedAttempts {
|
||||
t.Errorf("expected %d attempts, got %d", expectedAttempts, attemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RetryAfterHeader(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that returns 429 with Retry-After header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
if attemptCount == 1 {
|
||||
w.Header().Set("Retry-After", "2")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
start := time.Now()
|
||||
body, err := api.get(server.URL)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have waited at least 2s as specified in Retry-After
|
||||
if elapsed < 2*time.Second {
|
||||
t.Errorf("expected at least 2s delay from Retry-After header, got %v", elapsed)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_NonOKStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{"bad request", http.StatusBadRequest},
|
||||
{"unauthorized", http.StatusUnauthorized},
|
||||
{"forbidden", http.StatusForbidden},
|
||||
{"not found", http.StatusNotFound},
|
||||
{"internal server error", http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error, got nil")
|
||||
}
|
||||
|
||||
expectedError := fmt.Sprintf("unexpected status code: %d", tt.statusCode)
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("get() expected error containing %q, got: %v", expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_NetworkError(t *testing.T) {
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get("http://invalid-domain-that-does-not-exist.local")
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error for invalid domain, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "http.DefaultClient.Do") {
|
||||
t.Errorf("get() expected network error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_InvalidURL(t *testing.T) {
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get("://invalid-url")
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error for invalid URL, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "http.NewRequest") {
|
||||
t.Errorf("get() expected URL parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_ReadBodyError(t *testing.T) {
|
||||
// Create a test server that closes connection before body is read
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Don't write anything, causing a read error
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
|
||||
// Note: This test may not always fail as expected due to how httptest works
|
||||
// In real scenarios, network issues would cause io.ReadAll to fail
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
// Just verify we got a response (this test is mainly for coverage)
|
||||
if err != nil && !strings.Contains(err.Error(), "io.ReadAll") {
|
||||
t.Logf("get() error (expected in some cases): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkRequestURL(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
requestURL("movie", "550", "credits")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildURL(b *testing.B) {
|
||||
params := map[string]string{
|
||||
"query": "Inception",
|
||||
"page": "1",
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
buildURL([]string{"search", "movie"}, params)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAPIGet(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, `{"success": true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
api.get(server.URL)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -63,17 +63,19 @@ func (movie *ResultMovie) ReleaseYear() string {
|
||||
// return genres[:len(genres)-2]
|
||||
// }
|
||||
|
||||
func SearchMovies(token string, query string, adult bool, page int) (*ResultMovies, error) {
|
||||
url := "https://api.themoviedb.org/3/search/movie" +
|
||||
fmt.Sprintf("?query=%s", url.QueryEscape(query)) +
|
||||
fmt.Sprintf("&include_adult=%t", adult) +
|
||||
fmt.Sprintf("&page=%v", page) +
|
||||
"&language=en-US"
|
||||
response, err := tmdbGet(url, token)
|
||||
func (api *API) SearchMovies(query string, adult bool, page int64) (*ResultMovies, error) {
|
||||
path := []string{"searc", "movie"}
|
||||
params := map[string]string{
|
||||
"query": url.QueryEscape(query),
|
||||
"include_adult": strconv.FormatBool(adult),
|
||||
"page": strconv.FormatInt(page, 10),
|
||||
}
|
||||
url := buildURL(path, params)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
var results ResultMovies
|
||||
json.Unmarshal(response, &results)
|
||||
json.Unmarshal(data, &results)
|
||||
return &results, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user