diff --git a/CHANGELOG.md b/CHANGELOG.md index 450d8e7..6867097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - xxxx-xx-xx ### Added +- Added remote mode for Mojang's textures queue. - New StatsD metrics: - Counters: - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit` diff --git a/cmd/serve.go b/cmd/serve.go index 2ca852d..e06a66b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "log" + "net/url" "time" + "github.com/mono83/slf/wd" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -19,6 +21,7 @@ var serveCmd = &cobra.Command{ Use: "serve", Short: "Starts http handler for the skins system", Run: func(cmd *cobra.Command, args []string) { + // TODO: this is a mess, need to organize this code somehow to make services initialization more compact logger, err := bootstrap.CreateLogger(viper.GetString("statsd.addr"), viper.GetString("sentry.dsn")) if err != nil { log.Fatal(fmt.Printf("Cannot initialize logger: %v", err)) @@ -52,15 +55,32 @@ var serveCmd = &cobra.Command{ return } - texturesStorage := mojangtextures.NewInMemoryTexturesStorage() - texturesStorage.Start() - mojangTexturesProvider := &mojangtextures.Provider{ - Logger: logger, - UuidsProvider: &mojangtextures.BatchUuidsProvider{ + var uuidsProvider mojangtextures.UuidsProvider + preferredUuidsProvider := viper.GetString("mojang_textures.uuids_provider.driver") + if preferredUuidsProvider == "remote" { + remoteUrl, err := url.Parse(viper.GetString("mojang_textures.uuids_provider.url")) + if err != nil { + logger.Emergency("Unable to parse remote url :err", wd.ErrParam(err)) + return + } + + uuidsProvider = &mojangtextures.RemoteApiUuidsProvider{ + Url: *remoteUrl, + Logger: logger, + } + } else { + uuidsProvider = &mojangtextures.BatchUuidsProvider{ IterationDelay: time.Duration(viper.GetInt("queue.loop_delay")) * time.Millisecond, IterationSize: viper.GetInt("queue.batch_size"), Logger: logger, - }, + } + } + + texturesStorage := mojangtextures.NewInMemoryTexturesStorage() + texturesStorage.Start() + mojangTexturesProvider := &mojangtextures.Provider{ + Logger: logger, + UuidsProvider: uuidsProvider, TexturesProvider: &mojangtextures.MojangApiTexturesProvider{ Logger: logger, }, diff --git a/mojangtextures/remote_api_uuids_provider.go b/mojangtextures/remote_api_uuids_provider.go new file mode 100644 index 0000000..0980176 --- /dev/null +++ b/mojangtextures/remote_api_uuids_provider.go @@ -0,0 +1,71 @@ +package mojangtextures + +import ( + "encoding/json" + "io/ioutil" + "net/http" + . "net/url" + "path" + "time" + + "github.com/mono83/slf/wd" + + "github.com/elyby/chrly/api/mojang" + "github.com/elyby/chrly/bootstrap" +) + +var HttpClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConnsPerHost: 1024, + }, +} + +type RemoteApiUuidsProvider struct { + Url URL + Logger wd.Watchdog +} + +func (ctx *RemoteApiUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) { + ctx.Logger.IncCounter("mojang_textures.usernames.request", 1) + + url := ctx.Url + url.Path = path.Join(url.Path, username) + + request, _ := http.NewRequest("GET", url.String(), nil) + request.Header.Add("Accept", "application/json") + // Change default User-Agent to allow specify "Username -> UUID at time" Mojang's api endpoint + request.Header.Add("User-Agent", "Chrly/"+bootstrap.GetVersion()) + + start := time.Now() + response, err := HttpClient.Do(request) + ctx.Logger.RecordTimer("mojang_textures.usernames.request_time", time.Since(start)) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == 204 { + return nil, nil + } + + if response.StatusCode != 200 { + return nil, &UnexpectedRemoteApiResponse{response} + } + + var result *mojang.ProfileInfo + body, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(body, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +type UnexpectedRemoteApiResponse struct { + Response *http.Response +} + +func (*UnexpectedRemoteApiResponse) Error() string { + return "Unexpected remote api response" +} diff --git a/mojangtextures/remote_api_uuids_provider_test.go b/mojangtextures/remote_api_uuids_provider_test.go new file mode 100644 index 0000000..b47edca --- /dev/null +++ b/mojangtextures/remote_api_uuids_provider_test.go @@ -0,0 +1,149 @@ +package mojangtextures + +import ( + "net" + "net/http" + "net/url" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + mocks "github.com/elyby/chrly/tests" +) + +type remoteApiUuidsProviderTestSuite struct { + suite.Suite + + Provider *RemoteApiUuidsProvider + Logger *mocks.WdMock +} + +func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() { + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client +} + +func (suite *remoteApiUuidsProviderTestSuite) SetupTest() { + suite.Logger = &mocks.WdMock{} + suite.Provider = &RemoteApiUuidsProvider{ + Logger: suite.Logger, + } +} + +func (suite *remoteApiUuidsProviderTestSuite) TearDownTest() { + suite.Logger.AssertExpectations(suite.T()) + gock.Off() +} + +func TestRemoteApiUuidsProvider(t *testing.T) { + suite.Run(t, new(remoteApiUuidsProviderTestSuite)) +} + +func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForValidUsername() { + suite.Logger.On("IncCounter", "mojang_textures.usernames.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.request_time", mock.Anything).Once() + + gock.New("http://example.com"). + Get("/subpath/username"). + Reply(200). + JSON(map[string]interface{}{ + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "username", + }) + + suite.Provider.Url = shouldParseUrl("http://example.com/subpath") + result, err := suite.Provider.GetUuid("username") + + assert := suite.Assert() + if assert.NoError(err) { + assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", result.Id) + assert.Equal("username", result.Name) + assert.False(result.IsLegacy) + assert.False(result.IsDemo) + } +} + +func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotExistsUsername() { + suite.Logger.On("IncCounter", "mojang_textures.usernames.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.request_time", mock.Anything).Once() + + gock.New("http://example.com"). + Get("/subpath/username"). + Reply(204) + + suite.Provider.Url = shouldParseUrl("http://example.com/subpath") + result, err := suite.Provider.GetUuid("username") + + assert := suite.Assert() + assert.Nil(result) + assert.Nil(err) +} + +func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNon20xResponse() { + suite.Logger.On("IncCounter", "mojang_textures.usernames.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.request_time", mock.Anything).Once() + + gock.New("http://example.com"). + Get("/subpath/username"). + Reply(504). + BodyString("504 Gateway Timeout") + + suite.Provider.Url = shouldParseUrl("http://example.com/subpath") + result, err := suite.Provider.GetUuid("username") + + assert := suite.Assert() + assert.Nil(result) + assert.EqualError(err, "Unexpected remote api response") +} + +func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotSuccessRequest() { + suite.Logger.On("IncCounter", "mojang_textures.usernames.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.request_time", mock.Anything).Once() + + expectedError := &net.OpError{Op: "dial"} + + gock.New("http://example.com"). + Get("/subpath/username"). + ReplyError(expectedError) + + suite.Provider.Url = shouldParseUrl("http://example.com/subpath") + result, err := suite.Provider.GetUuid("username") + + assert := suite.Assert() + assert.Nil(result) + if assert.Error(err) { + assert.IsType(&url.Error{}, err) + casterErr, _ := err.(*url.Error) + assert.Equal(expectedError, casterErr.Err) + } +} + +func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForInvalidSuccessResponse() { + suite.Logger.On("IncCounter", "mojang_textures.usernames.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.request_time", mock.Anything).Once() + + gock.New("http://example.com"). + Get("/subpath/username"). + Reply(200). + BodyString("completely not json") + + suite.Provider.Url = shouldParseUrl("http://example.com/subpath") + result, err := suite.Provider.GetUuid("username") + + assert := suite.Assert() + assert.Nil(result) + assert.Error(err) +} + +func shouldParseUrl(rawUrl string) url.URL { + url, err := url.Parse(rawUrl) + if err != nil { + panic(err) + } + + return *url +}