Merge pull request #17 from elyby/event_dispatcher_refactoring

Event dispatcher refactoring
This commit is contained in:
ErickSkrauch 2020-04-06 16:32:04 +03:00 committed by GitHub
commit 262babbeaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1806 additions and 838 deletions

View File

@ -18,11 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
username to its UUID. It can be used with some load balancing software to increase throughput of Mojang's textures
proxy by splitting the load across multiple servers with its own IPs.
- Textures extra param is now can be configured via `TEXTURES_EXTRA_PARAM_NAME` and `TEXTURES_EXTRA_PARAM_VALUE`.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common).
### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and
@ -34,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING**: Event `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` has been renamed into
`ely.skinsystem.{hostname}.app.mojang_textures.already_scheduled`.
### Removed
- **BREAKING**: `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` counter has been removed.
## [4.3.0] - 2019-11-08
### Added
- 403 Forbidden errors from the Mojang's API are now logged.

11
Gopkg.lock generated
View File

@ -14,6 +14,15 @@
revision = "f6df55f235c24f236d11dbcf665249a59ac2021f"
version = "1.1"
[[projects]]
branch = "publish_nil_values"
digest = "1:d02c8323070a3d8d8ca039d0d180198ead0a75eac4fb1003af812435a2b391e8"
name = "github.com/asaskevich/EventBus"
packages = ["."]
pruneopts = ""
revision = "33b3bc6a7ddca2f99683c5c3ee86b24f80a7a075"
source = "https://github.com/erickskrauch/EventBus.git"
[[projects]]
digest = "1:c7b11da9bf0707e6920e1b361fbbbbe9b277ef3a198377baa4527f6e31049be0"
name = "github.com/certifi/gocertifi"
@ -301,6 +310,7 @@
input-imports = [
"github.com/SermoDigital/jose/crypto",
"github.com/SermoDigital/jose/jws",
"github.com/asaskevich/EventBus",
"github.com/getsentry/raven-go",
"github.com/gorilla/mux",
"github.com/h2non/gock",
@ -308,6 +318,7 @@
"github.com/mediocregopher/radix.v2/redis",
"github.com/mediocregopher/radix.v2/util",
"github.com/mono83/slf",
"github.com/mono83/slf/params",
"github.com/mono83/slf/rays",
"github.com/mono83/slf/recievers/sentry",
"github.com/mono83/slf/recievers/statsd",

View File

@ -36,6 +36,11 @@ ignored = ["github.com/elyby/chrly"]
name = "github.com/tevino/abool"
branch = "master"
[[constraint]]
name = "github.com/asaskevich/EventBus"
source = "https://github.com/erickskrauch/EventBus.git"
branch = "publish_nil_values"
# Testing dependencies
[[constraint]]

View File

@ -342,7 +342,7 @@ If your Redis instance isn't located at the `localhost`, you can change host by
`STORAGE_REDIS_HOST`.
After all of that `go run main.go serve` should successfully start the application.
To run tests execute `go test ./...`. If your Go version is older than 1.9, then run a `/script/test`.
To run tests execute `go test ./...`.
[ico-lang]: https://img.shields.io/badge/lang-go%201.13-blue.svg?style=flat-square
[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square

View File

@ -3,8 +3,10 @@ package mojang
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
)
@ -85,7 +87,8 @@ func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
// Obtains textures information for provided uuid
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + uuid
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + normalizedUuid
if signed {
url += "?unsigned=false"
}
@ -146,7 +149,7 @@ type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "Empty Response"
return "200: Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {
@ -161,7 +164,7 @@ type BadRequestError struct {
}
func (e *BadRequestError) Error() string {
return e.Message
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
}
func (*BadRequestError) IsMojangError() bool {
@ -174,7 +177,7 @@ type ForbiddenError struct {
}
func (*ForbiddenError) Error() string {
return "Forbidden"
return "403: Forbidden"
}
// When you exceed the set limit of requests, this error will be returned
@ -183,7 +186,7 @@ type TooManyRequestsError struct {
}
func (*TooManyRequestsError) Error() string {
return "Too Many Requests"
return "429: Too Many Requests"
}
func (*TooManyRequestsError) IsMojangError() bool {
@ -197,7 +200,7 @@ type ServerError struct {
}
func (e *ServerError) Error() string {
return "Server error"
return fmt.Sprintf("%d: %s", e.Status, "Server error")
}
func (*ServerError) IsMojangError() bool {

View File

@ -98,7 +98,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{""})
assert.Nil(result)
assert.IsType(&BadRequestError{}, err)
assert.EqualError(err, "profileName can not be null or empty.")
assert.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
assert.Implements((*ResponseError)(nil), err)
})
@ -119,7 +119,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ForbiddenError{}, err)
assert.EqualError(err, "Forbidden")
assert.EqualError(err, "403: Forbidden")
assert.Implements((*ResponseError)(nil), err)
})
@ -143,7 +143,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.EqualError(err, "429: Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
@ -164,7 +164,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.EqualError(err, "500: Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})
@ -205,7 +205,7 @@ func TestUuidToTextures(t *testing.T) {
}
})
t.Run("obtain signed textures", func(t *testing.T) {
t.Run("obtain signed textures with dashed uuid", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
@ -230,7 +230,7 @@ func TestUuidToTextures(t *testing.T) {
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", true)
result, err := UuidToTextures("4566e69f-c907-48ee-8d71-d7ba5aa00d20", true)
if assert.NoError(err) {
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
assert.Equal("Thinkofdeath", result.Name)
@ -258,7 +258,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&EmptyResponse{}, err)
assert.EqualError(err, "Empty Response")
assert.EqualError(err, "200: Empty Response")
assert.Implements((*ResponseError)(nil), err)
})
@ -282,7 +282,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.EqualError(err, "429: Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
@ -303,7 +303,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.EqualError(err, "500: Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})

View File

@ -1,97 +0,0 @@
package auth
import (
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken_Success(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{[]byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(err)
assert.NotNil(token)
}
func TestJwtAuth_NewToken_KeyNotAvailable(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(err, "signing key not available")
assert.Nil(token)
}
func TestJwtAuth_Check_EmptyRequest(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Authentication header not presented")
}
func TestJwtAuth_Check_NonBearer(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot recognize JWT token in passed value")
}
func TestJwtAuth_Check_BearerButNotJwt(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot parse passed JWT token")
}
func TestJwtAuth_Check_SecretNotAvailable(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{}
err := jwt.Check(req)
assert.Error(err, "Signing key not set")
}
func TestJwtAuth_Check_SecretInvalid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("this is another secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "JWT token have invalid signature. It may be corrupted or expired.")
}
func TestJwtAuth_Check_Valid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.Nil(err)
}

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/getsentry/raven-go"
"github.com/mono83/slf"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
@ -13,31 +14,18 @@ import (
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/dispatcher"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
"github.com/elyby/chrly/version"
)
func CreateLogger(statsdAddr string, sentryAddr string) (wd.Watchdog, error) {
func CreateLogger(sentryAddr string) (slf.Logger, error) {
wd.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if statsdAddr != "" {
hostname, _ := os.Hostname()
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
wd.AddReceiver(statsdReceiver)
}
if sentryAddr != "" {
ravenClient, err := raven.New(sentryAddr)
if err != nil {
@ -64,12 +52,28 @@ func CreateLogger(statsdAddr string, sentryAddr string) (wd.Watchdog, error) {
return wd.New("", "").WithParams(rays.Host), nil
}
func CreateStatsReceiver(statsdAddr string) (slf.StatsReporter, error) {
hostname, _ := os.Hostname()
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
wd.AddReceiver(statsdReceiver)
return wd.New("", "").WithParams(rays.Host), nil
}
func init() {
viper.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
viper.SetDefault("queue.batch_size", 10)
}
func CreateMojangUUIDsProvider(logger wd.Watchdog) (mojangtextures.UUIDsProvider, error) {
func CreateMojangUUIDsProvider(emitter http.Emitter) (mojangtextures.UUIDsProvider, error) {
var uuidsProvider mojangtextures.UUIDsProvider
preferredUuidsProvider := viper.GetString("mojang_textures.uuids_provider.driver")
if preferredUuidsProvider == "remote" {
@ -79,16 +83,20 @@ func CreateMojangUUIDsProvider(logger wd.Watchdog) (mojangtextures.UUIDsProvider
}
uuidsProvider = &mojangtextures.RemoteApiUuidsProvider{
Url: *remoteUrl,
Logger: logger,
Emitter: emitter,
Url: *remoteUrl,
}
} else {
uuidsProvider = &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: viper.GetDuration("queue.loop_delay"),
IterationSize: viper.GetInt("queue.batch_size"),
Logger: logger,
}
}
return uuidsProvider, nil
}
func CreateEventDispatcher() dispatcher.EventDispatcher {
return dispatcher.New()
}

View File

@ -3,14 +3,15 @@ package cmd
import (
"fmt"
"log"
"os"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
@ -19,21 +20,36 @@ var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts HTTP handler for the skins system",
Run: func(cmd *cobra.Command, args []string) {
dispatcher := bootstrap.CreateEventDispatcher()
// 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"))
logger, err := bootstrap.CreateLogger(viper.GetString("sentry.dsn"))
if err != nil {
log.Fatal(fmt.Printf("Cannot initialize logger: %v", err))
log.Fatalf("Cannot initialize logger: %v", err)
}
logger.Info("Logger successfully initialized")
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
statsdAddr := viper.GetString("statsd.addr")
if statsdAddr != "" {
statsdReporter, err := bootstrap.CreateStatsReceiver(statsdAddr)
if err != nil {
logger.Emergency("Invalid statsd configuration :err", wd.ErrParam(err))
os.Exit(1)
}
(&eventsubscribers.StatsReporter{StatsReporter: statsdReporter}).ConfigureWithDispatcher(dispatcher)
}
storageFactory := db.StorageFactory{Config: viper.GetViper()}
logger.Info("Initializing skins repository")
redisFactory := storageFactory.CreateFactory("redis")
skinsRepo, err := redisFactory.CreateSkinsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
return
logger.Emergency("Error on creating skins repo: :err", wd.ErrParam(err))
os.Exit(1)
}
logger.Info("Skins repository successfully initialized")
@ -41,31 +57,31 @@ var serveCmd = &cobra.Command{
filesystemFactory := storageFactory.CreateFactory("filesystem")
capesRepo, err := filesystemFactory.CreateCapesRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
return
logger.Emergency("Error on creating capes repo: :err", wd.ErrParam(err))
os.Exit(1)
}
logger.Info("Capes repository successfully initialized")
logger.Info("Preparing Mojang's textures queue")
mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating mojang uuids repo: %v", err))
return
logger.Emergency("Error on creating mojang uuids repo: :err", wd.ErrParam(err))
os.Exit(1)
}
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(logger)
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(dispatcher)
if err != nil {
logger.Emergency("Unable to parse remote url :err", wd.ErrParam(err))
return
os.Exit(1)
}
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
mojangTexturesProvider := &mojangtextures.Provider{
Logger: logger,
Emitter: dispatcher,
UUIDsProvider: uuidsProvider,
TexturesProvider: &mojangtextures.MojangApiTexturesProvider{
Logger: logger,
Emitter: dispatcher,
},
Storage: &mojangtextures.SeparatedStorage{
UuidsStorage: mojangUuidsRepository,
@ -74,28 +90,29 @@ var serveCmd = &cobra.Command{
}
logger.Info("Mojang's textures queue is successfully initialized")
cfg := &http.Skinsystem{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
address := fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port"))
handler := (&http.Skinsystem{
Emitter: dispatcher,
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
MojangTexturesProvider: mojangTexturesProvider,
Logger: logger,
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
Authenticator: &http.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
TexturesExtraParamName: viper.GetString("textures.extra_param_name"),
TexturesExtraParamValue: viper.GetString("textures.extra_param_value"),
}
}).CreateHandler()
finishChan := make(chan bool)
go func() {
if err := cfg.Run(); err != nil {
logger.Error(fmt.Sprintf("Error in main(): %v", err))
logger.Info("Starting the app, HTTP on: :addr", wd.StringParam("addr", address))
if err := http.Serve(address, handler); err != nil {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
finishChan <- true
}
}()
go func() {
s := waitForExitSignal()
logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
logger.Info("Got signal: :signal, exiting", wd.StringParam("signal", s.String()))
finishChan <- true
}()

View File

@ -4,7 +4,7 @@ import (
"fmt"
"log"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/http"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -14,8 +14,8 @@ var tokenCmd = &cobra.Command{
Use: "token",
Short: "Creates a new token, which allows to interact with Chrly API",
Run: func(cmd *cobra.Command, args []string) {
jwtAuth := &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}
token, err := jwtAuth.NewToken(auth.SkinScope)
jwtAuth := &http.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}
token, err := jwtAuth.NewToken(http.SkinScope)
if err != nil {
log.Fatalf("Unable to create new token. The error is %v\n", err)
}

View File

@ -3,12 +3,14 @@ package cmd
import (
"fmt"
"log"
"os"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
)
@ -16,35 +18,52 @@ var workerCmd = &cobra.Command{
Use: "worker",
Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker",
Run: func(cmd *cobra.Command, args []string) {
logger, err := bootstrap.CreateLogger(viper.GetString("statsd.addr"), viper.GetString("sentry.dsn"))
dispatcher := bootstrap.CreateEventDispatcher()
// TODO: need to find a way to unify this initialization with the serve command
logger, err := bootstrap.CreateLogger(viper.GetString("sentry.dsn"))
if err != nil {
log.Fatal(fmt.Printf("Cannot initialize logger: %v", err))
log.Fatalf("Cannot initialize logger: %v", err)
}
logger.Info("Logger successfully initialized")
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(logger)
if err != nil {
logger.Emergency("Unable to parse remote url :err", wd.ErrParam(err))
return
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
statsdAddr := viper.GetString("statsd.addr")
if statsdAddr != "" {
statsdReporter, err := bootstrap.CreateStatsReceiver(statsdAddr)
if err != nil {
logger.Emergency("Invalid statsd configuration :err", wd.ErrParam(err))
os.Exit(1)
}
(&eventsubscribers.StatsReporter{StatsReporter: statsdReporter}).ConfigureWithDispatcher(dispatcher)
}
cfg := &http.UUIDsWorker{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
UUIDsProvider: uuidsProvider,
Logger: logger,
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(dispatcher)
if err != nil {
logger.Emergency("Unable to parse remote url :err", wd.ErrParam(err))
os.Exit(1)
}
address := fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port"))
handler := (&http.UUIDsWorker{
Emitter: dispatcher,
UUIDsProvider: uuidsProvider,
}).CreateHandler()
finishChan := make(chan bool)
go func() {
if err := cfg.Run(); err != nil {
logger.Error(fmt.Sprintf("Error in main(): %v", err))
logger.Info("Starting the worker, HTTP on: :addr", wd.StringParam("addr", address))
if err := http.Serve(address, handler); err != nil {
logger.Error("Error in main(): :err", wd.ErrParam(err))
finishChan <- true
}
}()
go func() {
s := waitForExitSignal()
logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
logger.Info("Got signal: :code, exiting.", wd.StringParam("code", s.String()))
finishChan <- true
}()

26
dispatcher/dispatcher.go Normal file
View File

@ -0,0 +1,26 @@
package dispatcher
import "github.com/asaskevich/EventBus"
type EventDispatcher interface {
Subscribe(topic string, fn interface{})
Emit(topic string, args ...interface{})
}
type LocalEventDispatcher struct {
bus EventBus.Bus
}
func (d *LocalEventDispatcher) Subscribe(topic string, fn interface{}) {
_ = d.bus.Subscribe(topic, fn)
}
func (d *LocalEventDispatcher) Emit(topic string, args ...interface{}) {
d.bus.Publish(topic, args...)
}
func New() EventDispatcher {
return &LocalEventDispatcher{
bus: EventBus.New(),
}
}

View File

@ -0,0 +1,94 @@
package eventsubscribers
import (
"net"
"net/http"
"net/url"
"strings"
"syscall"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
type Logger struct {
slf.Logger
}
func (l *Logger) ConfigureWithDispatcher(d Subscriber) {
d.Subscribe("skinsystem:after_request", l.handleAfterSkinsystemRequest)
d.Subscribe("mojang_textures:usernames:after_call", l.createMojangTexturesErrorHandler("usernames"))
d.Subscribe("mojang_textures:textures:after_call", l.createMojangTexturesErrorHandler("textures"))
}
func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int) {
path := req.URL.Path
if req.URL.RawQuery != "" {
path += "?" + req.URL.RawQuery
}
l.Info(
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
wd.StringParam("ip", trimPort(req.RemoteAddr)),
wd.StringParam("method", req.Method),
wd.StringParam("path", path),
wd.IntParam("statusCode", statusCode),
wd.StringParam("userAgent", req.UserAgent()),
wd.StringParam("forwardedIp", req.Header.Get("X-Forwarded-For")),
)
}
func (l *Logger) createMojangTexturesErrorHandler(provider string) func(identity string, result interface{}, err error) {
providerParam := wd.NameParam(provider)
return func(identity string, result interface{}, err error) {
if err == nil {
return
}
errParam := wd.ErrParam(err)
switch err.(type) {
case *mojang.BadRequestError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.ForbiddenError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.TooManyRequestsError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
l.Error(":name: Unexpected Mojang response error: :err", providerParam, errParam)
}
}
func (l *Logger) logMojangTexturesWarning(providerParam slf.Param, errParam slf.Param) {
l.Warning(":name: :err", providerParam, errParam)
}
func trimPort(ip string) string {
// Don't care about possible -1 result because RemoteAddr will always contain ip and port
cutTo := strings.LastIndexByte(ip, ':')
return ip[0:cutTo]
}

View File

@ -0,0 +1,256 @@
package eventsubscribers
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"syscall"
"testing"
"github.com/mono83/slf"
"github.com/mono83/slf/params"
"github.com/stretchr/testify/mock"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type LoggerMock struct {
mock.Mock
}
func prepareLoggerArgs(message string, params []slf.Param) []interface{} {
args := []interface{}{message}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
func (l *LoggerMock) Trace(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Debug(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Info(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Warning(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Error(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Alert(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Emergency(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
type LoggerTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var loggerTestCases = map[string]*LoggerTestCase{
"should log each request to the skinsystem": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png", nil)
req.Header.Add("User-Agent", "Test user agent")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "ip" && strParam.Value == "192.0.2.1"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "method" && strParam.Value == "GET"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png"
}),
mock.MatchedBy(func(strParam params.Int) bool {
return strParam.Key == "statusCode" && strParam.Value == 201
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "userAgent" && strParam.Value == "Test user agent"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == ""
}),
},
},
},
"should log each request to the skinsystem 2": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png?authlib=1.5.2", nil)
req.Header.Add("User-Agent", "Test user agent")
req.Header.Add("X-Forwarded-For", "1.2.3.4")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png?authlib=1.5.2"
}),
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == "1.2.3.4"
}),
},
},
},
}
type timeoutError struct{}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
func init() {
// mojang_textures providers errors
for _, providerName := range []string{"usernames", "textures"} {
pn := providerName // Store pointer to iteration value
loggerTestCases["should not log when no error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: nil,
}
loggerTestCases["should not log when some network errors occured for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &timeoutError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &url.Error{Op: "GET", URL: "http://localhost"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "read"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "dial"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, syscall.ECONNREFUSED},
},
ExpectedCalls: nil,
}
loggerTestCases["should log expected mojang errors for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.BadRequestError{
ErrorType: "IllegalArgumentException",
Message: "profileName can not be null or empty.",
}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ForbiddenError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.TooManyRequestsError{}},
},
ExpectedCalls: [][]interface{}{
{"Warning",
":name: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.BadRequestError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.ForbiddenError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.TooManyRequestsError); ok {
return true
}
return false
}),
},
},
}
loggerTestCases["should call error when unexpected error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ServerError{Status: 500}},
},
ExpectedCalls: [][]interface{}{
{"Error",
":name: Unexpected Mojang response error: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.ServerError); !ok {
return false
}
return true
}),
},
},
}
}
}
func TestLogger(t *testing.T) {
for name, c := range loggerTestCases {
t.Run(name, func(t *testing.T) {
loggerMock := &LoggerMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.On(topicName, c[1:]...)
}
}
reporter := &Logger{
Logger: loggerMock,
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, args := range c.Events {
eventName, _ := args[0].(string)
d.Emit(eventName, args[1:]...)
}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.AssertCalled(t, topicName, c[1:]...)
}
}
})
}
}

