From 9046338396e14317bba062f028d6ec1372ff887c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 16 Apr 2020 19:42:38 +0300 Subject: [PATCH 01/11] Introduce di into the project --- Gopkg.lock | 14 ++++++ Gopkg.toml | 4 ++ db/factory.go | 3 +- di/config.go | 14 ++++++ di/db.go | 77 ++++++++++++++++++++++++++++++++ di/di.go | 33 ++++++++++++++ di/dispatcher.go | 20 +++++++++ di/handlers.go | 41 +++++++++++++++++ di/logger.go | 96 ++++++++++++++++++++++++++++++++++++++++ di/mojang_textures.go | 91 +++++++++++++++++++++++++++++++++++++ dispatcher/dispatcher.go | 8 ++-- http/uuids_worker.go | 5 +-- 12 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 di/config.go create mode 100644 di/db.go create mode 100644 di/di.go create mode 100644 di/dispatcher.go create mode 100644 di/handlers.go create mode 100644 di/logger.go create mode 100644 di/mojang_textures.go diff --git a/Gopkg.lock b/Gopkg.lock index 22a403d..9b31053 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -63,6 +63,19 @@ pruneopts = "" revision = "919484f041ea21e7e27be291cee1d6af7bc98864" +[[projects]] + digest = "1:c17a0163edf9a1b0f5d9e856673413b924939cf433ebf041ec309e8273fd9d2b" + name = "github.com/goava/di" + packages = [ + ".", + "internal/graph", + "internal/reflection", + "internal/stacktrace", + ] + pruneopts = "" + revision = "6dcd92e58bd0fb2ff77cec60557abea1dae46571" + version = "v1.1.0" + [[projects]] digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2" name = "github.com/gorilla/mux" @@ -321,6 +334,7 @@ "github.com/asaskevich/EventBus", "github.com/etherlabsio/healthcheck", "github.com/getsentry/raven-go", + "github.com/goava/di", "github.com/gorilla/mux", "github.com/h2non/gock", "github.com/mediocregopher/radix.v2/pool", diff --git a/Gopkg.toml b/Gopkg.toml index 28174fd..fb0ec61 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -45,6 +45,10 @@ ignored = ["github.com/elyby/chrly"] name = "github.com/etherlabsio/healthcheck" version = "2.0.3" +[[constraint]] + name = "github.com/goava/di" + version = "^1.0.2" + # Testing dependencies [[constraint]] diff --git a/db/factory.go b/db/factory.go index 03c9923..a8a27a6 100644 --- a/db/factory.go +++ b/db/factory.go @@ -1,9 +1,9 @@ package db import ( - "github.com/elyby/chrly/http" "github.com/spf13/viper" + "github.com/elyby/chrly/http" "github.com/elyby/chrly/mojangtextures" ) @@ -17,6 +17,7 @@ type RepositoriesCreator interface { CreateMojangUuidsRepository() (mojangtextures.UuidsStorage, error) } +// TODO: redundant func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator { switch backend { case "redis": diff --git a/di/config.go b/di/config.go new file mode 100644 index 0000000..78df0b9 --- /dev/null +++ b/di/config.go @@ -0,0 +1,14 @@ +package di + +import ( + "github.com/goava/di" + "github.com/spf13/viper" +) + +var config = di.Options( + di.Provide(newConfig), +) + +func newConfig() *viper.Viper { + return viper.GetViper() +} diff --git a/di/db.go b/di/db.go new file mode 100644 index 0000000..3ea784a --- /dev/null +++ b/di/db.go @@ -0,0 +1,77 @@ +package di + +import ( + "github.com/goava/di" + "github.com/spf13/viper" + + dbModule "github.com/elyby/chrly/db" + "github.com/elyby/chrly/http" + "github.com/elyby/chrly/mojangtextures" +) + +var db = di.Options( + di.Provide(newRedisFactory, di.WithName("redis")), + di.Provide(newFSFactory, di.WithName("fs")), + di.Provide(newSkinsRepository), + di.Provide(newCapesRepository), + di.Provide(newMojangUUIDsRepository), + di.Provide(newMojangSignedTexturesStorage), +) + +func newRedisFactory(config *viper.Viper) dbModule.RepositoriesCreator { + return &dbModule.RedisFactory{ + Host: config.GetString("storage.redis.host"), + Port: config.GetInt("storage.redis.port"), + PoolSize: config.GetInt("storage.redis.poolSize"), + } +} + +func newFSFactory(config *viper.Viper) dbModule.RepositoriesCreator { + return &dbModule.FilesystemFactory{ + BasePath: config.GetString("storage.filesystem.basePath"), + CapesDirName: config.GetString("storage.filesystem.capesDirName"), + } +} + +// v4 had the idea that it would be possible to separate backends for storing skins and capes. +// But in v5 the storage will be unified, so this is just temporary constructors before large reworking. +// +// Since there are no options for selecting target backends, +// all constants in this case point to static specific implementations. + +func newSkinsRepository(container *di.Container) (http.SkinsRepository, error) { + var factory dbModule.RepositoriesCreator + err := container.Resolve(&factory, di.Name("redis")) + if err != nil { + return nil, err + } + + return factory.CreateSkinsRepository() +} + +func newCapesRepository(container *di.Container) (http.CapesRepository, error) { + var factory dbModule.RepositoriesCreator + err := container.Resolve(&factory, di.Name("fs")) + if err != nil { + return nil, err + } + + return factory.CreateCapesRepository() +} + +func newMojangUUIDsRepository(container *di.Container) (mojangtextures.UuidsStorage, error) { + var factory dbModule.RepositoriesCreator + err := container.Resolve(&factory, di.Name("redis")) + if err != nil { + return nil, err + } + + return factory.CreateMojangUuidsRepository() +} + +func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage { + texturesStorage := mojangtextures.NewInMemoryTexturesStorage() + texturesStorage.Start() + + return texturesStorage +} diff --git a/di/di.go b/di/di.go new file mode 100644 index 0000000..69114b6 --- /dev/null +++ b/di/di.go @@ -0,0 +1,33 @@ +package di + +import "github.com/goava/di" + +func New() (*di.Container, error) { + container, err := di.New( + di.WithCompile(), + config, + dispatcher, + logger, + db, + mojangTextures, + ) + if err != nil { + return nil, err + } + + // Inject container itself into dependencies graph + // See https://github.com/goava/di/issues/8#issuecomment-614227320 + err = container.Provide(func() *di.Container { + return container + }) + if err != nil { + return nil, err + } + + err = container.Compile() + if err != nil { + return nil, err + } + + return container, nil +} diff --git a/di/dispatcher.go b/di/dispatcher.go new file mode 100644 index 0000000..4d6518f --- /dev/null +++ b/di/dispatcher.go @@ -0,0 +1,20 @@ +package di + +import ( + "github.com/goava/di" + + dispatcherModule "github.com/elyby/chrly/dispatcher" + "github.com/elyby/chrly/http" + "github.com/elyby/chrly/mojangtextures" +) + +var dispatcher = di.Options( + di.Provide(newDispatcher, + di.As(new(http.Emitter)), + di.As(new(mojangtextures.Emitter)), + ), +) + +func newDispatcher() dispatcherModule.EventDispatcher { + return dispatcherModule.New() +} diff --git a/di/handlers.go b/di/handlers.go new file mode 100644 index 0000000..40beab4 --- /dev/null +++ b/di/handlers.go @@ -0,0 +1,41 @@ +package di + +import ( + "github.com/goava/di" + "github.com/gorilla/mux" + "github.com/spf13/viper" + + "github.com/elyby/chrly/http" +) + +var handlers = di.Options( + di.Provide(newSkinsystemHandler, di.WithName("skinsystem")), +) + +func newSkinsystemHandler( + config *viper.Viper, + emitter http.Emitter, + skinsRepository http.SkinsRepository, + capesRepository http.CapesRepository, + mojangTexturesProvider http.MojangTexturesProvider, +) *mux.Router { + handlerFactory := &http.Skinsystem{ + Emitter: emitter, + SkinsRepo: skinsRepository, + CapesRepo: capesRepository, + MojangTexturesProvider: mojangTexturesProvider, + TexturesExtraParamName: config.GetString("textures.extra_param_name"), + TexturesExtraParamValue: config.GetString("textures.extra_param_value"), + } + + return handlerFactory.CreateHandler() +} + +// TODO: pin implementation to make it non-configurable +func newUUIDsWorkerHandler(mojangUUIDsProvider http.MojangUuidsProvider) *mux.Router { + handlerFactory := &http.UUIDsWorker{ + UUIDsProvider: mojangUUIDsProvider, + } + + return handlerFactory.CreateHandler() +} diff --git a/di/logger.go b/di/logger.go new file mode 100644 index 0000000..883a219 --- /dev/null +++ b/di/logger.go @@ -0,0 +1,96 @@ +package di + +import ( + "os" + + "github.com/getsentry/raven-go" + "github.com/goava/di" + "github.com/mono83/slf" + "github.com/mono83/slf/rays" + "github.com/mono83/slf/recievers/sentry" + "github.com/mono83/slf/recievers/statsd" + "github.com/mono83/slf/recievers/writer" + "github.com/mono83/slf/wd" + "github.com/spf13/viper" + + "github.com/elyby/chrly/version" +) + +var logger = di.Options( + di.Provide(newLogger), + di.Provide(newSentry), + di.Provide(newStatsReporter), +) + +type loggerParams struct { + di.Inject + + SentryRaven *raven.Client `di:"" optional:"true"` +} + +func newLogger(params loggerParams) slf.Logger { + dispatcher := &slf.Dispatcher{} + dispatcher.AddReceiver(writer.New(writer.Options{ + Marker: false, + TimeFormat: "15:04:05.000", + })) + + if params.SentryRaven != nil { + sentryReceiver, _ := sentry.NewReceiverWithCustomRaven( + params.SentryRaven, + &sentry.Config{ + MinLevel: "warn", + }, + ) + dispatcher.AddReceiver(sentryReceiver) + } + + logger := wd.Custom("", "", dispatcher) + logger.WithParams(rays.Host) + + return logger +} + +func newSentry(config *viper.Viper) (*raven.Client, error) { + sentryAddr := config.GetString("sentry.dsn") + if sentryAddr == "" { + return nil, nil + } + + ravenClient, err := raven.New(sentryAddr) + if err != nil { + return nil, err + } + + ravenClient.SetEnvironment("production") + ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver") + ravenClient.SetRelease(version.Version()) + + return ravenClient, nil +} + +func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) { + statsdAddr := config.GetString("statsd.addr") + if statsdAddr == "" { + return nil, nil + } + + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + + statsdReceiver, err := statsd.NewReceiver(statsd.Config{ + Address: statsdAddr, + Prefix: "ely.skinsystem." + hostname + ".app.", + FlushEvery: 1, + }) + if err != nil { + return nil, err + } + + dispatcher := &slf.Dispatcher{} + dispatcher.AddReceiver(statsdReceiver) + + return wd.Custom("", "", dispatcher), nil +} diff --git a/di/mojang_textures.go b/di/mojang_textures.go new file mode 100644 index 0000000..cd0e57f --- /dev/null +++ b/di/mojang_textures.go @@ -0,0 +1,91 @@ +package di + +import ( + "fmt" + "net/url" + + "github.com/goava/di" + "github.com/spf13/viper" + + "github.com/elyby/chrly/http" + "github.com/elyby/chrly/mojangtextures" +) + +var mojangTextures = di.Options( + di.Provide(newMojangTexturesProviderFactory), + di.Provide(newMojangTexturesProvider), + di.Provide(newMojangTexturesUuidsProvider), + di.Provide(newMojangSignedTexturesProvider), + di.Provide(newMojangTexturesStorageFactory), +) + +func newMojangTexturesProviderFactory( + container *di.Container, + config *viper.Viper, +) (http.MojangTexturesProvider, error) { + if !config.GetBool("mojang_textures.enabled") { + return &mojangtextures.NilProvider{}, nil + } + + var provider *mojangtextures.Provider + err := container.Resolve(&provider) + if err != nil { + return nil, err + } + + return provider, nil +} + +func newMojangTexturesProvider( + emitter mojangtextures.Emitter, + uuidsProvider mojangtextures.UUIDsProvider, + texturesProvider mojangtextures.TexturesProvider, + storage mojangtextures.Storage, +) *mojangtextures.Provider { + return &mojangtextures.Provider{ + Emitter: emitter, + UUIDsProvider: uuidsProvider, + TexturesProvider: texturesProvider, + Storage: storage, + } +} + +func newMojangTexturesUuidsProvider( + config *viper.Viper, + emitter mojangtextures.Emitter, +) (mojangtextures.UUIDsProvider, error) { + preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver") + if preferredUuidsProvider == "remote" { + remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url")) + if err != nil { + return nil, fmt.Errorf("Unable to parse remote url: %w", err) + } + + return &mojangtextures.RemoteApiUuidsProvider{ + Emitter: emitter, + Url: *remoteUrl, + }, nil + } + + return &mojangtextures.BatchUuidsProvider{ + Emitter: emitter, + IterationDelay: config.GetDuration("queue.loop_delay"), + IterationSize: config.GetInt("queue.batch_size"), + }, nil +} + +func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider { + return &mojangtextures.MojangApiTexturesProvider{ + Emitter: emitter, + } +} + +func newMojangTexturesStorageFactory( + uuidsStorage mojangtextures.UuidsStorage, + texturesStorage mojangtextures.TexturesStorage, +) mojangtextures.Storage { + return &mojangtextures.SeparatedStorage{ + UuidsStorage: uuidsStorage, + TexturesStorage: texturesStorage, + } +} diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 8488202..86042be 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -7,20 +7,20 @@ type EventDispatcher interface { Emit(topic string, args ...interface{}) } -type LocalEventDispatcher struct { +type localEventDispatcher struct { bus EventBus.Bus } -func (d *LocalEventDispatcher) Subscribe(topic string, fn interface{}) { +func (d *localEventDispatcher) Subscribe(topic string, fn interface{}) { _ = d.bus.Subscribe(topic, fn) } -func (d *LocalEventDispatcher) Emit(topic string, args ...interface{}) { +func (d *localEventDispatcher) Emit(topic string, args ...interface{}) { d.bus.Publish(topic, args...) } func New() EventDispatcher { - return &LocalEventDispatcher{ + return &localEventDispatcher{ bus: EventBus.New(), } } diff --git a/http/uuids_worker.go b/http/uuids_worker.go index b161827..8efe5de 100644 --- a/http/uuids_worker.go +++ b/http/uuids_worker.go @@ -7,16 +7,15 @@ import ( "github.com/gorilla/mux" "github.com/elyby/chrly/api/mojang" - "github.com/elyby/chrly/mojangtextures" ) -type UuidsProvider interface { +type MojangUuidsProvider interface { GetUuid(username string) (*mojang.ProfileInfo, error) } type UUIDsWorker struct { Emitter - UUIDsProvider mojangtextures.UUIDsProvider + UUIDsProvider MojangUuidsProvider } func (ctx *UUIDsWorker) CreateHandler() *mux.Router { From 3f81a0c18ab81737ee5f5e279df30c5e97e4890a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 19 Apr 2020 02:31:09 +0300 Subject: [PATCH 02/11] Completely move app configuration from cmd to di container Implemented graceful server shutdown Extract records manipulating API into separate handlers group --- CHANGELOG.md | 2 + Gopkg.lock | 7 +- Gopkg.toml | 2 +- README.md | 2 + bootstrap/bootstrap.go | 103 ----- cmd/root.go | 39 +- cmd/serve.go | 127 +----- cmd/token.go | 11 +- cmd/worker.go | 81 +--- di/db.go | 45 +-- di/di.go | 17 +- di/dispatcher.go | 20 +- di/handlers.go | 148 ++++++- di/logger.go | 31 +- di/mojang_textures.go | 80 +++- di/server.go | 47 +++ dispatcher/dispatcher.go | 1 + eventsubscribers/health_checkers_test.go | 40 +- http/api.go | 209 ++++++++++ http/api_test.go | 442 +++++++++++++++++++++ http/http.go | 35 +- http/http_test.go | 4 +- http/skinsystem.go | 236 +----------- http/skinsystem_test.go | 466 +---------------------- http/uuids_worker.go | 22 +- http/uuids_worker_test.go | 18 +- 26 files changed, 1096 insertions(+), 1139 deletions(-) delete mode 100644 bootstrap/bootstrap.go create mode 100644 di/server.go create mode 100644 http/api.go create mode 100644 http/api_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d7d2d..7d0ca7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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). +- Added `/healthcheck` endpoint (at the moment checks are only available for the batch Mojang UUIDs provider). +- Graceful server shutdown. ### Fixed - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and diff --git a/Gopkg.lock b/Gopkg.lock index 9b31053..788bc15 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -64,17 +64,16 @@ revision = "919484f041ea21e7e27be291cee1d6af7bc98864" [[projects]] - digest = "1:c17a0163edf9a1b0f5d9e856673413b924939cf433ebf041ec309e8273fd9d2b" + branch = "master" + digest = "1:c6d147728b7bf08b508b13c4547edfd72f900a2b8467b8a7e86d525badef268b" name = "github.com/goava/di" packages = [ ".", - "internal/graph", "internal/reflection", "internal/stacktrace", ] pruneopts = "" - revision = "6dcd92e58bd0fb2ff77cec60557abea1dae46571" - version = "v1.1.0" + revision = "30d61f45552a08a92f8aa54eaad3e7495f091260" [[projects]] digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2" diff --git a/Gopkg.toml b/Gopkg.toml index fb0ec61..78fccf1 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -47,7 +47,7 @@ ignored = ["github.com/elyby/chrly"] [[constraint]] name = "github.com/goava/di" - version = "^1.0.2" + branch = "master" # Testing dependencies diff --git a/README.md b/README.md index 86073e8..0de85ce 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ response will be: } ``` +TODO: add notes about worker mode and healthcheck + ## Development First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH` diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go deleted file mode 100644 index 143b6bf..0000000 --- a/bootstrap/bootstrap.go +++ /dev/null @@ -1,103 +0,0 @@ -package bootstrap - -import ( - "fmt" - "net/url" - "os" - "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" - "github.com/mono83/slf/recievers/writer" - "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(sentryAddr string) (slf.Logger, error) { - wd.AddReceiver(writer.New(writer.Options{ - Marker: false, - TimeFormat: "15:04:05.000", - })) - - if sentryAddr != "" { - ravenClient, err := raven.New(sentryAddr) - if err != nil { - return nil, err - } - - ravenClient.SetEnvironment("production") - ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver") - programVersion := version.Version() - if programVersion != "" { - raven.SetRelease(programVersion) - } - - sentryReceiver, err := sentry.NewReceiverWithCustomRaven(ravenClient, &sentry.Config{ - MinLevel: "warn", - }) - if err != nil { - return nil, err - } - - wd.AddReceiver(sentryReceiver) - } - - 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(emitter http.Emitter) (mojangtextures.UUIDsProvider, error) { - var uuidsProvider mojangtextures.UUIDsProvider - preferredUuidsProvider := viper.GetString("mojang_textures.uuids_provider.driver") - if preferredUuidsProvider == "remote" { - remoteUrl, err := url.Parse(viper.GetString("mojang_textures.uuids_provider.url")) - if err != nil { - return nil, fmt.Errorf("Unable to parse remote url: %w", err) - } - - uuidsProvider = &mojangtextures.RemoteApiUuidsProvider{ - Emitter: emitter, - Url: *remoteUrl, - } - } else { - uuidsProvider = &mojangtextures.BatchUuidsProvider{ - Emitter: emitter, - IterationDelay: viper.GetDuration("queue.loop_delay"), - IterationSize: viper.GetInt("queue.batch_size"), - } - } - - return uuidsProvider, nil -} - -func CreateEventDispatcher() dispatcher.EventDispatcher { - return dispatcher.New() -} diff --git a/cmd/root.go b/cmd/root.go index eb17903..b69d8ed 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,14 +2,16 @@ package cmd import ( "fmt" + "log" "os" - "os/signal" "strings" - "syscall" + . "github.com/goava/di" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/elyby/chrly/di" + "github.com/elyby/chrly/http" "github.com/elyby/chrly/version" ) @@ -28,6 +30,32 @@ func Execute() { } } +func shouldGetContainer() *Container { + container, err := di.New() + if err != nil { + panic(err) + } + + return container +} + +func startServer(modules []string) { + container := shouldGetContainer() + + var config *viper.Viper + err := container.Resolve(&config) + if err != nil { + log.Fatal(err) + } + + config.Set("modules", modules) + + err = container.Invoke(http.StartServer) + if err != nil { + log.Fatal(err) + } +} + func init() { cobra.OnInitialize(initConfig) } @@ -37,10 +65,3 @@ func initConfig() { replacer := strings.NewReplacer(".", "_") viper.SetEnvKeyReplacer(replacer) } - -func waitForExitSignal() os.Signal { - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) - - return <-ch -} diff --git a/cmd/serve.go b/cmd/serve.go index 07a3253..91a1b94 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -1,142 +1,17 @@ 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/db" - "github.com/elyby/chrly/eventsubscribers" - "github.com/elyby/chrly/http" - "github.com/elyby/chrly/mojangtextures" ) 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("sentry.dsn")) - if err != nil { - 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("Error on creating skins repo: :err", wd.ErrParam(err)) - os.Exit(1) - } - logger.Info("Skins repository successfully initialized") - - logger.Info("Initializing capes repository") - filesystemFactory := storageFactory.CreateFactory("filesystem") - capesRepo, err := filesystemFactory.CreateCapesRepository() - if err != nil { - logger.Emergency("Error on creating capes repo: :err", wd.ErrParam(err)) - os.Exit(1) - } - logger.Info("Capes repository successfully initialized") - - var mojangTexturesProvider http.MojangTexturesProvider - if viper.GetBool("mojang_textures.enabled") { - logger.Info("Preparing Mojang's textures queue") - mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository() - if err != nil { - logger.Emergency("Error on creating mojang uuids repo: :err", wd.ErrParam(err)) - os.Exit(1) - } - - uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(dispatcher) - if err != nil { - logger.Emergency("Unable to create mojang uuids provider: :err", wd.ErrParam(err)) - os.Exit(1) - } - - texturesStorage := mojangtextures.NewInMemoryTexturesStorage() - texturesStorage.Start() - mojangTexturesProvider = &mojangtextures.Provider{ - Emitter: dispatcher, - UUIDsProvider: uuidsProvider, - TexturesProvider: &mojangtextures.MojangApiTexturesProvider{ - Emitter: dispatcher, - }, - Storage: &mojangtextures.SeparatedStorage{ - UuidsStorage: mojangUuidsRepository, - TexturesStorage: texturesStorage, - }, - } - logger.Info("Mojang's textures queue is successfully initialized") - } else { - logger.Info("Mojang's textures queue is disabled") - mojangTexturesProvider = &mojangtextures.NilProvider{} - } - - address := fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")) - handler := (&http.Skinsystem{ - Emitter: dispatcher, - SkinsRepo: skinsRepo, - CapesRepo: capesRepo, - MojangTexturesProvider: mojangTexturesProvider, - Authenticator: &http.JwtAuth{ - Key: []byte(viper.GetString("chrly.secret")), - Emitter: dispatcher, - }, - TexturesExtraParamName: viper.GetString("textures.extra_param_name"), - TexturesExtraParamValue: viper.GetString("textures.extra_param_value"), - }).CreateHandler() - - finishChan := make(chan bool) - go func() { - 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("Got signal: :signal, exiting", wd.StringParam("signal", s.String())) - finishChan <- true - }() - - <-finishChan + startServer([]string{"skinsystem", "api"}) }, } func init() { RootCmd.AddCommand(serveCmd) - viper.SetDefault("server.host", "") - viper.SetDefault("server.port", 80) - viper.SetDefault("storage.redis.host", "localhost") - viper.SetDefault("storage.redis.port", 6379) - viper.SetDefault("storage.redis.poll", 10) - viper.SetDefault("storage.filesystem.basePath", "data") - viper.SetDefault("storage.filesystem.capesDirName", "capes") - viper.SetDefault("mojang_textures.enabled", true) } diff --git a/cmd/token.go b/cmd/token.go index ba891fb..75144ef 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -7,15 +7,20 @@ import ( "github.com/elyby/chrly/http" "github.com/spf13/cobra" - "github.com/spf13/viper" ) 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 := &http.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))} - token, err := jwtAuth.NewToken(http.SkinScope) + container := shouldGetContainer() + var auth *http.JwtAuth + err := container.Resolve(&auth) + if err != nil { + log.Fatal(err) + } + + token, err := auth.NewToken(http.SkinScope) if err != nil { log.Fatalf("Unable to create new token. The error is %v\n", err) } diff --git a/cmd/worker.go b/cmd/worker.go index 119ca4d..b3bd4aa 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -1,96 +1,17 @@ package cmd import ( - "fmt" - "log" - "os" - "time" - - "github.com/etherlabsio/healthcheck" - "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" ) var workerCmd = &cobra.Command{ Use: "worker", Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker", Run: func(cmd *cobra.Command, args []string) { - 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.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) - } - - 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() - handler.Handle("/healthcheck", healthcheck.Handler( - healthcheck.WithChecker( - "mojang-batch-uuids-provider-response", - eventsubscribers.MojangBatchUuidsProviderResponseChecker( - dispatcher, - viper.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"), - ), - ), - healthcheck.WithChecker( - "mojang-batch-uuids-provider-queue-length", - eventsubscribers.MojangBatchUuidsProviderQueueLengthChecker( - dispatcher, - viper.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"), - ), - ), - )).Methods("GET") - - finishChan := make(chan bool) - go func() { - 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("Got signal: :code, exiting.", wd.StringParam("code", s.String())) - finishChan <- true - }() - - <-finishChan + startServer([]string{"worker"}) }, } func init() { RootCmd.AddCommand(workerCmd) - viper.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute) - viper.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50) } diff --git a/di/db.go b/di/db.go index 3ea784a..6b49a8c 100644 --- a/di/db.go +++ b/di/db.go @@ -4,30 +4,37 @@ import ( "github.com/goava/di" "github.com/spf13/viper" - dbModule "github.com/elyby/chrly/db" + . "github.com/elyby/chrly/db" "github.com/elyby/chrly/http" "github.com/elyby/chrly/mojangtextures" ) var db = di.Options( - di.Provide(newRedisFactory, di.WithName("redis")), - di.Provide(newFSFactory, di.WithName("fs")), + di.Provide(newRedisFactory), + di.Provide(newFSFactory), di.Provide(newSkinsRepository), di.Provide(newCapesRepository), di.Provide(newMojangUUIDsRepository), di.Provide(newMojangSignedTexturesStorage), ) -func newRedisFactory(config *viper.Viper) dbModule.RepositoriesCreator { - return &dbModule.RedisFactory{ +func newRedisFactory(config *viper.Viper) *RedisFactory { + config.SetDefault("storage.redis.host", "localhost") + config.SetDefault("storage.redis.port", 6379) + config.SetDefault("storage.redis.poll", 10) + + return &RedisFactory{ Host: config.GetString("storage.redis.host"), Port: config.GetInt("storage.redis.port"), PoolSize: config.GetInt("storage.redis.poolSize"), } } -func newFSFactory(config *viper.Viper) dbModule.RepositoriesCreator { - return &dbModule.FilesystemFactory{ +func newFSFactory(config *viper.Viper) *FilesystemFactory { + config.SetDefault("storage.filesystem.basePath", "data") + config.SetDefault("storage.filesystem.capesDirName", "capes") + + return &FilesystemFactory{ BasePath: config.GetString("storage.filesystem.basePath"), CapesDirName: config.GetString("storage.filesystem.capesDirName"), } @@ -39,33 +46,15 @@ func newFSFactory(config *viper.Viper) dbModule.RepositoriesCreator { // Since there are no options for selecting target backends, // all constants in this case point to static specific implementations. -func newSkinsRepository(container *di.Container) (http.SkinsRepository, error) { - var factory dbModule.RepositoriesCreator - err := container.Resolve(&factory, di.Name("redis")) - if err != nil { - return nil, err - } - +func newSkinsRepository(factory *RedisFactory) (http.SkinsRepository, error) { return factory.CreateSkinsRepository() } -func newCapesRepository(container *di.Container) (http.CapesRepository, error) { - var factory dbModule.RepositoriesCreator - err := container.Resolve(&factory, di.Name("fs")) - if err != nil { - return nil, err - } - +func newCapesRepository(factory *FilesystemFactory) (http.CapesRepository, error) { return factory.CreateCapesRepository() } -func newMojangUUIDsRepository(container *di.Container) (mojangtextures.UuidsStorage, error) { - var factory dbModule.RepositoriesCreator - err := container.Resolve(&factory, di.Name("redis")) - if err != nil { - return nil, err - } - +func newMojangUUIDsRepository(factory *RedisFactory) (mojangtextures.UuidsStorage, error) { return factory.CreateMojangUuidsRepository() } diff --git a/di/di.go b/di/di.go index 69114b6..0b63b33 100644 --- a/di/di.go +++ b/di/di.go @@ -4,30 +4,17 @@ import "github.com/goava/di" func New() (*di.Container, error) { container, err := di.New( - di.WithCompile(), config, dispatcher, logger, db, mojangTextures, + handlers, + server, ) if err != nil { return nil, err } - // Inject container itself into dependencies graph - // See https://github.com/goava/di/issues/8#issuecomment-614227320 - err = container.Provide(func() *di.Container { - return container - }) - if err != nil { - return nil, err - } - - err = container.Compile() - if err != nil { - return nil, err - } - return container, nil } diff --git a/di/dispatcher.go b/di/dispatcher.go index 4d6518f..6a59e11 100644 --- a/di/dispatcher.go +++ b/di/dispatcher.go @@ -2,8 +2,10 @@ package di import ( "github.com/goava/di" + "github.com/mono83/slf" - dispatcherModule "github.com/elyby/chrly/dispatcher" + d "github.com/elyby/chrly/dispatcher" + "github.com/elyby/chrly/eventsubscribers" "github.com/elyby/chrly/http" "github.com/elyby/chrly/mojangtextures" ) @@ -12,9 +14,21 @@ var dispatcher = di.Options( di.Provide(newDispatcher, di.As(new(http.Emitter)), di.As(new(mojangtextures.Emitter)), + di.As(new(eventsubscribers.Subscriber)), ), + di.Invoke(enableEventsHandlers), ) -func newDispatcher() dispatcherModule.EventDispatcher { - return dispatcherModule.New() +func newDispatcher() d.EventDispatcher { + return d.New() +} + +func enableEventsHandlers( + dispatcher d.EventDispatcher, + logger slf.Logger, + statsReporter slf.StatsReporter, +) { + // TODO: use idea from https://github.com/goava/di/issues/10#issuecomment-615869852 + (&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher) + (&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher) } diff --git a/di/handlers.go b/di/handlers.go index 40beab4..f326c38 100644 --- a/di/handlers.go +++ b/di/handlers.go @@ -1,41 +1,161 @@ package di import ( + "net/http" + "strings" + + "github.com/etherlabsio/healthcheck" "github.com/goava/di" "github.com/gorilla/mux" "github.com/spf13/viper" - "github.com/elyby/chrly/http" + . "github.com/elyby/chrly/http" + "github.com/elyby/chrly/mojangtextures" ) var handlers = di.Options( + di.Provide(newHandlerFactory, di.As(new(http.Handler))), di.Provide(newSkinsystemHandler, di.WithName("skinsystem")), + di.Provide(newApiHandler, di.WithName("api")), + di.Provide(newUUIDsWorkerHandler, di.WithName("worker")), ) +func newHandlerFactory( + container *di.Container, + config *viper.Viper, + emitter Emitter, +) (*mux.Router, error) { + enabledModules := config.GetStringSlice("modules") + + // gorilla.mux has no native way to combine multiple routers. + // The hack used later in the code works for prefixes in addresses, but leads to misbehavior + // if you set an empty prefix. Since the main application should be mounted at the root prefix, + // we use it as the base router + var router *mux.Router + if hasValue(enabledModules, "skinsystem") { + if err := container.Resolve(&router, di.Name("skinsystem")); err != nil { + return nil, err + } + } else { + router = mux.NewRouter() + } + + router.StrictSlash(true) + requestEventsMiddleware := CreateRequestEventsMiddleware(emitter, "skinsystem") + router.Use(requestEventsMiddleware) + // 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(NotFoundHandler)) + + // Enable the worker module before api to allow gorilla.mux to correctly find the target router + // as it uses the first matching and /api overrides the more accurate /api/worker + if hasValue(enabledModules, "worker") { + var workerRouter *mux.Router + if err := container.Resolve(&workerRouter, di.Name("worker")); err != nil { + return nil, err + } + + mount(router, "/api/worker", workerRouter) + } + + if hasValue(enabledModules, "api") { + var apiRouter *mux.Router + if err := container.Resolve(&apiRouter, di.Name("api")); err != nil { + return nil, err + } + + var authenticator Authenticator + if err := container.Resolve(&authenticator); err != nil { + return nil, err + } + + apiRouter.Use(CreateAuthenticationMiddleware(authenticator)) + + mount(router, "/api", apiRouter) + } + + // Resolve health checkers last, because all the services required by the application + // must first be initialized and each of them can publish its own checkers + var healthCheckers []namedHealthCheckerInterface + if container.Has(&healthCheckers) { + if err := container.Resolve(&healthCheckers); err != nil { + return nil, err + } + + checkersOptions := make([]healthcheck.Option, len(healthCheckers)) + for i, checker := range healthCheckers { + checkersOptions[i] = healthcheck.WithChecker(checker.GetName(), checker.GetChecker()) + } + + router.Handle("/healthcheck", healthcheck.Handler()).Methods("GET") + } + + return router, nil +} + func newSkinsystemHandler( config *viper.Viper, - emitter http.Emitter, - skinsRepository http.SkinsRepository, - capesRepository http.CapesRepository, - mojangTexturesProvider http.MojangTexturesProvider, + emitter Emitter, + skinsRepository SkinsRepository, + capesRepository CapesRepository, + mojangTexturesProvider MojangTexturesProvider, ) *mux.Router { - handlerFactory := &http.Skinsystem{ + return (&Skinsystem{ Emitter: emitter, SkinsRepo: skinsRepository, CapesRepo: capesRepository, MojangTexturesProvider: mojangTexturesProvider, TexturesExtraParamName: config.GetString("textures.extra_param_name"), TexturesExtraParamValue: config.GetString("textures.extra_param_value"), - } - - return handlerFactory.CreateHandler() + }).Handler() } -// TODO: pin implementation to make it non-configurable -func newUUIDsWorkerHandler(mojangUUIDsProvider http.MojangUuidsProvider) *mux.Router { - handlerFactory := &http.UUIDsWorker{ - UUIDsProvider: mojangUUIDsProvider, +func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router { + return (&Api{ + Emitter: emitter, + SkinsRepo: skinsRepository, + }).Handler() +} + +func newUUIDsWorkerHandler(mojangUUIDsProvider *mojangtextures.BatchUuidsProvider) *mux.Router { + return (&UUIDsWorker{ + MojangUuidsProvider: mojangUUIDsProvider, + }).Handler() +} + +func hasValue(slice []string, needle string) bool { + for _, value := range slice { + if value == needle { + return true + } } - return handlerFactory.CreateHandler() + return false +} + +func mount(router *mux.Router, path string, handler http.Handler) { + router.PathPrefix(path).Handler( + http.StripPrefix( + strings.TrimSuffix(path, "/"), + handler, + ), + ) +} + +type namedHealthCheckerInterface interface { + GetName() string + GetChecker() healthcheck.Checker +} + +type namedHealthChecker struct { + Name string + Checker healthcheck.Checker +} + +func (c *namedHealthChecker) GetName() string { + return c.Name +} + +func (c *namedHealthChecker) GetChecker() healthcheck.Checker { + return c.Checker } diff --git a/di/logger.go b/di/logger.go index 883a219..017062c 100644 --- a/di/logger.go +++ b/di/logger.go @@ -70,27 +70,26 @@ func newSentry(config *viper.Viper) (*raven.Client, error) { } func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) { + dispatcher := &slf.Dispatcher{} + statsdAddr := config.GetString("statsd.addr") if statsdAddr == "" { - return nil, nil - } + hostname, err := os.Hostname() + if err != nil { + return nil, err + } - hostname, err := os.Hostname() - if err != nil { - return nil, err - } + statsdReceiver, err := statsd.NewReceiver(statsd.Config{ + Address: statsdAddr, + Prefix: "ely.skinsystem." + hostname + ".app.", + FlushEvery: 1, + }) + if err != nil { + return nil, err + } - statsdReceiver, err := statsd.NewReceiver(statsd.Config{ - Address: statsdAddr, - Prefix: "ely.skinsystem." + hostname + ".app.", - FlushEvery: 1, - }) - if err != nil { - return nil, err + dispatcher.AddReceiver(statsdReceiver) } - dispatcher := &slf.Dispatcher{} - dispatcher.AddReceiver(statsdReceiver) - return wd.Custom("", "", dispatcher), nil } diff --git a/di/mojang_textures.go b/di/mojang_textures.go index cd0e57f..54f56ad 100644 --- a/di/mojang_textures.go +++ b/di/mojang_textures.go @@ -3,10 +3,12 @@ package di import ( "fmt" "net/url" + "time" "github.com/goava/di" "github.com/spf13/viper" + es "github.com/elyby/chrly/eventsubscribers" "github.com/elyby/chrly/http" "github.com/elyby/chrly/mojangtextures" ) @@ -14,7 +16,9 @@ import ( var mojangTextures = di.Options( di.Provide(newMojangTexturesProviderFactory), di.Provide(newMojangTexturesProvider), - di.Provide(newMojangTexturesUuidsProvider), + di.Provide(newMojangTexturesUuidsProviderFactory), + di.Provide(newMojangTexturesBatchUUIDsProvider), + di.Provide(newMojangTexturesRemoteUUIDsProvider), di.Provide(newMojangSignedTexturesProvider), di.Provide(newMojangTexturesStorageFactory), ) @@ -23,6 +27,7 @@ func newMojangTexturesProviderFactory( container *di.Container, config *viper.Viper, ) (http.MojangTexturesProvider, error) { + config.SetDefault("mojang_textures.enabled", true) if !config.GetBool("mojang_textures.enabled") { return &mojangtextures.NilProvider{}, nil } @@ -50,23 +55,61 @@ func newMojangTexturesProvider( } } -func newMojangTexturesUuidsProvider( +func newMojangTexturesUuidsProviderFactory( config *viper.Viper, - emitter mojangtextures.Emitter, + container *di.Container, ) (mojangtextures.UUIDsProvider, error) { preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver") if preferredUuidsProvider == "remote" { - remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url")) - if err != nil { - return nil, fmt.Errorf("Unable to parse remote url: %w", err) - } + var provider *mojangtextures.RemoteApiUuidsProvider + err := container.Resolve(&provider) - return &mojangtextures.RemoteApiUuidsProvider{ - Emitter: emitter, - Url: *remoteUrl, - }, nil + return provider, err } + var provider *mojangtextures.BatchUuidsProvider + err := container.Resolve(&provider) + + return provider, err +} + +func newMojangTexturesBatchUUIDsProvider( + container *di.Container, + config *viper.Viper, + emitter mojangtextures.Emitter, +) (*mojangtextures.BatchUuidsProvider, error) { + // TODO: remove usage of di.WithName() when https://github.com/goava/di/issues/11 will be resolved + if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { + config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute) + + return &namedHealthChecker{ + Name: "mojang-batch-uuids-provider-response", + Checker: es.MojangBatchUuidsProviderResponseChecker( + emitter, + config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"), + ), + } + }, di.As(new(namedHealthCheckerInterface)), di.WithName("mojangBatchUuidsProviderResponseChecker")); err != nil { + return nil, err + } + + if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { + config.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50) + + return &namedHealthChecker{ + Name: "mojang-batch-uuids-provider-queue-length", + Checker: es.MojangBatchUuidsProviderQueueLengthChecker( + emitter, + config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"), + ), + } + }, di.As(new(namedHealthCheckerInterface)), di.WithName("mojangBatchUuidsProviderQueueLengthChecker")); err != nil { + return nil, err + } + + config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond) + config.SetDefault("queue.batch_size", 10) + return &mojangtextures.BatchUuidsProvider{ Emitter: emitter, IterationDelay: config.GetDuration("queue.loop_delay"), @@ -74,6 +117,21 @@ func newMojangTexturesUuidsProvider( }, nil } +func newMojangTexturesRemoteUUIDsProvider( + config *viper.Viper, + emitter mojangtextures.Emitter, +) (*mojangtextures.RemoteApiUuidsProvider, error) { + remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url")) + if err != nil { + return nil, fmt.Errorf("unable to parse remote url: %w", err) + } + + return &mojangtextures.RemoteApiUuidsProvider{ + Emitter: emitter, + Url: *remoteUrl, + }, nil +} + func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider { return &mojangtextures.MojangApiTexturesProvider{ Emitter: emitter, diff --git a/di/server.go b/di/server.go new file mode 100644 index 0000000..7c134c0 --- /dev/null +++ b/di/server.go @@ -0,0 +1,47 @@ +package di + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/goava/di" + "github.com/spf13/viper" + + . "github.com/elyby/chrly/http" +) + +var server = di.Options( + di.Provide(newAuthenticator, di.As(new(Authenticator))), + di.Provide(newServer), +) + +func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) { + key := config.GetString("chrly.secret") + if key == "" { + return nil, errors.New("chrly.secret must be set in order to use authenticator") + } + + return &JwtAuth{ + Key: []byte(key), + Emitter: emitter, + }, nil +} + +func newServer(config *viper.Viper, handler http.Handler) *http.Server { + config.SetDefault("server.host", "") + config.SetDefault("server.port", 80) + + address := fmt.Sprintf("%s:%d", config.GetString("server.host"), config.GetInt("server.port")) + server := &http.Server{ + Addr: address, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 16, + Handler: handler, + } + + return server +} diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 86042be..b20872b 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -2,6 +2,7 @@ package dispatcher import "github.com/asaskevich/EventBus" +// TODO: split on 2 interfaces and use them across the application type EventDispatcher interface { Subscribe(topic string, fn interface{}) Emit(topic string, args ...interface{}) diff --git a/eventsubscribers/health_checkers_test.go b/eventsubscribers/health_checkers_test.go index d2df137..5e95f63 100644 --- a/eventsubscribers/health_checkers_test.go +++ b/eventsubscribers/health_checkers_test.go @@ -9,36 +9,36 @@ import ( "github.com/stretchr/testify/assert" "github.com/elyby/chrly/api/mojang" - "github.com/elyby/chrly/bootstrap" + "github.com/elyby/chrly/dispatcher" ) func TestMojangBatchUuidsProviderChecker(t *testing.T) { t.Run("empty state", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) + d := dispatcher.New() + checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) assert.Nil(t, checker(context.Background())) }) t.Run("when no error occurred", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) - dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil) + d := dispatcher.New() + checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) + d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil) assert.Nil(t, checker(context.Background())) }) t.Run("when error occurred", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) + d := dispatcher.New() + checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) err := errors.New("some error occurred") - dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) + d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) assert.Equal(t, err, checker(context.Background())) }) t.Run("should reset value after passed duration", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderResponseChecker(dispatcher, 20*time.Millisecond) + d := dispatcher.New() + checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond) err := errors.New("some error occurred") - dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) + d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) assert.Equal(t, err, checker(context.Background())) time.Sleep(40 * time.Millisecond) assert.Nil(t, checker(context.Background())) @@ -47,22 +47,22 @@ func TestMojangBatchUuidsProviderChecker(t *testing.T) { func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) { t.Run("empty state", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) + d := dispatcher.New() + checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) assert.Nil(t, checker(context.Background())) }) t.Run("less than allowed limit", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) - dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9) + d := dispatcher.New() + checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) + d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9) assert.Nil(t, checker(context.Background())) }) t.Run("greater than allowed limit", func(t *testing.T) { - dispatcher := bootstrap.CreateEventDispatcher() - checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) - dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10) + d := dispatcher.New() + checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) + d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10) checkResult := checker(context.Background()) if assert.Error(t, checkResult) { assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error()) diff --git a/http/api.go b/http/api.go new file mode 100644 index 0000000..b533278 --- /dev/null +++ b/http/api.go @@ -0,0 +1,209 @@ +package http + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + + "github.com/gorilla/mux" + "github.com/thedevsaddam/govalidator" + + "github.com/elyby/chrly/model" +) + +//noinspection GoSnakeCaseUsage +const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + +var regexUuidAny = regexp.MustCompile(UUID_ANY) + +func init() { + govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error { + if message == "" { + message = "Skin uploading is temporary unavailable" + } + + return errors.New(message) + }) + + // Add ability to validate any possible uuid form + govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error { + str := value.(string) + if !regexUuidAny.MatchString(str) { + if message == "" { + message = fmt.Sprintf("The %s field must contain valid UUID", field) + } + + return errors.New(message) + } + + return nil + }) +} + +type Api struct { + Emitter + SkinsRepo SkinsRepository +} + +func (ctx *Api) Handler() *mux.Router { + router := mux.NewRouter().StrictSlash(true) + router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost) + router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete) + router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete) + + return router +} + +func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { + validationErrors := validatePostSkinRequest(req) + if validationErrors != nil { + apiBadRequest(resp, validationErrors) + return + } + + identityId, _ := strconv.Atoi(req.Form.Get("identityId")) + username := req.Form.Get("username") + + record, err := ctx.findIdentityOrCleanup(identityId, username) + if err != nil { + ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err)) + apiServerError(resp) + return + } + + skinId, _ := strconv.Atoi(req.Form.Get("skinId")) + is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) + isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) + + record.Uuid = req.Form.Get("uuid") + record.SkinId = skinId + record.Is1_8 = is18 + record.IsSlim = isSlim + record.Url = req.Form.Get("url") + record.MojangTextures = req.Form.Get("mojangTextures") + record.MojangSignature = req.Form.Get("mojangSignature") + + err = ctx.SkinsRepo.Save(record) + if err != nil { + ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err)) + apiServerError(resp) + return + } + + resp.WriteHeader(http.StatusCreated) +} + +func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) { + id, _ := strconv.Atoi(mux.Vars(req)["id"]) + skin, err := ctx.SkinsRepo.FindByUserId(id) + ctx.deleteSkin(skin, err, resp) +} + +func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) { + username := mux.Vars(req)["username"] + skin, err := ctx.SkinsRepo.FindByUsername(username) + ctx.deleteSkin(skin, err, resp) +} + +func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) { + if err != nil { + 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 + } + + err = ctx.SkinsRepo.RemoveByUserId(skin.UserId) + if err != nil { + ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err)) + apiServerError(resp) + return + } + + resp.WriteHeader(http.StatusNoContent) +} + +func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) { + var record *model.Skin + record, err := ctx.SkinsRepo.FindByUserId(identityId) + if err != nil { + if _, isSkinNotFound := err.(*SkinNotFoundError); !isSkinNotFound { + return nil, err + } + + record, err = ctx.SkinsRepo.FindByUsername(username) + if err == nil { + _ = ctx.SkinsRepo.RemoveByUsername(username) + record.UserId = identityId + } else { + record = &model.Skin{ + UserId: identityId, + Username: username, + } + } + } else if record.Username != username { + _ = ctx.SkinsRepo.RemoveByUserId(identityId) + record.Username = username + } + + return record, nil +} + +func validatePostSkinRequest(request *http.Request) map[string][]string { + const maxMultipartMemory int64 = 32 << 20 + const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both" + + _ = request.ParseMultipartForm(maxMultipartMemory) + + validationRules := govalidator.MapData{ + "identityId": {"required", "numeric", "min:1"}, + "username": {"required"}, + "uuid": {"required", "uuid_any"}, + "skinId": {"required", "numeric", "min:1"}, + "url": {"url"}, + "file:skin": {"ext:png", "size:24576", "mime:image/png"}, + "is1_8": {"bool"}, + "isSlim": {"bool"}, + } + + shouldAppendSkinRequiredError := false + url := request.Form.Get("url") + _, _, skinErr := request.FormFile("skin") + if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) { + shouldAppendSkinRequiredError = true + } else if skinErr == nil { + validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable") + } else if url != "" { + validationRules["is1_8"] = append(validationRules["is1_8"], "required") + validationRules["isSlim"] = append(validationRules["isSlim"], "required") + } + + mojangTextures := request.Form.Get("mojangTextures") + if mojangTextures != "" { + validationRules["mojangSignature"] = []string{"required"} + } + + validator := govalidator.New(govalidator.Options{ + Request: request, + Rules: validationRules, + RequiredDefault: false, + FormSize: maxMultipartMemory, + }) + validationResults := validator.Validate() + if shouldAppendSkinRequiredError { + validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage) + validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage) + } + + if len(validationResults) != 0 { + return validationResults + } + + return nil +} diff --git a/http/api_test.go b/http/api_test.go new file mode 100644 index 0000000..db60f13 --- /dev/null +++ b/http/api_test.go @@ -0,0 +1,442 @@ +package http + +import ( + "bytes" + "encoding/base64" + "errors" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/elyby/chrly/model" +) + +/*************** + * Setup mocks * + ***************/ + +type apiTestSuite struct { + suite.Suite + + App *Api + + SkinsRepository *skinsRepositoryMock + Emitter *emitterMock +} + +/******************** + * Setup test suite * + ********************/ + +func (suite *apiTestSuite) SetupTest() { + suite.SkinsRepository = &skinsRepositoryMock{} + suite.Emitter = &emitterMock{} + + suite.App = &Api{ + SkinsRepo: suite.SkinsRepository, + Emitter: suite.Emitter, + } +} + +func (suite *apiTestSuite) TearDownTest() { + suite.SkinsRepository.AssertExpectations(suite.T()) + suite.Emitter.AssertExpectations(suite.T()) +} + +func (suite *apiTestSuite) RunSubTest(name string, subTest func()) { + suite.SetupTest() + suite.Run(name, subTest) + suite.TearDownTest() +} + +/************* + * Run tests * + *************/ + +func TestApi(t *testing.T) { + suite.Run(t, new(apiTestSuite)) +} + +/************************* + * Post skin tests cases * + *************************/ + +type postSkinTestCase struct { + Name string + Form io.Reader + BeforeTest func(suite *apiTestSuite) + AfterTest func(suite *apiTestSuite, response *http.Response) +} + +var postSkinTestsCases = []*postSkinTestCase{ + { + Name: "Upload new identity with textures data", + Form: bytes.NewBufferString(url.Values{ + "identityId": {"1"}, + "username": {"mock_username"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://example.com/skin.png"}, + }.Encode()), + BeforeTest: func(suite *apiTestSuite) { + suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { + suite.Equal(1, model.UserId) + suite.Equal("mock_username", model.Username) + suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) + suite.Equal(5, model.SkinId) + suite.False(model.Is1_8) + suite.False(model.IsSlim) + suite.Equal("http://example.com/skin.png", model.Url) + + return true + })).Times(1).Return(nil) + }, + AfterTest: func(suite *apiTestSuite, response *http.Response) { + suite.Equal(201, response.StatusCode) + body, _ := ioutil.ReadAll(response.Body) + suite.Empty(body) + }, + }, + { + Name: "Update exists identity by changing only textures data", + 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 *apiTestSuite) { + suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil) + suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { + suite.Equal(1, model.UserId) + suite.Equal("mock_username", model.Username) + suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) + suite.Equal(5, model.SkinId) + suite.True(model.Is1_8) + suite.True(model.IsSlim) + suite.Equal("http://textures-server.com/skin.png", model.Url) + + return true + })).Times(1).Return(nil) + }, + AfterTest: func(suite *apiTestSuite, response *http.Response) { + suite.Equal(201, response.StatusCode) + body, _ := ioutil.ReadAll(response.Body) + suite.Empty(body) + }, + }, + { + Name: "Update exists identity by changing its identityId", + Form: bytes.NewBufferString(url.Values{ + "identityId": {"2"}, + "username": {"mock_username"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://example.com/skin.png"}, + }.Encode()), + BeforeTest: func(suite *apiTestSuite) { + suite.SkinsRepository.On("FindByUserId", 2).Return(nil, &SkinNotFoundError{Who: "unknown"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) + suite.SkinsRepository.On("RemoveByUsername", "mock_username").Times(1).Return(nil) + suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { + suite.Equal(2, model.UserId) + suite.Equal("mock_username", model.Username) + suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) + + return true + })).Times(1).Return(nil) + }, + AfterTest: func(suite *apiTestSuite, response *http.Response) { + suite.Equal(201, response.StatusCode) + body, _ := ioutil.ReadAll(response.Body) + suite.Empty(body) + }, + }, + { + Name: "Update exists identity by changing its username", + 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 *apiTestSuite) { + suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil) + suite.SkinsRepository.On("RemoveByUserId", 1).Times(1).Return(nil) + suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { + suite.Equal(1, model.UserId) + suite.Equal("changed_username", model.Username) + suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) + + return true + })).Times(1).Return(nil) + }, + AfterTest: func(suite *apiTestSuite, response *http.Response) { + suite.Equal(201, response.StatusCode) + body, _ := ioutil.ReadAll(response.Body) + 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 *apiTestSuite) { + 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 *apiTestSuite, 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 *apiTestSuite) { + 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 *apiTestSuite, response *http.Response) { + suite.Equal(500, response.StatusCode) + body, _ := ioutil.ReadAll(response.Body) + suite.Empty(body) + }, + }, +} + +func (suite *apiTestSuite) TestPostSkin() { + for _, testCase := range postSkinTestsCases { + suite.RunSubTest(testCase.Name, func() { + testCase.BeforeTest(suite) + + req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + testCase.AfterTest(suite, w.Result()) + }) + } + + suite.RunSubTest("Get errors about required fields", func() { + req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{ + "mojangTextures": {"someBase64EncodedString"}, + }.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(400, resp.StatusCode) + body, _ := ioutil.ReadAll(resp.Body) + suite.JSONEq(`{ + "errors": { + "identityId": [ + "The identityId field is required", + "The identityId field must be numeric", + "The identityId field must be minimum 1 char" + ], + "skinId": [ + "The skinId field is required", + "The skinId field must be numeric", + "The skinId field must be minimum 1 char" + ], + "username": [ + "The username field is required" + ], + "uuid": [ + "The uuid field is required", + "The uuid field must contain valid UUID" + ], + "url": [ + "One of url or skin should be provided, but not both" + ], + "skin": [ + "One of url or skin should be provided, but not both" + ], + "mojangSignature": [ + "The mojangSignature field is required" + ] + } + }`, string(body)) + }) + + suite.RunSubTest("Upload textures with skin as file", func() { + inputBody := &bytes.Buffer{} + writer := multipart.NewWriter(inputBody) + + part, _ := writer.CreateFormFile("skin", "char.png") + _, _ = part.Write(loadSkinFile()) + + _ = writer.WriteField("identityId", "1") + _ = writer.WriteField("username", "mock_user") + _ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3") + _ = writer.WriteField("skinId", "5") + + err := writer.Close() + if err != nil { + panic(err) + } + + req := httptest.NewRequest("POST", "http://chrly/skins", inputBody) + req.Header.Add("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(400, resp.StatusCode) + responseBody, _ := ioutil.ReadAll(resp.Body) + suite.JSONEq(`{ + "errors": { + "skin": [ + "Skin uploading is temporary unavailable" + ] + } + }`, string(responseBody)) + }) +} + +/************************************** + * Delete skin by user id tests cases * + **************************************/ + +func (suite *apiTestSuite) TestDeleteByUserId() { + suite.RunSubTest("Delete skin by its identity id", func() { + suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil) + suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil) + + req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil) + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(204, resp.StatusCode) + body, _ := ioutil.ReadAll(resp.Body) + suite.Empty(body) + }) + + suite.RunSubTest("Try to remove not exists identity id", func() { + suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"}) + + req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil) + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(404, resp.StatusCode) + body, _ := ioutil.ReadAll(resp.Body) + suite.JSONEq(`[ + "Cannot find record for the requested identifier" + ]`, string(body)) + }) +} + +/*************************************** + * Delete skin by username tests cases * + ***************************************/ + +func (suite *apiTestSuite) TestDeleteByUsername() { + suite.RunSubTest("Delete skin by its identity username", func() { + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) + suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil) + + req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil) + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(204, resp.StatusCode) + body, _ := ioutil.ReadAll(resp.Body) + suite.Empty(body) + }) + + suite.RunSubTest("Try to remove not exists identity username", func() { + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + + req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil) + w := httptest.NewRecorder() + + suite.App.Handler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + suite.Equal(404, resp.StatusCode) + body, _ := ioutil.ReadAll(resp.Body) + suite.JSONEq(`[ + "Cannot find record for the requested identifier" + ]`, string(body)) + }) +} + +/************* + * Utilities * + *************/ + +// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png +var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==") + +func loadSkinFile() []byte { + result := make([]byte, 92) + _, err := base64.StdEncoding.Decode(result, OnePxPng) + if err != nil { + panic(err) + } + + return result +} diff --git a/http/http.go b/http/http.go index c1ab843..ef02d34 100644 --- a/http/http.go +++ b/http/http.go @@ -1,13 +1,18 @@ package http import ( + "context" "encoding/json" "net" "net/http" + "os" + "os/signal" "strings" "time" "github.com/gorilla/mux" + "github.com/mono83/slf" + "github.com/mono83/slf/wd" ) type Emitter interface { @@ -31,6 +36,34 @@ func Serve(address string, handler http.Handler) error { return server.Serve(listener) } +func StartServer(server *http.Server, logger slf.Logger) { + done := make(chan bool, 1) + go func() { + logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr)) + if err := server.ListenAndServe(); err != nil { + logger.Emergency("Error in main(): :err", wd.ErrParam(err)) + close(done) + } + }() + + go func() { + s := waitForExitSignal() + logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String())) + server.Shutdown(context.Background()) + logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String())) + close(done) + }() + + <-done +} + +func waitForExitSignal() os.Signal { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, os.Kill) + + return <-ch +} + type loggingResponseWriter struct { http.ResponseWriter statusCode int @@ -78,7 +111,7 @@ func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc { } } -func NotFound(response http.ResponseWriter, _ *http.Request) { +func NotFoundHandler(response http.ResponseWriter, _ *http.Request) { data, _ := json.Marshal(map[string]string{ "status": "404", "message": "Not Found", diff --git a/http/http_test.go b/http/http_test.go index d9abfe5..ab9a63d 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -93,13 +93,13 @@ func TestCreateAuthenticationMiddleware(t *testing.T) { }) } -func TestNotFound(t *testing.T) { +func TestNotFoundHandler(t *testing.T) { assert := testify.New(t) req := httptest.NewRequest("GET", "http://example.com", nil) w := httptest.NewRecorder() - NotFound(w, req) + NotFoundHandler(w, req) resp := w.Result() assert.Equal(404, resp.StatusCode) diff --git a/http/skinsystem.go b/http/skinsystem.go index a7829a0..2681b97 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -3,49 +3,16 @@ package http import ( "encoding/json" "errors" - "fmt" "io" "net/http" - "regexp" - "strconv" "strings" "github.com/gorilla/mux" - "github.com/thedevsaddam/govalidator" "github.com/elyby/chrly/api/mojang" "github.com/elyby/chrly/model" ) -//noinspection GoSnakeCaseUsage -const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - -var regexUuidAny = regexp.MustCompile(UUID_ANY) - -func init() { - govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error { - if message == "" { - message = "Skin uploading is temporary unavailable" - } - - return errors.New(message) - }) - - // Add ability to validate any possible uuid form - govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error { - str := value.(string) - if !regexUuidAny.MatchString(str) { - if message == "" { - message = fmt.Sprintf("The %s field must contain valid UUID", field) - } - - return errors.New(message) - } - - return nil - }) -} - type SkinsRepository interface { FindByUsername(username string) (*model.Skin, error) FindByUserId(id int) (*model.Skin, error) @@ -58,6 +25,7 @@ type CapesRepository interface { FindByUsername(username string) (*model.Cape, error) } +// TODO: can I get rid of this? type SkinNotFoundError struct { Who string } @@ -70,6 +38,7 @@ type CapeNotFoundError struct { Who string } +// TODO: can I get rid of this? func (e CapeNotFoundError) Error() string { return "cape file not found" } @@ -80,42 +49,28 @@ type MojangTexturesProvider interface { type Skinsystem struct { Emitter - TexturesExtraParamName string - TexturesExtraParamValue string SkinsRepo SkinsRepository CapesRepo CapesRepository MojangTexturesProvider MojangTexturesProvider - Authenticator Authenticator + TexturesExtraParamName string + TexturesExtraParamValue string } -func (ctx *Skinsystem) CreateHandler() *mux.Router { - requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem") - +func (ctx *Skinsystem) Handler() *mux.Router { 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) + router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet) + router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks") + router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet) + router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet) // Legacy - router.HandleFunc("/skins", ctx.SkinGET).Methods(http.MethodGet) - router.HandleFunc("/cloaks", ctx.CapeGET).Methods(http.MethodGet) - // API - apiRouter := router.PathPrefix("/api").Subrouter() - 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 - // 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)) + router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet) + router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet) return router } -func (ctx *Skinsystem) Skin(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := ctx.SkinsRepo.FindByUsername(username) if err == nil && rec.SkinId != 0 { @@ -139,7 +94,7 @@ func (ctx *Skinsystem) Skin(response http.ResponseWriter, request *http.Request) http.Redirect(response, request, skin.Url, 301) } -func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) { username := request.URL.Query().Get("name") if username == "" { response.WriteHeader(http.StatusBadRequest) @@ -149,10 +104,10 @@ func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Reque mux.Vars(request)["username"] = username mux.Vars(request)["converted"] = "1" - ctx.Skin(response, request) + ctx.skinHandler(response, request) } -func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := ctx.CapesRepo.FindByUsername(username) if err == nil { @@ -177,7 +132,7 @@ func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request) http.Redirect(response, request, cape.Url, 301) } -func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) { username := request.URL.Query().Get("name") if username == "" { response.WriteHeader(http.StatusBadRequest) @@ -187,10 +142,10 @@ func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Reque mux.Vars(request)["username"] = username mux.Vars(request)["converted"] = "1" - ctx.Cape(response, request) + ctx.capeHandler(response, request) } -func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) var textures *mojang.TexturesResponse @@ -233,6 +188,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ } textures = texturesProp.Textures + // TODO: return 204 in case when there is no skin and cape on mojang textures } responseData, _ := json.Marshal(textures) @@ -240,7 +196,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ _, _ = response.Write(responseData) } -func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *http.Request) { +func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) var responseData *mojang.SignedTexturesResponse @@ -280,158 +236,6 @@ func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *htt _, _ = response.Write(responseJson) } -func (ctx *Skinsystem) PostSkin(resp http.ResponseWriter, req *http.Request) { - validationErrors := validatePostSkinRequest(req) - if validationErrors != nil { - apiBadRequest(resp, validationErrors) - return - } - - identityId, _ := strconv.Atoi(req.Form.Get("identityId")) - username := req.Form.Get("username") - - record, err := findIdentity(ctx.SkinsRepo, identityId, username) - if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err)) - apiServerError(resp) - return - } - - skinId, _ := strconv.Atoi(req.Form.Get("skinId")) - is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) - isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) - - record.Uuid = req.Form.Get("uuid") - record.SkinId = skinId - record.Is1_8 = is18 - record.IsSlim = isSlim - record.Url = req.Form.Get("url") - record.MojangTextures = req.Form.Get("mojangTextures") - record.MojangSignature = req.Form.Get("mojangSignature") - - err = ctx.SkinsRepo.Save(record) - if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err)) - apiServerError(resp) - return - } - - resp.WriteHeader(http.StatusCreated) -} - -func (ctx *Skinsystem) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) { - id, _ := strconv.Atoi(mux.Vars(req)["id"]) - skin, err := ctx.SkinsRepo.FindByUserId(id) - ctx.deleteSkin(skin, err, resp) -} - -func (ctx *Skinsystem) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) { - 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 { - 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 - } - - err = ctx.SkinsRepo.RemoveByUserId(skin.UserId) - if err != nil { - ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err)) - apiServerError(resp) - return - } - - resp.WriteHeader(http.StatusNoContent) -} - -func validatePostSkinRequest(request *http.Request) map[string][]string { - const maxMultipartMemory int64 = 32 << 20 - const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both" - - _ = request.ParseMultipartForm(maxMultipartMemory) - - validationRules := govalidator.MapData{ - "identityId": {"required", "numeric", "min:1"}, - "username": {"required"}, - "uuid": {"required", "uuid_any"}, - "skinId": {"required", "numeric", "min:1"}, - "url": {"url"}, - "file:skin": {"ext:png", "size:24576", "mime:image/png"}, - "is1_8": {"bool"}, - "isSlim": {"bool"}, - } - - shouldAppendSkinRequiredError := false - url := request.Form.Get("url") - _, _, skinErr := request.FormFile("skin") - if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) { - shouldAppendSkinRequiredError = true - } else if skinErr == nil { - validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable") - } else if url != "" { - validationRules["is1_8"] = append(validationRules["is1_8"], "required") - validationRules["isSlim"] = append(validationRules["isSlim"], "required") - } - - mojangTextures := request.Form.Get("mojangTextures") - if mojangTextures != "" { - validationRules["mojangSignature"] = []string{"required"} - } - - validator := govalidator.New(govalidator.Options{ - Request: request, - Rules: validationRules, - RequiredDefault: false, - FormSize: maxMultipartMemory, - }) - validationResults := validator.Validate() - if shouldAppendSkinRequiredError { - validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage) - validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage) - } - - if len(validationResults) != 0 { - return validationResults - } - - return nil -} - -func findIdentity(repo SkinsRepository, identityId int, username string) (*model.Skin, error) { - var record *model.Skin - record, err := repo.FindByUserId(identityId) - if err != nil { - if _, isSkinNotFound := err.(*SkinNotFoundError); !isSkinNotFound { - return nil, err - } - - record, err = repo.FindByUsername(username) - if err == nil { - _ = repo.RemoveByUsername(username) - record.UserId = identityId - } else { - record = &model.Skin{ - UserId: identityId, - Username: username, - } - } - } else if record.Username != username { - _ = repo.RemoveByUserId(identityId) - record.Username = username - } - - return record, nil -} - func parseUsername(username string) string { return strings.TrimSuffix(username, ".png") } diff --git a/http/skinsystem_test.go b/http/skinsystem_test.go index 89f317d..a02ac16 100644 --- a/http/skinsystem_test.go +++ b/http/skinsystem_test.go @@ -2,16 +2,11 @@ package http import ( "bytes" - "encoding/base64" - "errors" "image" "image/png" - "io" "io/ioutil" - "mime/multipart" "net/http" "net/http/httptest" - "net/url" "testing" "time" @@ -102,7 +97,6 @@ type skinsystemTestSuite struct { SkinsRepository *skinsRepositoryMock CapesRepository *capesRepositoryMock MojangTexturesProvider *mojangTexturesProviderMock - Auth *authCheckerMock Emitter *emitterMock } @@ -114,14 +108,12 @@ func (suite *skinsystemTestSuite) SetupTest() { suite.SkinsRepository = &skinsRepositoryMock{} suite.CapesRepository = &capesRepositoryMock{} suite.MojangTexturesProvider = &mojangTexturesProviderMock{} - suite.Auth = &authCheckerMock{} suite.Emitter = &emitterMock{} suite.App = &Skinsystem{ SkinsRepo: suite.SkinsRepository, CapesRepo: suite.CapesRepository, MojangTexturesProvider: suite.MojangTexturesProvider, - Authenticator: suite.Auth, Emitter: suite.Emitter, } } @@ -130,7 +122,6 @@ func (suite *skinsystemTestSuite) TearDownTest() { suite.SkinsRepository.AssertExpectations(suite.T()) suite.CapesRepository.AssertExpectations(suite.T()) suite.MojangTexturesProvider.AssertExpectations(suite.T()) - suite.Auth.AssertExpectations(suite.T()) suite.Emitter.AssertExpectations(suite.T()) } @@ -205,28 +196,24 @@ var skinsTestsCases = []*skinsystemTestCase{ func (suite *skinsystemTestSuite) TestSkin() { for _, testCase := range skinsTestsCases { suite.RunSubTest(testCase.Name, func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) } suite.RunSubTest("Pass username with png extension", func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) resp := w.Result() suite.Equal(301, resp.StatusCode) @@ -237,27 +224,23 @@ func (suite *skinsystemTestSuite) TestSkin() { func (suite *skinsystemTestSuite) TestSkinGET() { for _, testCase := range skinsTestsCases { suite.RunSubTest(testCase.Name, func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) } 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() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) resp := w.Result() suite.Equal(400, resp.StatusCode) @@ -317,28 +300,24 @@ var capesTestsCases = []*skinsystemTestCase{ func (suite *skinsystemTestSuite) TestCape() { for _, testCase := range capesTestsCases { suite.RunSubTest(testCase.Name, func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) } suite.RunSubTest("Pass username with png extension", func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) resp := w.Result() suite.Equal(200, resp.StatusCode) @@ -351,27 +330,23 @@ func (suite *skinsystemTestSuite) TestCape() { func (suite *skinsystemTestSuite) TestCapeGET() { for _, testCase := range capesTestsCases { suite.RunSubTest(testCase.Name, func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) } 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() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) resp := w.Result() suite.Equal(400, resp.StatusCode) @@ -494,14 +469,12 @@ var texturesTestsCases = []*skinsystemTestCase{ func (suite *skinsystemTestSuite) TestTextures() { for _, testCase := range texturesTestsCases { suite.RunSubTest(testCase.Name, func() { - 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) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) @@ -619,8 +592,6 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ func (suite *skinsystemTestSuite) TestSignedTextures() { for _, testCase := range signedTexturesTestsCases { suite.RunSubTest(testCase.Name, func() { - 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 @@ -633,417 +604,13 @@ func (suite *skinsystemTestSuite) TestSignedTextures() { req := httptest.NewRequest("GET", target, nil) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) } } -/************************* - * Post skin tests cases * - *************************/ - -type postSkinTestCase struct { - Name string - Form io.Reader - ExpectSuccess bool - BeforeTest func(suite *skinsystemTestSuite) - AfterTest func(suite *skinsystemTestSuite, response *http.Response) -} - -var postSkinTestsCases = []*postSkinTestCase{ - { - Name: "Upload new identity with textures data", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"mock_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) { - suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"}) - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) - suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - suite.Equal(5, model.SkinId) - suite.False(model.Is1_8) - suite.False(model.IsSlim) - suite.Equal("http://example.com/skin.png", model.Url) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing only textures data", - 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) - suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - suite.Equal(5, model.SkinId) - suite.True(model.Is1_8) - suite.True(model.IsSlim) - suite.Equal("http://textures-server.com/skin.png", model.Url) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing its identityId", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"2"}, - "username": {"mock_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) { - suite.SkinsRepository.On("FindByUserId", 2).Return(nil, &SkinNotFoundError{Who: "unknown"}) - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveByUsername", "mock_username").Times(1).Return(nil) - suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(2, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing its username", - 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) { - suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveByUserId", 1).Times(1).Return(nil) - suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("changed_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - 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.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) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - testCase.AfterTest(suite, w.Result()) - }) - } - - suite.RunSubTest("Get errors about required fields", func() { - 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"}, - }.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(400, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`{ - "errors": { - "identityId": [ - "The identityId field is required", - "The identityId field must be numeric", - "The identityId field must be minimum 1 char" - ], - "skinId": [ - "The skinId field is required", - "The skinId field must be numeric", - "The skinId field must be minimum 1 char" - ], - "username": [ - "The username field is required" - ], - "uuid": [ - "The uuid field is required", - "The uuid field must contain valid UUID" - ], - "url": [ - "One of url or skin should be provided, but not both" - ], - "skin": [ - "One of url or skin should be provided, but not both" - ], - "mojangSignature": [ - "The mojangSignature field is required" - ] - } - }`, string(body)) - }) - - 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("Authenticate", mock.Anything).Return(errors.New("Cannot parse passed JWT token")) - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(403, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`{ - "error": "Cannot parse passed JWT token" - }`, string(body)) - }) - - suite.RunSubTest("Upload textures with skin as file", func() { - 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) - - part, _ := writer.CreateFormFile("skin", "char.png") - _, _ = part.Write(loadSkinFile()) - - _ = writer.WriteField("identityId", "1") - _ = writer.WriteField("username", "mock_user") - _ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3") - _ = writer.WriteField("skinId", "5") - - err := writer.Close() - if err != nil { - panic(err) - } - - req := httptest.NewRequest("POST", "http://chrly/api/skins", inputBody) - req.Header.Add("Content-Type", writer.FormDataContentType()) - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(400, resp.StatusCode) - responseBody, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`{ - "errors": { - "skin": [ - "Skin uploading is temporary unavailable" - ] - } - }`, string(responseBody)) - }) -} - -/************************************** - * Delete skin by user id tests cases * - **************************************/ - -func (suite *skinsystemTestSuite) TestDeleteByUserId() { - suite.RunSubTest("Delete skin by its identity id", func() { - 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) - - req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil) - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(204, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.Empty(body) - }) - - suite.RunSubTest("Try to remove not exists identity id", func() { - 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"}) - - req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil) - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(404, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`[ - "Cannot find record for the requested identifier" - ]`, string(body)) - }) -} - -/*************************************** - * Delete skin by username tests cases * - ***************************************/ - -func (suite *skinsystemTestSuite) TestDeleteByUsername() { - suite.RunSubTest("Delete skin by its identity username", func() { - 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) - - req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil) - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(204, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.Empty(body) - }) - - suite.RunSubTest("Try to remove not exists identity username", func() { - 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"}) - - req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil) - w := httptest.NewRecorder() - - suite.App.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - suite.Equal(404, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`[ - "Cannot find record for the requested identifier" - ]`, string(body)) - }) -} - /**************** * Custom tests * ****************/ @@ -1118,16 +685,3 @@ func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedText return response } - -// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png -var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==") - -func loadSkinFile() []byte { - result := make([]byte, 92) - _, err := base64.StdEncoding.Decode(result, OnePxPng) - if err != nil { - panic(err) - } - - return result -} diff --git a/http/uuids_worker.go b/http/uuids_worker.go index 8efe5de..a5224de 100644 --- a/http/uuids_worker.go +++ b/http/uuids_worker.go @@ -14,29 +14,19 @@ type MojangUuidsProvider interface { } type UUIDsWorker struct { - Emitter - UUIDsProvider MojangUuidsProvider + MojangUuidsProvider } -func (ctx *UUIDsWorker) CreateHandler() *mux.Router { - requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem") // This prefix should be unified - +func (ctx *UUIDsWorker) Handler() *mux.Router { router := mux.NewRouter().StrictSlash(true) - router.Use(requestEventsMiddleware) - - router.Handle("/api/worker/mojang-uuid/{username}", http.HandlerFunc(ctx.GetUUID)).Methods("GET") - - // 404 - // 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)) + router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET") return router } -func (ctx *UUIDsWorker) GetUUID(response http.ResponseWriter, request *http.Request) { - username := parseUsername(mux.Vars(request)["username"]) - profile, err := ctx.UUIDsProvider.GetUuid(username) +func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) { + username := mux.Vars(request)["username"] + profile, err := ctx.GetUuid(username) if err != nil { if _, ok := err.(*mojang.TooManyRequestsError); ok { response.WriteHeader(http.StatusTooManyRequests) diff --git a/http/uuids_worker_test.go b/http/uuids_worker_test.go index b48b53e..4c32ee1 100644 --- a/http/uuids_worker_test.go +++ b/http/uuids_worker_test.go @@ -37,7 +37,6 @@ type uuidsWorkerTestSuite struct { App *UUIDsWorker UuidsProvider *uuidsProviderMock - Emitter *emitterMock } /******************** @@ -46,17 +45,14 @@ type uuidsWorkerTestSuite struct { func (suite *uuidsWorkerTestSuite) SetupTest() { suite.UuidsProvider = &uuidsProviderMock{} - suite.Emitter = &emitterMock{} suite.App = &UUIDsWorker{ - UUIDsProvider: suite.UuidsProvider, - Emitter: suite.Emitter, + MojangUuidsProvider: suite.UuidsProvider, } } func (suite *uuidsWorkerTestSuite) TearDownTest() { suite.UuidsProvider.AssertExpectations(suite.T()) - suite.Emitter.AssertExpectations(suite.T()) } func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) { @@ -87,8 +83,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{ { Name: "Success provider response", BeforeTest: func(suite *uuidsWorkerTestSuite) { - suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything) - suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 200) suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{ Id: "0fcc38620f1845f3a54e1b523c1bd1c7", Name: "mock_username", @@ -107,8 +101,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{ { Name: "Receive empty response from UUIDs provider", BeforeTest: func(suite *uuidsWorkerTestSuite) { - suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything) - suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 204) suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil) }, AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { @@ -120,8 +112,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{ { Name: "Receive error from UUIDs provider", BeforeTest: func(suite *uuidsWorkerTestSuite) { - suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything) - suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 500) err := errors.New("this is an error") suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) }, @@ -137,8 +127,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{ { Name: "Receive Too Many Requests from UUIDs provider", BeforeTest: func(suite *uuidsWorkerTestSuite) { - suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything) - suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 429) err := &mojang.TooManyRequestsError{} suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) }, @@ -155,10 +143,10 @@ func (suite *uuidsWorkerTestSuite) TestGetUUID() { suite.RunSubTest(testCase.Name, func() { testCase.BeforeTest(suite) - req := httptest.NewRequest("GET", "http://chrly/api/worker/mojang-uuid/mock_username", nil) + req := httptest.NewRequest("GET", "http://chrly/mojang-uuid/mock_username", nil) w := httptest.NewRecorder() - suite.App.CreateHandler().ServeHTTP(w, req) + suite.App.Handler().ServeHTTP(w, req) testCase.AfterTest(suite, w.Result()) }) From f58b98094825b264b05e4ccb3d60198ce122208e Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 13:23:02 +0300 Subject: [PATCH 03/11] Simplify health checkers initialization --- Gopkg.lock | 4 ++-- di/handlers.go | 17 ++--------------- di/mojang_textures.go | 4 ++-- http/skinsystem.go | 2 +- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 788bc15..395335e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -65,7 +65,7 @@ [[projects]] branch = "master" - digest = "1:c6d147728b7bf08b508b13c4547edfd72f900a2b8467b8a7e86d525badef268b" + digest = "1:8c9f13aac9e92f3754ea591b39ada87b9f89f1e75c4b90ccbd0b1084069c436f" name = "github.com/goava/di" packages = [ ".", @@ -73,7 +73,7 @@ "internal/stacktrace", ] pruneopts = "" - revision = "30d61f45552a08a92f8aa54eaad3e7495f091260" + revision = "1eb6eb721bf050edff0efbf15c31636def701b4b" [[projects]] digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2" diff --git a/di/handlers.go b/di/handlers.go index f326c38..4a2d657 100644 --- a/di/handlers.go +++ b/di/handlers.go @@ -76,7 +76,7 @@ func newHandlerFactory( // Resolve health checkers last, because all the services required by the application // must first be initialized and each of them can publish its own checkers - var healthCheckers []namedHealthCheckerInterface + var healthCheckers []*namedHealthChecker if container.Has(&healthCheckers) { if err := container.Resolve(&healthCheckers); err != nil { return nil, err @@ -84,7 +84,7 @@ func newHandlerFactory( checkersOptions := make([]healthcheck.Option, len(healthCheckers)) for i, checker := range healthCheckers { - checkersOptions[i] = healthcheck.WithChecker(checker.GetName(), checker.GetChecker()) + checkersOptions[i] = healthcheck.WithChecker(checker.Name, checker.Checker) } router.Handle("/healthcheck", healthcheck.Handler()).Methods("GET") @@ -142,20 +142,7 @@ func mount(router *mux.Router, path string, handler http.Handler) { ) } -type namedHealthCheckerInterface interface { - GetName() string - GetChecker() healthcheck.Checker -} - type namedHealthChecker struct { Name string Checker healthcheck.Checker } - -func (c *namedHealthChecker) GetName() string { - return c.Name -} - -func (c *namedHealthChecker) GetChecker() healthcheck.Checker { - return c.Checker -} diff --git a/di/mojang_textures.go b/di/mojang_textures.go index 54f56ad..afd0851 100644 --- a/di/mojang_textures.go +++ b/di/mojang_textures.go @@ -89,7 +89,7 @@ func newMojangTexturesBatchUUIDsProvider( config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"), ), } - }, di.As(new(namedHealthCheckerInterface)), di.WithName("mojangBatchUuidsProviderResponseChecker")); err != nil { + }); err != nil { return nil, err } @@ -103,7 +103,7 @@ func newMojangTexturesBatchUUIDsProvider( config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"), ), } - }, di.As(new(namedHealthCheckerInterface)), di.WithName("mojangBatchUuidsProviderQueueLengthChecker")); err != nil { + }); err != nil { return nil, err } diff --git a/http/skinsystem.go b/http/skinsystem.go index 2681b97..d8f7335 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -227,7 +227,7 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque } responseData.Props = append(responseData.Props, &mojang.Property{ - Name: getStringOrDefault(ctx.TexturesExtraParamName, "chrly"), + Name: getStringOrDefault(ctx.TexturesExtraParamName, "chrly"), // TODO: extract to the default param value Value: getStringOrDefault(ctx.TexturesExtraParamValue, "how do you tame a horse in Minecraft?"), }) From 2ea4c55d37af3659e069a6ceb24b0d839a6de99a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 14:29:33 +0300 Subject: [PATCH 04/11] Split Dispatcher interface and use it across application --- di/dispatcher.go | 6 ++++-- dispatcher/dispatcher.go | 13 ++++++++++--- eventsubscribers/subscriber.go | 4 +++- http/http.go | 23 +++-------------------- mojangtextures/mojang_textures.go | 3 ++- 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/di/dispatcher.go b/di/dispatcher.go index 6a59e11..13416bd 100644 --- a/di/dispatcher.go +++ b/di/dispatcher.go @@ -12,6 +12,8 @@ import ( var dispatcher = di.Options( di.Provide(newDispatcher, + di.As(new(d.Emitter)), + di.As(new(d.Subscriber)), di.As(new(http.Emitter)), di.As(new(mojangtextures.Emitter)), di.As(new(eventsubscribers.Subscriber)), @@ -19,12 +21,12 @@ var dispatcher = di.Options( di.Invoke(enableEventsHandlers), ) -func newDispatcher() d.EventDispatcher { +func newDispatcher() d.Dispatcher { return d.New() } func enableEventsHandlers( - dispatcher d.EventDispatcher, + dispatcher d.Subscriber, logger slf.Logger, statsReporter slf.StatsReporter, ) { diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index b20872b..4c692cd 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -2,12 +2,19 @@ package dispatcher import "github.com/asaskevich/EventBus" -// TODO: split on 2 interfaces and use them across the application -type EventDispatcher interface { +type Subscriber interface { Subscribe(topic string, fn interface{}) +} + +type Emitter interface { Emit(topic string, args ...interface{}) } +type Dispatcher interface { + Subscriber + Emitter +} + type localEventDispatcher struct { bus EventBus.Bus } @@ -20,7 +27,7 @@ func (d *localEventDispatcher) Emit(topic string, args ...interface{}) { d.bus.Publish(topic, args...) } -func New() EventDispatcher { +func New() Dispatcher { return &localEventDispatcher{ bus: EventBus.New(), } diff --git a/eventsubscribers/subscriber.go b/eventsubscribers/subscriber.go index 8b64092..00aea01 100644 --- a/eventsubscribers/subscriber.go +++ b/eventsubscribers/subscriber.go @@ -1,5 +1,7 @@ package eventsubscribers +import "github.com/elyby/chrly/dispatcher" + type Subscriber interface { - Subscribe(topic string, fn interface{}) + dispatcher.Subscriber } diff --git a/http/http.go b/http/http.go index ef02d34..b6e373a 100644 --- a/http/http.go +++ b/http/http.go @@ -3,37 +3,20 @@ package http import ( "context" "encoding/json" - "net" "net/http" "os" "os/signal" "strings" - "time" "github.com/gorilla/mux" "github.com/mono83/slf" "github.com/mono83/slf/wd" + + "github.com/elyby/chrly/dispatcher" ) 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) + dispatcher.Emitter } func StartServer(server *http.Server, logger slf.Logger) { diff --git a/mojangtextures/mojang_textures.go b/mojangtextures/mojang_textures.go index 08885c3..205431c 100644 --- a/mojangtextures/mojang_textures.go +++ b/mojangtextures/mojang_textures.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/elyby/chrly/api/mojang" + "github.com/elyby/chrly/dispatcher" ) type broadcastResult struct { @@ -72,7 +73,7 @@ type TexturesProvider interface { } type Emitter interface { - Emit(name string, args ...interface{}) + dispatcher.Emitter } type Provider struct { From cc4cd2874c7e6356e633207d8502a4a83838389e Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 15:16:15 +0300 Subject: [PATCH 05/11] Drop usage of the SkinNotFoundError and CapeNotFoundError More accurate redis results checking Return correct errors from filesystem db driver --- db/filesystem.go | 8 +++-- db/redis.go | 31 +++++++++---------- http/api.go | 66 +++++++++++++++++++++++++---------------- http/api_test.go | 10 +++---- http/skinsystem.go | 33 +++++---------------- http/skinsystem_test.go | 32 ++++++++++---------- 6 files changed, 89 insertions(+), 91 deletions(-) diff --git a/db/filesystem.go b/db/filesystem.go index d94e53f..e03b479 100644 --- a/db/filesystem.go +++ b/db/filesystem.go @@ -49,13 +49,17 @@ type filesStorage struct { func (repository *filesStorage) FindByUsername(username string) (*model.Cape, error) { if username == "" { - return nil, &http.CapeNotFoundError{Who: username} + return nil, nil } capePath := path.Join(repository.path, strings.ToLower(username)+".png") file, err := os.Open(capePath) if err != nil { - return nil, &http.CapeNotFoundError{Who: username} + if os.IsNotExist(err) { + return nil, nil + } + + return nil, err } return &model.Cape{ diff --git a/db/redis.go b/db/redis.go index ec218b6..899eca1 100644 --- a/db/redis.go +++ b/db/redis.go @@ -5,7 +5,6 @@ import ( "compress/zlib" "encoding/json" "fmt" - "github.com/elyby/chrly/http" "io" "strconv" "strings" @@ -15,6 +14,7 @@ import ( "github.com/mediocregopher/radix.v2/redis" "github.com/mediocregopher/radix.v2/util" + "github.com/elyby/chrly/http" "github.com/elyby/chrly/model" "github.com/elyby/chrly/mojangtextures" ) @@ -147,14 +147,10 @@ func (db *redisDb) StoreUuid(username string, uuid string) error { } func findByUsername(username string, conn util.Cmder) (*model.Skin, error) { - if username == "" { - return nil, &http.SkinNotFoundError{Who: username} - } - redisKey := buildUsernameKey(username) response := conn.Cmd("GET", redisKey) - if !response.IsType(redis.Str) { - return nil, &http.SkinNotFoundError{Who: username} + if response.IsType(redis.Nil) { + return nil, nil } encodedResult, err := response.Bytes() @@ -180,11 +176,14 @@ func findByUsername(username string, conn util.Cmder) (*model.Skin, error) { func findByUserId(id int, conn util.Cmder) (*model.Skin, error) { response := conn.Cmd("HGET", accountIdToUsernameKey, id) - if !response.IsType(redis.Str) { - return nil, &http.SkinNotFoundError{Who: "unknown"} + if response.IsType(redis.Nil) { + return nil, nil } - username, _ := response.Str() + username, err := response.Str() + if err != nil { + return nil, err + } return findByUsername(username, conn) } @@ -192,9 +191,7 @@ func findByUserId(id int, conn util.Cmder) (*model.Skin, error) { func removeByUserId(id int, conn util.Cmder) error { record, err := findByUserId(id, conn) if err != nil { - if _, ok := err.(*http.SkinNotFoundError); !ok { - return err - } + return err } conn.Cmd("MULTI") @@ -212,13 +209,13 @@ func removeByUserId(id int, conn util.Cmder) error { func removeByUsername(username string, conn util.Cmder) error { record, err := findByUsername(username, conn) if err != nil { - if _, ok := err.(*http.SkinNotFoundError); ok { - return nil - } - return err } + if record == nil { + return nil + } + conn.Cmd("MULTI") conn.Cmd("DEL", buildUsernameKey(record.Username)) diff --git a/http/api.go b/http/api.go index b533278..75fe9a9 100644 --- a/http/api.go +++ b/http/api.go @@ -73,6 +73,13 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { return } + if record == nil { + record = &model.Skin{ + UserId: identityId, + Username: username, + } + } + skinId, _ := strconv.Atoi(req.Form.Get("skinId")) is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) @@ -109,13 +116,13 @@ func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http. func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) { if err != nil { - 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) - } + ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err)) + apiServerError(resp) + return + } + if skin == nil { + apiNotFound(resp, "Cannot find record for the requested identifier") return } @@ -130,29 +137,38 @@ func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter } func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) { - var record *model.Skin record, err := ctx.SkinsRepo.FindByUserId(identityId) if err != nil { - if _, isSkinNotFound := err.(*SkinNotFoundError); !isSkinNotFound { - return nil, err - } - - record, err = ctx.SkinsRepo.FindByUsername(username) - if err == nil { - _ = ctx.SkinsRepo.RemoveByUsername(username) - record.UserId = identityId - } else { - record = &model.Skin{ - UserId: identityId, - Username: username, - } - } - } else if record.Username != username { - _ = ctx.SkinsRepo.RemoveByUserId(identityId) - record.Username = username + return nil, err } - return record, nil + if record != nil { + // The username may have changed in the external database, + // so we need to remove the old association + if record.Username != username { + _ = ctx.SkinsRepo.RemoveByUserId(identityId) + record.Username = username + } + + return record, nil + } + + // If the requested id was not found, then username was reassigned to another user + // who has not uploaded his data to Chrly yet + record, err = ctx.SkinsRepo.FindByUsername(username) + if err != nil { + return nil, err + } + + // If the target username does exist, clear it as it will be reassigned to the new user + if record != nil { + _ = ctx.SkinsRepo.RemoveByUsername(username) + record.UserId = identityId + + return record, nil + } + + return nil, nil } func validatePostSkinRequest(request *http.Request) map[string][]string { diff --git a/http/api_test.go b/http/api_test.go index db60f13..fe4f1cb 100644 --- a/http/api_test.go +++ b/http/api_test.go @@ -88,8 +88,8 @@ var postSkinTestsCases = []*postSkinTestCase{ "url": {"http://example.com/skin.png"}, }.Encode()), BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"}) - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUserId", 1).Return(nil, nil) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { suite.Equal(1, model.UserId) suite.Equal("mock_username", model.Username) @@ -151,7 +151,7 @@ var postSkinTestsCases = []*postSkinTestCase{ "url": {"http://example.com/skin.png"}, }.Encode()), BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindByUserId", 2).Return(nil, &SkinNotFoundError{Who: "unknown"}) + suite.SkinsRepository.On("FindByUserId", 2).Return(nil, nil) suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) suite.SkinsRepository.On("RemoveByUsername", "mock_username").Times(1).Return(nil) suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool { @@ -368,7 +368,7 @@ func (suite *apiTestSuite) TestDeleteByUserId() { }) suite.RunSubTest("Try to remove not exists identity id", func() { - suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"}) + suite.SkinsRepository.On("FindByUserId", 1).Return(nil, nil) req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil) w := httptest.NewRecorder() @@ -407,7 +407,7 @@ func (suite *apiTestSuite) TestDeleteByUsername() { }) suite.RunSubTest("Try to remove not exists identity username", func() { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil) w := httptest.NewRecorder() diff --git a/http/skinsystem.go b/http/skinsystem.go index d8f7335..3a065d4 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -25,24 +25,6 @@ type CapesRepository interface { FindByUsername(username string) (*model.Cape, error) } -// TODO: can I get rid of this? -type SkinNotFoundError struct { - Who string -} - -func (e SkinNotFoundError) Error() string { - return "skin data not found" -} - -type CapeNotFoundError struct { - Who string -} - -// TODO: can I get rid of this? -func (e CapeNotFoundError) Error() string { - return "cape file not found" -} - type MojangTexturesProvider interface { GetForUsername(username string) (*mojang.SignedTexturesResponse, error) } @@ -73,7 +55,7 @@ func (ctx *Skinsystem) Handler() *mux.Router { func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := ctx.SkinsRepo.FindByUsername(username) - if err == nil && rec.SkinId != 0 { + if err == nil && rec != nil && rec.SkinId != 0 { http.Redirect(response, request, rec.Url, 301) return } @@ -110,7 +92,7 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := ctx.CapesRepo.FindByUsername(username) - if err == nil { + if err == nil && rec != nil { request.Header.Set("Content-Type", "image/png") _, _ = io.Copy(response, rec.File) return @@ -150,11 +132,10 @@ func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *ht var textures *mojang.TexturesResponse skin, skinErr := ctx.SkinsRepo.FindByUsername(username) - _, capeErr := ctx.CapesRepo.FindByUsername(username) - if (skinErr == nil && skin.SkinId != 0) || capeErr == nil { + cape, capeErr := ctx.CapesRepo.FindByUsername(username) + if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) { textures = &mojang.TexturesResponse{} - - if skinErr == nil && skin.SkinId != 0 { + if skinErr == nil && skin != nil && skin.SkinId != 0 { skinTextures := &mojang.SkinTexturesResponse{ Url: skin.Url, } @@ -168,7 +149,7 @@ func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *ht textures.Skin = skinTextures } - if capeErr == nil { + if capeErr == nil && cape != nil { textures.Cape = &mojang.CapeTexturesResponse{ Url: request.URL.Scheme + "://" + request.Host + "/cloaks/" + username, } @@ -202,7 +183,7 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque var responseData *mojang.SignedTexturesResponse rec, err := ctx.SkinsRepo.FindByUsername(username) - if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" { + if err == nil && rec != nil && rec.SkinId != 0 && rec.MojangTextures != "" { responseData = &mojang.SignedTexturesResponse{ Id: strings.Replace(rec.Uuid, "-", "", -1), Name: rec.Username, diff --git a/http/skinsystem_test.go b/http/skinsystem_test.go index a02ac16..f0e8cb3 100644 --- a/http/skinsystem_test.go +++ b/http/skinsystem_test.go @@ -163,7 +163,7 @@ var skinsTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -174,7 +174,7 @@ var skinsTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -184,7 +184,7 @@ var skinsTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage and doesn't exists on Mojang", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -267,7 +267,7 @@ var capesTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", BeforeTest: func(suite *skinsystemTestSuite) { - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"}) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -278,7 +278,7 @@ var capesTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures", BeforeTest: func(suite *skinsystemTestSuite) { - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"}) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -288,7 +288,7 @@ var capesTestsCases = []*skinsystemTestCase{ { Name: "Username doesn't exists on the local storage and doesn't exists on Mojang", BeforeTest: func(suite *skinsystemTestSuite) { - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"}) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -362,7 +362,7 @@ var texturesTestsCases = []*skinsystemTestCase{ Name: "Username exists and has skin, no cape", BeforeTest: func(suite *skinsystemTestSuite) { suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"}) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { suite.Equal(200, response.StatusCode) @@ -379,7 +379,7 @@ var texturesTestsCases = []*skinsystemTestCase{ Name: "Username exists and has slim skin, no cape", BeforeTest: func(suite *skinsystemTestSuite) { suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil) - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"}) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { suite.Equal(200, response.StatusCode) @@ -398,7 +398,7 @@ var texturesTestsCases = []*skinsystemTestCase{ { Name: "Username exists and has cape, no skin", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.CapesRepository.On("FindByUsername", "mock_username").Return(createCapeModel(), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -435,8 +435,8 @@ var texturesTestsCases = []*skinsystemTestCase{ { Name: "Username not exists, but Mojang profile available", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{}) - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -456,8 +456,8 @@ var texturesTestsCases = []*skinsystemTestCase{ { Name: "Username not exists and Mojang profile unavailable", BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{}) - suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) + suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -524,7 +524,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ Name: "Username not exists", AllowProxy: false, BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { suite.Equal(204, response.StatusCode) @@ -551,7 +551,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ Name: "Username not exists, but Mojang profile is available and proxying is enabled", AllowProxy: true, BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { @@ -578,7 +578,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ Name: "Username not exists, Mojang profile is unavailable too and proxying is enabled", AllowProxy: true, BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"}) + suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil) suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) }, AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { From 0be85b356bff61498532bdfefe894880e20d1a25 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 15:34:52 +0300 Subject: [PATCH 06/11] Handling correctly closing the server --- http/http.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/http/http.go b/http/http.go index b6e373a..2fb4f0d 100644 --- a/http/http.go +++ b/http/http.go @@ -23,10 +23,11 @@ func StartServer(server *http.Server, logger slf.Logger) { done := make(chan bool, 1) go func() { logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr)) - if err := server.ListenAndServe(); err != nil { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Emergency("Error in main(): :err", wd.ErrParam(err)) - close(done) } + + close(done) }() go func() { From d9fbfe658a271f798e1f818d45f8aac925f3f337 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 15:40:20 +0300 Subject: [PATCH 07/11] Cleanup TODO items --- db/factory.go | 25 ------------------------- di/handlers.go | 3 +++ di/mojang_textures.go | 1 - http/skinsystem.go | 12 ++---------- 4 files changed, 5 insertions(+), 36 deletions(-) diff --git a/db/factory.go b/db/factory.go index a8a27a6..7baf993 100644 --- a/db/factory.go +++ b/db/factory.go @@ -1,37 +1,12 @@ package db import ( - "github.com/spf13/viper" - "github.com/elyby/chrly/http" "github.com/elyby/chrly/mojangtextures" ) -type StorageFactory struct { - Config *viper.Viper -} - type RepositoriesCreator interface { CreateSkinsRepository() (http.SkinsRepository, error) CreateCapesRepository() (http.CapesRepository, error) CreateMojangUuidsRepository() (mojangtextures.UuidsStorage, error) } - -// TODO: redundant -func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator { - switch backend { - case "redis": - return &RedisFactory{ - Host: factory.Config.GetString("storage.redis.host"), - Port: factory.Config.GetInt("storage.redis.port"), - PoolSize: factory.Config.GetInt("storage.redis.poolSize"), - } - case "filesystem": - return &FilesystemFactory{ - BasePath: factory.Config.GetString("storage.filesystem.basePath"), - CapesDirName: factory.Config.GetString("storage.filesystem.capesDirName"), - } - } - - return nil -} diff --git a/di/handlers.go b/di/handlers.go index 4a2d657..0fb5fdf 100644 --- a/di/handlers.go +++ b/di/handlers.go @@ -100,6 +100,9 @@ func newSkinsystemHandler( capesRepository CapesRepository, mojangTexturesProvider MojangTexturesProvider, ) *mux.Router { + config.SetDefault("textures.extra_param_name", "chrly") + config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") + return (&Skinsystem{ Emitter: emitter, SkinsRepo: skinsRepository, diff --git a/di/mojang_textures.go b/di/mojang_textures.go index afd0851..dee1bec 100644 --- a/di/mojang_textures.go +++ b/di/mojang_textures.go @@ -78,7 +78,6 @@ func newMojangTexturesBatchUUIDsProvider( config *viper.Viper, emitter mojangtextures.Emitter, ) (*mojangtextures.BatchUuidsProvider, error) { - // TODO: remove usage of di.WithName() when https://github.com/goava/di/issues/11 will be resolved if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute) diff --git a/http/skinsystem.go b/http/skinsystem.go index 3a065d4..52c36e3 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -208,8 +208,8 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque } responseData.Props = append(responseData.Props, &mojang.Property{ - Name: getStringOrDefault(ctx.TexturesExtraParamName, "chrly"), // TODO: extract to the default param value - Value: getStringOrDefault(ctx.TexturesExtraParamValue, "how do you tame a horse in Minecraft?"), + Name: ctx.TexturesExtraParamName, + Value: ctx.TexturesExtraParamValue, }) responseJson, _ := json.Marshal(responseData) @@ -220,11 +220,3 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque func parseUsername(username string) string { return strings.TrimSuffix(username, ".png") } - -func getStringOrDefault(value string, def string) string { - if value != "" { - return value - } - - return def -} From bca1436baf2e79fe71685747a6ccd8ee675b1442 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 17:12:58 +0300 Subject: [PATCH 08/11] Resolves #18. Log panics to the Sentry --- di/logger.go | 2 ++ di/server.go | 26 ++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/di/logger.go b/di/logger.go index 017062c..a88dcc7 100644 --- a/di/logger.go +++ b/di/logger.go @@ -66,6 +66,8 @@ func newSentry(config *viper.Viper) (*raven.Client, error) { ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver") ravenClient.SetRelease(version.Version()) + raven.DefaultClient = ravenClient + return ravenClient, nil } diff --git a/di/server.go b/di/server.go index 7c134c0..5b7a75a 100644 --- a/di/server.go +++ b/di/server.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/getsentry/raven-go" "github.com/goava/di" "github.com/spf13/viper" @@ -29,11 +30,28 @@ func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) { }, nil } -func newServer(config *viper.Viper, handler http.Handler) *http.Server { - config.SetDefault("server.host", "") - config.SetDefault("server.port", 80) +type serverParams struct { + di.Inject - address := fmt.Sprintf("%s:%d", config.GetString("server.host"), config.GetInt("server.port")) + Config *viper.Viper `di:""` + Handler http.Handler `di:""` + Sentry *raven.Client `di:"" optional:"true"` +} + +func newServer(params serverParams) *http.Server { + params.Config.SetDefault("server.host", "") + params.Config.SetDefault("server.port", 80) + + handler := params.Handler + if params.Sentry != nil { + // raven.Recoverer uses DefaultClient and nothing can be done about it + // To avoid code duplication, if the Sentry service is successfully initiated, + // it will also replace DefaultClient, so raven.Recoverer will work with the instance + // created in the application constructor + handler = raven.Recoverer(handler) + } + + address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port")) server := &http.Server{ Addr: address, ReadTimeout: 5 * time.Second, From e098b8d86f86249361670ab4f8b1086c93ba94ee Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 17:22:04 +0300 Subject: [PATCH 09/11] Fix tests --- http/skinsystem_test.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/http/skinsystem_test.go b/http/skinsystem_test.go index f0e8cb3..88bab3e 100644 --- a/http/skinsystem_test.go +++ b/http/skinsystem_test.go @@ -111,10 +111,12 @@ func (suite *skinsystemTestSuite) SetupTest() { suite.Emitter = &emitterMock{} suite.App = &Skinsystem{ - SkinsRepo: suite.SkinsRepository, - CapesRepo: suite.CapesRepository, - MojangTexturesProvider: suite.MojangTexturesProvider, - Emitter: suite.Emitter, + SkinsRepo: suite.SkinsRepository, + CapesRepo: suite.CapesRepository, + MojangTexturesProvider: suite.MojangTexturesProvider, + Emitter: suite.Emitter, + TexturesExtraParamName: "texturesParamName", + TexturesExtraParamValue: "texturesParamValue", } } @@ -513,8 +515,8 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ "value": "mocked textures base64" }, { - "name": "chrly", - "value": "how do you tame a horse in Minecraft?" + "name": "texturesParamName", + "value": "texturesParamValue" } ] }`, string(body)) @@ -567,8 +569,8 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{ "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==" }, { - "name": "chrly", - "value": "how do you tame a horse in Minecraft?" + "name": "texturesParamName", + "value": "texturesParamValue" } ] }`, string(body)) From ced4171eeff3bd3c33e151dbba5a5ba0477fa81f Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 17:22:19 +0300 Subject: [PATCH 10/11] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0ca7e..4029e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common). - Added `/healthcheck` endpoint (at the moment checks are only available for the batch Mojang UUIDs provider). - Graceful server shutdown. +- Panics in http are now logged in Sentry. ### Fixed - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and From a4cf29c7978efdd414cbb12578f1170793a11310 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 20 Apr 2020 18:41:24 +0300 Subject: [PATCH 11/11] Update README [skip ci] --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0de85ce..3fc740a 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ docker-compose up -d app true - MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER + MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER Specifies the preferred provider of the Mojang's UUIDs. Takes remote value. In any other case, the local queue will be used. @@ -308,7 +308,54 @@ response will be: } ``` -TODO: add notes about worker mode and healthcheck +### Worker mode + +The worker mode can be used in cooperation with the [remote server mode](#remote-mojang-uuids-provider) +to exchange Mojang usernames to UUIDs. This mode by itself doesn't solve the problem of +[extremely strict limits](https://github.com/elyby/chrly/issues/10) on the number of requests to the Mojang's API. +But with a proxying load balancer (e.g. HAProxy, Nginx, etc.) it's easy to build a cluster of workers, +which will multiply the bandwidth of the exchanging usernames to its UUIDs. + +The instructions for setting up a proxy load balancer are outside the context of this documentation, +but you get the idea ;) + +#### `GET /api/worker/mojang-uuid/{username}` + +Performs [batch usernames exchange to UUIDs](https://github.com/elyby/chrly/issues/1) and returns the result in the +[same format as it returns from the Mojang's API](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time): + +```json +{ + "id": "3e3ee6c35afa48abb61e8cd8c42fc0d9", + "name": "ErickSkrauch" +} +``` + +> **Note**: the results aren't cached. + +### Health check + +#### `GET /healthcheck` + +This endpoint can be used to programmatically check the status of the server. +If all internal checks are successful, the server will return `200` status code with the following body: + +```json +{ + "status": "OK" +} +``` + +If any of the checks fails, the server will return `503` status code with the following body: + +```json +{ + "status": "Service Unavailable", + "errors": { + "mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded" + } +} +``` ## Development