# Learn Go by Building *arr Service Packages This tutorial teaches Go fundamentals by building packages to interact with *arr services like Radarr, Sonarr, and Lidarr. You'll learn Go concepts incrementally while building real, useful code. ## Prerequisites - Install Go from [golang.org](https://golang.org/dl/) - Basic understanding of REST APIs - A text editor (VS Code with Go extension recommended) ## Chapter 1: Go Basics and Project Setup ### 1.1 Understanding Go Modules First, let's create our project structure: ```bash mkdir arr-client cd arr-client go mod init github.com/yourusername/arr-client ``` **Deep Dive - Go Modules:** Go modules are Go's dependency management system, similar to `package.json` in Node.js or `requirements.txt` in Python. When you run `go mod init`, Go creates a `go.mod` file that serves as the "root" of your module. The module path (`github.com/yourusername/arr-client`) serves two purposes: 1. **Unique identifier**: It tells Go this is a distinct module 2. **Import path**: Other code will import your packages using this path Think of it like a postal address for your code - it needs to be unique so Go can find and download it. Even if you never publish to GitHub, this convention helps organize your code. ### 1.2 Basic Go Syntax Create `main.go`: ```go package main import "fmt" func main() { fmt.Println("Hello, *arr services!") } ``` **Deep Dive - Program Structure:** Let's break down each line: 1. **`package main`**: Every Go file must start with a package declaration. The `main` package is special - it tells Go this is an executable program (not a library). When Go builds your code, it looks for a `main` package and a `main` function as the entry point. 2. **`import "fmt"`**: This brings in the `fmt` (format) package from Go's standard library. The import statement is like `#include` in C++ or `import` in Python. Go's standard library is extensive and includes packages for HTTP clients, JSON parsing, file I/O, and much more. 3. **`func main()`**: This is the entry point of your program. When you run `go run main.go`, Go looks for this exact function. The `func` keyword declares a function, similar to `def` in Python or `function` in JavaScript. 4. **`fmt.Println("Hello, *arr services!")`**: This calls the `Println` function from the `fmt` package. The dot notation (`fmt.Println`) is how you access exported (public) functions from imported packages. **Key Go Characteristics:** - No semicolons needed (Go automatically inserts them) - Curly braces `{}` define code blocks - Capitalized names are exported (public), lowercase are unexported (private) - Static typing (types are checked at compile time) Run it: `go run main.go` ## Chapter 2: Structs and JSON - Building Data Models ### 2.1 Defining Data Structures Create `types.go`: ```go package main import "time" // SystemStatus represents the system status response type SystemStatus struct { Version string `json:"version"` BuildTime string `json:"buildTime"` IsDebug bool `json:"isDebug"` IsProduction bool `json:"isProduction"` IsAdmin bool `json:"isAdmin"` IsUserInteractive bool `json:"isUserInteractive"` StartupPath string `json:"startupPath"` AppData string `json:"appData"` OsName string `json:"osName"` OsVersion string `json:"osVersion"` } // Movie represents a movie in Radarr type Movie struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Path string `json:"path"` Monitored bool `json:"monitored"` Added time.Time `json:"added"` QualityProfileID int `json:"qualityProfileId"` ImdbID string `json:"imdbId"` TmdbID int `json:"tmdbId"` } // Series represents a TV series in Sonarr type Series struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Path string `json:"path"` Monitored bool `json:"monitored"` Added time.Time `json:"added"` QualityProfileID int `json:"qualityProfileId"` TvdbID int `json:"tvdbId"` Seasons []Season `json:"seasons"` } // Season represents a season within a series type Season struct { SeasonNumber int `json:"seasonNumber"` Monitored bool `json:"monitored"` } ``` **Deep Dive - Structs:** Structs are Go's way of creating custom types by grouping related data together. Think of them like classes in other languages, but without methods (we'll add those later). **Anatomy of a struct:** ```go type Movie struct { ID int `json:"id"` Title string `json:"title"` } ``` 1. **`type Movie struct`**: Declares a new type named `Movie` that's a struct 2. **Field declarations**: Each line inside declares a field with a name and type 3. **Struct tags**: The backtick strings (`json:"id"`) are metadata **Why structs matter for our API client:** When the *arr services return JSON like this: ```json { "id": 123, "title": "The Matrix", "year": 1999, "monitored": true } ``` Go needs to know where to put each piece of data. Structs provide this mapping. **Struct Tags Deep Dive:** ```go Title string `json:"title"` ``` The `json:"title"` tag tells Go's JSON package: - When converting FROM JSON: Look for a JSON field named "title" and put it in the `Title` field - When converting TO JSON: Take the `Title` field and create a JSON field named "title" This is crucial because Go convention is PascalCase (`Title`) but JSON often uses camelCase (`title`) or snake_case (`title_name`). **Field Visibility Rules:** - `Title` (capitalized): Exported - other packages can access it - `title` (lowercase): Unexported - only this package can access it For JSON marshaling to work, fields must be exported (capitalized). **Composite Types:** ```go Seasons []Season `json:"seasons"` ``` This demonstrates Go's type system: - `[]Season`: A slice (dynamic array) of `Season` structs - Go supports slices, maps, pointers, and other composite types - The JSON tag tells Go to expect an array in the JSON **Connection to Previous Example:** Notice we're still in `package main` - this means these types are in the same package as our `main.go`. Later, we'll organize these into separate packages for better structure. ## Chapter 3: HTTP Client and Error Handling - Making API Calls ### 3.1 Basic HTTP Client Create `client.go`: ```go package main import ( "encoding/json" "fmt" "io" "net/http" "time" ) // Client represents an *arr service client type Client struct { BaseURL string APIKey string HTTPClient *http.Client } // NewClient creates a new *arr service client func NewClient(baseURL, apiKey string) *Client { return &Client{ BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, } } // GetSystemStatus fetches system status from the service func (c *Client) GetSystemStatus() (*SystemStatus, error) { url := fmt.Sprintf("%s/api/v3/system/status", c.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", c.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } var status SystemStatus if err := json.Unmarshal(body, &status); err != nil { return nil, fmt.Errorf("unmarshaling JSON: %w", err) } return &status, nil } ``` **Deep Dive - Pointers and Memory Management:** Let's understand the pointer syntax that might seem confusing: **Pointer Basics:** ```go // Without pointer - creates a copy func badNewClient() Client { return Client{BaseURL: "http://localhost"} } // With pointer - creates one instance, returns address func NewClient() *Client { return &Client{BaseURL: "http://localhost"} } ``` **Why use pointers here?** 1. **Efficiency**: Structs can be large. Copying a `Client` struct every time would be expensive 2. **Shared state**: Multiple parts of your code can reference the same client instance 3. **Method receivers**: Methods can modify the struct they're called on **The `&` and `*` operators:** ```go client := &Client{} // & means "address of" - get pointer to new Client var ptr *Client // * in declaration means "pointer to Client type" value := *ptr // * in expression means "dereference" - get value at pointer ``` **Deep Dive - Constructor Pattern:** ```go func NewClient(baseURL, apiKey string) *Client { return &Client{ BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, } } ``` This is Go's constructor pattern: 1. **Function naming**: `NewType` or `New` for constructors 2. **Initialization**: Sets up sensible defaults (like timeout) 3. **Encapsulation**: Hides the complexity of setting up the HTTP client 4. **Returns pointer**: So multiple callers can share the same instance **Connection to Previous Example:** Notice how we're using the `SystemStatus` struct we defined earlier. The HTTP client fetches JSON data and uses our struct to organize it. **Deep Dive - Method Receivers:** ```go func (c *Client) GetSystemStatus() (*SystemStatus, error) ``` This is a method, not a regular function: 1. **`(c *Client)`**: The receiver - this method "belongs to" the Client type 2. **`c`**: The receiver name (like `self` in Python or `this` in JavaScript) 3. **`*Client`**: Pointer receiver - can modify the client if needed **Why pointer receiver?** ```go // Pointer receiver - can modify, avoids copying func (c *Client) SetTimeout(d time.Duration) { c.HTTPClient.Timeout = d // This works! } // Value receiver - gets a copy, can't modify original func (c Client) SetTimeout(d time.Duration) { c.HTTPClient.Timeout = d // This modifies the copy, not original! } ``` **Deep Dive - Go's Error Handling Philosophy:** Go's error handling is explicit and verbose by design: ```go resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } ``` **Why this pattern?** 1. **Explicit**: You can't ignore errors (compiler forces you to handle them) 2. **Predictable**: Every function that can fail returns `(result, error)` 3. **Composable**: You can wrap errors to add context **Error Wrapping:** ```go fmt.Errorf("making request: %w", err) ``` The `%w` verb wraps the original error, creating an error chain. This means: - You get context about where the error happened - You can still check the original error type if needed - Error messages become more informative **The `defer` Statement:** ```go defer resp.Body.Close() ``` `defer` schedules a function call to happen when the current function returns: 1. **Cleanup guarantee**: Even if your function panics, deferred calls run 2. **LIFO order**: Multiple defers run in Last-In-First-Out order 3. **Common pattern**: Always defer cleanup operations after checking for errors **Deep Dive - JSON Unmarshaling:** ```go var status SystemStatus if err := json.Unmarshal(body, &status); err != nil { return nil, fmt.Errorf("unmarshaling JSON: %w", err) } ``` This is where our struct definition pays off: 1. **`json.Unmarshal`**: Converts JSON bytes to Go structs 2. **`&status`**: We pass a pointer so `Unmarshal` can modify our struct 3. **Struct tags**: The `json:"version"` tags tell `Unmarshal` where to put each field **Connection to Previous Examples:** This builds directly on Chapter 2's struct definitions. The HTTP client fetches raw JSON, then uses our carefully designed structs to organize that data into usable Go objects. ## Chapter 4: Interfaces and Abstraction - Building Flexible APIs ### 4.1 Creating Service Interfaces Create `interfaces.go`: ```go package main // ServiceClient defines the interface all *arr services must implement type ServiceClient interface { GetSystemStatus() (*SystemStatus, error) GetHealth() ([]HealthCheck, error) } // MovieService defines movie-specific operations (Radarr) type MovieService interface { ServiceClient GetMovies() ([]Movie, error) GetMovie(id int) (*Movie, error) AddMovie(movie *Movie) (*Movie, error) } // SeriesService defines TV series operations (Sonarr) type SeriesService interface { ServiceClient GetSeries() ([]Series, error) GetSeriesById(id int) (*Series, error) AddSeries(series *Series) (*Series, error) } // HealthCheck represents a health check result type HealthCheck struct { Source string `json:"source"` Type string `json:"type"` Message string `json:"message"` } ``` **Deep Dive - Interface Philosophy in Go:** Interfaces in Go are fundamentally different from other languages: **1. Implicit Satisfaction:** ```go // You DON'T write this (like in Java/C#): type RadarrClient struct implements MovieService { // ... } // Instead, if RadarrClient has all the required methods, // it automatically satisfies the interface ``` **2. Behavior Definition:** Interfaces define what something can DO, not what it IS: ```go // Bad: Defining what something is type DatabaseConnection interface { Connect() GetConnectionString() string } // Good: Defining what something does type DataStore interface { Save(data []byte) error Load(id string) ([]byte, error) } ``` **Deep Dive - Interface Embedding:** ```go type MovieService interface { ServiceClient // Embedded interface GetMovies() ([]Movie, error) // ... more methods } ``` Interface embedding means `MovieService` includes all methods from `ServiceClient` PLUS its own methods. Any type that satisfies `MovieService` must implement: - `GetSystemStatus()` (from ServiceClient) - `GetHealth()` (from ServiceClient) - `GetMovies()` (from MovieService) - `GetMovie()` (from MovieService) - `AddMovie()` (from MovieService) **Why Design This Way?** This interface hierarchy models the real-world relationship: 1. **All *arr services** have common operations (status, health) 2. **Movie services** have movie-specific operations 3. **Series services** have series-specific operations **Connection to Previous Examples:** Notice how the interface methods match the method we implemented in `client.go`. Our `Client` struct from Chapter 3 partially satisfies `ServiceClient` (it has `GetSystemStatus` but we haven't implemented `GetHealth` yet). ### 4.2 Implementing Specific Services Create `radarr.go`: ```go package main import ( "encoding/json" "fmt" "io" "net/http" "strconv" ) // RadarrClient implements the MovieService interface type RadarrClient struct { *Client // Embedded struct } // NewRadarrClient creates a new Radarr client func NewRadarrClient(baseURL, apiKey string) *RadarrClient { return &RadarrClient{ Client: NewClient(baseURL, apiKey), } } // GetHealth implements ServiceClient interface func (r *RadarrClient) GetHealth() ([]HealthCheck, error) { url := fmt.Sprintf("%s/api/v3/health", r.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() var health []HealthCheck if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return health, nil } // GetMovies retrieves all movies func (r *RadarrClient) GetMovies() ([]Movie, error) { url := fmt.Sprintf("%s/api/v3/movie", r.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() var movies []Movie if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return movies, nil } // GetMovie retrieves a specific movie by ID func (r *RadarrClient) GetMovie(id int) (*Movie, error) { url := fmt.Sprintf("%s/api/v3/movie/%s", r.BaseURL, strconv.Itoa(id)) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("movie with ID %d not found", id) } var movie Movie if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &movie, nil } // AddMovie adds a new movie (simplified - real implementation needs more fields) func (r *RadarrClient) AddMovie(movie *Movie) (*Movie, error) { // Implementation would POST to /api/v3/movie // This is left as an exercise - involves JSON marshaling and POST requests return nil, fmt.Errorf("AddMovie not implemented yet") } ``` **Deep Dive - Struct Embedding:** ```go type RadarrClient struct { *Client // Embedded struct } ``` This is composition, not inheritance. Here's what happens: **Field Promotion:** ```go radarr := NewRadarrClient("http://localhost:7878", "api-key") // These all work because Client fields are "promoted": fmt.Println(radarr.BaseURL) // Promoted from embedded Client fmt.Println(radarr.APIKey) // Promoted from embedded Client radarr.HTTPClient.Timeout = 60 // Direct access to embedded fields ``` **Method Promotion:** ```go // RadarrClient automatically gets all Client methods: status, err := radarr.GetSystemStatus() // This method is promoted from Client! ``` **Why Use Embedding Instead of Composition?** Compare these approaches: ```go // Composition (verbose) type RadarrClient struct { client *Client } func (r *RadarrClient) GetSystemStatus() (*SystemStatus, error) { return r.client.GetSystemStatus() // Must delegate manually } // Embedding (automatic) type RadarrClient struct { *Client } // GetSystemStatus is automatically available! ``` **Connection to Previous Examples:** The `RadarrClient` reuses our `Client` from Chapter 3, but adds movie-specific functionality. Notice how `NewRadarrClient` calls our `NewClient` constructor - we're building on previous work. **Deep Dive - Alternative JSON Decoding:** ```go // Chapter 3 approach - read all, then unmarshal body, err := io.ReadAll(resp.Body) var status SystemStatus json.Unmarshal(body, &status) // This chapter's approach - stream decode var health []HealthCheck json.NewDecoder(resp.Body).Decode(&health) ``` **When to use each:** - `json.Unmarshal`: When you need to inspect/modify the raw JSON - `json.NewDecoder`: When streaming directly from an io.Reader (more efficient for large responses) **Deep Dive - String Conversion:** ```go strconv.Itoa(id) // Integer to ASCII ``` Go is strict about types - you can't just concatenate an `int` with a `string`. `strconv.Itoa()` converts integers to strings. Other useful conversions: ```go strconv.Atoi("123") // String to int strconv.ParseBool("true") // String to bool strconv.FormatFloat(3.14, 'f', 2, 64) // Float to string ``` **Interface Satisfaction Check:** At this point, `RadarrClient` satisfies the `MovieService` interface because it has: - `GetSystemStatus()` (promoted from embedded `Client`) - `GetHealth()` (implemented in this chapter) - `GetMovies()` (implemented in this chapter) - `GetMovie()` (implemented in this chapter) - `AddMovie()` (stub implementation) ## Chapter 5: Packages and Organization - Scaling Your Codebase ### 5.1 Understanding the Problem Right now, all our code is in `package main`. This works for small programs, but creates problems as code grows: 1. **Single namespace**: All types/functions compete for names 2. **No encapsulation**: Everything is visible to everything else 3. **Hard to test**: Can't import `main` package in tests 4. **Monolithic**: Changes anywhere affect everything ### 5.2 Restructuring into Packages Let's reorganize into proper packages: ``` arr-client/ ├── go.mod ├── main.go ├── pkg/ │ ├── client/ │ │ ├── client.go │ │ └── interfaces.go │ ├── radarr/ │ │ └── radarr.go │ ├── sonarr/ │ │ └── sonarr.go │ └── types/ │ └── types.go └── examples/ └── basic_usage.go ``` **Deep Dive - Package Organization Philosophy:** **The `pkg/` directory:** This is a Go convention meaning "library code that's safe for others to import." Not required, but signals intent. **Package naming principles:** 1. **Short, clear names**: `types`, `client`, `radarr` (not `radarr_client_implementation`) 2. **No underscores**: Go prefers `httputil` over `http_util` 3. **Singular nouns**: `client` not `clients` 4. **Domain-focused**: Each package has a single, clear purpose `pkg/types/types.go`: ```go package types import "time" // SystemStatus represents system status for any *arr service type SystemStatus struct { Version string `json:"version"` BuildTime string `json:"buildTime"` IsDebug bool `json:"isDebug"` IsProduction bool `json:"isProduction"` IsAdmin bool `json:"isAdmin"` IsUserInteractive bool `json:"isUserInteractive"` StartupPath string `json:"startupPath"` AppData string `json:"appData"` OsName string `json:"osName"` OsVersion string `json:"osVersion"` } // Movie represents a movie (Radarr) type Movie struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Path string `json:"path"` Added time.Time `json:"added"` Monitored bool `json:"monitored"` QualityProfileID int `json:"qualityProfileId"` ImdbID string `json:"imdbId"` TmdbID int `json:"tmdbId"` } // Series represents a TV series (Sonarr) type Series struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Path string `json:"path"` Monitored bool `json:"monitored"` Added time.Time `json:"added"` QualityProfileID int `json:"qualityProfileId"` TvdbID int `json:"tvdbId"` Seasons []Season `json:"seasons"` } // Season represents a season within a series type Season struct { SeasonNumber int `json:"seasonNumber"` Monitored bool `json:"monitored"` } // HealthCheck represents a health check result type HealthCheck struct { Source string `json:"source"` Type string `json:"type"` Message string `json:"message"` } ``` **Deep Dive - Why a Separate Types Package?** This prevents circular imports: ```go // Without types package - CIRCULAR IMPORT! package radarr import "myproject/sonarr" // Radarr imports Sonarr package sonarr import "myproject/radarr" // Sonarr imports Radarr - CIRCULAR! // With types package - NO CIRCULAR IMPORT package radarr import "myproject/types" // Radarr imports types package sonarr import "myproject/types" // Sonarr imports types - OK! ``` `pkg/client/interfaces.go`: ```go package client import "github.com/yourusername/arr-client/pkg/types" // ServiceClient defines common operations for all *arr services type ServiceClient interface { GetSystemStatus() (*types.SystemStatus, error) GetHealth() ([]types.HealthCheck, error) } // MovieService defines movie-specific operations type MovieService interface { ServiceClient GetMovies() ([]types.Movie, error) GetMovie(id int) (*types.Movie, error) AddMovie(movie *types.Movie) (*types.Movie, error) } // SeriesService defines series-specific operations type SeriesService interface { ServiceClient GetSeries() ([]types.Series, error) GetSeriesById(id int) (*types.Series, error) AddSeries(series *types.Series) (*types.Series, error) } ``` `pkg/client/client.go`: ```go package client import ( "encoding/json" "fmt" "io" "net/http" "time" "github.com/yourusername/arr-client/pkg/types" ) // BaseClient provides common HTTP client functionality type BaseClient struct { BaseURL string APIKey string HTTPClient *http.Client } // NewBaseClient creates a new base client func NewBaseClient(baseURL, apiKey string) *BaseClient { return &BaseClient{ BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, } } // GetSystemStatus fetches system status from the service func (c *BaseClient) GetSystemStatus() (*types.SystemStatus, error) { url := fmt.Sprintf("%s/api/v3/system/status", c.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", c.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } var status types.SystemStatus if err := json.Unmarshal(body, &status); err != nil { return nil, fmt.Errorf("unmarshaling JSON: %w", err) } return &status, nil } ``` **Deep Dive - Import Paths:** ```go import "github.com/yourusername/arr-client/pkg/types" ``` This import path is built from: 1. **Module path**: `github.com/yourusername/arr-client` (from go.mod) 2. **Package path**: `pkg/types` (directory structure) Go uses this to find the package, whether it's: - Local (in your project) - Downloaded from GitHub - In your GOPATH (older Go versions) **Connection to Previous Examples:** We've taken all the code from Chapters 1-4 and organized it properly. The functionality is the same, but now it's maintainable and reusable. **Deep Dive - Exported vs Unexported Names:** ```go // In pkg/types/types.go type Movie struct { // Exported - other packages can use ID int // Exported field title string // Unexported - only types package can access } func NewMovie() *Movie { } // Exported function func validateTitle() bool {} // Unexported function ``` **Visibility Rules:** - **Same package**: Can access everything (exported and unexported) - **Different package**: Can only access exported names (capitalized) This is Go's encapsulation mechanism - much simpler than `public/private/protected` keywords. `pkg/radarr/radarr.go`: ```go package radarr import ( "encoding/json" "fmt" "net/http" "strconv" "github.com/yourusername/arr-client/pkg/client" "github.com/yourusername/arr-client/pkg/types" ) // Client implements the MovieService interface for Radarr type Client struct { *client.BaseClient // Embedded from client package } // NewClient creates a new Radarr client func NewClient(baseURL, apiKey string) *Client { return &Client{ BaseClient: client.NewBaseClient(baseURL, apiKey), } } // GetHealth implements ServiceClient interface func (r *Client) GetHealth() ([]types.HealthCheck, error) { url := fmt.Sprintf("%s/api/v3/health", r.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() var health []types.HealthCheck if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return health, nil } // GetMovies retrieves all movies func (r *Client) GetMovies() ([]types.Movie, error) { url := fmt.Sprintf("%s/api/v3/movie", r.BaseURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() var movies []types.Movie if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return movies, nil } // GetMovie retrieves a specific movie by ID func (r *Client) GetMovie(id int) (*types.Movie, error) { url := fmt.Sprintf("%s/api/v3/movie/%s", r.BaseURL, strconv.Itoa(id)) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("X-Api-Key", r.APIKey) resp, err := r.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("movie with ID %d not found", id) } var movie types.Movie if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &movie, nil } // AddMovie adds a new movie func (r *Client) AddMovie(movie *types.Movie) (*types.Movie, error) { // Implementation would POST to /api/v3/movie // This is left as an exercise return nil, fmt.Errorf("AddMovie not implemented yet") } ``` **Connection to Previous Examples:** Notice how the `radarr.Client` now embeds `client.BaseClient` instead of the old `Client` type. We've separated concerns: `client` package handles common HTTP operations, `radarr` package handles Radarr-specific logic. **Deep Dive - Import Organization:** Go has conventions for organizing imports: ```go import ( // Standard library first "encoding/json" "fmt" "net/http" // Third-party packages next "github.com/gin-gonic/gin" // Your own packages last "github.com/yourusername/arr-client/pkg/client" "github.com/yourusername/arr-client/pkg/types" ) ``` Most editors automatically organize imports this way. ## Chapter 6: Testing - Ensuring Code Quality ### 6.1 Understanding Go Testing Philosophy Go's testing philosophy is simple: 1. **Tests live alongside code**: `client.go` and `client_test.go` in same package 2. **Minimal framework**: Just functions that start with `Test` 3. **Table-driven tests**: Test multiple scenarios with data structures 4. **Fail fast**: Tests should be fast and give clear feedback ### 6.2 Writing Unit Tests Create `pkg/radarr/radarr_test.go`: ```go package radarr import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/yourusername/arr-client/pkg/types" ) func TestClient_GetSystemStatus(t *testing.T) { // Create a test server that mimics Radarr's API server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify the request is correct if r.URL.Path != "/api/v3/system/status" { t.Errorf("Expected path /api/v3/system/status, got %s", r.URL.Path) } if r.Header.Get("X-Api-Key") != "test-api-key" { t.Errorf("Expected API key header to be 'test-api-key'") } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type header to be 'application/json'") } // Return mock response that matches what real Radarr would return status := types.SystemStatus{ Version: "4.0.0.5831", BuildTime: "2023-01-15T14:30:26Z", IsDebug: false, IsProduction: true, IsAdmin: true, OsName: "ubuntu", OsVersion: "20.04", } // Send JSON response w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) })) defer server.Close() // Clean up when test finishes // Create client with test server URL client := NewClient(server.URL, "test-api-key") // Test the method status, err := client.GetSystemStatus() if err != nil { t.Fatalf("GetSystemStatus failed: %v", err) } // Verify results if status.Version != "4.0.0.5831" { t.Errorf("Expected version 4.0.0.5831, got %s", status.Version) } if status.IsProduction != true { t.Errorf("Expected IsProduction to be true, got %v", status.IsProduction) } if status.OsName != "ubuntu" { t.Errorf("Expected OsName 'ubuntu', got %s", status.OsName) } } func TestClient_GetMovies(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify correct endpoint if r.URL.Path != "/api/v3/movie" { t.Errorf("Expected path /api/v3/movie, got %s", r.URL.Path) } // Mock response with multiple movies movies := []types.Movie{ { ID: 1, Title: "The Matrix", Year: 1999, Path: "/movies/The Matrix (1999)", Monitored: true, Added: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC), ImdbID: "tt0133093", TmdbID: 603, }, { ID: 2, Title: "Inception", Year: 2010, Path: "/movies/Inception (2010)", Monitored: true, Added: time.Date(2023, 1, 16, 10, 0, 0, 0, time.UTC), ImdbID: "tt1375666", TmdbID: 27205, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(movies) })) defer server.Close() client := NewClient(server.URL, "test-api-key") movies, err := client.GetMovies() if err != nil { t.Fatalf("GetMovies failed: %v", err) } // Test the slice length if len(movies) != 2 { t.Errorf("Expected 2 movies, got %d", len(movies)) } // Test specific movie data if movies[0].Title != "The Matrix" { t.Errorf("Expected first movie 'The Matrix', got %s", movies[0].Title) } if movies[0].Year != 1999 { t.Errorf("Expected first movie year 1999, got %d", movies[0].Year) } if movies[1].Title != "Inception" { t.Errorf("Expected second movie 'Inception', got %s", movies[1].Title) } } func TestClient_GetMovie_NotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Simulate 404 response w.WriteHeader(http.StatusNotFound) })) defer server.Close() client := NewClient(server.URL, "test-api-key") movie, err := client.GetMovie(999) // Should return error for 404 if err == nil { t.Fatal("Expected error for 404 response, got nil") } // Should not return a movie if movie != nil { t.Errorf("Expected nil movie for 404, got %+v", movie) } // Check error message expectedMsg := "movie with ID 999 not found" if err.Error() != expectedMsg { t.Errorf("Expected error '%s', got '%s'", expectedMsg, err.Error()) } } ``` **Deep Dive - httptest Package:** ```go server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handle request })) defer server.Close() ``` `httptest` creates a real HTTP server for testing: 1. **Real HTTP**: Your code makes actual HTTP calls (not mocked) 2. **Controllable**: You control exactly what the server returns 3. **Isolated**: Each test gets its own server 4. **Fast**: Runs in-memory, no network calls **Connection to Previous Examples:** We're testing the `radarr.Client` methods we built in previous chapters. The test server mimics what a real Radarr API would return, letting us verify our HTTP client code works correctly. **Deep Dive - Test Function Naming:** ```go func TestClient_GetSystemStatus(t *testing.T) func TestClient_GetMovies(t *testing.T) func TestClient_GetMovie_NotFound(t *testing.T) ``` Go test naming convention: - `Test` + what you're testing + specific scenario - `TestClient_GetMovies` = testing the `GetMovies` method on `Client` - `TestClient_GetMovie_NotFound` = testing `GetMovie` when movie isn't found **Deep Dive - Error Testing Patterns:** ```go // Testing for expected errors movie, err := client.GetMovie(999) if err == nil { t.Fatal("Expected error for 404 response, got nil") } // Testing for no errors when success expected status, err := client.GetSystemStatus() if err != nil { t.Fatalf("GetSystemStatus failed: %v", err) } ``` **`t.Errorf` vs `t.Fatalf`:** - `t.Errorf`: Log error but continue test (for multiple assertions) - `t.Fatalf`: Log error and stop test immediately (when further testing is pointless) ### 6.3 Table-Driven Tests Let's add a more sophisticated test using Go's table-driven testing pattern: ```go func TestClient_GetMovie_Success(t *testing.T) { // Test cases as a slice of structs testCases := []struct { name string movieID int expectedTitle string expectedYear int expectedImdbID string }{ { name: "The Matrix", movieID: 1, expectedTitle: "The Matrix", expectedYear: 1999, expectedImdbID: "tt0133093", }, { name: "Inception", movieID: 2, expectedTitle: "Inception", expectedYear: 2010, expectedImdbID: "tt1375666", }, { name: "Interstellar", movieID: 3, expectedTitle: "Interstellar", expectedYear: 2014, expectedImdbID: "tt0816692", }, } // Run each test case for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Create response based on requested ID movie := types.Movie{ ID: tc.movieID, Title: tc.expectedTitle, Year: tc.expectedYear, ImdbID: tc.expectedImdbID, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(movie) })) defer server.Close() client := NewClient(server.URL, "test-api-key") movie, err := client.GetMovie(tc.movieID) if err != nil { t.Fatalf("GetMovie failed: %v", err) } if movie.Title != tc.expectedTitle { t.Errorf("Expected title %s, got %s", tc.expectedTitle, movie.Title) } if movie.Year != tc.expectedYear { t.Errorf("Expected year %d, got %d", tc.expectedYear, movie.Year) } if movie.ImdbID != tc.expectedImdbID { t.Errorf("Expected IMDB ID %s, got %s", tc.expectedImdbID, movie.ImdbID) } }) } } ``` **Deep Dive - Table-Driven Tests:** This pattern is idiomatic Go: 1. **Test data in structs**: Each test case is a struct with inputs and expected outputs 2. **Loop through cases**: `for _, tc := range testCases` 3. **Subtests**: `t.Run(tc.name, func(t *testing.T) {...})` creates individual subtests 4. **Clear failure reporting**: When a test fails, you know exactly which case failed **Benefits:** - Easy to add new test cases (just add to slice) - Reduces code duplication - Clear separation of test data and test logic - Individual subtests can pass/fail independently Run tests: `go test ./pkg/radarr/` **Test output will look like:** ``` === RUN TestClient_GetMovie_Success === RUN TestClient_GetMovie_Success/The_Matrix === RUN TestClient_GetMovie_Success/Inception === RUN TestClient_GetMovie_Success/Interstellar --- PASS: TestClient_GetMovie_Success (0.00s) --- PASS: TestClient_GetMovie_Success/The_Matrix (0.00s) --- PASS: TestClient_GetMovie_Success/Inception (0.00s) --- PASS: TestClient_GetMovie_Success/Interstellar (0.00s) ``` **Connection to Previous Examples:** These tests validate all the HTTP client code we built in Chapters 3-5. We're testing not just that the code runs, but that it correctly parses responses, handles errors, and returns the right data structures. ## Chapter 7: Error Handling and Logging - Building Robust Applications ### 7.1 Understanding Go's Error Philosophy Go treats errors as values, not exceptions. This has profound implications: ```go // Not Go - exceptions (Java/Python style) try { result = dangerousOperation() } catch (Exception e) { handleError(e) } // Go - errors are values result, err := dangerousOperation() if err != nil { return handleError(err) } ``` **Why this approach?** 1. **Explicit**: You can't ignore errors 2. **Performance**: No stack unwinding overhead 3. **Predictable**: Error handling is in your control flow 4. **Composable**: Errors can be wrapped and annotated ### 7.2 Custom Error Types Create `pkg/client/errors.go`: ```go package client import ( "fmt" "net/http" ) // APIError represents an API-specific error with rich context type APIError struct { StatusCode int Message string Endpoint string Method string RequestID string // Some APIs provide request IDs for debugging } // Error implements the error interface func (e *APIError) Error() string { if e.RequestID != "" { return fmt.Sprintf("API error %d at %s %s (request: %s): %s", e.StatusCode, e.Method, e.Endpoint, e.RequestID, e.Message) } return fmt.Sprintf("API error %d at %s %s: %s", e.StatusCode, e.Method, e.Endpoint, e.Message) } // IsTemporary returns true if the error might succeed if retried func (e *APIError) IsTemporary() bool { // 5xx errors are typically temporary (server issues) // 429 (rate limited) is temporary return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests } // IsClientError returns true if error is due to client mistake (4xx) func (e *APIError) IsClientError() bool { return e.StatusCode >= 400 && e.StatusCode < 500 } // IsServerError returns true if error is server-side (5xx) func (e *APIError) IsServerError() bool { return e.StatusCode >= 500 } // ValidationError represents errors with input validation type ValidationError struct { Field string Message string Value interface{} } func (e *ValidationError) Error() string { return fmt.Sprintf("validation error for field '%s': %s (value: %v)", e.Field, e.Message, e.Value) } // Helper functions for error checking func IsNotFound(err error) bool { if apiErr, ok := err.(*APIError); ok { return apiErr.StatusCode == http.StatusNotFound } return false } func IsUnauthorized(err error) bool { if apiErr, ok := err.(*APIError); ok { return apiErr.StatusCode == http.StatusUnauthorized } return false } func IsForbidden(err error) bool { if apiErr, ok := err.(*APIError); ok { return apiErr.StatusCode == http.StatusForbidden } return false } // IsTemporary checks if any error is temporary (not just APIError) func IsTemporary(err error) bool { // Check if it's our APIError if apiErr, ok := err.(*APIError); ok { return apiErr.IsTemporary() } // Check if it implements temporary interface (net package does this) if tempErr, ok := err.(interface{ Temporary() bool }); ok { return tempErr.Temporary() } return false } ``` **Deep Dive - Custom Error Types:** ```go type APIError struct { StatusCode int Message string Endpoint string Method string RequestID string } func (e *APIError) Error() string { return fmt.Sprintf("API error %d at %s %s: %s", e.StatusCode, e.Method, e.Endpoint, e.Message) } ``` **Why create custom error types?** 1. **Rich context**: More information than just a string 2. **Type-safe checking**: `IsNotFound(err)` instead of string matching 3. **Behavior methods**: `IsTemporary()` tells you if you should retry 4. **Structured logging**: Error fields can be logged separately **The error interface:** ```go type error interface { Error() string } ``` Any type with an `Error() string` method satisfies this interface. This is why our custom types work as errors. **Deep Dive - Type Assertions for Error Checking:** ```go func IsNotFound(err error) bool { if apiErr, ok := err.(*APIError); ok { return apiErr.StatusCode == http.StatusNotFound } return false } ``` This uses Go's type assertion: 1. **`err.(*APIError)`**: Try to convert `err` to `*APIError` 2. **`ok`**: Boolean indicating if conversion succeeded 3. **`apiErr`**: The converted value (if ok is true) **Connection to Previous Examples:** Instead of just returning `fmt.Errorf("unexpected status code: %d", resp.StatusCode)`, we can now return rich `APIError` instances with all the context needed for debugging. ### 7.3 Enhanced HTTP Client with Better Error Handling Let's update `pkg/client/client.go` to use our custom errors: ```go package client import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "time" "github.com/yourusername/arr-client/pkg/types" ) // BaseClient provides common HTTP client functionality with enhanced error handling type BaseClient struct { BaseURL string APIKey string HTTPClient *http.Client Logger Logger } // Logger interface allows pluggable logging type Logger interface { Printf(format string, v ...interface{}) Println(v ...interface{}) } // NewBaseClient creates a new base client with sensible defaults func NewBaseClient(baseURL, apiKey string) *BaseClient { return &BaseClient{ BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, Logger: log.New(os.Stdout, "[ARR-CLIENT] ", log.LstdFlags), } } // SetLogger allows customizing the logger func (c *BaseClient) SetLogger(logger Logger) { c.Logger = logger } // SetTimeout allows customizing the HTTP timeout func (c *BaseClient) SetTimeout(timeout time.Duration) { c.HTTPClient.Timeout = timeout } // doRequest performs an HTTP request with comprehensive error handling func (c *BaseClient) doRequest(method, endpoint string, body io.Reader) (*http.Response, error) { url := fmt.Sprintf("%s%s", c.BaseURL, endpoint) c.Logger.Printf("Making %s request to %s", method, url) req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } // Set required headers req.Header.Set("X-Api-Key", c.APIKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "arr-client/1.0") start := time.Now() resp, err := c.HTTPClient.Do(req) duration := time.Since(start) if err != nil { c.Logger.Printf("Request failed after %v: %v", duration, err) return nil, fmt.Errorf("making request: %w", err) } c.Logger.Printf("Request completed in %v with status %d", duration, resp.StatusCode) // Check for API errors if resp.StatusCode >= 400 { defer resp.Body.Close() // Try to read error message from response body, _ := io.ReadAll(resp.Body) message := string(body) if message == "" { message = http.StatusText(resp.StatusCode) } return nil, &APIError{ StatusCode: resp.StatusCode, Message: message, Endpoint: endpoint, Method: method, RequestID: resp.Header.Get("X-Request-ID"), // Some APIs provide this } } return resp, nil } // GetSystemStatus fetches system status with enhanced error handling func (c *BaseClient) GetSystemStatus() (*types.SystemStatus, error) { resp, err := c.doRequest("GET", "/api/v3/system/status", nil) if err != nil { return nil, fmt.Errorf("getting system status: %w", err) } defer resp.Body.Close() var status types.SystemStatus if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { return nil, fmt.Errorf("decoding system status response: %w", err) } c.Logger.Printf("System status retrieved: version %s", status.Version) return &status, nil } // GetHealth fetches health status func (c *BaseClient) GetHealth() ([]types.HealthCheck, error) { resp, err := c.doRequest("GET", "/api/v3/health", nil) if err != nil { return nil, fmt.Errorf("getting health status: %w", err) } defer resp.Body.Close() var health []types.HealthCheck if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { return nil, fmt.Errorf("decoding health response: %w", err) } c.Logger.Printf("Health check retrieved: %d checks", len(health)) return health, nil } ``` **Deep Dive - Refactoring for Reusability:** The `doRequest` method eliminates duplication: **Before (duplicated in every method):** ```go req, err := http.NewRequest("GET", url, nil) // error handling req.Header.Set("X-Api-Key", c.APIKey) // more headers resp, err := c.HTTPClient.Do(req) // more error handling ``` **After (centralized):** ```go resp, err := c.doRequest("GET", "/api/v3/system/status", nil) if err != nil { return nil, fmt.Errorf("getting system status: %w", err) } ``` **Benefits of this refactoring:** 1. **DRY principle**: Don't Repeat Yourself 2. **Consistent error handling**: All requests handled the same way 3. **Easier to modify**: Change headers/logging in one place 4. **Testability**: Can test the common request logic separately **Deep Dive - Logging Interface:** ```go type Logger interface { Printf(format string, v ...interface{}) Println(v ...interface{}) } ``` **Why use an interface instead of `*log.Logger` directly?** 1. **Flexibility**: Can plug in any logger (stdlib, logrus, zap, etc.) 2. **Testing**: Can use a mock logger that captures logs for testing 3. **Configuration**: Different log levels, formats, destinations **Example usage:** ```go // Use standard library logger (default) client := NewBaseClient("http://localhost", "api-key") // Use custom logger customLogger := log.New(file, "[CUSTOM] ", log.LstdFlags) client.SetLogger(customLogger) // Use no-op logger for silence client.SetLogger(&NoOpLogger{}) ``` **Connection to Previous Examples:** We've taken the basic HTTP client from Chapter 3 and made it production-ready with proper error handling, logging, and reusable request logic. ### 7.4 Error Handling in Action Let's see how to use these enhanced errors in `pkg/radarr/radarr.go`: ```go package radarr import ( "encoding/json" "fmt" "strconv" "github.com/yourusername/arr-client/pkg/client" "github.com/yourusername/arr-client/pkg/types" ) // Client implements the MovieService interface for Radarr type Client struct { *client.BaseClient } // NewClient creates a new Radarr client func NewClient(baseURL, apiKey string) *Client { return &Client{ BaseClient: client.NewBaseClient(baseURL, apiKey), } } // GetMovies retrieves all movies with proper error context func (r *Client) GetMovies() ([]types.Movie, error) { resp, err := r.doRequest("GET", "/api/v3/movie", nil) if err != nil { // Error is already wrapped by doRequest, just add context return nil, fmt.Errorf("retrieving movies: %w", err) } defer resp.Body.Close() var movies []types.Movie if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil { return nil, fmt.Errorf("parsing movies response: %w", err) } r.Logger.Printf("Retrieved %d movies", len(movies)) return movies, nil } // GetMovie retrieves a specific movie with enhanced error handling func (r *Client) GetMovie(id int) (*types.Movie, error) { endpoint := fmt.Sprintf("/api/v3/movie/%s", strconv.Itoa(id)) resp, err := r.doRequest("GET", endpoint, nil) if err != nil { // Check if it's a 404 error and provide better context if client.IsNotFound(err) { return nil, fmt.Errorf("movie with ID %d not found: %w", id, err) } return nil, fmt.Errorf("retrieving movie %d: %w", id, err) } defer resp.Body.Close() var movie types.Movie if err := json.NewDecoder(resp.Body).Decode(&movie); err != nil { return nil, fmt.Errorf("parsing movie %d response: %w", id, err) } r.Logger.Printf("Retrieved movie: %s (%d)", movie.Title, movie.Year) return &movie, nil } // AddMovie adds a new movie with comprehensive error handling and validation func (r *Client) AddMovie(movie *types.Movie) (*types.Movie, error) { // Validate input if err := r.validateMovie(movie); err != nil { return nil, fmt.Errorf("invalid movie data: %w", err) } // Convert movie to JSON movieData, err := json.Marshal(movie) if err != nil { return nil, fmt.Errorf("encoding movie data: %w", err) } resp, err := r.doRequest("POST", "/api/v3/movie", bytes.NewReader(movieData)) if err != nil { // Provide context based on error type if client.IsUnauthorized(err) { return nil, fmt.Errorf("unauthorized to add movies - check API key: %w", err) } if client.IsForbidden(err) { return nil, fmt.Errorf("forbidden to add movies - insufficient permissions: %w", err) } return nil, fmt.Errorf("adding movie '%s': %w", movie.Title, err) } defer resp.Body.Close() var addedMovie types.Movie if err := json.NewDecoder(resp.Body).Decode(&addedMovie); err != nil { return nil, fmt.Errorf("parsing added movie response: %w", err) } r.Logger.Printf("Added movie: %s (%d) with ID %d", addedMovie.Title, addedMovie.Year, addedMovie.ID) return &addedMovie, nil } // validateMovie performs client-side validation func (r *Client) validateMovie(movie *types.Movie) error { if movie == nil { return &client.ValidationError{ Field: "movie", Message: "movie cannot be nil", Value: nil, } } if movie.Title == "" { return &client.ValidationError{ Field: "title", Message: "title is required", Value: movie.Title, } } if movie.Year < 1800 || movie.Year > 2100 { return &client.ValidationError{ Field: "year", Message: "year must be between 1800 and 2100", Value: movie.Year, } } if movie.TmdbID <= 0 { return &client.ValidationError{ Field: "tmdbId", Message: "TMDB ID is required and must be positive", Value: movie.TmdbID, } } return nil } ``` **Deep Dive - Error Propagation and Context:** Notice the error handling pattern: ```go resp, err := r.doRequest("GET", endpoint, nil) if err != nil { if client.IsNotFound(err) { return nil, fmt.Errorf("movie with ID %d not found: %w", id, err) } return nil, fmt.Errorf("retrieving movie %d: %w", id, err) } ``` **This demonstrates:** 1. **Error checking**: Check for specific error types first 2. **Context addition**: Add domain-specific context ("movie with ID %d") 3. **Error wrapping**: Use `%w` to preserve the original error **The error chain will look like:** ``` retrieving movie 123: API error 404 at GET /api/v3/movie/123: Not Found ``` **Deep Dive - Validation Errors:** Client-side validation prevents unnecessary API calls: ```go func (r *Client) validateMovie(movie *types.Movie) error { if movie.Title == "" { return &client.ValidationError{ Field: "title", Message: "title is required", Value: movie.Title, } } // ... } ``` **Benefits:** 1. **Fast failure**: Catch errors before expensive network calls 2. **Clear feedback**: User knows exactly what's wrong 3. **Structured errors**: Validation errors are different from API errors **Connection to Previous Examples:** We've enhanced the basic Radarr client from Chapter 4 with robust error handling, logging, and validation. The core functionality is the same, but now it's production-ready. ## Chapter 8: Concurrency - Handling Multiple Operations ### 8.1 Understanding Go's Concurrency Model Go's concurrency is based on two main concepts: 1. **Goroutines**: Lightweight threads managed by Go runtime 2. **Channels**: Type-safe pipes for communication between goroutines **Philosophy**: "Don't communicate by sharing memory; share memory by communicating." ### 8.2 Basic Goroutines and Channels Create `pkg/client/concurrent.go`: ```go package client import ( "context" "fmt" "sync" "time" "github.com/yourusername/arr-client/pkg/types" ) // ServiceResult represents the result of a service operation type ServiceResult struct { ServiceName string Success bool Error error Duration time.Duration } // HealthResult represents health check result with service name type HealthResult struct { ServiceName string Health []types.HealthCheck Error error Duration time.Duration } // CheckHealthConcurrently checks health of multiple services concurrently func CheckHealthConcurrently(services map[string]ServiceClient) []HealthResult { results := make(chan HealthResult, len(services)) var wg sync.WaitGroup // Start a goroutine for each service for name, service := range services { wg.Add(1) go func(serviceName string, svc ServiceClient) { defer wg.Done() start := time.Now() health, err := svc.GetHealth() duration := time.Since(start) results <- HealthResult{ ServiceName: serviceName, Health: health, Error: err, Duration: duration, } }(name, service) } // Close the results channel when all goroutines complete go func() { wg.Wait() close(results) }() // Collect all results var healthResults []HealthResult for result := range results { healthResults = append(healthResults, result) } return healthResults } // CheckSystemStatusConcurrently checks system status of multiple services func CheckSystemStatusConcurrently(services map[string]ServiceClient) []ServiceResult { results := make(chan ServiceResult, len(services)) var wg sync.WaitGroup for name, service := range services { wg.Add(1) go func(serviceName string, svc ServiceClient) { defer wg.Done() start := time.Now() _, err := svc.GetSystemStatus() duration := time.Since(start) results <- ServiceResult{ ServiceName: serviceName, Success: err == nil, Error: err, Duration: duration, } }(name, service) } go func() { wg.Wait() close(results) }() var statusResults []ServiceResult for result := range results { statusResults = append(statusResults, result) } return statusResults } ``` **Deep Dive - Goroutines:** ```go go func(serviceName string, svc ServiceClient) { defer wg.Done() // ... do work }(name, service) ``` **Breaking this down:** 1. **`go`**: Starts a new goroutine (like a lightweight thread) 2. **Anonymous function**: The function to run in the goroutine 3. **Parameters**: `(serviceName string, svc ServiceClient)` - captures loop variables safely 4. **Function call**: `(name, service)` - passes current loop values to the function **Why pass parameters instead of capturing loop variables?** ```go // WRONG - captures loop variable reference for name, service := range services { go func() { // name and service will be the LAST values from the loop! health, err := service.GetHealth() }() } // CORRECT - passes values to function parameters for name, service := range services { go func(serviceName string, svc ServiceClient) { // serviceName and svc are copies of current loop values health, err := svc.GetHealth() }(name, service) } ``` **Deep Dive - Channels:** ```go results := make(chan HealthResult, len(services)) ``` **Channel basics:** 1. **Type-safe**: `chan HealthResult` only carries `HealthResult` values 2. **Buffered**: `len(services)` capacity means sends won't block 3. **Communication**: Goroutines send results, main goroutine receives **Channel operations:** ```go results <- HealthResult{...} // Send to channel result := <-results // Receive from channel close(results) // Close channel (no more sends) ``` **Deep Dive - WaitGroups:** ```go var wg sync.WaitGroup wg.Add(1) // Add 1 to the counter defer wg.Done() // Subtract 1 from counter when function exits wg.Wait() // Block until counter reaches 0 ``` **WaitGroup pattern:** 1. **Add** before starting goroutines 2. **Done** when goroutine finishes (usually with `defer`) 3. **Wait** for all goroutines to finish **Connection to Previous Examples:** We're using the `ServiceClient` interface from Chapter 4. Any type that implements this interface (like our `radarr.Client`) can be used with these concurrent functions. ### 8.3 Context for Cancellation and Timeouts Add timeout and cancellation support: ```go // CheckHealthWithTimeout checks health with a timeout func CheckHealthWithTimeout(ctx context.Context, service ServiceClient, timeout time.Duration) (*[]types.HealthCheck, error) { // Create a context with timeout timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // Always call cancel to free resources type result struct { health []types.HealthCheck err error } resultCh := make(chan result, 1) // Start the health check in a goroutine go func() { health, err := service.GetHealth() select { case resultCh <- result{health: health, err: err}: // Result sent successfully case <-timeoutCtx.Done(): // Context cancelled, don't send result return } }() // Wait for either result or timeout select { case res := <-resultCh: return &res.health, res.err case <-timeoutCtx.Done(): return nil, fmt.Errorf("health check timed out after %v: %w", timeout, timeoutCtx.Err()) } } // CheckMultipleServicesWithTimeout checks multiple services with individual timeouts func CheckMultipleServicesWithTimeout(ctx context.Context, services map[string]ServiceClient, timeout time.Duration) []HealthResult { results := make(chan HealthResult, len(services)) var wg sync.WaitGroup for name, service := range services { wg.Add(1) go func(serviceName string, svc ServiceClient) { defer wg.Done() start := time.Now() health, err := CheckHealthWithTimeout(ctx, svc, timeout) duration := time.Since(start) var healthSlice []types.HealthCheck if health != nil { healthSlice = *health } results <- HealthResult{ ServiceName: serviceName, Health: healthSlice, Error: err, Duration: duration, } }(name, service) } go func() { wg.Wait() close(results) }() var healthResults []HealthResult for result := range results { healthResults = append(healthResults, result) } return healthResults } // RetryWithBackoff retries an operation with exponential backoff func RetryWithBackoff(ctx context.Context, maxRetries int, baseDelay time.Duration, operation func() error) error { var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { // Check if context is cancelled select { case <-ctx.Done(): return fmt.Errorf("operation cancelled: %w", ctx.Err()) default: } // Try the operation if err := operation(); err == nil { return nil // Success! } else { lastErr = err } // Don't delay after the last attempt if attempt == maxRetries-1 { break } // Calculate delay with exponential backoff delay := baseDelay * time.Duration(1<