View File

@ -0,0 +1,177 @@
package eventsubscribers
import (
"net/http"
"strings"
"sync"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
)
type StatsReporter struct {
slf.StatsReporter
Prefix string
timersMap map[string]time.Time
timersMutex sync.Mutex
}
func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
s.timersMap = make(map[string]time.Time)
// Per request events
d.Subscribe("skinsystem:before_request", s.handleBeforeRequest)
d.Subscribe("skinsystem:after_request", s.handleAfterRequest)
// Authentication events
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.success"))
d.Subscribe("authentication:error", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authentication:error", s.incCounterHandler("authentication.failed"))
// Mojang signed textures source events
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, err error) {
if err != nil {
return
}
if uuid == "" {
s.IncCounter("mojang_textures:usernames:cache_hit_nil", 1)
} else {
s.IncCounter("mojang_textures:usernames:cache_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures != nil {
s.IncCounter("mojang_textures.textures.cache_hit", 1)
}
})
d.Subscribe("mojang_textures:already_processing", s.incCounterHandler("mojang_textures.already_scheduled"))
d.Subscribe("mojang_textures:usernames:after_call", func(username string, profile *mojang.ProfileInfo, err error) {
if err != nil {
return
}
if profile == nil {
s.IncCounter("mojang_textures.usernames.uuid_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:before_call", s.incCounterHandler("mojang_textures.textures.request"))
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures == nil {
s.IncCounter("mojang_textures.usernames.textures_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.textures_hit", 1)
}
})
d.Subscribe("mojang_textures:before_result", func(username string, uuid string) {
s.startTimeRecording("mojang_textures_result_time_" + username)
})
d.Subscribe("mojang_textures:after_result", func(username string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_result_time_"+username, "mojang_textures.result_time")
})
d.Subscribe("mojang_textures:textures:before_call", func(uuid string) {
s.startTimeRecording("mojang_textures_provider_time_" + uuid)
})
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_provider_time_"+uuid, "mojang_textures.textures.request_time")
})
// Mojang UUIDs batch provider metrics
d.Subscribe("mojang_textures:batch_uuids_provider:queued", s.incCounterHandler("mojang_textures.usernames.queued"))
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
})
d.Subscribe("mojang_textures:batch_uuids_provider:before_round", func() {
s.startTimeRecording("batch_uuids_provider_round_time")
})
d.Subscribe("mojang_textures:batch_uuids_provider:after_round", func() {
s.finalizeTimeRecording("batch_uuids_provider_round_time", "mojang_textures.usernames.round_time")
})
}
func (s *StatsReporter) handleBeforeRequest(req *http.Request) {
var key string
m := req.Method
p := req.URL.Path
if p == "/skins" {
key = "skins.get_request"
} else if strings.HasPrefix(p, "/skins/") {
key = "skins.request"
} else if p == "/cloaks" {
key = "capes.get_request"
} else if strings.HasPrefix(p, "/cloaks/") {
key = "capes.request"
} else if strings.HasPrefix(p, "/textures/signed/") {
key = "signed_textures.request"
} else if strings.HasPrefix(p, "/textures/") {
key = "textures.request"
} else if m == http.MethodPost && p == "/api/skins" {
key = "api.skins.post.request"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") {
key = "api.skins.delete.request"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) handleAfterRequest(req *http.Request, code int) {
var key string
m := req.Method
p := req.URL.Path
if m == http.MethodPost && p == "/api/skins" && code == http.StatusCreated {
key = "api.skins.post.success"
} else if m == http.MethodPost && p == "/api/skins" && code == http.StatusBadRequest {
key = "api.skins.post.validation_failed"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNoContent {
key = "api.skins.delete.success"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNotFound {
key = "api.skins.delete.not_found"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) incCounterHandler(name string) func(...interface{}) {
return func(...interface{}) {
s.IncCounter(name, 1)
}
}
func (s *StatsReporter) startTimeRecording(timeKey string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
s.timersMap[timeKey] = time.Now()
}
func (s *StatsReporter) finalizeTimeRecording(timeKey string, statName string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
startedAt, ok := s.timersMap[timeKey]
if !ok {
return
}
delete(s.timersMap, timeKey)
s.RecordTimer(statName, time.Since(startedAt))
}

View File

@ -0,0 +1,383 @@
package eventsubscribers
import (
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
"github.com/stretchr/testify/mock"
)
func prepareStatsReporterArgs(name string, value interface{}, params []slf.Param) []interface{} {
args := []interface{}{name, value}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
type StatsReporterMock struct {
mock.Mock
}
func (r *StatsReporterMock) IncCounter(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) UpdateGauge(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) RecordTimer(name string, duration time.Duration, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, duration, params)...)
}
func (r *StatsReporterMock) Timer(name string, params ...slf.Param) slf.Timer {
return slf.NewTimer(name, params, r)
}
type StatsReporterTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var statsReporterTestCases = []*StatsReporterTestCase{
// Before request
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/signed/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "signed_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/unknown", nil)},
},
ExpectedCalls: nil,
},
// After request
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 201},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 400},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.validation_failed", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/unknown", nil), 404},
},
ExpectedCalls: nil,
},
// Authenticator
{
Events: [][]interface{}{
{"authenticator:success"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"authentication:error", errors.New("error")},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.failed", int64(1)},
},
},
// Mojang signed textures provider
{
Events: [][]interface{}{
{"mojang_textures:call", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:already_processing", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.already_scheduled", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:before_result", "username", ""},
{"mojang_textures:after_result", "username", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.result_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:before_call", "аааааааааааааааааааааааааааааааа"},
{"mojang_textures:textures:after_call", "аааааааааааааааааааааааааааааааа", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.request", int64(1)},
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
{"RecordTimer", "mojang_textures.textures.request_time", mock.AnythingOfType("time.Duration")},
},
},
// Batch UUIDs provider
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:queued", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.queued", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:before_round"},
{"mojang_textures:batch_uuids_provider:after_round"},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
}
func TestStatsReporter(t *testing.T) {
for _, c := range statsReporterTestCases {
t.Run("handle events", func(t *testing.T) {
statsReporterMock := &StatsReporterMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
statsReporterMock.On(topicName, c[1:]...).Once()
}
}
reporter := &StatsReporter{
StatsReporter: statsReporterMock,
Prefix: "mock_prefix",
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, e := range c.Events {
eventName, _ := e[0].(string)
d.Emit(eventName, e[1:]...)
}
statsReporterMock.AssertExpectations(t)
})
}
}

View File

@ -0,0 +1,5 @@
package eventsubscribers
type Subscriber interface {
Subscribe(topic string, fn interface{})
}

View File

@ -2,9 +2,82 @@ package http
import (
"encoding/json"
"net"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
)
type Emitter interface {
Emit(name string, args ...interface{})
}
func Serve(address string, handler http.Handler) error {
listener, err := net.Listen("tcp", address)
if err != nil {
return err
}
server := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server.Serve(listener)
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func CreateRequestEventsMiddleware(emitter Emitter, prefix string) mux.MiddlewareFunc {
beforeTopic := strings.Join([]string{prefix, "before_request"}, ":")
afterTopic := strings.Join([]string{prefix, "after_request"}, ":")
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
emitter.Emit(beforeTopic, req)
lrw := &loggingResponseWriter{
ResponseWriter: resp,
statusCode: http.StatusOK,
}
handler.ServeHTTP(lrw, req)
emitter.Emit(afterTopic, req, lrw.statusCode)
})
}
}
type Authenticator interface {
Authenticate(req *http.Request) error
}
func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
err := checker.Authenticate(req)
if err != nil {
apiForbidden(resp, err.Error())
return
}
handler.ServeHTTP(resp, req)
})
}
}
func NotFound(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",

View File

@ -1,14 +1,99 @@
package http
import (
"errors"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestConfig_NotFound(t *testing.T) {
type emitterMock struct {
mock.Mock
}
func (e *emitterMock) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
func TestCreateRequestEventsMiddleware(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
emitter := &emitterMock{}
emitter.On("Emit", "test_prefix:before_request", req)
emitter.On("Emit", "test_prefix:after_request", req, 400)
isHandlerCalled := false
middlewareFunc := CreateRequestEventsMiddleware(emitter, "test_prefix")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(400)
isHandlerCalled = true
})).ServeHTTP(resp, req)
if !isHandlerCalled {
t.Fatal("Handler isn't called from the middleware")
}
emitter.AssertExpectations(t)
}
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Authenticate(req *http.Request) error {
args := m.Called(req)
return args.Error(0)
}
func TestCreateAuthenticationMiddleware(t *testing.T) {
t.Run("pass", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(nil)
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isHandlerCalled, "Handler isn't called from the middleware")
auth.AssertExpectations(t)
})
t.Run("fail", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(errors.New("error reason"))
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isHandlerCalled, "Handler shouldn't be called")
testify.Equal(t, 403, resp.Code)
body, _ := ioutil.ReadAll(resp.Body)
testify.JSONEq(t, `{
"error": "error reason"
}`, string(body))
auth.AssertExpectations(t)
})
}
func TestNotFound(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://example.com", nil)

View File

@ -1,4 +1,4 @@
package auth
package http
import (
"errors"
@ -21,6 +21,7 @@ var (
)
type JwtAuth struct {
Emitter
Key []byte
}
@ -41,42 +42,37 @@ func (t *JwtAuth) NewToken(scopes ...Scope) ([]byte, error) {
return token, nil
}
func (t *JwtAuth) Check(req *http.Request) error {
func (t *JwtAuth) Authenticate(req *http.Request) error {
if len(t.Key) == 0 {
return &Unauthorized{"Signing key not set"}
return t.emitErr(errors.New("Signing key not set"))
}
bearerToken := req.Header.Get("Authorization")
if bearerToken == "" {
return &Unauthorized{"Authentication header not presented"}
return t.emitErr(errors.New("Authentication header not presented"))
}
if !strings.EqualFold(bearerToken[0:7], "BEARER ") {
return &Unauthorized{"Cannot recognize JWT token in passed value"}
return t.emitErr(errors.New("Cannot recognize JWT token in passed value"))
}
tokenStr := bearerToken[7:]
token, err := jws.ParseJWT([]byte(tokenStr))
if err != nil {
return &Unauthorized{"Cannot parse passed JWT token"}
return t.emitErr(errors.New("Cannot parse passed JWT token"))
}
err = token.Validate(t.Key, hashAlg)
if err != nil {
return &Unauthorized{"JWT token have invalid signature. It may be corrupted or expired."}
return t.emitErr(errors.New("JWT token have invalid signature. It may be corrupted or expired"))
}
t.Emit("authentication:success")
return nil
}
type Unauthorized struct {
Reason string
}
func (e *Unauthorized) Error() string {
if e.Reason != "" {
return e.Reason
}
return "Unauthorized"
func (t *JwtAuth) emitErr(err error) error {
t.Emit("authentication:error", err)
return err
}

127
http/jwt_test.go Normal file
View File

@ -0,0 +1,127 @@
package http
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken(t *testing.T) {
t.Run("success", func(t *testing.T) {
jwt := &JwtAuth{Key: []byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(t, err)
assert.NotNil(t, token)
})
t.Run("key not provided", func(t *testing.T) {
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(t, err, "signing key not available")
assert.Nil(t, token)
})
}
func TestJwtAuth_Authenticate(t *testing.T) {
t.Run("success", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:success")
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Nil(t, err)
emitter.AssertExpectations(t)
})
t.Run("request without auth header", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Authentication header not presented")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Authentication header not presented")
emitter.AssertExpectations(t)
})
t.Run("no bearer token prefix", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot recognize JWT token in passed value")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot recognize JWT token in passed value")
emitter.AssertExpectations(t)
})
t.Run("bearer token but not jwt", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot parse passed JWT token")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot parse passed JWT token")
emitter.AssertExpectations(t)
})
t.Run("when secret is not set", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Signing key not set")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Signing key not set")
emitter.AssertExpectations(t)
})
t.Run("invalid signature", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("this is another secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
emitter.AssertExpectations(t)
})
}

