- Created `client.go` for the main client structure and methods to interact with *arr services. - Added `types.go` to define data structures for system status, movies, and series. - Implemented `radarr.go` for Radarr-specific client methods including health checks and movie retrieval. - Introduced `interfaces.go` to define service interfaces for common operations across *arr services. - Established a basic `main.go` for application entry point. - Included a tutorial markdown file to guide users through building the client and understanding Go concepts. - Initialized `go.mod` for module management. - Organized code into appropriate packages for better structure and maintainability.
80 KiB
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
- 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:
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:
- Unique identifier: It tells Go this is a distinct module
- 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:
package main
import "fmt"
func main() {
fmt.Println("Hello, *arr services!")
}
Deep Dive - Program Structure:
Let's break down each line:
-
package main: Every Go file must start with a package declaration. Themainpackage is special - it tells Go this is an executable program (not a library). When Go builds your code, it looks for amainpackage and amainfunction as the entry point. -
import "fmt": This brings in thefmt(format) package from Go's standard library. The import statement is like#includein C++ orimportin Python. Go's standard library is extensive and includes packages for HTTP clients, JSON parsing, file I/O, and much more. -
func main(): This is the entry point of your program. When you rungo run main.go, Go looks for this exact function. Thefunckeyword declares a function, similar todefin Python orfunctionin JavaScript. -
fmt.Println("Hello, *arr services!"): This calls thePrintlnfunction from thefmtpackage. 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:
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:
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
}
type Movie struct: Declares a new type namedMoviethat's a struct- Field declarations: Each line inside declares a field with a name and type
- Struct tags: The backtick strings (
json:"id") are metadata
Why structs matter for our API client: When the *arr services return JSON like this:
{
"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:
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
Titlefield - When converting TO JSON: Take the
Titlefield 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 ittitle(lowercase): Unexported - only this package can access it
For JSON marshaling to work, fields must be exported (capitalized).
Composite Types:
Seasons []Season `json:"seasons"`
This demonstrates Go's type system:
[]Season: A slice (dynamic array) ofSeasonstructs- 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:
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:
// 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?
- Efficiency: Structs can be large. Copying a
Clientstruct every time would be expensive - Shared state: Multiple parts of your code can reference the same client instance
- Method receivers: Methods can modify the struct they're called on
The & and * operators:
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:
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:
- Function naming:
NewTypeorNewfor constructors - Initialization: Sets up sensible defaults (like timeout)
- Encapsulation: Hides the complexity of setting up the HTTP client
- 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:
func (c *Client) GetSystemStatus() (*SystemStatus, error)
This is a method, not a regular function:
(c *Client): The receiver - this method "belongs to" the Client typec: The receiver name (likeselfin Python orthisin JavaScript)*Client: Pointer receiver - can modify the client if needed
Why pointer receiver?
// 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:
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
Why this pattern?
- Explicit: You can't ignore errors (compiler forces you to handle them)
- Predictable: Every function that can fail returns
(result, error) - Composable: You can wrap errors to add context
Error Wrapping:
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:
defer resp.Body.Close()
defer schedules a function call to happen when the current function returns:
- Cleanup guarantee: Even if your function panics, deferred calls run
- LIFO order: Multiple defers run in Last-In-First-Out order
- Common pattern: Always defer cleanup operations after checking for errors
Deep Dive - JSON Unmarshaling:
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:
json.Unmarshal: Converts JSON bytes to Go structs&status: We pass a pointer soUnmarshalcan modify our struct- Struct tags: The
json:"version"tags tellUnmarshalwhere 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:
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:
// 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:
// 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:
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:
- *All arr services have common operations (status, health)
- Movie services have movie-specific operations
- 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:
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:
type RadarrClient struct {
*Client // Embedded struct
}
This is composition, not inheritance. Here's what happens:
Field Promotion:
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:
// 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:
// 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:
// 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 JSONjson.NewDecoder: When streaming directly from an io.Reader (more efficient for large responses)
Deep Dive - String Conversion:
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:
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 embeddedClient)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:
- Single namespace: All types/functions compete for names
- No encapsulation: Everything is visible to everything else
- Hard to test: Can't import
mainpackage in tests - 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:
- Short, clear names:
types,client,radarr(notradarr_client_implementation) - No underscores: Go prefers
httputiloverhttp_util - Singular nouns:
clientnotclients - Domain-focused: Each package has a single, clear purpose
pkg/types/types.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:
// 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:
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:
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:
import "github.com/yourusername/arr-client/pkg/types"
This import path is built from:
- Module path:
github.com/yourusername/arr-client(from go.mod) - 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:
// 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:
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:
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:
- Tests live alongside code:
client.goandclient_test.goin same package - Minimal framework: Just functions that start with
Test - Table-driven tests: Test multiple scenarios with data structures
- Fail fast: Tests should be fast and give clear feedback
6.2 Writing Unit Tests
Create pkg/radarr/radarr_test.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:
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:
- Real HTTP: Your code makes actual HTTP calls (not mocked)
- Controllable: You control exactly what the server returns
- Isolated: Each test gets its own server
- 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:
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 scenarioTestClient_GetMovies= testing theGetMoviesmethod onClientTestClient_GetMovie_NotFound= testingGetMoviewhen movie isn't found
Deep Dive - Error Testing Patterns:
// 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:
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:
- Test data in structs: Each test case is a struct with inputs and expected outputs
- Loop through cases:
for _, tc := range testCases - Subtests:
t.Run(tc.name, func(t *testing.T) {...})creates individual subtests - 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:
// 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?
- Explicit: You can't ignore errors
- Performance: No stack unwinding overhead
- Predictable: Error handling is in your control flow
- Composable: Errors can be wrapped and annotated
7.2 Custom Error Types
Create pkg/client/errors.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:
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?
- Rich context: More information than just a string
- Type-safe checking:
IsNotFound(err)instead of string matching - Behavior methods:
IsTemporary()tells you if you should retry - Structured logging: Error fields can be logged separately
The error interface:
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:
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}
This uses Go's type assertion:
err.(*APIError): Try to converterrto*APIErrorok: Boolean indicating if conversion succeededapiErr: 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:
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):
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):
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:
- DRY principle: Don't Repeat Yourself
- Consistent error handling: All requests handled the same way
- Easier to modify: Change headers/logging in one place
- Testability: Can test the common request logic separately
Deep Dive - Logging Interface:
type Logger interface {
Printf(format string, v ...interface{})
Println(v ...interface{})
}
Why use an interface instead of *log.Logger directly?
- Flexibility: Can plug in any logger (stdlib, logrus, zap, etc.)
- Testing: Can use a mock logger that captures logs for testing
- Configuration: Different log levels, formats, destinations
Example usage:
// 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:
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:
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:
- Error checking: Check for specific error types first
- Context addition: Add domain-specific context ("movie with ID %d")
- Error wrapping: Use
%wto 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:
func (r *Client) validateMovie(movie *types.Movie) error {
if movie.Title == "" {
return &client.ValidationError{
Field: "title",
Message: "title is required",
Value: movie.Title,
}
}
// ...
}
Benefits:
- Fast failure: Catch errors before expensive network calls
- Clear feedback: User knows exactly what's wrong
- 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:
- Goroutines: Lightweight threads managed by Go runtime
- 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:
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 func(serviceName string, svc ServiceClient) {
defer wg.Done()
// ... do work
}(name, service)
Breaking this down:
go: Starts a new goroutine (like a lightweight thread)- Anonymous function: The function to run in the goroutine
- Parameters:
(serviceName string, svc ServiceClient)- captures loop variables safely - Function call:
(name, service)- passes current loop values to the function
Why pass parameters instead of capturing loop variables?
// 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:
results := make(chan HealthResult, len(services))
Channel basics:
- Type-safe:
chan HealthResultonly carriesHealthResultvalues - Buffered:
len(services)capacity means sends won't block - Communication: Goroutines send results, main goroutine receives
Channel operations:
results <- HealthResult{...} // Send to channel
result := <-results // Receive from channel
close(results) // Close channel (no more sends)
Deep Dive - WaitGroups:
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:
- Add before starting goroutines
- Done when goroutine finishes (usually with
defer) - 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:
// 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<<uint(attempt)) // 2^attempt
select {
case <-ctx.Done():
return fmt.Errorf("operation cancelled during backoff: %w", ctx.Err())
case <-time.After(delay):
// Wait for delay or cancellation
}
}
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
}
Deep Dive - Context Package:
Context is Go's way of handling cancellation, timeouts, and request-scoped values:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Context types:
context.Background(): Root context, never cancelledcontext.WithTimeout(): Cancelled after a timeoutcontext.WithCancel(): Can be cancelled manuallycontext.WithDeadline(): Cancelled at a specific time
Deep Dive - Select Statement:
The select statement is like a switch for channel operations:
select {
case res := <-resultCh:
// Received result from channel
return &res.health, res.err
case <-timeoutCtx.Done():
// Context was cancelled/timed out
return nil, timeoutCtx.Err()
}
Select behavior:
- Multiple cases ready: Chooses one at random
- No cases ready: Blocks until one becomes ready
- Default case: Makes select non-blocking
Deep Dive - Exponential Backoff:
delay := baseDelay * time.Duration(1<<uint(attempt)) // 2^attempt
Why exponential backoff?
- Attempt 0: Wait baseDelay (e.g., 1 second)
- Attempt 1: Wait 2 * baseDelay (2 seconds)
- Attempt 2: Wait 4 * baseDelay (4 seconds)
- Attempt 3: Wait 8 * baseDelay (8 seconds)
This prevents overwhelming a struggling server with rapid retries.
Connection to Previous Examples: These concurrent functions use our enhanced error handling from Chapter 7. When operations fail, we get rich error information that helps with debugging.
8.4 Practical Usage Example
Let's create an example that ties everything together:
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/radarr"
"github.com/yourusername/arr-client/pkg/sonarr"
)
func main() {
// Create clients for multiple services
services := map[string]client.ServiceClient{
"radarr": radarr.NewClient("http://localhost:7878", "radarr-api-key"),
"sonarr": sonarr.NewClient("http://localhost:8989", "sonarr-api-key"),
}
// Example 1: Basic concurrent health checks
fmt.Println("=== Basic Concurrent Health Checks ===")
healthResults := client.CheckHealthConcurrently(services)
for _, result := range healthResults {
if result.Error != nil {
fmt.Printf("%s: ERROR - %v (took %v)\n",
result.ServiceName, result.Error, result.Duration)
} else {
fmt.Printf("%s: OK - %d health checks (took %v)\n",
result.ServiceName, len(result.Health), result.Duration)
}
}
// Example 2: Health checks with timeout
fmt.Println("\n=== Health Checks with Timeout ===")
ctx := context.Background()
timeoutResults := client.CheckMultipleServicesWithTimeout(ctx, services, 5*time.Second)
for _, result := range timeoutResults {
if result.Error != nil {
fmt.Printf("%s: FAILED - %v (took %v)\n",
result.ServiceName, result.Error, result.Duration)
} else {
fmt.Printf("%s: SUCCESS - %d checks (took %v)\n",
result.ServiceName, len(result.Health), result.Duration)
}
}
// Example 3: Retry with backoff
fmt.Println("\n=== Retry with Backoff Example ===")
radarrClient := radarr.NewClient("http://localhost:7878", "api-key")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := client.RetryWithBackoff(ctx, 3, 1*time.Second, func() error {
_, err := radarrClient.GetSystemStatus()
if err != nil {
log.Printf("Attempt failed: %v", err)
return err
}
return nil
})
if err != nil {
fmt.Printf("Failed to get system status after retries: %v\n", err)
} else {
fmt.Println("Successfully got system status!")
}
}
Real-world applications:
- Health dashboards: Check all services concurrently for fast updates
- Batch operations: Add multiple movies in parallel
- Resilient clients: Retry failed operations with backoff
- Timeout handling: Don't let slow services block your application
Connection to All Previous Examples: This brings together everything we've learned:
- Structs and interfaces (Chapters 2-4)
- Package organization (Chapter 5)
- Error handling and logging (Chapter 7)
- Concurrent operations with proper resource management
The concurrency layer adds reliability and performance without changing the core API design.
Chapter 9: Configuration and CLI - Building User-Friendly Tools
9.1 Configuration Management
Real applications need flexible configuration. Let's create a robust config system.
Create pkg/config/config.go:
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Config holds all application configuration
type Config struct {
// Service configurations
Services map[string]*ServiceConfig `json:"services"`
// Global settings
Timeout Duration `json:"timeout,omitempty"`
LogLevel string `json:"log_level,omitempty"`
LogFormat string `json:"log_format,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
}
// ServiceConfig represents configuration for a single *arr service
type ServiceConfig struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
Timeout Duration `json:"timeout,omitempty"`
Enabled bool `json:"enabled"`
// Service-specific settings
QualityProfile string `json:"quality_profile,omitempty"`
RootFolder string `json:"root_folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
// Duration is a wrapper around time.Duration for JSON marshaling
type Duration struct {
time.Duration
}
// MarshalJSON implements JSON marshaling for Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
// UnmarshalJSON implements JSON unmarshaling for Duration
func (d *Duration) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
duration, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration format '%s': %w", s, err)
}
d.Duration = duration
return nil
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
Services: make(map[string]*ServiceConfig),
Timeout: Duration{30 * time.Second},
LogLevel: "info",
LogFormat: "text",
MaxRetries: 3,
}
}
// LoadConfig loads configuration from a file
func LoadConfig(filename string) (*Config, error) {
// Start with defaults
config := DefaultConfig()
// Check if file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
return nil, fmt.Errorf("config file does not exist: %s", filename)
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
if err := json.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// LoadConfigWithDefaults loads config, creating default file if it doesn't exist
func LoadConfigWithDefaults(filename string) (*Config, error) {
if _, err := os.Stat(filename); os.IsNotExist(err) {
// Create default config file
defaultConfig := DefaultConfig()
// Add example services
defaultConfig.Services["radarr"] = &ServiceConfig{
URL: "http://localhost:7878",
APIKey: "your-radarr-api-key-here",
Enabled: false, // Disabled by default
}
defaultConfig.Services["sonarr"] = &ServiceConfig{
URL: "http://localhost:8989",
APIKey: "your-sonarr-api-key-here",
Enabled: false,
}
if err := defaultConfig.SaveConfig(filename); err != nil {
return nil, fmt.Errorf("creating default config: %w", err)
}
return defaultConfig, nil
}
return LoadConfig(filename)
}
// SaveConfig saves configuration to file
func (c *Config) SaveConfig(filename string) error {
// Create directory if it doesn't exist
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("writing config file: %w", err)
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Timeout.Duration <= 0 {
return fmt.Errorf("timeout must be positive, got %v", c.Timeout.Duration)
}
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if !validLogLevels[c.LogLevel] {
return fmt.Errorf("invalid log level '%s', must be one of: debug, info, warn, error", c.LogLevel)
}
if c.MaxRetries < 0 {
return fmt.Errorf("max_retries must be non-negative, got %d", c.MaxRetries)
}
// Validate each service
for name, service := range c.Services {
if err := service.Validate(name); err != nil {
return fmt.Errorf("service '%s': %w", name, err)
}
}
return nil
}
// Validate checks if a service configuration is valid
func (sc *ServiceConfig) Validate(serviceName string) error {
if sc.URL == "" {
return fmt.Errorf("URL is required")
}
if sc.APIKey == "" && sc.Enabled {
return fmt.Errorf("API key is required for enabled services")
}
if sc.Timeout.Duration < 0 {
return fmt.Errorf("timeout cannot be negative")
}
return nil
}
// GetService returns configuration for a specific service
func (c *Config) GetService(name string) (*ServiceConfig, bool) {
service, exists := c.Services[name]
return service, exists
}
// GetEnabledServices returns a map of only enabled services
func (c *Config) GetEnabledServices() map[string]*ServiceConfig {
enabled := make(map[string]*ServiceConfig)
for name, service := range c.Services {
if service.Enabled {
enabled[name] = service
}
}
return enabled
}
Deep Dive - Custom JSON Marshaling:
type Duration struct {
time.Duration
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
Why custom marshaling?
time.Duration marshals as nanoseconds (not user-friendly):
{"timeout": 30000000000} // 30 billion nanoseconds = 30 seconds
With custom marshaling:
{"timeout": "30s"} // Much clearer!
Deep Dive - Configuration Patterns:
- Defaults + Override: Start with sensible defaults, override from file
- Validation: Check configuration is valid before using it
- Auto-creation: Create default config file if none exists
- Nested structure: Group related settings (services, logging, etc.)
Connection to Previous Examples:
The configuration maps directly to our client constructors. A ServiceConfig contains everything needed to create a radarr.Client or sonarr.Client.
9.2 Command Line Interface
Update main.go with a comprehensive CLI:
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/yourusername/arr-client/pkg/client"
"github.com/yourusername/arr-client/pkg/config"
"github.com/yourusername/arr-client/pkg/radarr"
"github.com/yourusername/arr-client/pkg/sonarr"
)
var (
// Version info (set by build process)
version = "dev"
buildTime = "unknown"
gitCommit = "unknown"
)
func main() {
var (
configFile = flag.String("config", getDefaultConfigPath(), "Configuration file path")
command = flag.String("cmd", "status", "Command: status, health, movies, series, add-movie")
serviceName = flag.String("service", "", "Service to use (leave empty for all enabled services)")
timeout = flag.Duration("timeout", 0, "Request timeout (overrides config)")
verbose = flag.Bool("verbose", false, "Verbose output")
showVersion = flag.Bool("version", false, "Show version information")
// Command-specific flags
movieTitle = flag.String("title", "", "Movie title (for add-movie command)")
movieYear = flag.Int("year", 0, "Movie year (for add-movie command)")
tmdbID = flag.Int("tmdb-id", 0, "TMDB ID (for add-movie command)")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "A command-line client for *arr services (Radarr, Sonarr, etc.)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nCommands:\n")
fmt.Fprintf(os.Stderr, " status - Get system status\n")
fmt.Fprintf(os.Stderr, " health - Check service health\n")
fmt.Fprintf(os.Stderr, " movies - List movies (Radarr only)\n")
fmt.Fprintf(os.Stderr, " series - List series (Sonarr only)\n")
fmt.Fprintf(os.Stderr, " add-movie - Add a movie (requires -title, -year, -tmdb-id)\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s -cmd status\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd health -service radarr\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd movies -service radarr\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -cmd add-movie -service radarr