diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..e6b830c --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,33 @@ +name: "Go Coverage" + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +jobs: + coverage: + # Ignore drafts + if: github.event.pull_request.draft == false + name: Go test with coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 10 + + - name: Set up Golang + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install dependencies + run: go mod tidy + + - uses: gwatts/go-coverage-action@v2 + id: coverage + with: + coverage-threshold: 60 + cover-pkg: ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7847ffc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Create Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Golang + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install dependencies + run: go mod tidy + + - name: Testing + run: go test -v ./... + + - name: Publish to pkg.go.dev + run: | + echo "VERSION=${{ github.ref_name }}" + GOPROXY=proxy.golang.org go list -m github.com/HawAPI/go-sdk@${{ github.ref_name }} \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..26c099a --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/hawapi + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/go-sdk.iml b/.idea/go-sdk.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/go-sdk.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..d7faeaa --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..f6d2542 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d74548e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 1837063..e6211e3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ -# go-sdk +# HawAPI - go-sdk + HawAPI SDK for Golang + +- [API Docs](https://hawapi.theproject.id/docs/) +- [SDK Docs](https://pkg.go.dev/github.com/HawAPI/go-sdk) + +## Topics + +- [Installation](#installation) +- [Usage](#usage) + - [Init client](#init-client) + - [Fetch information](#fetch-information) + - [Error handling](#error-handling) + +## Installation + +``` +go get github.com/HawAPI/go-sdk@latest +``` + +## Usage + +- [See examples](./examples) + +### Init client + +```go +package main + +import ( + "fmt" + + "github.com/HawAPI/go-sdk" +) + +func main() { + // Create a new client with default options + client := hawapi.NewClient() + + // Create client with custom options + client = hawapi.NewClientWithOpts(hawapi.Options{ + Endpoint: "http://localhost:8080/api", + // When using 'WithOpts' or 'NewClientWithOpts' the value of + // 'UseInMemoryCache' will be set to false + UseInMemoryCache: true, + // Version + // Language + // Token + // ... + }) + + // You can also change the options later + client.WithOpts(hawapi.Options{ + Language: "pt-BR", + // When using 'WithOpts' or 'NewClientWithOpts' the value of + // 'UseInMemoryCache' will be set to false + UseInMemoryCache: true, + }) +} +``` + +### Fetch information + +```go +package main + +import ( + "fmt" + + "github.com/HawAPI/go-sdk" +) + +func main() { + client := hawapi.NewClient() + + res, err := client.ListActors() + if err != nil { + panic(err) + } + + fmt.Println(res) +} +``` + +### Error handling + +- Check out the [hawapi.ErrorResponse](./pkg/hawapi/error.go) + +```go +package main + +import ( + "fmt" + + "github.com/HawAPI/go-sdk" + "github.com/google/uuid" +) + +func main() { + client := hawapi.NewClient() + + id, _ := uuid.Parse("") + res, err := client.FindActor(id) + if err != nil { + // If the error is coming from the API request, + // it'll be of type hawapi.ErrorResponse. + if resErr, ok := err.(hawapi.ErrorResponse); ok { + fmt.Printf("API error %d Message: %s\n", resErr.Code, resErr.Message) + } else { + fmt.Println("SDK error:", err) + } + } + + fmt.Println(res) +} +``` \ No newline at end of file diff --git a/examples/create.go b/examples/create.go new file mode 100644 index 0000000..51b9b4a --- /dev/null +++ b/examples/create.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + + "github.com/HawAPI/go-sdk/pkg/hawapi" +) + +func main() { + client := hawapi.NewClient() + client.WithOpts(hawapi.Options{ + // JWT auth is required when performing any POST, PATCH and DELETE requests + Token: "", + }) + + actor := hawapi.CreateActor{ + // ... + } + res, err := client.CreateActor(actor) + if err != nil { + panic(err) + } + + fmt.Println(res.FirstName) +} diff --git a/examples/list.go b/examples/list.go new file mode 100644 index 0000000..3fa788a --- /dev/null +++ b/examples/list.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + + "github.com/HawAPI/go-sdk/pkg/hawapi" +) + +func main() { + // Create a new client with default options + client := hawapi.NewClient() + + // Override options + client.WithOpts(hawapi.Options{ + Endpoint: "http://localhost:8080/api", + // When using 'WithOpts' or 'NewClientWithOpts' the value of + // 'UseInMemoryCache' will be set to false + UseInMemoryCache: true, + }) + + res, err := client.ListActors() + if err != nil { + panic(err) + } + + fmt.Println(res) + fmt.Println(len(res.Data)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ceb5da --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/HawAPI/go-sdk + +go 1.22.0 + +require ( + github.com/fatih/color v1.17.0 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..152870e --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..278554e --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,49 @@ +package cache + +// Cache is a simple key / value cache +type Cache interface { + Get(key string) (any, bool) + Set(key string, value any) + Del(key string) + Size() int + Clear() int +} + +type memoryCache struct { + cache map[string]any +} + +// NewMemoryCache creates a new Cache +func NewMemoryCache() Cache { + return &memoryCache{ + cache: make(map[string]any), + } +} + +// Get will try to get associated with a key from the cache, if present +func (c *memoryCache) Get(key string) (any, bool) { + v, ok := c.cache[key] + return v, ok +} + +// Set will store a key-value pair in the cache +func (c *memoryCache) Set(key string, value any) { + c.cache[key] = value +} + +// Del will remove a key and its associated value from the cache. +func (c *memoryCache) Del(key string) { + delete(c.cache, key) +} + +// Size will return the current number of entries in the cache. +func (c *memoryCache) Size() int { + return len(c.cache) +} + +// Clear will empty the cache, removing all stored key-value pairs. +func (c *memoryCache) Clear() int { + count := len(c.cache) + c.cache = make(map[string]any) + return count +} diff --git a/pkg/hawapi/actor.go b/pkg/hawapi/actor.go new file mode 100644 index 0000000..f07cbff --- /dev/null +++ b/pkg/hawapi/actor.go @@ -0,0 +1,148 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const actorOrigin = "actors" + +type Social struct { + Social string `json:"social,omitempty"` + Handle string `json:"handle,omitempty"` + URL string `json:"url,omitempty"` +} + +type Actor struct { + UUID uuid.UUID `json:"uuid"` + Href string `json:"href"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Nicknames []string `json:"nicknames,omitempty"` + Socials []Social `json:"socials,omitempty"` + Nationality string `json:"nationality,omitempty"` + BirthDate string `json:"birth_date,omitempty"` + DeathDate string `json:"death_date,omitempty"` + Gender int `json:"gender,omitempty"` + Seasons []string `json:"seasons,omitempty"` + Awards []string `json:"awards,omitempty"` + Character string `json:"character"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateActor struct { + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Nicknames []string `json:"nicknames,omitempty"` + Socials []Social `json:"socials,omitempty"` + Nationality string `json:"nationality,omitempty"` + BirthDate string `json:"birth_date,omitempty"` + DeathDate string `json:"death_date,omitempty"` + Gender int `json:"gender,omitempty"` + Seasons []string `json:"seasons,omitempty"` + Awards []string `json:"awards,omitempty"` + Character string `json:"character,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchActor = CreateActor + +type ActorResponse struct { + BaseResponse + Data Actor `json:"data"` +} + +type ActorListResponse struct { + BaseResponse + Data []Actor `json:"data"` +} + +// ListActors will get all actors +func (c *Client) ListActors(options ...QueryOptions) (ActorListResponse, error) { + var actors []Actor + var res ActorListResponse + + doRes, err := c.doGetRequest(actorOrigin, options, &actors) + if err != nil { + return res, err + } + + res = ActorListResponse{ + BaseResponse: doRes, + Data: actors, + } + + return res, nil +} + +// FindActor will get a single item by uuid +func (c *Client) FindActor(id uuid.UUID) (ActorResponse, error) { + var actor Actor + var res ActorResponse + + doRes, err := c.doGetRequest(actorOrigin+"/"+id.String(), nil, &actor) + if err != nil { + return res, err + } + + res = ActorResponse{ + BaseResponse: doRes, + Data: actor, + } + + return res, nil +} + +func (c *Client) RandomActor() (ActorResponse, error) { + var actor Actor + var res ActorResponse + + doRes, err := c.doGetRequest(actorOrigin+"/random", nil, &actor) + if err != nil { + return res, err + } + + res = ActorResponse{ + BaseResponse: doRes, + Data: actor, + } + + return res, nil +} + +func (c *Client) CreateActor(s CreateActor) (Actor, error) { + var actor Actor + + err := c.doPostRequest(actorOrigin, s, &actor) + if err != nil { + return actor, err + } + + return actor, nil +} + +func (c *Client) PatchActor(id uuid.UUID, p PatchActor) (Actor, error) { + var actor Actor + + err := c.doPatchRequest(actorOrigin+"/"+id.String(), &p) + if err != nil { + return actor, err + } + + res, err := c.FindActor(id) + if err != nil { + return actor, err + } + + actor = res.Data + return actor, nil +} + +func (c *Client) DeleteActor(id uuid.UUID) error { + return c.doDeleteRequest(actorOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/character.go b/pkg/hawapi/character.go new file mode 100644 index 0000000..a622754 --- /dev/null +++ b/pkg/hawapi/character.go @@ -0,0 +1,134 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const characterOrigin = "characters" + +type Character struct { + Uuid uuid.UUID `json:"uuid"` + Href string `json:"href"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Nicknames []string `json:"nicknames,omitempty"` + Gender int `json:"gender"` + Actor string `json:"actor"` + BirthDate string `json:"birth_date,omitempty"` + DeathDate string `json:"death_date,omitempty"` + Thumbnail string `json:"thumbnail"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateCharacter struct { + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Nicknames []string `json:"nicknames,omitempty"` + Gender int `json:"gender,omitempty"` + Actor string `json:"actor,omitempty"` + BirthDate string `json:"birth_date,omitempty"` + DeathDate string `json:"death_date,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchCharacter = CreateCharacter + +type CharacterResponse struct { + BaseResponse + Data Character `json:"data"` +} + +type CharacterListResponse struct { + BaseResponse + Data []Character `json:"data"` +} + +// ListCharacters will get all characters +func (c *Client) ListCharacters(options ...QueryOptions) (CharacterListResponse, error) { + var characters []Character + var res CharacterListResponse + + doRes, err := c.doGetRequest(characterOrigin, options, &characters) + if err != nil { + return res, err + } + + res = CharacterListResponse{ + BaseResponse: doRes, + Data: characters, + } + + return res, nil +} + +// FindCharacter will get a single item by uuid +func (c *Client) FindCharacter(id uuid.UUID) (CharacterResponse, error) { + var character Character + var res CharacterResponse + + doRes, err := c.doGetRequest(characterOrigin+"/"+id.String(), nil, &character) + if err != nil { + return res, err + } + + res = CharacterResponse{ + BaseResponse: doRes, + Data: character, + } + + return res, nil +} + +func (c *Client) RandomCharacter() (CharacterResponse, error) { + var character Character + var res CharacterResponse + + doRes, err := c.doGetRequest(characterOrigin+"/random", nil, &character) + if err != nil { + return res, err + } + + res = CharacterResponse{ + BaseResponse: doRes, + Data: character, + } + + return res, nil +} + +func (c *Client) CreateCharacter(s CreateCharacter) (Character, error) { + var character Character + + err := c.doPostRequest(characterOrigin, s, &character) + if err != nil { + return character, err + } + + return character, nil +} + +func (c *Client) PatchCharacter(id uuid.UUID, p PatchCharacter) (Character, error) { + var character Character + + err := c.doPatchRequest(characterOrigin+"/"+id.String(), &p) + if err != nil { + return character, err + } + + res, err := c.FindCharacter(id) + if err != nil { + return character, err + } + + character = res.Data + return character, nil +} + +func (c *Client) DeleteCharacter(id uuid.UUID) error { + return c.doDeleteRequest(characterOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/episode.go b/pkg/hawapi/episode.go new file mode 100644 index 0000000..89cd660 --- /dev/null +++ b/pkg/hawapi/episode.go @@ -0,0 +1,136 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const episodeOrigin = "episodes" + +type Episode struct { + Uuid uuid.UUID `json:"uuid"` + Href string `json:"href"` + Title string `json:"title"` + Description string `json:"description"` + Language string `json:"language"` + Duration int64 `json:"duration"` + Season string `json:"season"` + EpisodeNum byte `json:"episode_num"` + NextEpisode string `json:"next_episode,omitempty"` + PrevEpisode string `json:"prev_episode,omitempty"` + Thumbnail string `json:"thumbnail"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateEpisode struct { + Title string `json:"title"` + Description string `json:"description"` + Language string `json:"language"` + Duration int64 `json:"duration"` + Season string `json:"season"` + EpisodeNum byte `json:"episode_num"` + NextEpisode string `json:"next_episode,omitempty"` + PrevEpisode string `json:"prev_episode,omitempty"` + Thumbnail string `json:"thumbnail"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchEpisode = CreateEpisode + +type EpisodeResponse struct { + BaseResponse + Data Episode `json:"data"` +} + +type EpisodeListResponse struct { + BaseResponse + Data []Episode `json:"data"` +} + +// ListEpisodes will get all episodes +func (c *Client) ListEpisodes(options ...QueryOptions) (EpisodeListResponse, error) { + var episodes []Episode + var res EpisodeListResponse + + doRes, err := c.doGetRequest(episodeOrigin, options, &episodes) + if err != nil { + return res, err + } + + res = EpisodeListResponse{ + BaseResponse: doRes, + Data: episodes, + } + + return res, nil +} + +// FindEpisode will get a single item by uuid +func (c *Client) FindEpisode(id uuid.UUID) (EpisodeResponse, error) { + var episode Episode + var res EpisodeResponse + + doRes, err := c.doGetRequest(episodeOrigin+"/"+id.String(), nil, &episode) + if err != nil { + return res, err + } + + res = EpisodeResponse{ + BaseResponse: doRes, + Data: episode, + } + + return res, nil +} + +func (c *Client) RandomEpisode() (EpisodeResponse, error) { + var episode Episode + var res EpisodeResponse + + doRes, err := c.doGetRequest(episodeOrigin+"/random", nil, &episode) + if err != nil { + return res, err + } + + res = EpisodeResponse{ + BaseResponse: doRes, + Data: episode, + } + + return res, nil +} + +func (c *Client) CreateEpisode(s CreateEpisode) (Episode, error) { + var episode Episode + + err := c.doPostRequest(episodeOrigin, s, &episode) + if err != nil { + return episode, err + } + + return episode, nil +} + +func (c *Client) PatchEpisode(id uuid.UUID, p PatchEpisode) (Episode, error) { + var episode Episode + + err := c.doPatchRequest(episodeOrigin+"/"+id.String(), &p) + if err != nil { + return episode, err + } + + res, err := c.FindEpisode(id) + if err != nil { + return episode, err + } + + episode = res.Data + return episode, nil +} + +func (c *Client) DeleteEpisode(id uuid.UUID) error { + return c.doDeleteRequest(episodeOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/error.go b/pkg/hawapi/error.go new file mode 100644 index 0000000..1717ef1 --- /dev/null +++ b/pkg/hawapi/error.go @@ -0,0 +1,26 @@ +package hawapi + +import "fmt" + +type ErrorResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Method string `json:"method"` + Cause string `json:"cause"` + Url string `json:"url"` + Message string `json:"message,omitempty"` +} + +func (e ErrorResponse) Error() string { + msg := fmt.Sprintf("request error [%s %d] using %s method", e.Status, e.Code, e.Method) + + if len(e.Url) != 0 { + msg = fmt.Sprintf("%s on '%s'", msg, e.Url) + } + + if len(e.Message) != 0 { + msg = fmt.Sprintf("%s: %s", msg, e.Message) + } + + return msg +} diff --git a/pkg/hawapi/game.go b/pkg/hawapi/game.go new file mode 100644 index 0000000..cfe22b0 --- /dev/null +++ b/pkg/hawapi/game.go @@ -0,0 +1,150 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const gameOrigin = "games" + +type Game struct { + Uuid string `json:"uuid"` + Href string `json:"href"` + Name string `json:"name"` + Description string `json:"description"` + Playtime int64 `json:"playtime"` + Language string `json:"language"` + Platforms []string `json:"platforms,omitempty"` + Stores []string `json:"stores,omitempty"` + Modes []string `json:"modes,omitempty"` + Genres []string `json:"genres,omitempty"` + Publishers []string `json:"publishers,omitempty"` + Developers []string `json:"developers,omitempty"` + Website string `json:"website"` + Tags []string `json:"tags,omitempty"` + Trailer string `json:"trailer"` + AgeRating string `json:"age_rating"` + ReleaseDate string `json:"release_date"` + Thumbnail string `json:"thumbnail"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateGame struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Playtime int64 `json:"playtime,omitempty"` + Language string `json:"language,omitempty"` + Platforms []string `json:"platforms,omitempty,omitempty"` + Stores []string `json:"stores,omitempty,omitempty"` + Modes []string `json:"modes,omitempty,omitempty"` + Genres []string `json:"genres,omitempty,omitempty"` + Publishers []string `json:"publishers,omitempty,omitempty"` + Developers []string `json:"developers,omitempty,omitempty"` + Website string `json:"website,omitempty"` + Tags []string `json:"tags,omitempty,omitempty"` + Trailer string `json:"trailer,omitempty"` + AgeRating string `json:"age_rating,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchGame = CreateGame + +type GameResponse struct { + BaseResponse + Data Game `json:"data"` +} + +type GameListResponse struct { + BaseResponse + Data []Game `json:"data"` +} + +// ListGames will get all games +func (c *Client) ListGames(options ...QueryOptions) (GameListResponse, error) { + var games []Game + var res GameListResponse + + doRes, err := c.doGetRequest(gameOrigin, options, &games) + if err != nil { + return res, err + } + + res = GameListResponse{ + BaseResponse: doRes, + Data: games, + } + + return res, nil +} + +// FindGame will get a single item by uuid +func (c *Client) FindGame(id uuid.UUID) (GameResponse, error) { + var game Game + var res GameResponse + + doRes, err := c.doGetRequest(gameOrigin+"/"+id.String(), nil, &game) + if err != nil { + return res, err + } + + res = GameResponse{ + BaseResponse: doRes, + Data: game, + } + + return res, nil +} + +func (c *Client) RandomGame() (GameResponse, error) { + var game Game + var res GameResponse + + doRes, err := c.doGetRequest(gameOrigin+"/random", nil, &game) + if err != nil { + return res, err + } + + res = GameResponse{ + BaseResponse: doRes, + Data: game, + } + + return res, nil +} + +func (c *Client) CreateGame(s CreateGame) (Game, error) { + var game Game + + err := c.doPostRequest(gameOrigin, s, &game) + if err != nil { + return game, err + } + + return game, nil +} + +func (c *Client) PatchGame(id uuid.UUID, p PatchGame) (Game, error) { + var game Game + + err := c.doPatchRequest(gameOrigin+"/"+id.String(), &p) + if err != nil { + return game, err + } + + res, err := c.FindGame(id) + if err != nil { + return game, err + } + + game = res.Data + return game, nil +} + +func (c *Client) DeleteGame(id uuid.UUID) error { + return c.doDeleteRequest(gameOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/hawapi.go b/pkg/hawapi/hawapi.go new file mode 100644 index 0000000..d1fba0e --- /dev/null +++ b/pkg/hawapi/hawapi.go @@ -0,0 +1,166 @@ +package hawapi + +import ( + "log/slog" + "net/http" + "os" + "time" + + "github.com/HawAPI/go-sdk/pkg/cache" +) + +const ( + DefaultLogLevel = slog.LevelInfo + DefaultEndpoint = "https://hawapi.theproject.id/api" + DefaultVersion = "v1" + DefaultLanguage = "en-US" + DefaultSize = 10 + DefaultTimeout = 10 + DefaultUseInMemoryCache = true +) + +// DefaultOptions for Go HawAPI SDK +var DefaultOptions = Options{ + Endpoint: DefaultEndpoint, + Version: DefaultVersion, + Language: DefaultLanguage, + Size: DefaultSize, + Timeout: DefaultTimeout, + UseInMemoryCache: DefaultUseInMemoryCache, + LogLevel: DefaultLogLevel, + LogHandler: nil, +} + +type Options struct { + // The endpoint of the HawAPI instance + // + // Default value: DefaultEndpoint + Endpoint string + + // The version of the API + Version string + + // The language of items for all requests + // + // Note: This value can be overwritten later + Language string + + // The size of items for all requests + // + // Note: This value can be overwritten later + Size int + + // The timeout of a response in milliseconds + Timeout int + + // The HawAPI token (JWT) + // + // By default, all requests are made with 'ANONYMOUS' tier + Token string + + // Define if the package should save (in-memory) all request results + UseInMemoryCache bool + + // Define the level of SDK logging + // + // NOTE: If you are using a custom LogHandler, use slog.HandlerOptions to define a new log level or the SDK will panic + LogLevel slog.Level + + // Defines the log handler. + // + // If set to nil, it defaults to NewFormattedHandler + LogHandler slog.Handler +} + +// Client is the [HawAPI] golang client. +// +// - [GitHub] +// - [Examples] +// +// [HawAPI]: https://github.com/HawAPI/HawAPI +// [GitHub]: https://github.com/HawAPI/go-sdk/ +// [Examples]: https://github.com/HawAPI/go-sdk/examples/ +type Client struct { + options Options + client *http.Client + logger *slog.Logger + cache cache.Cache +} + +// NewClient creates a new HawAPI client using the default options. +func NewClient() Client { + c := Client{options: DefaultOptions} + + c.client = &http.Client{ + Timeout: time.Duration(c.options.Timeout) * time.Second, + } + + c.logger = slog.New(NewFormattedHandler(os.Stdout, &slog.HandlerOptions{ + Level: c.options.LogLevel, + })) + + c.cache = cache.NewMemoryCache() + return c +} + +// NewClientWithOpts creates a new HawAPI client using custom options. +func NewClientWithOpts(options Options) Client { + c := NewClient() + c.WithOpts(options) + return c +} + +// WithOpts will set or override current client options +func (c *Client) WithOpts(options Options) { + if len(options.Endpoint) != 0 { + c.options.Endpoint = options.Endpoint + } + + if len(options.Version) != 0 { + c.options.Version = options.Version + } + + if len(options.Language) != 0 { + c.options.Language = options.Language + } + + if options.Size != 0 { + c.options.Size = options.Size + } + + if options.Timeout != 0 { + c.options.Timeout = options.Timeout + } + + if options.LogLevel != DefaultLogLevel { + c.options.LogLevel = options.LogLevel + + if options.LogHandler != nil { + panic("when defining log handler, use slog.HandlerOptions instead") + } + + c.logger = slog.New(NewFormattedHandler(os.Stdout, &slog.HandlerOptions{ + Level: options.LogLevel, + })) + } + + if options.LogHandler != nil { + c.logger = slog.New(options.LogHandler) + } + + if !options.UseInMemoryCache { + c.logger.Warn("Using WithOpts method, the value of UseInMemoryCache will be set to false") + } + + c.options.UseInMemoryCache = options.UseInMemoryCache +} + +// ClearCache deletes all values from the cache and returns the count of deleted items +func (c *Client) ClearCache() int { + return c.cache.Clear() +} + +// CacheSize returns the count cache items +func (c *Client) CacheSize() int { + return c.cache.Size() +} diff --git a/pkg/hawapi/info.go b/pkg/hawapi/info.go new file mode 100644 index 0000000..59333a4 --- /dev/null +++ b/pkg/hawapi/info.go @@ -0,0 +1,35 @@ +package hawapi + +import "net/http" + +type Info struct { + Title string `json:"title"` + Description string `json:"description"` + Version string `json:"version"` + Url string `json:"url"` + Docs string `json:"docs"` + Github string `json:"github"` + License string `json:"license"` + GithubHome string `json:"github_home"` + ApiUrl string `json:"api_url"` + ApiVersion string `json:"api_version"` + ApiPath string `json:"api_path"` + ApiBaseUrl string `json:"api_base_url"` + LicenseUrl string `json:"license_url"` +} + +func (c *Client) Info() (Info, error) { + var info Info + + req, err := http.NewRequest(http.MethodGet, c.options.Endpoint, nil) + if err != nil { + return info, err + } + + _, err = c.doRequest(req, http.StatusOK, &info) + if err != nil { + return info, err + } + + return info, nil +} diff --git a/pkg/hawapi/location.go b/pkg/hawapi/location.go new file mode 100644 index 0000000..6831b4f --- /dev/null +++ b/pkg/hawapi/location.go @@ -0,0 +1,126 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const locationOrigin = "locations" + +type Location struct { + Uuid uuid.UUID `json:"uuid"` + Href string `json:"href"` + Name string `json:"name"` + Description string `json:"description"` + Language string `json:"language"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateLocation struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Language string `json:"language,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchLocation = CreateLocation + +type LocationResponse struct { + BaseResponse + Data Location `json:"data"` +} + +type LocationListResponse struct { + BaseResponse + Data []Location `json:"data"` +} + +// ListLocations will get all locations +func (c *Client) ListLocations(options ...QueryOptions) (LocationListResponse, error) { + var locations []Location + var res LocationListResponse + + doRes, err := c.doGetRequest(locationOrigin, options, &locations) + if err != nil { + return res, err + } + + res = LocationListResponse{ + BaseResponse: doRes, + Data: locations, + } + + return res, nil +} + +// FindLocation will get a single item by uuid +func (c *Client) FindLocation(id uuid.UUID) (LocationResponse, error) { + var location Location + var res LocationResponse + + doRes, err := c.doGetRequest(locationOrigin+"/"+id.String(), nil, &location) + if err != nil { + return res, err + } + + res = LocationResponse{ + BaseResponse: doRes, + Data: location, + } + + return res, nil +} + +func (c *Client) RandomLocation() (LocationResponse, error) { + var location Location + var res LocationResponse + + doRes, err := c.doGetRequest(locationOrigin+"/random", nil, &location) + if err != nil { + return res, err + } + + res = LocationResponse{ + BaseResponse: doRes, + Data: location, + } + + return res, nil +} + +func (c *Client) CreateLocation(s CreateLocation) (Location, error) { + var location Location + + err := c.doPostRequest(locationOrigin, s, &location) + if err != nil { + return location, err + } + + return location, nil +} + +func (c *Client) PatchLocation(id uuid.UUID, p PatchLocation) (Location, error) { + var location Location + + err := c.doPatchRequest(locationOrigin+"/"+id.String(), &p) + if err != nil { + return location, err + } + + res, err := c.FindLocation(id) + if err != nil { + return location, err + } + + location = res.Data + return location, nil +} + +func (c *Client) DeleteLocation(id uuid.UUID) error { + return c.doDeleteRequest(locationOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/logger.go b/pkg/hawapi/logger.go new file mode 100644 index 0000000..15e2a35 --- /dev/null +++ b/pkg/hawapi/logger.go @@ -0,0 +1,70 @@ +package hawapi + +import ( + "context" + "encoding/json" + "io" + "log" + "log/slog" + + "github.com/fatih/color" +) + +type FormatterHandler struct { + slog.Handler + logger *log.Logger + attrs []slog.Attr +} + +func NewFormattedHandler(out io.Writer, opts *slog.HandlerOptions) *FormatterHandler { + return &FormatterHandler{ + Handler: slog.NewJSONHandler(out, opts), + logger: log.New(out, "", 0), + } +} + +func (h *FormatterHandler) Handle(_ context.Context, r slog.Record) error { + fields := make(map[string]interface{}, r.NumAttrs()) + + r.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + return true + }) + + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + + var b []byte + var err error + if len(fields) > 0 { + b, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return err + } + } + + timeStr := r.Time.Local().Format("2006/01/02 15:04:05") + msg := r.Message + + level := r.Level.String() + ":" + switch r.Level { + case slog.LevelDebug: + level = color.BlueString(level) + case slog.LevelInfo: + level = color.GreenString(level) + case slog.LevelWarn: + level = color.YellowString(level) + case slog.LevelError: + level = color.RedString(level) + } + + h.logger.Println( + timeStr, + level, + msg, + color.WhiteString(string(b)), + ) + + return nil +} diff --git a/pkg/hawapi/overview.go b/pkg/hawapi/overview.go new file mode 100644 index 0000000..4cc456f --- /dev/null +++ b/pkg/hawapi/overview.go @@ -0,0 +1,37 @@ +package hawapi + +type DataCount struct { + Actors int `json:"actors"` + Characters int `json:"characters"` + Episodes int `json:"episodes"` + Games int `json:"games"` + Locations int `json:"locations"` + Seasons int `json:"seasons"` + Soundtracks int `json:"soundtracks"` +} + +type Overview struct { + Uuid string `json:"uuid"` + Href string `json:"href"` + Sources []string `json:"sources"` + Thumbnail string `json:"thumbnail"` + Title string `json:"title"` + Description string `json:"description"` + Language string `json:"language"` + Languages []string `json:"languages"` + Creators []string `json:"creators"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DataCount DataCount `json:"data_count"` +} + +func (c *Client) Overview(options ...QueryOptions) (Overview, error) { + var overview Overview + + _, err := c.doGetRequest("overview", options, &overview) + if err != nil { + return overview, err + } + + return overview, nil +} diff --git a/pkg/hawapi/pageable.go b/pkg/hawapi/pageable.go new file mode 100644 index 0000000..6b503e4 --- /dev/null +++ b/pkg/hawapi/pageable.go @@ -0,0 +1,15 @@ +package hawapi + +type Pageable struct { + Page int `json:"page"` + Size int `json:"size"` + Sort string `json:"sort"` + Order string `json:"order"` +} + +var DefaultPageable = Pageable{ + Page: 1, + Size: DefaultSize, + Sort: "", + Order: "ASC", +} diff --git a/pkg/hawapi/query_options.go b/pkg/hawapi/query_options.go new file mode 100644 index 0000000..69036fb --- /dev/null +++ b/pkg/hawapi/query_options.go @@ -0,0 +1,80 @@ +package hawapi + +type Filters map[string]string + +type queryOptions struct { + Pageable + Filters +} + +type QueryOptions func(*queryOptions) + +func NewQueryOptions(pageable Pageable, filters Filters) QueryOptions { + return func(o *queryOptions) { + o.Pageable = pageable + o.Filters = filters + } +} + +// newQueryOptions wil create a new queryOptions with default and pre-defined values. +// +// Values like 'page size' and 'language' are configured on Client initialization +func (c *Client) newQueryOptions() queryOptions { + opts := queryOptions{ + Pageable: Pageable{ + Page: 1, + Size: DefaultSize, + Sort: "", + Order: "ASC", + }, + Filters: make(Filters), + } + + return opts +} + +func WithFilters(filters Filters) QueryOptions { + return func(o *queryOptions) { + o.Filters = filters + } +} + +func WithFilter(key string, value string) QueryOptions { + return func(o *queryOptions) { + o.Filters[key] = value + } +} + +func WithLanguage(language string) QueryOptions { + return WithFilter("language", language) +} + +func WithPageable(pageable Pageable) QueryOptions { + return func(o *queryOptions) { + o.Pageable = pageable + } +} + +func WithPage(page int) QueryOptions { + return func(o *queryOptions) { + o.Page = page + } +} + +func WithSize(size int) QueryOptions { + return func(o *queryOptions) { + o.Size = size + } +} + +func WithSort(sort string) QueryOptions { + return func(o *queryOptions) { + o.Sort = sort + } +} + +func WithOrder(order string) QueryOptions { + return func(o *queryOptions) { + o.Order = order + } +} diff --git a/pkg/hawapi/response.go b/pkg/hawapi/response.go new file mode 100644 index 0000000..2242485 --- /dev/null +++ b/pkg/hawapi/response.go @@ -0,0 +1,27 @@ +package hawapi + +// Quota represents the quota status +type Quota struct { + Remaining int `json:"remaining,omitempty"` +} + +// HeaderResponse represents the formatted header response from a request +type HeaderResponse struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + PageTotal int `json:"page_total,omitempty"` + ItemSize int `json:"item_size,omitempty"` + NextPage int `json:"next_page,omitempty"` + PrevPage int `json:"prev_page,omitempty"` + Language string `json:"language,omitempty"` + Quota Quota `json:"quota"` + Etag string `json:"etag"` + Length int `json:"length"` +} + +// BaseResponse represents all required response fields +type BaseResponse struct { + HeaderResponse + Cached bool `json:"cached,omitempty"` + Status int `json:"status"` +} diff --git a/pkg/hawapi/season.go b/pkg/hawapi/season.go new file mode 100644 index 0000000..2f29c87 --- /dev/null +++ b/pkg/hawapi/season.go @@ -0,0 +1,144 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const seasonOrigin = "seasons" + +type Season struct { + Uuid uuid.UUID `json:"uuid"` + Href string `json:"href"` + Title string `json:"title"` + Description string `json:"description"` + Language string `json:"language"` + Genres []string `json:"genres,omitempty"` + Episodes []string `json:"episodes,omitempty"` + Trailers []string `json:"trailers,omitempty"` + Budget int `json:"budget"` + DurationTotal int64 `json:"duration_total"` + SeasonNum byte `json:"season_num"` + ReleaseDate string `json:"release_date"` + NextSeason string `json:"next_season,omitempty"` + PrevSeason string `json:"prev_season,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateSeason struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Language string `json:"language,omitempty"` + Genres []string `json:"genres,omitempty"` + Episodes []string `json:"episodes,omitempty"` + Trailers []string `json:"trailers,omitempty"` + Budget int `json:"budget,omitempty"` + DurationTotal int64 `json:"duration_total,omitempty"` + SeasonNum byte `json:"season_num,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + NextSeason string `json:"next_season,omitempty"` + PrevSeason string `json:"prev_season,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchSeason = CreateSeason + +type SeasonResponse struct { + BaseResponse + Data Season `json:"data"` +} + +type SeasonListResponse struct { + BaseResponse + Data []Season `json:"data"` +} + +// ListSeasons will get all seasons +func (c *Client) ListSeasons(options ...QueryOptions) (SeasonListResponse, error) { + var seasons []Season + var res SeasonListResponse + + doRes, err := c.doGetRequest(seasonOrigin, options, &seasons) + if err != nil { + return res, err + } + + res = SeasonListResponse{ + BaseResponse: doRes, + Data: seasons, + } + + return res, nil +} + +// FindSeason will get a single item by uuid +func (c *Client) FindSeason(id uuid.UUID) (SeasonResponse, error) { + var season Season + var res SeasonResponse + + doRes, err := c.doGetRequest(seasonOrigin+"/"+id.String(), nil, &season) + if err != nil { + return res, err + } + + res = SeasonResponse{ + BaseResponse: doRes, + Data: season, + } + + return res, nil +} + +func (c *Client) RandomSeason() (SeasonResponse, error) { + var season Season + var res SeasonResponse + + doRes, err := c.doGetRequest(seasonOrigin+"/random", nil, &season) + if err != nil { + return res, err + } + + res = SeasonResponse{ + BaseResponse: doRes, + Data: season, + } + + return res, nil +} + +func (c *Client) CreateSeason(s CreateSeason) (Season, error) { + var season Season + + err := c.doPostRequest(seasonOrigin, s, &season) + if err != nil { + return season, err + } + + return season, nil +} + +func (c *Client) PatchSeason(id uuid.UUID, p PatchSeason) (Season, error) { + var season Season + + err := c.doPatchRequest(seasonOrigin+"/"+id.String(), &p) + if err != nil { + return season, err + } + + res, err := c.FindSeason(id) + if err != nil { + return season, err + } + + season = res.Data + return season, nil +} + +func (c *Client) DeleteSeason(id uuid.UUID) error { + return c.doDeleteRequest(seasonOrigin + "/" + id.String()) +} diff --git a/pkg/hawapi/service.go b/pkg/hawapi/service.go new file mode 100644 index 0000000..689a72b --- /dev/null +++ b/pkg/hawapi/service.go @@ -0,0 +1,358 @@ +package hawapi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strconv" + "strings" +) + +type cachedBaseResponse struct { + BaseResponse + data []byte +} + +const ( + // ApiHeaderRateLimitRemaining is the API rate limit remaining + apiHeaderRateLimitRemaining = "X-Rate-Limit-Remaining" + + // ApiHeaderPageIndex is the API page index header + apiHeaderPageIndex = "X-Pagination-Page-Index" + + // ApiHeaderPageSize is the API page size header + apiHeaderPageSize = "X-Pagination-Page-Size" + + // ApiHeaderPageTotal is the API page total header + apiHeaderPageTotal = "X-Pagination-Page-Total" + + // ApiHeaderItemTotal is the API item total header + apiHeaderItemTotal = "X-Pagination-Item-Total" + + // ApiHeaderContentLanguage is the API language header + apiHeaderContentLanguage = "Content-Language" + + // ApiHeaderContentLength is the API content length + apiHeaderContentLength = "Content-Length" + + // ApiHeaderEtag is the API content etag + apiHeaderEtag = "ETag" +) + +func (c *Client) doRequest(req *http.Request, wantStatus int, out any) (http.Header, error) { + if r := reflect.ValueOf(out); out != nil && r.Kind() != reflect.Ptr { + return nil, fmt.Errorf("out must be a pointer") + } + + req.Header.Set("Content-Type", "application/json") + + // Token is optional + if len(c.options.Token) != 0 { + req.Header.Set("Authorization", "Bearer "+c.options.Token) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + if res.StatusCode != wantStatus { + var resErr ErrorResponse + if err := json.Unmarshal(body, &resErr); err != nil { + return nil, errors.New("failed to parse error message: " + err.Error()) + } + return nil, resErr + } + + if out != nil { + if err := json.Unmarshal(body, out); err != nil { + return nil, err + } + } + + return res.Header, nil +} + +func (c *Client) doGetRequest(origin string, query []QueryOptions, out any) (BaseResponse, error) { + var res BaseResponse + + // This will fix 'buildUrl' ignoring url options if 'query' is nil + if query == nil { + query = []QueryOptions{} + } + + url := c.buildUrl(origin, query) + + cached, ok := c.cache.Get(url) + if ok { + cbr := cached.(cachedBaseResponse) + + // If the cache doesn't work, we fetch the data again + if err := json.Unmarshal(cbr.data, out); err == nil { + c.logger.Debug(fmt.Sprintf("found cached response for key %s", url)) + return cbr.BaseResponse, nil + } + + c.logger.Warn("failed to parse response from in-memory cache, fetching...") + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return res, err + } + + httpHeader, err := c.doRequest(req, http.StatusOK, out) + if err != nil { + return res, err + } + + headers := c.extractHeaders(httpHeader) + res = BaseResponse{ + HeaderResponse: headers, + Status: http.StatusOK, + } + + if c.options.UseInMemoryCache { + res.Cached = true + + bOut, err := json.Marshal(out) + if err != nil { + return res, err + } + + cbr := cachedBaseResponse{ + BaseResponse: res, + data: bOut, + } + + c.logger.Debug(fmt.Sprintf("cached response using '%s' as key", url)) + c.cache.Set(url, cbr) + } + + return res, nil +} + +func (c *Client) doPostRequest(origin string, in any, out any) error { + if len(c.options.Token) == 0 { + return fmt.Errorf("token is required for post request") + } + + url := c.buildUrl(origin, nil) + body, err := json.Marshal(in) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + _, err = c.doRequest(req, http.StatusCreated, out) + if err != nil { + return err + } + + return nil +} + +func (c *Client) doPatchRequest(origin string, patch any) error { + if len(c.options.Token) == 0 { + return fmt.Errorf("token is required for put request") + } + + var item any + _, err := c.doGetRequest(origin, nil, &item) + if err != nil { + return err + } + + res, err := json.Marshal(patch) + if err != nil { + return err + } + + err = json.Unmarshal(res, &item) + if err != nil { + return err + } + + itemBytes, err := json.Marshal(item) + if err != nil { + return err + } + + url := c.buildUrl(origin, nil) + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(itemBytes)) + if err != nil { + return err + } + + _, err = c.doRequest(req, http.StatusOK, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) doDeleteRequest(origin string) error { + if len(c.options.Token) == 0 { + return fmt.Errorf("token is required for delete request") + } + + url := c.buildUrl(origin, nil) + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return err + } + + _, err = c.doRequest(req, http.StatusNoContent, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) buildUrl(origin string, query []QueryOptions) string { + url := fmt.Sprintf("%s/%s/%s", c.options.Endpoint, c.options.Version, origin) + + // No options to append + if query == nil { + c.logger.Debug("building url without query options") + return url + } + + var params []string + + // Don't set language param if it's the same as default + if len(c.options.Language) != 0 && c.options.Language != DefaultLanguage { + params = pushOrOverwrite(params, "language", c.options.Language) + } + + // Don't set size param if it's the same as default + if c.options.Size != 0 && c.options.Size != DefaultSize { + params = pushOrOverwrite(params, "size", strconv.Itoa(c.options.Size)) + } + + opts := c.newQueryOptions() + for _, opt := range query { + opt(&opts) + } + + for key, value := range opts.Filters { + if value != "" { + params = pushOrOverwrite(params, key, value) + } + } + + if opts.Pageable.Page != 0 && opts.Pageable.Page != 1 { + params = pushOrOverwrite(params, "page", strconv.Itoa(opts.Pageable.Page)) + } + + if opts.Pageable.Size != 0 && opts.Pageable.Size != DefaultSize { + params = pushOrOverwrite(params, "size", strconv.Itoa(opts.Pageable.Size)) + } + + if opts.Pageable.Sort != "" { + sortParam := opts.Pageable.Sort + if opts.Pageable.Order != "" { + sortParam = fmt.Sprintf("%s,%s", sortParam, opts.Pageable.Order) + } + params = pushOrOverwrite(params, "sort", sortParam) + } + + paramsStr := "" + if len(params) > 0 { + paramsStr = "?" + strings.Join(params, "&") + } + + url += paramsStr + c.logger.Debug("final url: " + url) + return url +} + +func pushOrOverwrite(params []string, key, value string) []string { + for i, param := range params { + if strings.HasPrefix(param, key+"=") { + params[i] = fmt.Sprintf("%s=%s", key, value) + return params + } + } + return append(params, fmt.Sprintf("%s=%s", key, value)) +} + +func (c *Client) extractHeaders(header http.Header) HeaderResponse { + var headers HeaderResponse + + rateLimitRemaining := header.Get(apiHeaderRateLimitRemaining) + headers.Quota.Remaining = c.parseInt(rateLimitRemaining) + + pageStr := header.Get(apiHeaderPageIndex) + headers.Page = c.parseInt(pageStr) + + pageSizeStr := header.Get(apiHeaderPageSize) + headers.PageSize = c.parseInt(pageSizeStr) + + pageTotalStr := header.Get(apiHeaderPageTotal) + headers.PageTotal = c.parseInt(pageTotalStr) + + itemStr := header.Get(apiHeaderItemTotal) + headers.ItemSize = c.parseInt(itemStr) + + lengthStr := header.Get(apiHeaderContentLength) + headers.Length = c.parseInt(lengthStr) + + nextPage := c.handlePagination(headers.Page, true) + headers.NextPage = nextPage + + prevPage := c.handlePagination(headers.Page, false) + headers.PrevPage = prevPage + + headers.Etag = header.Get(apiHeaderEtag) + headers.Language = header.Get(apiHeaderContentLanguage) + return headers +} + +func (c *Client) handlePagination(page int, increase bool) int { + if page <= 0 { + return -1 + } + + if increase { + page++ + } else { + page-- + } + + if page == 0 { + return -1 + } + + return page +} + +func (c *Client) parseInt(s string) int { + if len(s) == 0 { + return -1 + } + + i, err := strconv.Atoi(s) + if err != nil { + c.logger.Debug(fmt.Sprintf("failed to parse integer: %s", s)) + return -1 + } + + return i +} diff --git a/pkg/hawapi/service_test.go b/pkg/hawapi/service_test.go new file mode 100644 index 0000000..67dbc4e --- /dev/null +++ b/pkg/hawapi/service_test.go @@ -0,0 +1,403 @@ +package hawapi + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/HawAPI/go-sdk/pkg/cache" +) + +func TestClient_buildUrl(t *testing.T) { + type fields struct { + options Options + } + type args struct { + origin string + query []QueryOptions + } + tests := []struct { + name string + fields fields + args args + want string + }{ + { + name: "should build a simple url", + fields: fields{}, + args: args{ + origin: "actors", + }, + want: "https://hawapi.theproject.id/api/v1/actors", + }, + { + name: "should build url with custom endpoint and version", + fields: fields{ + options: Options{ + Endpoint: "https://hawapi.example.id/api", + Version: "v3", + }, + }, + args: args{ + origin: "actors", + }, + want: "https://hawapi.example.id/api/v3/actors", + }, + { + name: "should build url with global options", + fields: fields{ + options: Options{ + Language: "pt-BR", + Size: 20, + }, + }, + args: args{ + origin: "actors", + query: []QueryOptions{}, + }, + want: "https://hawapi.theproject.id/api/v1/actors?language=pt-BR&size=20", + }, + { + name: "should build url with pageable", + fields: fields{}, + args: args{ + origin: "actors", + query: []QueryOptions{ + WithPage(2), + WithSize(40), + }, + }, + want: "https://hawapi.theproject.id/api/v1/actors?page=2&size=40", + }, + { + name: "should build url with sort", + fields: fields{}, + args: args{ + origin: "actors", + query: []QueryOptions{ + WithSort("first_name"), + WithOrder("DESC"), + }, + }, + want: "https://hawapi.theproject.id/api/v1/actors?sort=first_name,DESC", + }, + { + name: "should build ignore order if sort is not present", + fields: fields{}, + args: args{ + origin: "actors", + query: []QueryOptions{ + WithOrder("DESC"), + }, + }, + want: "https://hawapi.theproject.id/api/v1/actors", + }, + { + name: "should build overwrite filter if is already set", + fields: fields{}, + args: args{ + origin: "actors", + query: []QueryOptions{ + WithFilter("gender", "1"), + WithFilter("first_name", "Finn"), + WithFilter("gender", "0"), + }, + }, + want: "https://hawapi.theproject.id/api/v1/actors?gender=0&first_name=Finn", + }, + { + name: "should build a complete url", + fields: fields{}, + args: args{ + origin: "actors", + query: []QueryOptions{ + WithLanguage("fr-FR"), + WithSize(20), + WithFilter("gender", "1"), + WithSort("first_name"), + WithOrder("DESC"), + }, + }, + want: "https://hawapi.theproject.id/api/v1/actors?language=fr-FR&gender=1&size=20&sort=first_name,DESC", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewClientWithOpts(tt.fields.options) + + if got := c.buildUrl(tt.args.origin, tt.args.query); got != tt.want { + t.Errorf("buildUrl() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_extractHeaders(t *testing.T) { + type args struct { + header http.Header + } + tests := []struct { + name string + args args + want HeaderResponse + }{ + { + name: "test", + args: args{ + header: http.Header{ + apiHeaderRateLimitRemaining: []string{"15"}, + apiHeaderContentLanguage: []string{"fr-FR"}, + apiHeaderContentLength: []string{"123"}, + apiHeaderItemTotal: []string{"10"}, + apiHeaderPageIndex: []string{"1"}, + apiHeaderPageSize: []string{"10"}, + apiHeaderPageTotal: []string{"1"}, + }, + }, + want: HeaderResponse{ + Quota: Quota{Remaining: 15}, + Language: "fr-FR", + Length: 123, + ItemSize: 10, + Page: 1, + PageSize: 10, + PageTotal: 1, + NextPage: 2, + PrevPage: -1, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractHeaders(tt.args.header); !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractHeaders() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClient_doRequest(t *testing.T) { + type fields struct { + options Options + } + type args struct { + reqMethod string + mockStatus int + wantStatus int + out any + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "should do request successfully", + fields: fields{}, + args: args{ + reqMethod: "GET", + mockStatus: http.StatusOK, + wantStatus: http.StatusOK, + out: nil, + }, + wantErr: false, + }, + { + name: "should return error if status is not as expected", + fields: fields{}, + args: args{ + reqMethod: "GET", + mockStatus: http.StatusInternalServerError, + wantStatus: http.StatusOK, + }, + wantErr: true, + }, + { + name: "should return error if out is not a pointer", + fields: fields{}, + args: args{ + reqMethod: "GET", + out: Actor{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewClientWithOpts(tt.fields.options) + + sv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.args.mockStatus) + })) + defer sv.Close() + + req, err := http.NewRequest(tt.args.reqMethod, sv.URL, nil) + if err != nil { + t.Fatal(err) + } + + _, err = c.doRequest(req, tt.args.wantStatus, tt.args.out) + if (err != nil) != tt.wantErr { + t.Errorf("doRequest() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func Test_handlePagination(t *testing.T) { + type args struct { + page int + increase bool + } + tests := []struct { + name string + args args + want int + }{ + { + name: "should return -1 with page value being 0 (decrease)", + args: args{ + page: 0, + increase: false, + }, + want: -1, + }, + { + name: "should return -1 with page value being 0 (increase)", + args: args{ + page: 0, + increase: true, + }, + want: -1, + }, + { + name: "should return -1 with page value being -1 (decrease)", + args: args{ + page: -1, + increase: false, + }, + want: -1, + }, + { + name: "should return 1 with page value being 2 (decrease)", + args: args{ + page: 2, + increase: false, + }, + want: 1, + }, + { + name: "should return -1 with page value being 1 (decrease)", + args: args{ + page: 1, + increase: false, + }, + want: -1, + }, + { + name: "should return 2 with page value being 1 (increase)", + args: args{ + page: 1, + increase: true, + }, + want: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := handlePagination(tt.args.page, tt.args.increase); got != tt.want { + t.Errorf("handlePagination() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClient_doGetRequest(t *testing.T) { + type fields struct { + options Options + cache cache.Cache + } + type args struct { + origin string + query []QueryOptions + out any + } + tests := []struct { + name string + fields fields + args args + want BaseResponse + wantErr bool + }{ + { + name: "should call get request successfully", + fields: fields{ + options: DefaultOptions, + cache: cache.NewMemoryCache(), + }, + args: args{ + origin: "actors", + out: &Actor{}, + }, + want: BaseResponse{ + HeaderResponse: HeaderResponse{ + Page: -1, + PageSize: -1, + PageTotal: -1, + ItemSize: -1, + NextPage: -1, + PrevPage: -1, + Language: "", + Quota: Quota{ + Remaining: -1, + }, + Etag: "", + Length: 45, + }, + Cached: true, + Status: 200, + }, + wantErr: false, + }, + { + name: "should return error if out is not a pointer", + fields: fields{ + options: DefaultOptions, + cache: cache.NewMemoryCache(), + }, + args: args{ + origin: "actors", + out: Actor{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Write([]byte(`{"first_name": "Lorem", "last_name": "Ipsum"}`)) + })) + defer server.Close() + + tt.fields.options.Endpoint = server.URL + c := &Client{ + options: tt.fields.options, + client: server.Client(), + cache: tt.fields.cache, + } + + got, err := c.doGetRequest(tt.args.origin, tt.args.query, tt.args.out) + if (err != nil) != tt.wantErr { + t.Errorf("doGetRequest() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("doGetRequest() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/hawapi/soundtrack.go b/pkg/hawapi/soundtrack.go new file mode 100644 index 0000000..f24826e --- /dev/null +++ b/pkg/hawapi/soundtrack.go @@ -0,0 +1,132 @@ +package hawapi + +import ( + "github.com/google/uuid" +) + +const soundtrackOrigin = "soundtracks" + +type Soundtrack struct { + UUID uuid.UUID `json:"uuid"` + Href string `json:"href"` + Name string `json:"name"` + Duration int64 `json:"duration"` + Artist string `json:"artist"` + Album string `json:"album,omitempty"` + ReleaseDate string `json:"release_date"` + Urls []string `json:"urls"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateSoundtrack struct { + Name string `json:"name,omitempty"` + Duration int64 `json:"duration,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + ReleaseDate string `json:"release_date"` + Urls []string `json:"urls,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + Images []string `json:"images,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type PatchSoundtrack = CreateSoundtrack + +type SoundtrackResponse struct { + BaseResponse + Data Soundtrack `json:"data"` +} + +type SoundtrackListResponse struct { + BaseResponse + Data []Soundtrack `json:"data"` +} + +// ListSoundtracks will get all soundtracks +func (c *Client) ListSoundtracks(options ...QueryOptions) (SoundtrackListResponse, error) { + var soundtracks []Soundtrack + var res SoundtrackListResponse + + doRes, err := c.doGetRequest(soundtrackOrigin, options, &soundtracks) + if err != nil { + return res, err + } + + res = SoundtrackListResponse{ + BaseResponse: doRes, + Data: soundtracks, + } + + return res, nil +} + +// FindSoundtrack will get a single item by uuid +func (c *Client) FindSoundtrack(id uuid.UUID) (SoundtrackResponse, error) { + var soundtrack Soundtrack + var res SoundtrackResponse + + doRes, err := c.doGetRequest(soundtrackOrigin+"/"+id.String(), nil, &soundtrack) + if err != nil { + return res, err + } + + res = SoundtrackResponse{ + BaseResponse: doRes, + Data: soundtrack, + } + + return res, nil +} + +func (c *Client) RandomSoundtrack() (SoundtrackResponse, error) { + var soundtrack Soundtrack + var res SoundtrackResponse + + doRes, err := c.doGetRequest(soundtrackOrigin+"/random", nil, &soundtrack) + if err != nil { + return res, err + } + + res = SoundtrackResponse{ + BaseResponse: doRes, + Data: soundtrack, + } + + return res, nil +} + +func (c *Client) CreateSoundtrack(s CreateSoundtrack) (Soundtrack, error) { + var soundtrack Soundtrack + + err := c.doPostRequest(soundtrackOrigin, s, &soundtrack) + if err != nil { + return soundtrack, err + } + + return soundtrack, nil +} + +func (c *Client) PatchSoundtrack(id uuid.UUID, p PatchSoundtrack) (Soundtrack, error) { + var soundtrack Soundtrack + + err := c.doPatchRequest(soundtrackOrigin+"/"+id.String(), &p) + if err != nil { + return soundtrack, err + } + + res, err := c.FindSoundtrack(id) + if err != nil { + return soundtrack, err + } + + soundtrack = res.Data + return soundtrack, nil +} + +func (c *Client) DeleteSoundtrack(id uuid.UUID) error { + return c.doDeleteRequest(soundtrackOrigin + "/" + id.String()) +}