mirror of
https://github.com/elyby/chrly.git
synced 2025-05-31 14:11:51 +05:30
Rework project's structure
This commit is contained in:
114
internal/mojang/batch_uuids_provider.go
Normal file
114
internal/mojang/batch_uuids_provider.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/elyby/chrly/internal/utils"
|
||||
)
|
||||
|
||||
type BatchUuidsProvider struct {
|
||||
UsernamesToUuidsEndpoint func(usernames []string) ([]*ProfileInfo, error)
|
||||
batch int
|
||||
delay time.Duration
|
||||
fireOnFull bool
|
||||
|
||||
queue *utils.Queue[*job]
|
||||
fireChan chan any
|
||||
stopChan chan any
|
||||
onFirstCall sync.Once
|
||||
}
|
||||
|
||||
func NewBatchUuidsProvider(
|
||||
endpoint func(usernames []string) ([]*ProfileInfo, error),
|
||||
batchSize int,
|
||||
awaitDelay time.Duration,
|
||||
fireOnFull bool,
|
||||
) *BatchUuidsProvider {
|
||||
return &BatchUuidsProvider{
|
||||
UsernamesToUuidsEndpoint: endpoint,
|
||||
stopChan: make(chan any),
|
||||
batch: batchSize,
|
||||
delay: awaitDelay,
|
||||
fireOnFull: fireOnFull,
|
||||
queue: utils.NewQueue[*job](),
|
||||
fireChan: make(chan any),
|
||||
}
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Username string
|
||||
ResultChan chan<- *jobResult
|
||||
}
|
||||
|
||||
type jobResult struct {
|
||||
Profile *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) GetUuid(username string) (*ProfileInfo, error) {
|
||||
resultChan := make(chan *jobResult)
|
||||
n := ctx.queue.Enqueue(&job{username, resultChan})
|
||||
if ctx.fireOnFull && n%ctx.batch == 0 {
|
||||
ctx.fireChan <- struct{}{}
|
||||
}
|
||||
|
||||
ctx.onFirstCall.Do(ctx.startQueue)
|
||||
|
||||
result := <-resultChan
|
||||
|
||||
return result.Profile, result.Error
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) StopQueue() {
|
||||
close(ctx.stopChan)
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) startQueue() {
|
||||
go func() {
|
||||
for {
|
||||
t := time.NewTimer(ctx.delay)
|
||||
select {
|
||||
case <-ctx.stopChan:
|
||||
return
|
||||
case <-t.C:
|
||||
go ctx.fireRequest()
|
||||
case <-ctx.fireChan:
|
||||
t.Stop()
|
||||
go ctx.fireRequest()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ctx *BatchUuidsProvider) fireRequest() {
|
||||
jobs, _ := ctx.queue.Dequeue(ctx.batch)
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(jobs))
|
||||
for i, job := range jobs {
|
||||
usernames[i] = job.Username
|
||||
}
|
||||
|
||||
profiles, err := ctx.UsernamesToUuidsEndpoint(usernames)
|
||||
for _, job := range jobs {
|
||||
response := &jobResult{}
|
||||
if err == nil {
|
||||
// The profiles in the response aren't ordered, so we must search each username over full array
|
||||
for _, profile := range profiles {
|
||||
if strings.EqualFold(job.Username, profile.Name) {
|
||||
response.Profile = profile
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Error = err
|
||||
}
|
||||
|
||||
job.ResultChan <- response
|
||||
close(job.ResultChan)
|
||||
}
|
||||
}
|
173
internal/mojang/batch_uuids_provider_test.go
Normal file
173
internal/mojang/batch_uuids_provider_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var awaitDelay = 20 * time.Millisecond
|
||||
|
||||
type mojangUsernamesToUuidsRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
|
||||
args := o.Called(usernames)
|
||||
var result []*ProfileInfo
|
||||
if casted, ok := args.Get(0).([]*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type batchUuidsProviderGetUuidResult struct {
|
||||
Result *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
type batchUuidsProviderTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *BatchUuidsProvider
|
||||
|
||||
MojangApi *mojangUsernamesToUuidsRequestMock
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) SetupTest() {
|
||||
s.MojangApi = &mojangUsernamesToUuidsRequestMock{}
|
||||
s.Provider = NewBatchUuidsProvider(
|
||||
s.MojangApi.UsernamesToUuids,
|
||||
3,
|
||||
awaitDelay,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
s.Provider.StopQueue()
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
|
||||
startedChan := make(chan any)
|
||||
c := make(chan *batchUuidsProviderGetUuidResult, 1)
|
||||
go func() {
|
||||
close(startedChan)
|
||||
profile, err := s.Provider.GetUuid(username)
|
||||
c <- &batchUuidsProviderGetUuidResult{
|
||||
Result: profile,
|
||||
Error: err,
|
||||
}
|
||||
close(c)
|
||||
}()
|
||||
|
||||
<-startedChan
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesSuccessfully() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedResult1 := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
|
||||
expectedResult2 := &ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*ProfileInfo{
|
||||
expectedResult1,
|
||||
expectedResult2,
|
||||
}, nil)
|
||||
|
||||
chan1 := s.GetUuidAsync("username1")
|
||||
chan2 := s.GetUuidAsync("username2")
|
||||
|
||||
s.Require().Empty(chan1)
|
||||
s.Require().Empty(chan2)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
result1 := <-chan1
|
||||
result2 := <-chan2
|
||||
|
||||
s.Require().NoError(result1.Error)
|
||||
s.Require().Equal(expectedResult1, result1.Result)
|
||||
|
||||
s.Require().NoError(result2.Error)
|
||||
s.Require().Equal(expectedResult2, result2.Result)
|
||||
|
||||
// Await a few more iterations to ensure, that no requests will be performed when there are no additional tasks
|
||||
time.Sleep(awaitDelay * 3)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesSplitByMultipleIterations() {
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesFireOnFull() {
|
||||
s.Provider.fireOnFull = true
|
||||
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedError := errors.New("mock error")
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
|
||||
result1 := <-resultChan1
|
||||
s.Assert().Nil(result1.Result)
|
||||
s.Assert().Equal(expectedError, result1.Error)
|
||||
|
||||
result2 := <-resultChan2
|
||||
s.Assert().Nil(result2.Result)
|
||||
s.Assert().Equal(expectedError, result2.Error)
|
||||
}
|
||||
|
||||
func TestBatchUuidsProvider(t *testing.T) {
|
||||
suite.Run(t, new(batchUuidsProviderTestSuite))
|
||||
}
|
265
internal/mojang/client.go
Normal file
265
internal/mojang/client.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MojangApi struct {
|
||||
http *http.Client
|
||||
batchUuidsUrl string
|
||||
profileUrl string
|
||||
}
|
||||
|
||||
func NewMojangApi(
|
||||
http *http.Client,
|
||||
batchUuidsUrl string,
|
||||
profileUrl string,
|
||||
) *MojangApi {
|
||||
if batchUuidsUrl == "" {
|
||||
batchUuidsUrl = "https://api.mojang.com/profiles/minecraft"
|
||||
}
|
||||
|
||||
if profileUrl == "" {
|
||||
profileUrl = "https://sessionserver.mojang.com/session/minecraft/profile/"
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(profileUrl, "/") {
|
||||
profileUrl += "/"
|
||||
}
|
||||
|
||||
return &MojangApi{
|
||||
http,
|
||||
batchUuidsUrl,
|
||||
profileUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Exchanges usernames array to array of uuids
|
||||
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
|
||||
func (c *MojangApi) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
|
||||
requestBody, _ := json.Marshal(usernames)
|
||||
request, err := http.NewRequest("POST", c.batchUuidsUrl, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result []*ProfileInfo
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Obtains textures information for provided uuid
|
||||
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
|
||||
func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) {
|
||||
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
|
||||
url := c.profileUrl + normalizedUuid
|
||||
if signed {
|
||||
url += "?unsigned=false"
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == 204 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result *ProfileResponse
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Props []*Property `json:"properties"`
|
||||
|
||||
once sync.Once
|
||||
decodedTextures *TexturesProp
|
||||
decodedErr error
|
||||
}
|
||||
|
||||
type TexturesProp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Textures *TexturesResponse `json:"textures"`
|
||||
}
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
|
||||
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CapeTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (t *ProfileResponse) DecodeTextures() (*TexturesProp, error) {
|
||||
t.once.Do(func() {
|
||||
var texturesProp string
|
||||
for _, prop := range t.Props {
|
||||
if prop.Name == "textures" {
|
||||
texturesProp = prop.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if texturesProp == "" {
|
||||
return
|
||||
}
|
||||
|
||||
decodedTextures, err := DecodeTextures(texturesProp)
|
||||
if err != nil {
|
||||
t.decodedErr = err
|
||||
} else {
|
||||
t.decodedTextures = decodedTextures
|
||||
}
|
||||
})
|
||||
|
||||
return t.decodedTextures, t.decodedErr
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
Name string `json:"name"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ProfileInfo struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsLegacy bool `json:"legacy,omitempty"`
|
||||
IsDemo bool `json:"demo,omitempty"`
|
||||
}
|
||||
|
||||
func errorFromResponse(response *http.Response) error {
|
||||
switch {
|
||||
case response.StatusCode == 400:
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
var decodedError *errorResponse
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &decodedError)
|
||||
|
||||
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
|
||||
case response.StatusCode == 403:
|
||||
return &ForbiddenError{}
|
||||
case response.StatusCode == 429:
|
||||
return &TooManyRequestsError{}
|
||||
case response.StatusCode >= 500:
|
||||
return &ServerError{Status: response.StatusCode}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected response status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
// When passed request params are invalid, Mojang returns 400 Bad Request error
|
||||
type BadRequestError struct {
|
||||
ErrorType string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BadRequestError) Error() string {
|
||||
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
|
||||
}
|
||||
|
||||
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
|
||||
type ForbiddenError struct {
|
||||
}
|
||||
|
||||
func (*ForbiddenError) Error() string {
|
||||
return "403: Forbidden"
|
||||
}
|
||||
|
||||
// When you exceed the set limit of requests, this error will be returned
|
||||
type TooManyRequestsError struct {
|
||||
}
|
||||
|
||||
func (*TooManyRequestsError) Error() string {
|
||||
return "429: Too Many Requests"
|
||||
}
|
||||
|
||||
// ServerError happens when Mojang's API returns any response with 50* status
|
||||
type ServerError struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *ServerError) Error() string {
|
||||
return fmt.Sprintf("%d: %s", e.Status, "Server error")
|
||||
}
|
||||
|
||||
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
|
||||
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *TexturesProp
|
||||
err = json.Unmarshal(jsonStr, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EncodeTextures(textures *TexturesProp) string {
|
||||
jsonSerialized, _ := json.Marshal(textures)
|
||||
return base64.URLEncoding.EncodeToString(jsonSerialized)
|
||||
}
|
318
internal/mojang/client_test.go
Normal file
318
internal/mojang/client_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/h2non/gock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MojangApiSuite struct {
|
||||
suite.Suite
|
||||
api *MojangApi
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) SetupTest() {
|
||||
httpClient := &http.Client{}
|
||||
gock.InterceptClient(httpClient)
|
||||
s.api = NewMojangApi(httpClient, "", "")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TearDownTest() {
|
||||
gock.Off()
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsSuccessfully() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
JSON([]string{"Thinkofdeath", "maksimkurb"}).
|
||||
Reply(200).
|
||||
JSON([]map[string]any{
|
||||
{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"legacy": false,
|
||||
"demo": true,
|
||||
},
|
||||
{
|
||||
"id": "0d252b7218b648bfb86c2ae476954d32",
|
||||
"name": "maksimkurb",
|
||||
// There are no legacy or demo fields
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
if s.Assert().NoError(err) {
|
||||
s.Assert().Len(result, 2)
|
||||
s.Assert().Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
|
||||
s.Assert().Equal("Thinkofdeath", result[0].Name)
|
||||
s.Assert().False(result[0].IsLegacy)
|
||||
s.Assert().True(result[0].IsDemo)
|
||||
|
||||
s.Assert().Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
|
||||
s.Assert().Equal("maksimkurb", result[1].Name)
|
||||
s.Assert().False(result[1].IsLegacy)
|
||||
s.Assert().False(result[1].IsDemo)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsBadRequest() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(400).
|
||||
JSON(map[string]any{
|
||||
"error": "IllegalArgumentException",
|
||||
"errorMessage": "profileName can not be null or empty.",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{""})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&BadRequestError{}, err)
|
||||
s.Assert().EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsForbidden() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(403).
|
||||
BodyString("just because")
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ForbiddenError{}, err)
|
||||
s.Assert().EqualError(err, "403: Forbidden")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsTooManyRequests() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&TooManyRequestsError{}, err)
|
||||
s.Assert().EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsServerError() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ServerError{}, err)
|
||||
s.Assert().EqualError(err, "500: Server error")
|
||||
s.Assert().Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesSuccessfulResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(200).
|
||||
JSON(map[string]any{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []any{
|
||||
map[string]any{
|
||||
"name": "textures",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().NoError(err)
|
||||
s.Assert().Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
s.Assert().Equal("Thinkofdeath", result.Name)
|
||||
s.Assert().Equal(1, len(result.Props))
|
||||
s.Assert().Equal("textures", result.Props[0].Name)
|
||||
s.Assert().Equal(476, len(result.Props[0].Value))
|
||||
s.Assert().Equal("", result.Props[0].Signature)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesEmptyResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(204).
|
||||
BodyString("")
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().NoError(err)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesTooManyRequests() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&TooManyRequestsError{}, err)
|
||||
s.Assert().EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesServerError() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Assert().Nil(result)
|
||||
s.Assert().IsType(&ServerError{}, err)
|
||||
s.Assert().EqualError(err, "500: Server error")
|
||||
s.Assert().Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func TestMojangApi(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiSuite))
|
||||
}
|
||||
|
||||
func TestSignedTexturesResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &ProfileResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &ProfileResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
type texturesTestCase struct {
|
||||
Name string
|
||||
Encoded string
|
||||
Decoded *TexturesProp
|
||||
}
|
||||
|
||||
var texturesTestCases = []*texturesTestCase{
|
||||
{
|
||||
Name: "property without textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856010494),
|
||||
Textures: &TexturesResponse{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with classic skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856307412),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with alex skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856494791),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
|
||||
Metadata: &SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with skin and cape textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "d90b68bc81724329a047f1186dcd4336",
|
||||
ProfileName: "akronman1",
|
||||
Timestamp: int64(1555857675335),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
|
||||
},
|
||||
Cape: &CapeTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("decode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures(testCase.Encoded)
|
||||
assert.Nil(err)
|
||||
assert.Equal(testCase.Decoded, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("invalid base64")
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
|
||||
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("encode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result := EncodeTextures(testCase.Decoded)
|
||||
assert.Equal(testCase.Encoded, result)
|
||||
})
|
||||
}
|
||||
}
|
59
internal/mojang/provider.go
Normal file
59
internal/mojang/provider.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/brunomvsouza/singleflight"
|
||||
)
|
||||
|
||||
var InvalidUsername = errors.New("the username passed doesn't meet Mojang's requirements")
|
||||
|
||||
// https://help.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
|
||||
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`)
|
||||
|
||||
type UuidsProvider interface {
|
||||
GetUuid(username string) (*ProfileInfo, error)
|
||||
}
|
||||
|
||||
type TexturesProvider interface {
|
||||
GetTextures(uuid string) (*ProfileResponse, error)
|
||||
}
|
||||
|
||||
type MojangTexturesProvider struct {
|
||||
UuidsProvider
|
||||
TexturesProvider
|
||||
|
||||
group singleflight.Group[string, *ProfileResponse]
|
||||
}
|
||||
|
||||
func (p *MojangTexturesProvider) GetForUsername(username string) (*ProfileResponse, error) {
|
||||
if !allowedUsernamesRegex.MatchString(username) {
|
||||
return nil, InvalidUsername
|
||||
}
|
||||
|
||||
username = strings.ToLower(username)
|
||||
|
||||
result, err, _ := p.group.Do(username, func() (*ProfileResponse, error) {
|
||||
profile, err := p.UuidsProvider.GetUuid(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return p.TexturesProvider.GetTextures(profile.Id)
|
||||
})
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type NilProvider struct {
|
||||
}
|
||||
|
||||
func (*NilProvider) GetForUsername(username string) (*ProfileResponse, error) {
|
||||
return nil, nil
|
||||
}
|
166
internal/mojang/provider_test.go
Normal file
166
internal/mojang/provider_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type mockUuidsProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockUuidsProvider) GetUuid(username string) (*ProfileInfo, error) {
|
||||
args := m.Called(username)
|
||||
var result *ProfileInfo
|
||||
if casted, ok := args.Get(0).(*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type TexturesProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *TexturesProviderMock) GetTextures(uuid string) (*ProfileResponse, error) {
|
||||
args := m.Called(uuid)
|
||||
var result *ProfileResponse
|
||||
if casted, ok := args.Get(0).(*ProfileResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type providerTestSuite struct {
|
||||
suite.Suite
|
||||
Provider *MojangTexturesProvider
|
||||
UuidsProvider *mockUuidsProvider
|
||||
TexturesProvider *TexturesProviderMock
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) SetupTest() {
|
||||
suite.UuidsProvider = &mockUuidsProvider{}
|
||||
suite.TexturesProvider = &TexturesProviderMock{}
|
||||
|
||||
suite.Provider = &MojangTexturesProvider{
|
||||
UuidsProvider: suite.UuidsProvider,
|
||||
TexturesProvider: suite.TexturesProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TearDownTest() {
|
||||
suite.UuidsProvider.AssertExpectations(suite.T())
|
||||
suite.TexturesProvider.AssertExpectations(suite.T())
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
|
||||
|
||||
result, err := suite.Provider.GetForUsername("username")
|
||||
|
||||
suite.Assert().NoError(err)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForTheSameUsername() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
awaitChan := make(chan time.Time)
|
||||
|
||||
// If possible, then remove this .After call
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
results := make([]*ProfileResponse, 2)
|
||||
var wgStarted sync.WaitGroup
|
||||
var wgDone sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wgStarted.Add(1)
|
||||
wgDone.Add(1)
|
||||
go func(i int) {
|
||||
wgStarted.Done()
|
||||
textures, _ := suite.Provider.GetForUsername("username")
|
||||
results[i] = textures
|
||||
wgDone.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wgStarted.Wait()
|
||||
close(awaitChan)
|
||||
wgDone.Wait()
|
||||
|
||||
suite.Assert().Equal(expectedResult, results[0])
|
||||
suite.Assert().Equal(expectedResult, results[1])
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
|
||||
result, err := suite.Provider.GetForUsername("Not allowed")
|
||||
suite.Assert().ErrorIs(err, InvalidUsername)
|
||||
suite.Assert().Nil(result)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
|
||||
err := errors.New("mock error")
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
||||
|
||||
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
err := errors.New("mock error")
|
||||
|
||||
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
|
||||
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
|
||||
|
||||
result, resErr := suite.Provider.GetForUsername("username")
|
||||
suite.Assert().Nil(result)
|
||||
suite.Assert().Equal(err, resErr)
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
suite.Run(t, new(providerTestSuite))
|
||||
}
|
||||
|
||||
func TestNilProvider_GetForUsername(t *testing.T) {
|
||||
provider := &NilProvider{}
|
||||
result, err := provider.GetForUsername("username")
|
||||
require.Nil(t, result)
|
||||
require.NoError(t, err)
|
||||
}
|
67
internal/mojang/textures_provider.go
Normal file
67
internal/mojang/textures_provider.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
)
|
||||
|
||||
type MojangApiTexturesProvider struct {
|
||||
MojangApiTexturesEndpoint func(uuid string, signed bool) (*ProfileResponse, error)
|
||||
}
|
||||
|
||||
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*ProfileResponse, error) {
|
||||
return ctx.MojangApiTexturesEndpoint(uuid, true)
|
||||
}
|
||||
|
||||
// Perfectly there should be an object with provider and cache implementation,
|
||||
// but I decided not to introduce a layer and just implement cache in place.
|
||||
type TexturesProviderWithInMemoryCache struct {
|
||||
provider TexturesProvider
|
||||
once sync.Once
|
||||
cache *ttlcache.Cache[string, *ProfileResponse]
|
||||
}
|
||||
|
||||
func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesProviderWithInMemoryCache {
|
||||
storage := &TexturesProviderWithInMemoryCache{
|
||||
provider: provider,
|
||||
cache: ttlcache.New[string, *ProfileResponse](
|
||||
ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](),
|
||||
// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error
|
||||
),
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*ProfileResponse, error) {
|
||||
item := s.cache.Get(uuid)
|
||||
// Don't check item.IsExpired() since Get function is already did this check
|
||||
if item != nil {
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
result, err := s.provider.GetTextures(uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache.Set(uuid, result, time.Minute)
|
||||
// Call it only after first set so GC will work more often
|
||||
s.startGcOnce()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) StopGC() {
|
||||
// If you call the Stop() on a non-started GC, the process will hang trying to close the uninitialized channel
|
||||
s.startGcOnce()
|
||||
s.cache.Stop()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) startGcOnce() {
|
||||
s.once.Do(func() {
|
||||
go s.cache.Start()
|
||||
})
|
||||
}
|
139
internal/mojang/textures_provider_test.go
Normal file
139
internal/mojang/textures_provider_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var signedTexturesResponse = &ProfileResponse{
|
||||
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: EncodeTextures(&TexturesProp{
|
||||
Timestamp: time.Now().UnixNano() / 10e5,
|
||||
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
ProfileName: "mock",
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type MojangUuidToTexturesRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) {
|
||||
args := m.Called(uuid, signed)
|
||||
var result *ProfileResponse
|
||||
if casted, ok := args.Get(0).(*ProfileResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangApiTexturesProviderSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *MojangApiTexturesProvider
|
||||
MojangApi *MojangUuidToTexturesRequestMock
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) SetupTest() {
|
||||
s.MojangApi = &MojangUuidToTexturesRequestMock{}
|
||||
s.Provider = &MojangApiTexturesProvider{
|
||||
MojangApiTexturesEndpoint: s.MojangApi.UuidToTextures,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTextures() {
|
||||
s.MojangApi.On("UuidToTextures", "dead24f9a4fa4877b7b04c8c6c72bb46", true).Once().Return(signedTexturesResponse, nil)
|
||||
|
||||
result, err := s.Provider.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(signedTexturesResponse, result)
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTexturesWithError() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
s.Require().Nil(result)
|
||||
s.Require().Equal(expectedError, err)
|
||||
}
|
||||
|
||||
func TestMojangApiTexturesProvider(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiTexturesProviderSuite))
|
||||
}
|
||||
|
||||
type TexturesProviderWithInMemoryCacheSuite struct {
|
||||
suite.Suite
|
||||
Original *TexturesProviderMock
|
||||
Provider *TexturesProviderWithInMemoryCache
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) SetupTest() {
|
||||
s.Original = &TexturesProviderMock{}
|
||||
s.Provider = NewTexturesProviderWithInMemoryCache(s.Original)
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Provider.StopGC()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithSuccessfulOriginalProviderResponse() {
|
||||
s.Original.On("GetTextures", "uuid").Once().Return(signedTexturesResponse, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the Original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(signedTexturesResponse, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithEmptyOriginalProviderResponse() {
|
||||
s.Original.On("GetTextures", "uuid").Once().Return(nil, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithErrorFromOriginalProvider() {
|
||||
expectedErr := errors.New("mock error")
|
||||
s.Original.On("GetTextures", "uuid").Times(5).Return(nil, expectedErr)
|
||||
// Do the call multiple times to ensure, that the error will not be cached and there will be a request on each call
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures("uuid")
|
||||
|
||||
s.Require().Same(expectedErr, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTexturesProviderWithInMemoryCache(t *testing.T) {
|
||||
suite.Run(t, new(TexturesProviderWithInMemoryCacheSuite))
|
||||
}
|
45
internal/mojang/uuids_provider.go
Normal file
45
internal/mojang/uuids_provider.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package mojang
|
||||
|
||||
type MojangUuidsStorage interface {
|
||||
// The second argument must be returned as a incoming username in case,
|
||||
// when cached result indicates that there is no Mojang user with provided username
|
||||
GetUuidForMojangUsername(username string) (foundUuid string, foundUsername string, err error)
|
||||
// An empty uuid value can be passed if the corresponding account has not been found
|
||||
StoreMojangUuid(username string, uuid string) error
|
||||
}
|
||||
|
||||
type UuidsProviderWithCache struct {
|
||||
Provider UuidsProvider
|
||||
Storage MojangUuidsStorage
|
||||
}
|
||||
|
||||
func (p *UuidsProviderWithCache) GetUuid(username string) (*ProfileInfo, error) {
|
||||
uuid, foundUsername, err := p.Storage.GetUuidForMojangUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if foundUsername != "" {
|
||||
if uuid != "" {
|
||||
return &ProfileInfo{Id: uuid, Name: foundUsername}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profile, err := p.Provider.GetUuid(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
freshUuid := ""
|
||||
wellCasedUsername := username
|
||||
if profile != nil {
|
||||
freshUuid = profile.Id
|
||||
wellCasedUsername = profile.Name
|
||||
}
|
||||
|
||||
_ = p.Storage.StoreMojangUuid(wellCasedUsername, freshUuid)
|
||||
|
||||
return profile, nil
|
||||
}
|
131
internal/mojang/uuids_provider_test.go
Normal file
131
internal/mojang/uuids_provider_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var mockProfile = &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "UserName"}
|
||||
|
||||
type UuidsProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *UuidsProviderMock) GetUuid(username string) (*ProfileInfo, error) {
|
||||
args := m.Called(username)
|
||||
var result *ProfileInfo
|
||||
if casted, ok := args.Get(0).(*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangUuidsStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.String(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) StoreMojangUuid(username string, uuid string) error {
|
||||
m.Called(username, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
type UuidsProviderWithCacheSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Original *UuidsProviderMock
|
||||
Storage *MojangUuidsStorageMock
|
||||
Provider *UuidsProviderWithCache
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) SetupTest() {
|
||||
s.Original = &UuidsProviderMock{}
|
||||
s.Storage = &MojangUuidsStorageMock{}
|
||||
s.Provider = &UuidsProviderWithCache{
|
||||
Provider: s.Original,
|
||||
Storage: s.Storage,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Storage.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedSuccessfully() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", "UserName", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(mockProfile, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(mockProfile, result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedNotExistsMojangUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", "username", "").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(nil, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestKnownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("mock-uuid", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(result)
|
||||
s.Require().Equal("UserName", result.Name)
|
||||
s.Require().Equal("mock-uuid", result.Id)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUnknownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorDuringCacheQuery() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorFromOriginalProvider() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", "username").Return("", "", nil)
|
||||
|
||||
s.Original.On("GetUuid", "username").Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid("username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func TestUuidsProviderWithCache(t *testing.T) {
|
||||
suite.Run(t, new(UuidsProviderWithCacheSuite))
|
||||
}
|
Reference in New Issue
Block a user