Compare commits

..

11 Commits

Author SHA1 Message Date
cdd6b7a57c Merge branch 'tmdbconf' 2026-01-13 19:11:52 +11:00
1a099a3724 updated tmdb 2026-01-13 19:11:17 +11:00
7c91cbb08a updated hwsauth to use hlog 2026-01-13 18:07:11 +11:00
h
1c66e6dd66 Merge pull request 'hlogdoc' (#3) from hlogdoc into master
Reviewed-on: #3
2026-01-13 13:53:12 +11:00
h
614be4ed0e Merge branch 'master' into hlogdoc 2026-01-13 13:52:54 +11:00
h
ddd570230b Merge pull request 'h-patch-1' (#2) from h-patch-1 into master
Reviewed-on: #2
2026-01-13 13:33:39 +11:00
h
a255ee578e Update hlog/README.md 2026-01-13 13:31:47 +11:00
h
1b1fa12a45 Add hlog/LICENSE 2026-01-13 13:31:15 +11:00
h
90976ca98b Update hlog/README.md 2026-01-13 13:26:09 +11:00
h
328adaadee Merge pull request 'Updated hlog documentation to comply with GOLIB rules.' (#1) from hlogdoc into master
Reviewed-on: #1
2026-01-13 13:24:55 +11:00
h
5be9811afc Update hlog/README.md 2026-01-13 13:24:07 +11:00
14 changed files with 719 additions and 50 deletions

21
hlog/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 haelnorr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,4 @@
# HWSAuth - v0.3.2 # HWSAuth - v0.3.3
JWT-based authentication middleware for the HWS web framework. JWT-based authentication middleware for the HWS web framework.
@@ -32,6 +32,7 @@ import (
"net/http" "net/http"
"git.haelnorr.com/h/golib/hwsauth" "git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hlog"
) )
type User struct { type User struct {

View File

@@ -1,10 +1,10 @@
package hwsauth package hwsauth
import ( import (
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/jwt" "git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog"
) )
type Authenticator[T Model, TX DBTransaction] struct { type Authenticator[T Model, TX DBTransaction] struct {
@@ -12,7 +12,7 @@ type Authenticator[T Model, TX DBTransaction] struct {
load LoadFunc[T, TX] load LoadFunc[T, TX]
beginTx BeginTX beginTx BeginTX
ignoredPaths []string ignoredPaths []string
logger *zerolog.Logger logger *hlog.Logger
server *hws.Server server *hws.Server
errorPage hws.ErrorPageFunc errorPage hws.ErrorPageFunc
SSL bool // Use SSL for JWT tokens. Default true SSL bool // Use SSL for JWT tokens. Default true
@@ -28,7 +28,7 @@ func NewAuthenticator[T Model, TX DBTransaction](
load LoadFunc[T, TX], load LoadFunc[T, TX],
server *hws.Server, server *hws.Server,
beginTx BeginTX, beginTx BeginTX,
logger *zerolog.Logger, logger *hlog.Logger,
errorPage hws.ErrorPageFunc, errorPage hws.ErrorPageFunc,
) (*Authenticator[T, TX], error) { ) (*Authenticator[T, TX], error) {
if load == nil { if load == nil {

View File

@@ -8,11 +8,11 @@ require (
git.haelnorr.com/h/golib/hws v0.2.0 git.haelnorr.com/h/golib/hws v0.2.0
git.haelnorr.com/h/golib/jwt v0.10.0 git.haelnorr.com/h/golib/jwt v0.10.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.34.0 git.haelnorr.com/h/golib/hlog v0.9.1
) )
require ( require (
git.haelnorr.com/h/golib/hlog v0.9.1 // indirect github.com/rs/zerolog v1.34.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect

26
tmdb/api.go Normal file
View 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
}

View File

@@ -20,13 +20,17 @@ type Image struct {
StillSizes []string `json:"still_sizes"` StillSizes []string `json:"still_sizes"`
} }
func GetConfig(token string) (*Config, error) { func (api *API) getConfig() error {
url := "https://api.themoviedb.org/3/configuration" url := requestURL("configuration")
data, err := tmdbGet(url, token) data, err := api.get(url)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tmdbGet") return errors.Wrap(err, "api.get")
} }
config := Config{} config := Config{}
json.Unmarshal(data, &config) err = json.Unmarshal(data, &config)
return &config, nil if err != nil {
return errors.Wrap(err, "json.Unmarshal")
}
api.Config = &config
return nil
} }

View File

@@ -2,7 +2,7 @@ package tmdb
import ( import (
"encoding/json" "encoding/json"
"fmt" "strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -42,11 +42,12 @@ type Crew struct {
Job string `json:"job"` Job string `json:"job"`
} }
func GetCredits(movieid int32, token string) (*Credits, error) { func (api *API) GetCredits(movieid int64) (*Credits, error) {
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v/credits?language=en-US", movieid) path := []string{"movie", strconv.FormatInt(movieid, 10), "credits"}
data, err := tmdbGet(url, token) url := buildURL(path, nil)
data, err := api.get(url)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tmdbGet") return nil, errors.Wrap(err, "api.get")
} }
credits := Credits{} credits := Credits{}
json.Unmarshal(data, &credits) json.Unmarshal(data, &credits)

160
tmdb/doc.go Normal file
View 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

View File

@@ -2,4 +2,7 @@ module git.haelnorr.com/h/golib/tmdb
go 1.25.5 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
)

View File

@@ -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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

View File

@@ -2,7 +2,7 @@ package tmdb
import ( import (
"encoding/json" "encoding/json"
"fmt" "strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -33,11 +33,12 @@ type Movie struct {
Video bool `json:"video"` Video bool `json:"video"`
} }
func GetMovie(id int32, token string) (*Movie, error) { func (api *API) GetMovie(movieid int64) (*Movie, error) {
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v?language=en-US", id) path := []string{"movie", strconv.FormatInt(movieid, 10)}
data, err := tmdbGet(url, token) url := buildURL(path, nil)
data, err := api.get(url)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tmdbGet") return nil, errors.Wrap(err, "api.get")
} }
movie := Movie{} movie := Movie{}
json.Unmarshal(data, &movie) json.Unmarshal(data, &movie)

View File

@@ -4,25 +4,113 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings"
"time"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func tmdbGet(url string, token string) ([]byte, error) { 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) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "http.NewRequest") return nil, errors.Wrap(err, "http.NewRequest")
} }
req.Header.Add("accept", "application/json") req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.token))
res, err := http.DefaultClient.Do(req) res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "http.DefaultClient.Do") return nil, errors.Wrap(err, "http.DefaultClient.Do")
} }
defer res.Body.Close()
// 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) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "io.ReadAll") return nil, errors.Wrap(err, "io.ReadAll")
} }
return body, nil 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
View 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)
}
}

View File

@@ -2,9 +2,9 @@ package tmdb
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/url" "net/url"
"path" "path"
"strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -63,17 +63,19 @@ func (movie *ResultMovie) ReleaseYear() string {
// return genres[:len(genres)-2] // return genres[:len(genres)-2]
// } // }
func SearchMovies(token string, query string, adult bool, page int) (*ResultMovies, error) { func (api *API) SearchMovies(query string, adult bool, page int64) (*ResultMovies, error) {
url := "https://api.themoviedb.org/3/search/movie" + path := []string{"searc", "movie"}
fmt.Sprintf("?query=%s", url.QueryEscape(query)) + params := map[string]string{
fmt.Sprintf("&include_adult=%t", adult) + "query": url.QueryEscape(query),
fmt.Sprintf("&page=%v", page) + "include_adult": strconv.FormatBool(adult),
"&language=en-US" "page": strconv.FormatInt(page, 10),
response, err := tmdbGet(url, token) }
url := buildURL(path, params)
data, err := api.get(url)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "tmdbGet") return nil, errors.Wrap(err, "api.get")
} }
var results ResultMovies var results ResultMovies
json.Unmarshal(response, &results) json.Unmarshal(data, &results)
return &results, nil return &results, nil
} }