Completely move app configuration from cmd to di container

Implemented graceful server shutdown
Extract records manipulating API into separate handlers group
This commit is contained in:
ErickSkrauch
2020-04-19 02:31:09 +03:00
parent 9046338396
commit 3f81a0c18a
26 changed files with 1096 additions and 1139 deletions

View File

@@ -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` - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in - All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common). [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 ### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and

7
Gopkg.lock generated
View File

@@ -64,17 +64,16 @@
revision = "919484f041ea21e7e27be291cee1d6af7bc98864" revision = "919484f041ea21e7e27be291cee1d6af7bc98864"
[[projects]] [[projects]]
digest = "1:c17a0163edf9a1b0f5d9e856673413b924939cf433ebf041ec309e8273fd9d2b" branch = "master"
digest = "1:c6d147728b7bf08b508b13c4547edfd72f900a2b8467b8a7e86d525badef268b"
name = "github.com/goava/di" name = "github.com/goava/di"
packages = [ packages = [
".", ".",
"internal/graph",
"internal/reflection", "internal/reflection",
"internal/stacktrace", "internal/stacktrace",
] ]
pruneopts = "" pruneopts = ""
revision = "6dcd92e58bd0fb2ff77cec60557abea1dae46571" revision = "30d61f45552a08a92f8aa54eaad3e7495f091260"
version = "v1.1.0"
[[projects]] [[projects]]
digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2" digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2"

View File

@@ -47,7 +47,7 @@ ignored = ["github.com/elyby/chrly"]
[[constraint]] [[constraint]]
name = "github.com/goava/di" name = "github.com/goava/di"
version = "^1.0.2" branch = "master"
# Testing dependencies # Testing dependencies

View File

@@ -308,6 +308,8 @@ response will be:
} }
``` ```
TODO: add notes about worker mode and healthcheck
## Development ## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH` First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`

View File

@@ -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()
}

View File

@@ -2,14 +2,16 @@ package cmd
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/signal"
"strings" "strings"
"syscall"
. "github.com/goava/di"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/elyby/chrly/di"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/version" "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() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
} }
@@ -37,10 +65,3 @@ func initConfig() {
replacer := strings.NewReplacer(".", "_") replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer) viper.SetEnvKeyReplacer(replacer)
} }
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
return <-ch
}

View File

@@ -1,142 +1,17 @@
package cmd package cmd
import ( import (
"fmt"
"log"
"os"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra" "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{ var serveCmd = &cobra.Command{
Use: "serve", Use: "serve",
Short: "Starts HTTP handler for the skins system", Short: "Starts HTTP handler for the skins system",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
dispatcher := bootstrap.CreateEventDispatcher() startServer([]string{"skinsystem", "api"})
// 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
}, },
} }
func init() { func init() {
RootCmd.AddCommand(serveCmd) 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)
} }

View File

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

View File

@@ -1,96 +1,17 @@
package cmd package cmd
import ( import (
"fmt"
"log"
"os"
"time"
"github.com/etherlabsio/healthcheck"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra" "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{ var workerCmd = &cobra.Command{
Use: "worker", Use: "worker",
Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker", Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
dispatcher := bootstrap.CreateEventDispatcher() startServer([]string{"worker"})
// 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
}, },
} }
func init() { func init() {
RootCmd.AddCommand(workerCmd) 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)
} }

View File

@@ -4,30 +4,37 @@ import (
"github.com/goava/di" "github.com/goava/di"
"github.com/spf13/viper" "github.com/spf13/viper"
dbModule "github.com/elyby/chrly/db" . "github.com/elyby/chrly/db"
"github.com/elyby/chrly/http" "github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures" "github.com/elyby/chrly/mojangtextures"
) )
var db = di.Options( var db = di.Options(
di.Provide(newRedisFactory, di.WithName("redis")), di.Provide(newRedisFactory),
di.Provide(newFSFactory, di.WithName("fs")), di.Provide(newFSFactory),
di.Provide(newSkinsRepository), di.Provide(newSkinsRepository),
di.Provide(newCapesRepository), di.Provide(newCapesRepository),
di.Provide(newMojangUUIDsRepository), di.Provide(newMojangUUIDsRepository),
di.Provide(newMojangSignedTexturesStorage), di.Provide(newMojangSignedTexturesStorage),
) )
func newRedisFactory(config *viper.Viper) dbModule.RepositoriesCreator { func newRedisFactory(config *viper.Viper) *RedisFactory {
return &dbModule.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"), Host: config.GetString("storage.redis.host"),
Port: config.GetInt("storage.redis.port"), Port: config.GetInt("storage.redis.port"),
PoolSize: config.GetInt("storage.redis.poolSize"), PoolSize: config.GetInt("storage.redis.poolSize"),
} }
} }
func newFSFactory(config *viper.Viper) dbModule.RepositoriesCreator { func newFSFactory(config *viper.Viper) *FilesystemFactory {
return &dbModule.FilesystemFactory{ config.SetDefault("storage.filesystem.basePath", "data")
config.SetDefault("storage.filesystem.capesDirName", "capes")
return &FilesystemFactory{
BasePath: config.GetString("storage.filesystem.basePath"), BasePath: config.GetString("storage.filesystem.basePath"),
CapesDirName: config.GetString("storage.filesystem.capesDirName"), 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, // Since there are no options for selecting target backends,
// all constants in this case point to static specific implementations. // all constants in this case point to static specific implementations.
func newSkinsRepository(container *di.Container) (http.SkinsRepository, error) { func newSkinsRepository(factory *RedisFactory) (http.SkinsRepository, error) {
var factory dbModule.RepositoriesCreator
err := container.Resolve(&factory, di.Name("redis"))
if err != nil {
return nil, err
}
return factory.CreateSkinsRepository() return factory.CreateSkinsRepository()
} }
func newCapesRepository(container *di.Container) (http.CapesRepository, error) { func newCapesRepository(factory *FilesystemFactory) (http.CapesRepository, error) {
var factory dbModule.RepositoriesCreator
err := container.Resolve(&factory, di.Name("fs"))
if err != nil {
return nil, err
}
return factory.CreateCapesRepository() return factory.CreateCapesRepository()
} }
func newMojangUUIDsRepository(container *di.Container) (mojangtextures.UuidsStorage, error) { func newMojangUUIDsRepository(factory *RedisFactory) (mojangtextures.UuidsStorage, error) {
var factory dbModule.RepositoriesCreator
err := container.Resolve(&factory, di.Name("redis"))
if err != nil {
return nil, err
}
return factory.CreateMojangUuidsRepository() return factory.CreateMojangUuidsRepository()
} }

View File

@@ -4,30 +4,17 @@ import "github.com/goava/di"
func New() (*di.Container, error) { func New() (*di.Container, error) {
container, err := di.New( container, err := di.New(
di.WithCompile(),
config, config,
dispatcher, dispatcher,
logger, logger,
db, db,
mojangTextures, mojangTextures,
handlers,
server,
) )
if err != nil { if err != nil {
return nil, err 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 return container, nil
} }

View File

@@ -2,8 +2,10 @@ package di
import ( import (
"github.com/goava/di" "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/http"
"github.com/elyby/chrly/mojangtextures" "github.com/elyby/chrly/mojangtextures"
) )
@@ -12,9 +14,21 @@ var dispatcher = di.Options(
di.Provide(newDispatcher, di.Provide(newDispatcher,
di.As(new(http.Emitter)), di.As(new(http.Emitter)),
di.As(new(mojangtextures.Emitter)), di.As(new(mojangtextures.Emitter)),
di.As(new(eventsubscribers.Subscriber)),
), ),
di.Invoke(enableEventsHandlers),
) )
func newDispatcher() dispatcherModule.EventDispatcher { func newDispatcher() d.EventDispatcher {
return dispatcherModule.New() 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)
} }

View File

@@ -1,41 +1,161 @@
package di package di
import ( import (
"net/http"
"strings"
"github.com/etherlabsio/healthcheck"
"github.com/goava/di" "github.com/goava/di"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/elyby/chrly/http" . "github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
) )
var handlers = di.Options( var handlers = di.Options(
di.Provide(newHandlerFactory, di.As(new(http.Handler))),
di.Provide(newSkinsystemHandler, di.WithName("skinsystem")), 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( func newSkinsystemHandler(
config *viper.Viper, config *viper.Viper,
emitter http.Emitter, emitter Emitter,
skinsRepository http.SkinsRepository, skinsRepository SkinsRepository,
capesRepository http.CapesRepository, capesRepository CapesRepository,
mojangTexturesProvider http.MojangTexturesProvider, mojangTexturesProvider MojangTexturesProvider,
) *mux.Router { ) *mux.Router {
handlerFactory := &http.Skinsystem{ return (&Skinsystem{
Emitter: emitter, Emitter: emitter,
SkinsRepo: skinsRepository, SkinsRepo: skinsRepository,
CapesRepo: capesRepository, CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider, MojangTexturesProvider: mojangTexturesProvider,
TexturesExtraParamName: config.GetString("textures.extra_param_name"), TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"), TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
} }).Handler()
return handlerFactory.CreateHandler()
} }
// TODO: pin implementation to make it non-configurable func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router {
func newUUIDsWorkerHandler(mojangUUIDsProvider http.MojangUuidsProvider) *mux.Router { return (&Api{
handlerFactory := &http.UUIDsWorker{ Emitter: emitter,
UUIDsProvider: mojangUUIDsProvider, 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
} }

View File

@@ -70,27 +70,26 @@ func newSentry(config *viper.Viper) (*raven.Client, error) {
} }
func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) { func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) {
dispatcher := &slf.Dispatcher{}
statsdAddr := config.GetString("statsd.addr") statsdAddr := config.GetString("statsd.addr")
if statsdAddr == "" { if statsdAddr == "" {
return nil, nil hostname, err := os.Hostname()
} if err != nil {
return nil, err
}
hostname, err := os.Hostname() statsdReceiver, err := statsd.NewReceiver(statsd.Config{
if err != nil { Address: statsdAddr,
return nil, err Prefix: "ely.skinsystem." + hostname + ".app.",
} FlushEvery: 1,
})
if err != nil {
return nil, err
}
statsdReceiver, err := statsd.NewReceiver(statsd.Config{ dispatcher.AddReceiver(statsdReceiver)
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
} }
dispatcher := &slf.Dispatcher{}
dispatcher.AddReceiver(statsdReceiver)
return wd.Custom("", "", dispatcher), nil return wd.Custom("", "", dispatcher), nil
} }

View File

@@ -3,10 +3,12 @@ package di
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"time"
"github.com/goava/di" "github.com/goava/di"
"github.com/spf13/viper" "github.com/spf13/viper"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http" "github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures" "github.com/elyby/chrly/mojangtextures"
) )
@@ -14,7 +16,9 @@ import (
var mojangTextures = di.Options( var mojangTextures = di.Options(
di.Provide(newMojangTexturesProviderFactory), di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider), di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProvider), di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider), di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory), di.Provide(newMojangTexturesStorageFactory),
) )
@@ -23,6 +27,7 @@ func newMojangTexturesProviderFactory(
container *di.Container, container *di.Container,
config *viper.Viper, config *viper.Viper,
) (http.MojangTexturesProvider, error) { ) (http.MojangTexturesProvider, error) {
config.SetDefault("mojang_textures.enabled", true)
if !config.GetBool("mojang_textures.enabled") { if !config.GetBool("mojang_textures.enabled") {
return &mojangtextures.NilProvider{}, nil return &mojangtextures.NilProvider{}, nil
} }
@@ -50,23 +55,61 @@ func newMojangTexturesProvider(
} }
} }
func newMojangTexturesUuidsProvider( func newMojangTexturesUuidsProviderFactory(
config *viper.Viper, config *viper.Viper,
emitter mojangtextures.Emitter, container *di.Container,
) (mojangtextures.UUIDsProvider, error) { ) (mojangtextures.UUIDsProvider, error) {
preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver") preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver")
if preferredUuidsProvider == "remote" { if preferredUuidsProvider == "remote" {
remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url")) var provider *mojangtextures.RemoteApiUuidsProvider
if err != nil { err := container.Resolve(&provider)
return nil, fmt.Errorf("Unable to parse remote url: %w", err)
}
return &mojangtextures.RemoteApiUuidsProvider{ return provider, err
Emitter: emitter,
Url: *remoteUrl,
}, nil
} }
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{ return &mojangtextures.BatchUuidsProvider{
Emitter: emitter, Emitter: emitter,
IterationDelay: config.GetDuration("queue.loop_delay"), IterationDelay: config.GetDuration("queue.loop_delay"),
@@ -74,6 +117,21 @@ func newMojangTexturesUuidsProvider(
}, nil }, 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 { func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider {
return &mojangtextures.MojangApiTexturesProvider{ return &mojangtextures.MojangApiTexturesProvider{
Emitter: emitter, Emitter: emitter,

47
di/server.go Normal file
View File

@@ -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
}

View File

@@ -2,6 +2,7 @@ package dispatcher
import "github.com/asaskevich/EventBus" import "github.com/asaskevich/EventBus"
// TODO: split on 2 interfaces and use them across the application
type EventDispatcher interface { type EventDispatcher interface {
Subscribe(topic string, fn interface{}) Subscribe(topic string, fn interface{})
Emit(topic string, args ...interface{}) Emit(topic string, args ...interface{})

View File

@@ -9,36 +9,36 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/api/mojang" "github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/bootstrap" "github.com/elyby/chrly/dispatcher"
) )
func TestMojangBatchUuidsProviderChecker(t *testing.T) { func TestMojangBatchUuidsProviderChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) { t.Run("empty state", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background())) assert.Nil(t, checker(context.Background()))
}) })
t.Run("when no error occurred", func(t *testing.T) { t.Run("when no error occurred", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil) d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
assert.Nil(t, checker(context.Background())) assert.Nil(t, checker(context.Background()))
}) })
t.Run("when error occurred", func(t *testing.T) { t.Run("when error occurred", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond) checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred") 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())) assert.Equal(t, err, checker(context.Background()))
}) })
t.Run("should reset value after passed duration", func(t *testing.T) { t.Run("should reset value after passed duration", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, 20*time.Millisecond) checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred") 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())) assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond) time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background())) assert.Nil(t, checker(context.Background()))
@@ -47,22 +47,22 @@ func TestMojangBatchUuidsProviderChecker(t *testing.T) {
func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) { func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) { t.Run("empty state", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
assert.Nil(t, checker(context.Background())) assert.Nil(t, checker(context.Background()))
}) })
t.Run("less than allowed limit", func(t *testing.T) { t.Run("less than allowed limit", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9) d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
assert.Nil(t, checker(context.Background())) assert.Nil(t, checker(context.Background()))
}) })
t.Run("greater than allowed limit", func(t *testing.T) { t.Run("greater than allowed limit", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher() d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10) checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10) d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
checkResult := checker(context.Background()) checkResult := checker(context.Background())
if assert.Error(t, checkResult) { if assert.Error(t, checkResult) {
assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error()) assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error())

209
http/api.go Normal file
View File

@@ -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
}

442
http/api_test.go Normal file
View File

@@ -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
}

View File

@@ -1,13 +1,18 @@
package http package http
import ( import (
"context"
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"os"
"os/signal"
"strings" "strings"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
) )
type Emitter interface { type Emitter interface {
@@ -31,6 +36,34 @@ func Serve(address string, handler http.Handler) error {
return server.Serve(listener) 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 { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int 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{ data, _ := json.Marshal(map[string]string{
"status": "404", "status": "404",
"message": "Not Found", "message": "Not Found",

View File

@@ -93,13 +93,13 @@ func TestCreateAuthenticationMiddleware(t *testing.T) {
}) })
} }
func TestNotFound(t *testing.T) { func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t) assert := testify.New(t)
req := httptest.NewRequest("GET", "http://example.com", nil) req := httptest.NewRequest("GET", "http://example.com", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
NotFound(w, req) NotFoundHandler(w, req)
resp := w.Result() resp := w.Result()
assert.Equal(404, resp.StatusCode) assert.Equal(404, resp.StatusCode)

View File

@@ -3,49 +3,16 @@ package http
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/api/mojang" "github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model" "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 { type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error) FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error) FindByUserId(id int) (*model.Skin, error)
@@ -58,6 +25,7 @@ type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error) FindByUsername(username string) (*model.Cape, error)
} }
// TODO: can I get rid of this?
type SkinNotFoundError struct { type SkinNotFoundError struct {
Who string Who string
} }
@@ -70,6 +38,7 @@ type CapeNotFoundError struct {
Who string Who string
} }
// TODO: can I get rid of this?
func (e CapeNotFoundError) Error() string { func (e CapeNotFoundError) Error() string {
return "cape file not found" return "cape file not found"
} }
@@ -80,42 +49,28 @@ type MojangTexturesProvider interface {
type Skinsystem struct { type Skinsystem struct {
Emitter Emitter
TexturesExtraParamName string
TexturesExtraParamValue string
SkinsRepo SkinsRepository SkinsRepo SkinsRepository
CapesRepo CapesRepository CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider MojangTexturesProvider MojangTexturesProvider
Authenticator Authenticator TexturesExtraParamName string
TexturesExtraParamValue string
} }
func (ctx *Skinsystem) CreateHandler() *mux.Router { func (ctx *Skinsystem) Handler() *mux.Router {
requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem")
router := mux.NewRouter().StrictSlash(true) router := mux.NewRouter().StrictSlash(true)
router.Use(requestEventsMiddleware)
router.HandleFunc("/skins/{username}", ctx.Skin).Methods(http.MethodGet) router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.Cape).Methods(http.MethodGet).Name("cloaks") router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.Textures).Methods(http.MethodGet) router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.SignedTextures).Methods(http.MethodGet) router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
// Legacy // Legacy
router.HandleFunc("/skins", ctx.SkinGET).Methods(http.MethodGet) router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.CapeGET).Methods(http.MethodGet) router.HandleFunc("/cloaks", ctx.capeGetHandler).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))
return router 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"]) username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindByUsername(username) rec, err := ctx.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 { 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) 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") username := request.URL.Query().Get("name")
if username == "" { if username == "" {
response.WriteHeader(http.StatusBadRequest) 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)["username"] = username
mux.Vars(request)["converted"] = "1" 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"]) username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindByUsername(username) rec, err := ctx.CapesRepo.FindByUsername(username)
if err == nil { if err == nil {
@@ -177,7 +132,7 @@ func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request)
http.Redirect(response, request, cape.Url, 301) 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") username := request.URL.Query().Get("name")
if username == "" { if username == "" {
response.WriteHeader(http.StatusBadRequest) 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)["username"] = username
mux.Vars(request)["converted"] = "1" 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"]) username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse var textures *mojang.TexturesResponse
@@ -233,6 +188,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
} }
textures = texturesProp.Textures textures = texturesProp.Textures
// TODO: return 204 in case when there is no skin and cape on mojang textures
} }
responseData, _ := json.Marshal(textures) responseData, _ := json.Marshal(textures)
@@ -240,7 +196,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
_, _ = response.Write(responseData) _, _ = 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"]) username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse var responseData *mojang.SignedTexturesResponse
@@ -280,158 +236,6 @@ func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *htt
_, _ = response.Write(responseJson) _, _ = 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 { func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png") return strings.TrimSuffix(username, ".png")
} }

View File

@@ -2,16 +2,11 @@ package http
import ( import (
"bytes" "bytes"
"encoding/base64"
"errors"
"image" "image"
"image/png" "image/png"
"io"
"io/ioutil" "io/ioutil"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"time" "time"
@@ -102,7 +97,6 @@ type skinsystemTestSuite struct {
SkinsRepository *skinsRepositoryMock SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock MojangTexturesProvider *mojangTexturesProviderMock
Auth *authCheckerMock
Emitter *emitterMock Emitter *emitterMock
} }
@@ -114,14 +108,12 @@ func (suite *skinsystemTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{} suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{} suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{} suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.Auth = &authCheckerMock{}
suite.Emitter = &emitterMock{} suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{ suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository, SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository, CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider, MojangTexturesProvider: suite.MojangTexturesProvider,
Authenticator: suite.Auth,
Emitter: suite.Emitter, Emitter: suite.Emitter,
} }
} }
@@ -130,7 +122,6 @@ func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T()) suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T()) suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T()) suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.Auth.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T()) suite.Emitter.AssertExpectations(suite.T())
} }
@@ -205,28 +196,24 @@ var skinsTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestSkin() { func (suite *skinsystemTestSuite) TestSkin() {
for _, testCase := range skinsTestsCases { for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })
} }
suite.RunSubTest("Pass username with png extension", func() { 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) suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
suite.Equal(301, resp.StatusCode) suite.Equal(301, resp.StatusCode)
@@ -237,27 +224,23 @@ func (suite *skinsystemTestSuite) TestSkin() {
func (suite *skinsystemTestSuite) TestSkinGET() { func (suite *skinsystemTestSuite) TestSkinGET() {
for _, testCase := range skinsTestsCases { for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })
} }
suite.RunSubTest("Do not pass name param", func() { 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) req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
suite.Equal(400, resp.StatusCode) suite.Equal(400, resp.StatusCode)
@@ -317,28 +300,24 @@ var capesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestCape() { func (suite *skinsystemTestSuite) TestCape() {
for _, testCase := range capesTestsCases { for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })
} }
suite.RunSubTest("Pass username with png extension", func() { 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) suite.CapesRepository.On("FindByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
suite.Equal(200, resp.StatusCode) suite.Equal(200, resp.StatusCode)
@@ -351,27 +330,23 @@ func (suite *skinsystemTestSuite) TestCape() {
func (suite *skinsystemTestSuite) TestCapeGET() { func (suite *skinsystemTestSuite) TestCapeGET() {
for _, testCase := range capesTestsCases { for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })
} }
suite.RunSubTest("Do not pass name param", func() { 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) req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
suite.Equal(400, resp.StatusCode) suite.Equal(400, resp.StatusCode)
@@ -494,14 +469,12 @@ var texturesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestTextures() { func (suite *skinsystemTestSuite) TestTextures() {
for _, testCase := range texturesTestsCases { for _, testCase := range texturesTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })
@@ -619,8 +592,6 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
func (suite *skinsystemTestSuite) TestSignedTextures() { func (suite *skinsystemTestSuite) TestSignedTextures() {
for _, testCase := range signedTexturesTestsCases { for _, testCase := range signedTexturesTestsCases {
suite.RunSubTest(testCase.Name, func() { 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) testCase.BeforeTest(suite)
var target string var target string
@@ -633,417 +604,13 @@ func (suite *skinsystemTestSuite) TestSignedTextures() {
req := httptest.NewRequest("GET", target, nil) req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) 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 * * Custom tests *
****************/ ****************/
@@ -1118,16 +685,3 @@ func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedText
return response 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
}

View File

@@ -14,29 +14,19 @@ type MojangUuidsProvider interface {
} }
type UUIDsWorker struct { type UUIDsWorker struct {
Emitter MojangUuidsProvider
UUIDsProvider MojangUuidsProvider
} }
func (ctx *UUIDsWorker) CreateHandler() *mux.Router { func (ctx *UUIDsWorker) Handler() *mux.Router {
requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem") // This prefix should be unified
router := mux.NewRouter().StrictSlash(true) router := mux.NewRouter().StrictSlash(true)
router.Use(requestEventsMiddleware) router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET")
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))
return router return router
} }
func (ctx *UUIDsWorker) GetUUID(response http.ResponseWriter, request *http.Request) { func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"]) username := mux.Vars(request)["username"]
profile, err := ctx.UUIDsProvider.GetUuid(username) profile, err := ctx.GetUuid(username)
if err != nil { if err != nil {
if _, ok := err.(*mojang.TooManyRequestsError); ok { if _, ok := err.(*mojang.TooManyRequestsError); ok {
response.WriteHeader(http.StatusTooManyRequests) response.WriteHeader(http.StatusTooManyRequests)

View File

@@ -37,7 +37,6 @@ type uuidsWorkerTestSuite struct {
App *UUIDsWorker App *UUIDsWorker
UuidsProvider *uuidsProviderMock UuidsProvider *uuidsProviderMock
Emitter *emitterMock
} }
/******************** /********************
@@ -46,17 +45,14 @@ type uuidsWorkerTestSuite struct {
func (suite *uuidsWorkerTestSuite) SetupTest() { func (suite *uuidsWorkerTestSuite) SetupTest() {
suite.UuidsProvider = &uuidsProviderMock{} suite.UuidsProvider = &uuidsProviderMock{}
suite.Emitter = &emitterMock{}
suite.App = &UUIDsWorker{ suite.App = &UUIDsWorker{
UUIDsProvider: suite.UuidsProvider, MojangUuidsProvider: suite.UuidsProvider,
Emitter: suite.Emitter,
} }
} }
func (suite *uuidsWorkerTestSuite) TearDownTest() { func (suite *uuidsWorkerTestSuite) TearDownTest() {
suite.UuidsProvider.AssertExpectations(suite.T()) suite.UuidsProvider.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
} }
func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) { func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) {
@@ -87,8 +83,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{ {
Name: "Success provider response", Name: "Success provider response",
BeforeTest: func(suite *uuidsWorkerTestSuite) { 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{ suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{
Id: "0fcc38620f1845f3a54e1b523c1bd1c7", Id: "0fcc38620f1845f3a54e1b523c1bd1c7",
Name: "mock_username", Name: "mock_username",
@@ -107,8 +101,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{ {
Name: "Receive empty response from UUIDs provider", Name: "Receive empty response from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) { 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) suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil)
}, },
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
@@ -120,8 +112,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{ {
Name: "Receive error from UUIDs provider", Name: "Receive error from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) { 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") err := errors.New("this is an error")
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
}, },
@@ -137,8 +127,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{ {
Name: "Receive Too Many Requests from UUIDs provider", Name: "Receive Too Many Requests from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) { 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{} err := &mojang.TooManyRequestsError{}
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
}, },
@@ -155,10 +143,10 @@ func (suite *uuidsWorkerTestSuite) TestGetUUID() {
suite.RunSubTest(testCase.Name, func() { suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite) 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() w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req) suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result()) testCase.AfterTest(suite, w.Result())
}) })