A Go client for WaniKani's API.
See the full API reference on Go.dev.
Contents:
- Client initialization
- Making API requests
- Setting API parameters
- Nil versus non-nil on API response structs
- Pagination
- Logging
- Handling errors
- Contexts
- Conditional requests
- Automatic retries
All API requests are made through wanikaniapi.Client
. Make sure to include an API token:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
...
}
Use an initialized client to make API requests:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
voiceActors, err := client.VoiceActorList(&wanikaniapi.VoiceActorListParams{})
if err != nil {
panic(err)
}
...
}
Function naming follows the pattern of <API resource><Action>
like AssignmentList
. Most resources support *Get
and *List
, and some support mutating operations like *Create
or *Start
.
Go makes no distinction between a value that was left unset versus one set to an empty value (e.g. ""
for a string), so API parameters use pointers so it can be determined which values were meant to be sent and which ones weren't.
The package provides a set of helper functions to make setting pointers easy:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
voiceActors, err := client.VoiceActorList(&wanikaniapi.VoiceActorListParams{
IDs: []wanikaniapi.WKID{1, 2, 3},
UpdatedAfter: wanikaniapi.Time(time.Now()),
})
if err != nil {
panic(err)
}
...
}
The following helpers are available:
No helpers are needed for setting slices like IDs
because slices are nil
by default.
Values in API responses may be a pointer or non-pointer based on whether they're defined as nullable or not nullable by the WaniKani API:
type LevelProgressionData struct {
AbandonedAt *time.Time `json:"abandoned_at"`
CreatedAt time.Time `json:"created_at"`
...
CreatedAt
always has a value and is therefore time.Time
. AbandonedAt
may be set or unset, and is therefore *time.Time
instead.
List endpoints return list objects which contain only a single page worth of data, although they do have a pointer to where the next page's worth can be fetched:
package main
import (
"fmt"
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
subjects, err := client.SubjectList(&wanikaniapi.SubjectListParams{})
if err != nil {
panic(err)
}
fmt.Printf("next page URL: %+v\n", subjects.Pages.NextURL)
}
Use the PageFully
helper to fully paginate an endpoint:
package main
import (
"fmt"
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
var subjects []*wanikaniapi.Subject
err := client.PageFully(func(id *wanikaniapi.WKID) (*wanikaniapi.PageObject, error) {
page, err := client.SubjectList(&wanikaniapi.SubjectListParams{
ListParams: wanikaniapi.ListParams{
PageAfterID: id,
},
})
if err != nil {
return nil, err
}
subjects = append(subjects, page.Data...)
return &page.PageObject, nil
})
if err != nil {
panic(err)
}
fmt.Printf("num subjects: %v\n", len(subjects))
}
But remember to cache aggressively to minimize load on WaniKani. See conditional requests below.
Configure a logger by passing a Logger
parameter while initializing a client:
package main
import (
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
Logger: &wanikaniapi.LeveledLogger{Level: wanikaniapi.LevelDebug},
})
...
}
Logger
expects a LeveledLoggerInterface
:
type LeveledLoggerInterface interface {
Debugf(format string, v ...interface{})
Errorf(format string, v ...interface{})
Infof(format string, v ...interface{})
Warnf(format string, v ...interface{})
}
The package includes a basic logger called LeveledLogger
that implements it.
Some popular loggers like Logrus and Zap's SugaredLogger also support this interface out-of-the-box so it's possible to set DefaultLeveledLogger
to a *logrus.Logger
or *zap.SugaredLogger
directly. For others it may be necessary to write a shim layer to support them.
API errors are returned as the special error struct *APIError
:
package main
import (
"fmt"
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
_, err := client.SubjectList(&wanikaniapi.SubjectListParams{})
if err != nil {
if apiErr, ok := err.(*wanikaniapi.APIError); ok {
fmt.Printf("WaniKani API error; status: %v, message: %s\n",
apiErr.StatusCode, apiErr.Message)
} else {
fmt.Printf("other error: %+v\n", err)
}
}
...
}
API calls may still return non-APIError
errors for non-API problems (e.g. network error, TLS error, unmarshaling error, etc.).
Pass your own HTTP client into wanikaniapi.NewClient
:
package main
import (
"fmt"
"net/http"
"os"
"time"
"github.com/brandur/wanikaniapi"
)
func main() {
httpClient := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: true,
},
}
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
HTTPClient: httpClient,
})
...
}
Go contexts can be passed through Params
:
package main
import (
"context"
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
_, err := client.SubjectList(&wanikaniapi.SubjectListParams{
Params: wanikaniapi.Params{
Context: &ctx,
},
})
if err != nil {
panic(err)
}
...
}
Conditional requests reduce load on the server by asking for a response only when data has changed. There are two separate mechanisms for this: If-Modified-Since
and If-None-Match
.
If-Modified-Since
works by feeding a value of the Last-Modified
header into future requests:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
subjects1, err := client.SubjectList(&wanikaniapi.SubjectListParams{})
if err != nil {
panic(err)
}
subjects2, err := client.SubjectList(&wanikaniapi.SubjectListParams{
Params: wanikaniapi.Params{
IfModifiedSince: wanikaniapi.Time(subjects1.LastModified),
},
})
if err != nil {
panic(err)
}
...
}
If-None-Match
works by feeding a value of the Etag
header into future requests:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
})
subjects1, err := client.SubjectList(&wanikaniapi.SubjectListParams{})
if err != nil {
panic(err)
}
subjects2, err := client.SubjectList(&wanikaniapi.SubjectListParams{
Params: wanikaniapi.Params{
IfNoneMatch: wanikaniapi.String(subjects1.ETag),
},
})
if err != nil {
panic(err)
}
...
}
The client can be configured to automatically retry errors that are known to be safe to retry:
package main
import (
"os"
"github.com/brandur/wanikaniapi"
)
func main() {
client := wanikaniapi.NewClient(&wanikaniapi.ClientConfig{
APIToken: os.Getenv("WANI_KANI_API_TOKEN"),
MaxRetries: 2,
})
...
}
Run the test suite:
go test .
Tests generally compare recorded requests so that they don't have to make live API calls, but there are a few tests for the trickier cases which will only run when an API token is set:
export WANI_KANI_API_TOKEN=
go test .
All code expects to be formatted. Check the current state with:
scripts/check_gofmt.sh
Format code with:
gofmt -w -s *.go
- Add entry and summarize changes to
CHANGELOG.md
. - Commit changes with message like "Bump to v0.1.0".
- Tag with
git tag v0.1.0
. - Push commit and tag with
git push --tags origin master
.
Make sure to follow semantic versioning and introduce breaking changes only across major versions. Publish as few major versions as possible though, so try not to introduce breaking changes.
Major version changes will also necessitate changes in the Go import path like a bump from /v1
to /v2
. See publishing Go modules.