View File

@ -5,19 +5,15 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/model"
)
@ -67,7 +63,7 @@ type SkinNotFoundError struct {
}
func (e SkinNotFoundError) Error() string {
return "Skin data not found."
return "skin data not found"
}
type CapeNotFoundError struct {
@ -75,74 +71,51 @@ type CapeNotFoundError struct {
}
func (e CapeNotFoundError) Error() string {
return "Cape file not found."
return "cape file not found"
}
type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
}
type AuthChecker interface {
Check(req *http.Request) error
}
type Skinsystem struct {
ListenSpec string
Emitter
TexturesExtraParamName string
TexturesExtraParamValue string
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
Auth AuthChecker
Logger wd.Watchdog
}
func (ctx *Skinsystem) Run() error {
ctx.Logger.Info(fmt.Sprintf("Starting the app, HTTP on: %s\n", ctx.ListenSpec))
listener, err := net.Listen("tcp", ctx.ListenSpec)
if err != nil {
return err
}
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: ctx.CreateHandler(),
}
return server.Serve(listener)
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
Authenticator Authenticator
}
func (ctx *Skinsystem) CreateHandler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem")
router.HandleFunc("/skins/{username}", ctx.Skin).Methods("GET")
router.HandleFunc("/cloaks/{username}", ctx.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.Textures).Methods("GET")
router.HandleFunc("/textures/signed/{username}", ctx.SignedTextures).Methods("GET")
router := mux.NewRouter().StrictSlash(true)
router.Use(requestEventsMiddleware)
router.HandleFunc("/skins/{username}", ctx.Skin).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.Cape).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.Textures).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.SignedTextures).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", ctx.SkinGET).Methods("GET")
router.HandleFunc("/cloaks", ctx.CapeGET).Methods("GET")
router.HandleFunc("/skins", ctx.SkinGET).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.CapeGET).Methods(http.MethodGet)
// API
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(ctx.AuthenticationMiddleware)
apiRouter.Handle("/skins", http.HandlerFunc(ctx.PostSkin)).Methods("POST")
apiRouter.Handle("/skins/id:{id:[0-9]+}", http.HandlerFunc(ctx.DeleteSkinByUserId)).Methods("DELETE")
apiRouter.Handle("/skins/{username}", http.HandlerFunc(ctx.DeleteSkinByUsername)).Methods("DELETE")
apiRouter.Use(CreateAuthenticationMiddleware(ctx.Authenticator))
apiRouter.HandleFunc("/skins", ctx.PostSkin).Methods(http.MethodPost)
apiRouter.HandleFunc("/skins/id:{id:[0-9]+}", ctx.DeleteSkinByUserId).Methods(http.MethodDelete)
apiRouter.HandleFunc("/skins/{username}", ctx.DeleteSkinByUsername).Methods(http.MethodDelete)
// 404
router.NotFoundHandler = http.HandlerFunc(NotFound)
// NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually.
// See https://github.com/gorilla/mux/issues/416#issuecomment-600079279
router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFound))
return router
}
func (ctx *Skinsystem) Skin(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
ctx.Logger.IncCounter("skins.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 {
@ -173,7 +146,6 @@ func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Reque
return
}
ctx.Logger.IncCounter("skins.get_request", 1)
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
@ -181,10 +153,6 @@ func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Reque
}
func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
ctx.Logger.IncCounter("capes.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindByUsername(username)
if err == nil {
@ -216,7 +184,6 @@ func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Reque
return
}
ctx.Logger.IncCounter("capes.get_request", 1)
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
@ -224,7 +191,6 @@ func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Reque
}
func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Request) {
ctx.Logger.IncCounter("textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse
@ -261,8 +227,8 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
texturesProp := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusInternalServerError)
ctx.Logger.Error("Unable to find textures property")
ctx.Emit("skinsystem:error", errors.New("unable to find textures property"))
apiServerError(response)
return
}
@ -275,7 +241,6 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
}
func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *http.Request) {
ctx.Logger.IncCounter("signed_textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
@ -316,10 +281,8 @@ func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *htt
}
func (ctx *Skinsystem) PostSkin(resp http.ResponseWriter, req *http.Request) {
ctx.Logger.IncCounter("api.skins.post.request", 1)
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
ctx.Logger.IncCounter("api.skins.post.validation_failed", 1)
apiBadRequest(resp, validationErrors)
return
}
@ -329,7 +292,7 @@ func (ctx *Skinsystem) PostSkin(resp http.ResponseWriter, req *http.Request) {
record, err := findIdentity(ctx.SkinsRepo, identityId, username)
if err != nil {
ctx.Logger.Error("Error on requesting a skin from the repository: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
}
@ -348,71 +311,45 @@ func (ctx *Skinsystem) PostSkin(resp http.ResponseWriter, req *http.Request) {
err = ctx.SkinsRepo.Save(record)
if err != nil {
ctx.Logger.Error("Unable to save record to the repository: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
}
ctx.Logger.IncCounter("api.skins.post.success", 1)
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Skinsystem) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) {
ctx.Logger.IncCounter("api.skins.delete.request", 1)
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := ctx.SkinsRepo.FindByUserId(id)
if err != nil {
ctx.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested user id")
return
}
ctx.deleteSkin(skin, resp)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Skinsystem) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) {
ctx.Logger.IncCounter("api.skins.delete.request", 1)
username := mux.Vars(req)["username"]
skin, err := ctx.SkinsRepo.FindByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Skinsystem) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
ctx.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested username")
if _, ok := err.(*SkinNotFoundError); ok {
apiNotFound(resp, "Cannot find record for the requested identifier")
} else {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
}
return
}
ctx.deleteSkin(skin, resp)
}
func (ctx *Skinsystem) AuthenticationMiddleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
ctx.Logger.IncCounter("authentication.challenge", 1)
err := ctx.Auth.Check(req)
if err != nil {
if _, ok := err.(*auth.Unauthorized); ok {
ctx.Logger.IncCounter("authentication.failed", 1)
apiForbidden(resp, err.Error())
} else {
ctx.Logger.Error("Unknown error on validating api request: :err", wd.ErrParam(err))
apiServerError(resp)
}
return
}
ctx.Logger.IncCounter("authentication.success", 1)
handler.ServeHTTP(resp, req)
})
}
func (ctx *Skinsystem) deleteSkin(skin *model.Skin, resp http.ResponseWriter) {
err := ctx.SkinsRepo.RemoveByUserId(skin.UserId)
err = ctx.SkinsRepo.RemoveByUserId(skin.UserId)
if err != nil {
ctx.Logger.Error("Cannot delete skin by error: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
}
ctx.Logger.IncCounter("api.skins.delete.success", 1)
resp.WriteHeader(http.StatusNoContent)
}

View File

@ -3,8 +3,7 @@ package http
import (
"bytes"
"encoding/base64"
"github.com/elyby/chrly/auth"
testify "github.com/stretchr/testify/assert"
"errors"
"image"
"image/png"
"io"
@ -16,12 +15,12 @@ import (
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/tests"
)
/***************
@ -95,15 +94,6 @@ func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.Si
return result, args.Error(1)
}
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Check(req *http.Request) error {
args := m.Called(req)
return args.Error(0)
}
type skinsystemTestSuite struct {
suite.Suite
@ -113,7 +103,7 @@ type skinsystemTestSuite struct {
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
Auth *authCheckerMock
Logger *tests.WdMock
Emitter *emitterMock
}
/********************
@ -125,14 +115,14 @@ func (suite *skinsystemTestSuite) SetupTest() {
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.Auth = &authCheckerMock{}
suite.Logger = &tests.WdMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Auth: suite.Auth,
Logger: suite.Logger,
Authenticator: suite.Auth,
Emitter: suite.Emitter,
}
}
@ -141,7 +131,7 @@ func (suite *skinsystemTestSuite) TearDownTest() {
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.Auth.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *skinsystemTestSuite) RunSubTest(name string, subTest func()) {
@ -215,7 +205,8 @@ var skinsTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestSkin() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "skins.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
@ -228,7 +219,8 @@ func (suite *skinsystemTestSuite) TestSkin() {
}
suite.RunSubTest("Pass username with png extension", func() {
suite.Logger.On("IncCounter", "skins.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
@ -245,7 +237,8 @@ func (suite *skinsystemTestSuite) TestSkin() {
func (suite *skinsystemTestSuite) TestSkinGET() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "skins.get_request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
@ -258,6 +251,9 @@ func (suite *skinsystemTestSuite) TestSkinGET() {
}
suite.RunSubTest("Do not pass name param", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
@ -321,7 +317,8 @@ var capesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestCape() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "capes.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
@ -334,7 +331,8 @@ func (suite *skinsystemTestSuite) TestCape() {
}
suite.RunSubTest("Pass username with png extension", func() {
suite.Logger.On("IncCounter", "capes.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
@ -353,7 +351,8 @@ func (suite *skinsystemTestSuite) TestCape() {
func (suite *skinsystemTestSuite) TestCapeGET() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "capes.get_request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
@ -366,6 +365,9 @@ func (suite *skinsystemTestSuite) TestCapeGET() {
}
suite.RunSubTest("Do not pass name param", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
@ -492,7 +494,8 @@ var texturesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestTextures() {
for _, testCase := range texturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "textures.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
@ -616,7 +619,8 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
func (suite *skinsystemTestSuite) TestSignedTextures() {
for _, testCase := range signedTexturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "signed_textures.request", int64(1)).Once()
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
var target string
@ -769,16 +773,65 @@ var postSkinTestsCases = []*postSkinTestCase{
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("Save", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
func (suite *skinsystemTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.success", int64(1)).Once()
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
testCase.BeforeTest(suite)
req := httptest.NewRequest("POST", "http://chrly/api/skins", testCase.Form)
@ -792,11 +845,9 @@ func (suite *skinsystemTestSuite) TestPostSkin() {
}
suite.RunSubTest("Get errors about required fields", func() {
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.validation_failed", int64(1)).Once()
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
@ -843,13 +894,13 @@ func (suite *skinsystemTestSuite) TestPostSkin() {
})
suite.RunSubTest("Send request without authorization", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("POST", "http://chrly/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
w := httptest.NewRecorder()
suite.Auth.On("Check", mock.Anything).Return(&auth.Unauthorized{Reason: "Cannot parse passed JWT token"})
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.failed", int64(1)).Once()
suite.Auth.On("Authenticate", mock.Anything).Return(errors.New("Cannot parse passed JWT token"))
suite.App.CreateHandler().ServeHTTP(w, req)
@ -863,11 +914,9 @@ func (suite *skinsystemTestSuite) TestPostSkin() {
})
suite.RunSubTest("Upload textures with skin as file", func() {
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.post.validation_failed", int64(1)).Once()
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
@ -911,13 +960,11 @@ func (suite *skinsystemTestSuite) TestPostSkin() {
func (suite *skinsystemTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.success", int64(1)).Once()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
w := httptest.NewRecorder()
@ -932,12 +979,10 @@ func (suite *skinsystemTestSuite) TestDeleteByUserId() {
})
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"})
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.not_found", int64(1)).Once()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
w := httptest.NewRecorder()
@ -949,7 +994,7 @@ func (suite *skinsystemTestSuite) TestDeleteByUserId() {
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for requested user id"
"Cannot find record for the requested identifier"
]`, string(body))
})
}
@ -960,13 +1005,11 @@ func (suite *skinsystemTestSuite) TestDeleteByUserId() {
func (suite *skinsystemTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.success", int64(1)).Once()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil)
w := httptest.NewRecorder()
@ -981,12 +1024,10 @@ func (suite *skinsystemTestSuite) TestDeleteByUsername() {
})
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.Auth.On("Check", mock.Anything).Return(nil)
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.Logger.On("IncCounter", "authentication.challenge", int64(1)).Once()
suite.Logger.On("IncCounter", "authentication.success", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.request", int64(1)).Once()
suite.Logger.On("IncCounter", "api.skins.delete.not_found", int64(1)).Once()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil)
w := httptest.NewRecorder()
@ -998,7 +1039,7 @@ func (suite *skinsystemTestSuite) TestDeleteByUsername() {
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for requested username"
"Cannot find record for the requested identifier"
]`, string(body))
})
}

View File

@ -2,13 +2,9 @@ package http
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/mojangtextures"
@ -19,31 +15,11 @@ type UuidsProvider interface {
}
type UUIDsWorker struct {
ListenSpec string
Emitter
UUIDsProvider mojangtextures.UUIDsProvider
Logger wd.Watchdog
}
func (ctx *UUIDsWorker) Run() error {
ctx.Logger.Info(fmt.Sprintf("Starting the worker, HTTP on: %s\n", ctx.ListenSpec))
listener, err := net.Listen("tcp", ctx.ListenSpec)
if err != nil {
return err
}
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second, // TODO: should I adjust this values?
MaxHeaderBytes: 1 << 16,
Handler: ctx.CreateHandler(),
}
return server.Serve(listener)
}
func (ctx *UUIDsWorker) CreateHandler() http.Handler {
func (ctx *UUIDsWorker) CreateHandler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.NotFoundHandler = http.HandlerFunc(NotFound)
@ -56,13 +32,12 @@ func (ctx *UUIDsWorker) GetUUID(response http.ResponseWriter, request *http.Requ
username := parseUsername(mux.Vars(request)["username"])
profile, err := ctx.UUIDsProvider.GetUuid(username)
if err != nil {
ctx.Emit("uuids_provider:error", err) // TODO: do I need emitter here?
if _, ok := err.(*mojang.TooManyRequestsError); ok {
ctx.Logger.Warning("Got 429 Too Many Requests")
response.WriteHeader(http.StatusTooManyRequests)
return
}
ctx.Logger.Warning("Got non success response: :err", wd.ErrParam(err))
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusInternalServerError)
result, _ := json.Marshal(map[string]interface{}{

View File

@ -2,14 +2,15 @@ package http
import (
"errors"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/tests"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
/***************
@ -36,7 +37,7 @@ type uuidsWorkerTestSuite struct {
App *UUIDsWorker
UuidsProvider *uuidsProviderMock
Logger *tests.WdMock
Emitter *emitterMock
}
/********************
@ -45,17 +46,17 @@ type uuidsWorkerTestSuite struct {
func (suite *uuidsWorkerTestSuite) SetupTest() {
suite.UuidsProvider = &uuidsProviderMock{}
suite.Logger = &tests.WdMock{}
suite.Emitter = &emitterMock{}
suite.App = &UUIDsWorker{
UUIDsProvider: suite.UuidsProvider,
Logger: suite.Logger,
Emitter: suite.Emitter,
}
}
func (suite *uuidsWorkerTestSuite) TearDownTest() {
suite.UuidsProvider.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) {
@ -115,8 +116,9 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Receive error from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, errors.New("this is an error"))
suite.Logger.On("Warning", "Got non success response: :err", mock.Anything).Times(1)
err := errors.New("this is an error")
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
suite.Emitter.On("Emit", "uuids_provider:error", err).Times(1)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
@ -130,8 +132,9 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Receive Too Many Requests from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, &mojang.TooManyRequestsError{})
suite.Logger.On("Warning", "Got 429 Too Many Requests").Times(1)
err := &mojang.TooManyRequestsError{}
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
suite.Emitter.On("Emit", "uuids_provider:error", err).Times(1)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(429, response.StatusCode)

View File

@ -5,8 +5,6 @@ import (
"sync"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
@ -68,9 +66,10 @@ var forever = func() bool {
}
type BatchUuidsProvider struct {
Emitter
IterationDelay time.Duration
IterationSize int
Logger wd.Watchdog
onFirstCall sync.Once
queue jobsQueue
@ -84,7 +83,7 @@ func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, er
resultChan := make(chan *jobResult)
ctx.queue.Enqueue(&jobItem{username, resultChan})
ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1)
ctx.Emit("mojang_textures:batch_uuids_provider:queued", username)
result := <-resultChan
@ -95,10 +94,9 @@ func (ctx *BatchUuidsProvider) startQueue() {
go func() {
time.Sleep(ctx.IterationDelay)
for forever() {
start := time.Now()
ctx.Emit("mojang_textures:batch_uuids_provider:before_round")
ctx.queueRound()
elapsed := time.Since(start)
ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed)
ctx.Emit("mojang_textures:batch_uuids_provider:after_round")
time.Sleep(ctx.IterationDelay)
}
}()
@ -107,17 +105,17 @@ func (ctx *BatchUuidsProvider) startQueue() {
func (ctx *BatchUuidsProvider) queueRound() {
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(ctx.IterationSize)
ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs)))
ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs)))
if len(jobs) == 0 {
return
}
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.username)
}
ctx.Emit("mojang_textures:batch_uuids_provider:round", usernames, queueSize - len(jobs))
if len(usernames) == 0 {
return
}
profiles, err := usernamesToUuids(usernames)
for _, job := range jobs {
go func(job *jobItem) {

View File

@ -11,7 +11,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
func TestJobsQueue(t *testing.T) {
@ -85,7 +84,7 @@ type batchUuidsProviderTestSuite struct {
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Logger *mocks.WdMock
Emitter *mockEmitter
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
@ -94,10 +93,10 @@ type batchUuidsProviderTestSuite struct {
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Logger = &mocks.WdMock{}
suite.Emitter = &mockEmitter{}
suite.Provider = &BatchUuidsProvider{
Logger: suite.Logger,
Emitter: suite.Emitter,
IterationDelay: 0,
IterationSize: 10,
}
@ -120,7 +119,10 @@ func (suite *batchUuidsProviderTestSuite) SetupTest() {
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before all usernames
// will be queued.
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once().Run(func(args mock.Arguments) {
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
s <- true
})
@ -144,8 +146,8 @@ func (suite *batchUuidsProviderTestSuite) SetupTest() {
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func TestBatchUuidsProvider(t *testing.T) {
@ -155,9 +157,9 @@ func TestBatchUuidsProvider(t *testing.T) {
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
@ -174,9 +176,9 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username1", "username2"}).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
@ -203,18 +205,13 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames[i] = randStr(8)
}
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(10)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[0:10], 2).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[10:12], 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Twice()
suite.MojangApi.On("UsernamesToUuids", mock.MatchedBy(func(usernames []string) bool {
return len(usernames) == 10
})).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", mock.MatchedBy(func(usernames []string) bool {
return len(usernames) == 2
})).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return([]*mojang.ProfileInfo{}, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, len(usernames))
for i, username := range usernames {
@ -230,10 +227,11 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Times(3)
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Times(3)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
var nilStringSlice []string
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", nilStringSlice, 0).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Times(3)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
@ -254,9 +252,9 @@ func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
expectedError := &mojang.TooManyRequestsError{}
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", []string{"username1", "username2"}).Once().Return(nil, expectedError)

View File

@ -1,25 +1,19 @@
package mojangtextures
import (
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
var uuidToTextures = mojang.UuidToTextures
type MojangApiTexturesProvider struct {
Logger wd.Watchdog
Emitter
}
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Logger.IncCounter("mojang_textures.textures.request", 1)
start := time.Now()
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
result, err := uuidToTextures(uuid, true)
ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start))
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", result, err)
return result, err
}

View File

@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
type mojangUuidToTexturesRequestMock struct {
@ -28,16 +27,16 @@ type mojangApiTexturesProviderTestSuite struct {
suite.Suite
Provider *MojangApiTexturesProvider
Logger *mocks.WdMock
Emitter *mockEmitter
MojangApi *mojangUuidToTexturesRequestMock
}
func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
suite.Logger = &mocks.WdMock{}
suite.Emitter = &mockEmitter{}
suite.MojangApi = &mojangUuidToTexturesRequestMock{}
suite.Provider = &MojangApiTexturesProvider{
Logger: suite.Logger,
Emitter: suite.Emitter,
}
uuidToTextures = suite.MojangApi.UuidToTextures
@ -45,7 +44,7 @@ func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() {
suite.MojangApi.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func TestMojangApiTexturesProvider(t *testing.T) {
@ -59,8 +58,15 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(expectedResult, nil)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResult,
nil,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
@ -69,11 +75,19 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
var expectedResponse *mojang.SignedTexturesResponse
expectedError := &mojang.TooManyRequestsError{}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResponse,
expectedError,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")

View File

@ -2,15 +2,9 @@ package mojangtextures
import (
"errors"
"net"
"net/url"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
@ -77,11 +71,15 @@ type TexturesProvider interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
}
type Emitter interface {
Emit(name string, args ...interface{})
}
type Provider struct {
Emitter
UUIDsProvider
TexturesProvider
Storage
Logger wd.Watchdog
onFirstCall sync.Once
*broadcaster
@ -93,24 +91,20 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
})
if !allowedUsernamesRegex.MatchString(username) {
ctx.Logger.IncCounter("mojang_textures.invalid_username", 1)
return nil, errors.New("invalid username")
}
username = strings.ToLower(username)
ctx.Logger.IncCounter("mojang_textures.request", 1)
ctx.Emit("mojang_textures:call", username)
uuid, err := ctx.Storage.GetUuid(username)
uuid, err := ctx.getUuidFromCache(username)
if err == nil && uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
return nil, nil
}
if uuid != "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1)
textures, err := ctx.Storage.GetTextures(uuid)
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil {
ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1)
return textures, nil
}
}
@ -120,7 +114,7 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
if isFirstListener {
go ctx.getResultAndBroadcast(username, uuid)
} else {
ctx.Logger.IncCounter("mojang_textures.already_scheduled", 1)
ctx.Emit("mojang_textures:already_processing", username)
}
result := <-resultChan
@ -129,19 +123,17 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
}
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
start := time.Now()
ctx.Emit("mojang_textures:before_result", username, uuid)
result := ctx.getResult(username, uuid)
ctx.broadcaster.BroadcastAndRemove(username, result)
ctx.Emit("mojang_textures:after_result", username, result.textures, result.error)
ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start))
ctx.broadcaster.BroadcastAndRemove(username, result)
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
if uuid == "" {
profile, err := ctx.UUIDsProvider.GetUuid(username)
profile, err := ctx.getUuid(username)
if err != nil {
ctx.handleMojangApiResponseError(err, "usernames")
return &broadcastResult{nil, err}
}
@ -153,16 +145,12 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
_ = ctx.Storage.StoreUuid(username, uuid)
if uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1)
return &broadcastResult{nil, nil}
}
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
textures, err := ctx.TexturesProvider.GetTextures(uuid)
textures, err := ctx.getTextures(uuid)
if err != nil {
ctx.handleMojangApiResponseError(err, "textures")
return &broadcastResult{nil, err}
}
@ -170,56 +158,37 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
// therefore store the result even if textures is nil to prevent 429 error
ctx.Storage.StoreTextures(uuid, textures)
if textures != nil {
ctx.Logger.IncCounter("mojang_textures.usernames.textures_hit", 1)
} else {
ctx.Logger.IncCounter("mojang_textures.usernames.textures_miss", 1)
}
return &broadcastResult{textures, nil}
}
func (ctx *Provider) handleMojangApiResponseError(err error, threadName string) {
errParam := wd.ErrParam(err)
threadParam := wd.NameParam(threadName)
func (ctx *Provider) getUuidFromCache(username string) (string, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, err)
ctx.Logger.Debug(":name: Got response error :err", threadParam, errParam)
switch err.(type) {
case mojang.ResponseError:
if _, ok := err.(*mojang.BadRequestError); ok {
ctx.Logger.Warning(":name: Got 400 Bad Request :err", threadParam, errParam)
return
}
if _, ok := err.(*mojang.ForbiddenError); ok {
ctx.Logger.Warning(":name: Got 403 Forbidden :err", threadParam, errParam)
return
}
if _, ok := err.(*mojang.TooManyRequestsError); ok {
ctx.Logger.Warning(":name: Got 429 Too Many Requests :err", threadParam, errParam)
return
}
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
ctx.Logger.Emergency(":name: Unknown Mojang response error: :err", threadParam, errParam)
return uuid, err
}
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_cache", uuid)
textures, err := ctx.Storage.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_cache", uuid, textures, err)
return textures, err
}
func (ctx *Provider) getUuid(username string) (*mojang.ProfileInfo, error) {
ctx.Emit("mojang_textures:usernames:before_call", username)
profile, err := ctx.UUIDsProvider.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_call", username, profile, err)
return profile, err
}
func (ctx *Provider) getTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_call", uuid)
textures, err := ctx.TexturesProvider.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_call", uuid, textures, err)
return textures, err
}

View File

@ -2,10 +2,7 @@ package mojangtextures
import (
"errors"
"net"
"net/url"
"sync"
"syscall"
"testing"
"time"
@ -14,7 +11,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
mocks "github.com/elyby/chrly/tests"
)
func TestBroadcaster(t *testing.T) {
@ -86,6 +82,14 @@ func TestBroadcaster(t *testing.T) {
})
}
type mockEmitter struct {
mock.Mock
}
func (e *mockEmitter) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
type mockUuidsProvider struct {
mock.Mock
}
@ -145,32 +149,31 @@ func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTextures
type providerTestSuite struct {
suite.Suite
Provider *Provider
Emitter *mockEmitter
UuidsProvider *mockUuidsProvider
TexturesProvider *mockTexturesProvider
Storage *mockStorage
Logger *mocks.WdMock
}
func (suite *providerTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.UuidsProvider = &mockUuidsProvider{}
suite.TexturesProvider = &mockTexturesProvider{}
suite.Storage = &mockStorage{}
suite.Logger = &mocks.WdMock{}
suite.Provider = &Provider{
Emitter: suite.Emitter,
UUIDsProvider: suite.UuidsProvider,
TexturesProvider: suite.TexturesProvider,
Storage: suite.Storage,
Logger: suite.Logger,
}
}
func (suite *providerTestSuite) TearDownTest() {
// time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
suite.Emitter.AssertExpectations(suite.T())
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func TestProvider(t *testing.T) {
@ -178,21 +181,24 @@ func TestProvider(t *testing.T) {
}
func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
@ -202,12 +208,18 @@ func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
var expectedCachedTextures *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
@ -224,9 +236,11 @@ func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
@ -238,8 +252,9 @@ func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
@ -250,9 +265,16 @@ func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
@ -266,21 +288,24 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
@ -290,23 +315,26 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.already_scheduled", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.textures_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Twice()
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
// If possible, than remove this .After call
suite.UuidsProvider.On("GetUuid", "username").Once().After(time.Millisecond).Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.UuidsProvider.On("GetUuid", "username").Once().After(time.Millisecond).Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*mojang.SignedTexturesResponse, 2)
@ -326,114 +354,53 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
}
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once()
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(result)
}
type timeoutError struct {
}
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
var expectedErrors = []error{
&mojang.BadRequestError{},
&mojang.ForbiddenError{},
&mojang.TooManyRequestsError{},
&mojang.ServerError{},
&timeoutError{},
&url.Error{Op: "GET", URL: "http://localhost"},
&net.OpError{Op: "read"},
&net.OpError{Op: "dial"},
syscall.ECONNREFUSED,
}
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
func (suite *providerTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 403 Forbidden :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
for _, err := range expectedErrors {
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.UuidsProvider.AssertExpectations(suite.T())
suite.UuidsProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *providerTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, errors.New("unexpected error"))
result, err := suite.Provider.GetForUsername("username")
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.Assert().Equal(err, resErr)
}
func (suite *providerTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 403 Forbidden :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
// suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, &ValueNotFound{})
// suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", (*mojang.SignedTexturesResponse)(nil))
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
for _, err := range expectedErrors {
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.UuidsProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
suite.TexturesProvider.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *providerTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(&mojang.ProfileInfo{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, errors.New("unexpected error"))
result, err := suite.Provider.GetForUsername("username")
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().NotNil(err)
suite.Assert().Equal(err, resErr)
}

View File

@ -2,16 +2,13 @@ package mojangtextures
import (
"encoding/json"
"github.com/elyby/chrly/version"
"io/ioutil"
"net/http"
. "net/url"
"path"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/version"
)
var HttpClient = &http.Client{
@ -21,24 +18,23 @@ var HttpClient = &http.Client{
}
type RemoteApiUuidsProvider struct {
Emitter
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)
urlStr := url.String()
request, _ := http.NewRequest("GET", url.String(), nil)
request, _ := http.NewRequest("GET", urlStr, 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/"+version.Version())
start := time.Now()
ctx.Emit("mojang_textures:remote_api_uuids_provider:before_request", urlStr)
response, err := HttpClient.Do(request)
ctx.Logger.RecordTimer("mojang_textures.usernames.request_time", time.Since(start))
ctx.Emit("mojang_textures:remote_api_uuids_provider:after_request", response, err)
if err != nil {
return nil, err
}

View File

@ -3,21 +3,19 @@ package mojangtextures
import (
"net"
"net/http"
"net/url"
. "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
Emitter *mockEmitter
}
func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() {
@ -28,14 +26,14 @@ func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() {
}
func (suite *remoteApiUuidsProviderTestSuite) SetupTest() {
suite.Logger = &mocks.WdMock{}
suite.Emitter = &mockEmitter{}
suite.Provider = &RemoteApiUuidsProvider{
Logger: suite.Logger,
Emitter: suite.Emitter,
}
}
func (suite *remoteApiUuidsProviderTestSuite) TearDownTest() {
suite.Logger.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
gock.Off()
}
@ -44,8 +42,12 @@ func TestRemoteApiUuidsProvider(t *testing.T) {
}
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()
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
@ -68,8 +70,12 @@ func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForValidUsername() {
}
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()
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
@ -84,8 +90,12 @@ func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotExistsUsername()
}
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()
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
@ -101,8 +111,12 @@ func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNon20xResponse() {
}
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()
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
mock.AnythingOfType("*url.Error"),
).Once()
expectedError := &net.OpError{Op: "dial"}
@ -116,15 +130,19 @@ func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotSuccessRequest()
assert := suite.Assert()
assert.Nil(result)
if assert.Error(err) {
assert.IsType(&url.Error{}, err)
casterErr, _ := err.(*url.Error)
assert.IsType(&Error{}, err)
casterErr, _ := err.(*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()
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
@ -139,8 +157,8 @@ func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForInvalidSuccessRespon
assert.Error(err)
}
func shouldParseUrl(rawUrl string) url.URL {
url, err := url.Parse(rawUrl)
func shouldParseUrl(rawUrl string) URL {
url, err := Parse(rawUrl)
if err != nil {
panic(err)
}

View File

@ -1,46 +0,0 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/coverage
# Generate test coverage statistics for Go packages.
#
# Works around the fact that `go test -coverprofile` currently does not work
# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909
#
# Usage: script/coverage [--html]
#
# --html Additionally create HTML report and open it in browser
#
set -e
workdir=.cover
profile="$workdir/cover.out"
mode=count
generate_cover_data() {
rm -rf "$workdir"
mkdir "$workdir"
go test -i "$@" # compile dependencies first before serializing go test invocations
for pkg in "$@"; do
f="$workdir/$(echo $pkg | tr / -).cover"
go test -covermode="$mode" -coverprofile="$f" "$pkg"
done
echo "mode: $mode" >"$profile"
grep -h -v "^mode:" "$workdir"/*.cover >>"$profile"
}
show_cover_report() {
go tool cover -${1}="$profile"
}
generate_cover_data $(go list ./... | grep -v /vendor/)
show_cover_report func
case "$1" in
"")
;;
--html)
show_cover_report html ;;
*)
echo >&2 "error: invalid option: $1"; exit 1 ;;
esac

View File

@ -1,27 +0,0 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/test
# Run package tests for a file/directory, or all tests if no argument is passed.
# Useful to e.g. execute package tests for the file currently open in Vim.
# Usage: script/test [path]
set -e
go_pkg_from_path() {
path=$1
if test -d "$path"; then
dir="$path"
else
dir=$(dirname "$path")
fi
(cd "$dir" && go list)
}
if test $# -gt 0; then
pkg=$(go_pkg_from_path "$1")
verbose=-v
else
pkg=$(go list ./... | grep -v /vendor/)
verbose=
fi
exec go test ${GOTESTOPTS:-$verbose} $pkg

View File

@ -1,61 +0,0 @@
package tests
import (
"time"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/stretchr/testify/mock"
)
type WdMock struct {
mock.Mock
}
func (m *WdMock) Trace(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Debug(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Info(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Warning(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Error(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Alert(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Emergency(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) IncCounter(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) UpdateGauge(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) RecordTimer(name string, d time.Duration, p ...slf.Param) {
m.Called(name, d)
}
func (m *WdMock) Timer(name string, p ...slf.Param) slf.Timer {
return slf.NewTimer(name, p, m)
}
func (m *WdMock) WithParams(p ...slf.Param) wd.Watchdog {
panic("this method shouldn't be used")
}