From 3f81a0c18ab81737ee5f5e279df30c5e97e4890a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 19 Apr 2020 02:31:09 +0300 Subject: [PATCH] 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()) })