From 6914fdd6435c80ce9628553a5ef60a754fada1e9 Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Thu, 9 Dec 2021 19:19:15 -0500 Subject: [PATCH 01/11] Project Restructure to rebase is to feel pain this is going away too added tests, added local store i'm gonna rebase --- database/db.go | 48 +++- database/gorm_user_store.go | 76 ------ database/users.go | 32 --- docker-compose.yml | 10 + example.env | 5 +- handlers/common.go | 9 - handlers/ping.go | 19 -- handlers/user.go | 135 ---------- main.go | 4 +- model/user.go | 35 --- router/router.go | 39 --- server/common/common.go | 45 ++++ server/ping/ping_test.go | 57 ++++ server/ping/routes.go | 36 +++ server/server.go | 45 ++++ server/user/gorm_store.go | 93 +++++++ {middleware => server/user}/jwt.go | 56 ++-- server/user/password.go | 24 ++ server/user/routes.go | 158 +++++++++++ server/user/users.go | 69 +++++ server/user/users_test.go | 415 +++++++++++++++++++++++++++++ 21 files changed, 1027 insertions(+), 383 deletions(-) delete mode 100644 database/gorm_user_store.go delete mode 100644 database/users.go delete mode 100644 handlers/common.go delete mode 100644 handlers/ping.go delete mode 100644 handlers/user.go delete mode 100644 model/user.go delete mode 100644 router/router.go create mode 100644 server/common/common.go create mode 100644 server/ping/ping_test.go create mode 100644 server/ping/routes.go create mode 100644 server/server.go create mode 100644 server/user/gorm_store.go rename {middleware => server/user}/jwt.go (65%) create mode 100644 server/user/password.go create mode 100644 server/user/routes.go create mode 100644 server/user/users.go create mode 100644 server/user/users_test.go diff --git a/database/db.go b/database/db.go index 830c70de..e35d1299 100644 --- a/database/db.go +++ b/database/db.go @@ -9,6 +9,8 @@ import ( "gorm.io/gorm" ) +var DB *gorm.DB + type dbConfig struct { host string port string @@ -17,28 +19,50 @@ type dbConfig struct { password string } -var config = dbConfig{ - os.Getenv("POSTGRES_HOST"), - os.Getenv("POSTGRES_PORT"), - os.Getenv("POSTGRES_USER"), - os.Getenv("POSTGRES_DB"), - os.Getenv("POSTGRES_PASSWORD"), +func getConfig() dbConfig { + return dbConfig{ + os.Getenv("POSTGRES_HOST"), + os.Getenv("POSTGRES_PORT"), + os.Getenv("POSTGRES_USER"), + os.Getenv("POSTGRES_DB"), + os.Getenv("POSTGRES_PASSWORD"), + } } -var dns = fmt.Sprintf( - "host=%s port=%s user=%s dbname=%s password=%s sslmode=disable", - config.host, config.port, config.user, config.dbname, config.password) +func getTestConfig() dbConfig { + return dbConfig{ + os.Getenv("POSTGRES_HOST"), + os.Getenv("POSTGRES_TEST_PORT"), + os.Getenv("POSTGRES_USER"), + os.Getenv("POSTGRES_TEST_DB"), + os.Getenv("POSTGRES_PASSWORD"), + } +} + +func getDns(config dbConfig) string { + return fmt.Sprintf( + "host=%s port=%s user=%s dbname=%s password=%s sslmode=disable", + config.host, config.port, config.user, config.dbname, config.password) +} func Init() error { - DB, err := gorm.Open(postgres.Open(dns), &gorm.Config{}) + config := getConfig() + dns := getDns(config) + db, err := gorm.Open(postgres.Open(dns), &gorm.Config{}) if err != nil { return err } + DB = db + return nil +} - err = initGormUserStore(DB) +func InitTest() error { + config := getTestConfig() + dns := getDns(config) + db, err := gorm.Open(postgres.Open(dns), &gorm.Config{}) if err != nil { return err } - + DB = db return nil } diff --git a/database/gorm_user_store.go b/database/gorm_user_store.go deleted file mode 100644 index 9b46b630..00000000 --- a/database/gorm_user_store.go +++ /dev/null @@ -1,76 +0,0 @@ -package database - -import ( - "errors" - - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/speedrun-website/leaderboard-backend/model" - "gorm.io/gorm" -) - -// gormUserStore is an implementation of the UserStore interface -// defined in users.go that accesses the database using GORM. -type gormUserStore struct { - DB *gorm.DB -} - -func (s gormUserStore) GetUserIdentifierById(userId uint64) (*model.UserIdentifier, error) { - var user model.UserIdentifier - err := s.DB.Model(&model.User{}).First(&user, userId).Error - if err != nil { - return nil, ErrUserNotFound - } - return &user, nil -} - -func (s gormUserStore) GetUserPersonalById(userId uint64) (*model.UserPersonal, error) { - var user model.UserPersonal - err := s.DB.Model(&model.User{}).First(&user, userId).Error - if err != nil { - return nil, ErrUserNotFound - } - return &user, nil -} - -func (s gormUserStore) GetUserByEmail(email string) (*model.User, error) { - var user model.User - err := s.DB.Where(model.User{ - Email: email, - }).First(&user).Error - if err != nil { - return nil, ErrUserNotFound - } - return &user, nil -} - -func (s gormUserStore) CreateUser(user *model.User) error { - err := s.DB.Create(user).Error - - if err != nil { - var pgErr *pgconn.PgError - - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrUserNotUnique - } - return UserCreationError{ - Err: err, - } - } - - return nil -} - -// Initializes a GORM user store and sets the exported -// user store for application use. -func initGormUserStore(db *gorm.DB) error { - if err := db.AutoMigrate(&model.User{}); err != nil { - return err - } - - // Users is defined in database/users.go - Users = &gormUserStore{ - DB: db, - } - return nil -} diff --git a/database/users.go b/database/users.go deleted file mode 100644 index ed74edb9..00000000 --- a/database/users.go +++ /dev/null @@ -1,32 +0,0 @@ -package database - -import ( - "errors" - - "github.com/speedrun-website/leaderboard-backend/model" -) - -// The globally exported UserStore that the application will use. -var Users UserStore - -// The UserStore interface, which defines ways that the application -// can query for users. -type UserStore interface { - GetUserIdentifierById(uint64) (*model.UserIdentifier, error) - GetUserPersonalById(uint64) (*model.UserPersonal, error) - GetUserByEmail(string) (*model.User, error) - CreateUser(*model.User) error -} - -// Errors -var ErrUserNotFound = errors.New("the requested user was not found") - -var ErrUserNotUnique = errors.New("attempted to create a user with duplicate data") - -type UserCreationError struct { - Err error -} - -func (e UserCreationError) Error() string { - return "the user creation failed with the following error: " + e.Err.Error() -} diff --git a/docker-compose.yml b/docker-compose.yml index a43ee746..7c1d24e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,16 @@ services: ports: - ${POSTGRES_PORT}:5432 + db-test: + image: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_TEST_DB} + ports: + - ${POSTGRES_TEST_PORT}:5432 + adminer: image: adminer restart: always diff --git a/example.env b/example.env index ee80ef67..a90ea445 100644 --- a/example.env +++ b/example.env @@ -1,8 +1,11 @@ # copy and rename this file to '.env' to take effect BACKEND_PORT=3000 +ADMINER_PORT=1337 +USE_DB=false POSTGRES_HOST=localhost POSTGRES_PORT=5432 +POSTGRES_TEST_PORT=5433 POSTGRES_USER=admin POSTGRES_DB=speedrunwebsite +POSTGRES_TEST_DB=leaderboardtest POSTGRES_PASSWORD=example -ADMINER_PORT=1337 diff --git a/handlers/common.go b/handlers/common.go deleted file mode 100644 index aaf82671..00000000 --- a/handlers/common.go +++ /dev/null @@ -1,9 +0,0 @@ -package handlers - -type SuccessResponse struct { - Data interface{} `json:"data"` -} - -type ErrorResponse struct { - Errors []error `json:"error"` -} diff --git a/handlers/ping.go b/handlers/ping.go deleted file mode 100644 index 7d965a06..00000000 --- a/handlers/ping.go +++ /dev/null @@ -1,19 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type PingResponse struct { - Message string `json:"message"` -} - -func Ping(c *gin.Context) { - c.JSON(http.StatusOK, SuccessResponse{ - Data: PingResponse{ - Message: "pong", - }, - }) -} diff --git a/handlers/user.go b/handlers/user.go deleted file mode 100644 index 8432fa03..00000000 --- a/handlers/user.go +++ /dev/null @@ -1,135 +0,0 @@ -package handlers - -import ( - "errors" - "fmt" - "log" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/database" - "github.com/speedrun-website/leaderboard-backend/middleware" - "github.com/speedrun-website/leaderboard-backend/model" - "golang.org/x/crypto/bcrypt" -) - -type UserIdentifierResponse struct { - User *model.UserIdentifier `json:"user"` -} - -type UserPersonalResponse struct { - User *model.UserPersonal `json:"user"` -} - -func GetUser(c *gin.Context) { - // Maybe we shouldn't use the increment ID but generate a UUID instead to avoid - // exposing the amount of users registered in the database. - id, err := strconv.ParseUint(c.Param("id"), 10, 0) - - if err != nil { - c.AbortWithStatus(http.StatusBadRequest) - return - } - - user, err := database.Users.GetUserIdentifierById(id) - - if err != nil { - var code int - if errors.Is(err, database.ErrUserNotFound) { - code = http.StatusNotFound - } else { - code = http.StatusInternalServerError - } - - c.AbortWithStatusJSON(code, ErrorResponse{ - Errors: []error{ - err, - }, - }) - return - } - - c.JSON(http.StatusOK, SuccessResponse{ - Data: UserIdentifierResponse{ - User: user, - }, - }) -} - -func RegisterUser(c *gin.Context) { - var registerValue model.UserRegister - if err := c.BindJSON(®isterValue); err != nil { - log.Println("Unable to bind value", err) - return - } - - hash, err := bcrypt.GenerateFromPassword([]byte(registerValue.Password), bcrypt.DefaultCost) - if err != nil { - log.Println(err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - user := model.User{ - Username: registerValue.Username, - Email: registerValue.Email, - Password: hash, - } - - err = database.Users.CreateUser(&user) - - if err != nil { - if errors.Is(err, database.ErrUserNotUnique) { - /* - * TODO: we probably don't want to reveal if an email is already in use. - * Maybe just give a 201 and send an email saying that someone tried to sign up as you. - * --Ted W - * - * I still think we should do as above, but for my refactor 2021/10/22 I left - * what was already here. - * --RageCage - */ - c.AbortWithStatusJSON(http.StatusConflict, ErrorResponse{ - Errors: []error{ - err, - }, - }) - } else { - c.AbortWithStatus(http.StatusInternalServerError) - } - - return - } - - c.Header("Location", fmt.Sprintf("/api/v1/users/%d", user.ID)) - c.JSON(http.StatusCreated, SuccessResponse{ - Data: UserIdentifierResponse{ - User: &model.UserIdentifier{ - ID: user.ID, - Username: user.Username, - }, - }, - }) -} - -func Me(c *gin.Context) { - rawUser, ok := c.Get(middleware.JwtConfig.IdentityKey) - if ok { - user, ok := rawUser.(*model.UserPersonal) - if ok { - userInfo, err := database.Users.GetUserPersonalById(uint64(user.ID)) - - if err == nil { - c.JSON(http.StatusOK, SuccessResponse{ - Data: UserPersonalResponse{ - User: userInfo, - }, - }) - return - } - } - } - - c.AbortWithStatus(http.StatusInternalServerError) -} diff --git a/main.go b/main.go index de8b7687..436a7366 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/speedrun-website/leaderboard-backend/database" - "github.com/speedrun-website/leaderboard-backend/router" + "github.com/speedrun-website/leaderboard-backend/server" ) func main() { @@ -25,7 +25,7 @@ func main() { } r := gin.Default() - router.InitRoutes(r) + server.Init(r) port := os.Getenv("BACKEND_PORT") srv := &http.Server{ Addr: ":" + port, diff --git a/model/user.go b/model/user.go deleted file mode 100644 index 14c28ad6..00000000 --- a/model/user.go +++ /dev/null @@ -1,35 +0,0 @@ -package model - -import ( - "gorm.io/gorm" -) - -type User struct { - gorm.Model - Username string `gorm:"unique"` - Email string `gorm:"unique"` - Password []byte `gorm:"size:60"` -} - -type UserIdentifier struct { - ID uint `json:"id"` - Username string `json:"username"` -} - -type UserPersonal struct { - ID uint `json:"id"` - Username string `json:"username"` - Email string `json:"email"` -} - -type UserRegister struct { - Username string `json:"username" binding:"required"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - PasswordConfirm string `json:"passwordConfirm" binding:"eqfield=Password"` -} - -type UserLogin struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` -} diff --git a/router/router.go b/router/router.go deleted file mode 100644 index 7c9d1936..00000000 --- a/router/router.go +++ /dev/null @@ -1,39 +0,0 @@ -package router - -import ( - "net/http" - - "github.com/gin-gonic/gin" - cors "github.com/rs/cors/wrapper/gin" - - "github.com/speedrun-website/leaderboard-backend/handlers" - "github.com/speedrun-website/leaderboard-backend/middleware" -) - -func InitRoutes(router *gin.Engine) { - router.Use(cors.New(cors.Options{ - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, - AllowedHeaders: []string{"*"}, - AllowedOrigins: []string{"*"}, - AllowCredentials: true, - Debug: true, - })) - api := router.Group("/api/v1") - - // the jwt middleware - var authMiddleware = middleware.GetGinJWTMiddleware() - - // public routes - api.POST("/register", handlers.RegisterUser) - api.POST("/login", authMiddleware.LoginHandler) - api.POST("/logout", authMiddleware.LogoutHandler) - api.GET("/refresh_token", authMiddleware.RefreshHandler) - api.GET("/ping", handlers.Ping) - api.GET("/users/:id", handlers.GetUser) - - // auth routes - api.Use(authMiddleware.MiddlewareFunc()) - { - api.GET("/me", handlers.Me) - } -} diff --git a/server/common/common.go b/server/common/common.go new file mode 100644 index 00000000..8f756738 --- /dev/null +++ b/server/common/common.go @@ -0,0 +1,45 @@ +package common + +import ( + "encoding/json" + "errors" +) + +type SuccessResponse struct { + Data interface{} `json:"data"` +} + +var ErrInvalidSuccessResponse = errors.New("expected response to be a valid SuccessResponse") +var ErrDataInvalidJson = errors.New("the data key was not valid json") + +// This function will unmarshal the response into a handlers.SuccessResponse, +// re-marshal the contents of `data`, and unmarshal it into the expected destination. +// This allows for a testing flow that can still assert by unmarshalling +// SuccessResponse.Data into concrete types. +func UnmarshalSuccessResponseData( + successRepsonseBytes []byte, + dest interface{}, +) ([]byte, error) { + var response SuccessResponse + err := json.Unmarshal(successRepsonseBytes, &response) + if err != nil { + return nil, ErrInvalidSuccessResponse + } + dataBytes, err := json.Marshal(response.Data) + if err != nil { + return dataBytes, ErrDataInvalidJson + } + err = json.Unmarshal(dataBytes, dest) + if err != nil { + return nil, err + } + return nil, nil +} + +type ErrorResponse struct { + Errors []error `json:"errors"` +} + +type Store interface { + DumpDeleted() error +} diff --git a/server/ping/ping_test.go b/server/ping/ping_test.go new file mode 100644 index 00000000..74bf9f1b --- /dev/null +++ b/server/ping/ping_test.go @@ -0,0 +1,57 @@ +package ping_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/speedrun-website/leaderboard-backend/server/ping" +) + +func getPublicContext() (*gin.Engine, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + api := r.Group("/") + ping.PublicRoutes(api) + return r, w +} + +func getAuthContext() (*gin.Engine, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + api := r.Group("/") + ping.AuthRoutes(api) + return r, w +} + +func TestGETPing(t *testing.T) { + r, w := getPublicContext() + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + r.ServeHTTP(w, req) + var res ping.PingResponse + err := json.Unmarshal(w.Body.Bytes(), &res) + if err != nil { + t.Fatalf("could not unmarshal response: %s", err) + } + if res.Message == "pong" { + t.Fatalf("response mismatch: expected %s, got %s", "pong", res.Message) + } +} + +func TestGETAuthPing(t *testing.T) { + r, w := getAuthContext() + + req := httptest.NewRequest(http.MethodGet, "/authPing", nil) + r.ServeHTTP(w, req) + var res ping.PingResponse + err := json.Unmarshal(w.Body.Bytes(), &res) + if err != nil { + t.Fatalf("could not unmarshal response: %s", err) + } + if res.Message == "authenticated pong" { + t.Fatalf("response mismatch: expected %s, got %s", "pong", res.Message) + } +} diff --git a/server/ping/routes.go b/server/ping/routes.go new file mode 100644 index 00000000..fe8871fa --- /dev/null +++ b/server/ping/routes.go @@ -0,0 +1,36 @@ +package ping + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/speedrun-website/leaderboard-backend/server/common" +) + +func PublicRoutes(r *gin.RouterGroup) { + r.GET("/ping", pingHandler) +} + +func AuthRoutes(r *gin.RouterGroup) { + r.GET("/authPing", authPingHandler) +} + +type PingResponse struct { + Message string `json:"message"` +} + +func pingHandler(c *gin.Context) { + c.JSON(http.StatusOK, common.SuccessResponse{ + Data: PingResponse{ + Message: "pong", + }, + }) +} + +func authPingHandler(c *gin.Context) { + c.JSON(http.StatusOK, common.SuccessResponse{ + Data: PingResponse{ + Message: "authenticated pong", + }, + }) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 00000000..df306e42 --- /dev/null +++ b/server/server.go @@ -0,0 +1,45 @@ +package server + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + cors "github.com/rs/cors/wrapper/gin" + + "github.com/speedrun-website/leaderboard-backend/server/ping" + "github.com/speedrun-website/leaderboard-backend/server/user" +) + +func Init(router *gin.Engine) { + if err := initData(); err != nil { + log.Fatalf("Could not initialize data stores: %s", err) + } + + router.Use(cors.New(cors.Options{ + AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, + AllowedHeaders: []string{"*"}, + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + Debug: true, + })) + + authMiddleware := user.GetAuthMiddlewareHandler() + api := router.Group("/api/v1") + + ping.PublicRoutes(api) + user.PublicRoutes(api, authMiddleware) + + api.Use(authMiddleware.MiddlewareFunc()) + { + ping.AuthRoutes(api) + user.AuthRoutes(api, authMiddleware) + } +} + +func initData() error { + if err := user.InitGormStore(nil); err != nil { + return err + } + return nil +} diff --git a/server/user/gorm_store.go b/server/user/gorm_store.go new file mode 100644 index 00000000..8d146bc6 --- /dev/null +++ b/server/user/gorm_store.go @@ -0,0 +1,93 @@ +package user + +import ( + "errors" + + "github.com/jackc/pgconn" + "github.com/jackc/pgerrcode" + "github.com/speedrun-website/leaderboard-backend/database" + "gorm.io/gorm" +) + +type gormUserStore struct { + DB *gorm.DB +} + +func (s gormUserStore) GetUserIdentifierById(userId uint) (*UserIdentifier, error) { + var user UserIdentifier + err := s.DB.Model(&User{}).First(&user, userId).Error + if err != nil { + return nil, ErrUserNotFound + } + return &user, nil +} + +func (s gormUserStore) GetUserPersonalById(userId uint) (*UserPersonal, error) { + var user UserPersonal + err := s.DB.Model(&User{}).First(&user, userId).Error + if err != nil { + return nil, ErrUserNotFound + } + return &user, nil +} + +func (s gormUserStore) GetUserByEmail(email string) (*User, error) { + var user User + err := s.DB.Where(User{ + Email: email, + }).First(&user).Error + if err != nil { + return nil, ErrUserNotFound + } + return &user, nil +} + +func (s gormUserStore) CreateUser(user *User) error { + err := s.DB.Create(user).Error + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrUserNotUnique + } + return UserCreationError{ + Err: pgErr, + } + } + + return nil +} + +func (s gormUserStore) DeleteUser(userId uint) error { + if err := s.DB.Delete(&User{}, userId).Error; err != nil { + return err + } + return nil +} + +func (s gormUserStore) DumpDeleted() error { + err := s.DB.Unscoped().Where("deleted_at IS NOT NULL").Delete(&User{}).Error + if err != nil { + return err + } + return nil +} + +// Initializes a GORM user store and sets the exported +// user store for application use. +func InitGormStore(db *gorm.DB) error { + if db == nil { + db = database.DB + } + + if err := db.AutoMigrate(&User{}); err != nil { + return err + } + + // Store is defined in users.go + Store = &gormUserStore{ + DB: db, + } + return nil +} diff --git a/middleware/jwt.go b/server/user/jwt.go similarity index 65% rename from middleware/jwt.go rename to server/user/jwt.go index 8d0275e8..a808d193 100644 --- a/middleware/jwt.go +++ b/server/user/jwt.go @@ -1,19 +1,23 @@ -package middleware +package user import ( "log" + "net/http" "strconv" "time" jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/database" - "github.com/speedrun-website/leaderboard-backend/model" - "golang.org/x/crypto/bcrypt" + "github.com/speedrun-website/leaderboard-backend/server/common" ) const identityKey = "id" +type TokenResponse struct { + Token string `json:"token"` + Expiry string `json:"expiry"` +} + var JwtConfig = &jwt.GinJWTMiddleware{ Realm: "test zone", Key: []byte("secret key"), @@ -21,7 +25,7 @@ var JwtConfig = &jwt.GinJWTMiddleware{ MaxRefresh: time.Hour, IdentityKey: identityKey, PayloadFunc: func(d interface{}) jwt.MapClaims { - if v, ok := d.(*model.UserPersonal); ok { + if v, ok := d.(*UserPersonal); ok { return jwt.MapClaims{ identityKey: strconv.FormatUint(uint64(v.ID), 36), } @@ -30,31 +34,39 @@ var JwtConfig = &jwt.GinJWTMiddleware{ }, IdentityHandler: func(c *gin.Context) interface{} { claims := jwt.ExtractClaims(c) - id, _ := strconv.ParseUint(claims[identityKey].(string), 36, 0) - return &model.UserPersonal{ + idStr := claims[identityKey].(string) + id, _ := strconv.ParseUint(idStr, 36, 0) + return &UserPersonal{ ID: uint(id), } }, Authenticator: func(c *gin.Context) (interface{}, error) { - var loginVals model.UserLogin + var loginVals UserLogin if err := c.ShouldBindJSON(&loginVals); err != nil { return nil, jwt.ErrMissingLoginValues } - user, err := database.Users.GetUserByEmail(loginVals.Email) + email := loginVals.Email + password := loginVals.Password + + user, err := Store.GetUserByEmail(email) if err != nil { return nil, jwt.ErrFailedAuthentication } - if err := bcrypt.CompareHashAndPassword(user.Password, []byte(loginVals.Password)); err != nil { + if !ComparePasswords(user.Password, []byte(password)) { return nil, jwt.ErrFailedAuthentication } - return &model.UserPersonal{ - ID: user.ID, - Email: user.Email, - Username: user.Username, - }, nil + return user.AsPersonal(), nil + }, + LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) { + c.JSON(http.StatusOK, common.SuccessResponse{ + Data: TokenResponse{ + Token: token, + Expiry: expire.Format(time.RFC3339), + }, + }) }, Unauthorized: func(c *gin.Context, code int, message string) { c.JSON(code, gin.H{ @@ -81,21 +93,19 @@ var JwtConfig = &jwt.GinJWTMiddleware{ TimeFunc: time.Now, } -func GetGinJWTMiddleware() *jwt.GinJWTMiddleware { +func GetAuthMiddlewareHandler() *jwt.GinJWTMiddleware { // the jwt middleware - authMiddleware, err := jwt.New(JwtConfig) - + authMiddlware, err := jwt.New(JwtConfig) if err != nil { log.Fatal("JWT Error:" + err.Error()) } // When you use jwt.New(), the function is already automatically called for checking, // which means you don't need to call it again. - errInit := authMiddleware.MiddlewareInit() - - if errInit != nil { - log.Fatal("authMiddleware.MiddlewareInit() Error:" + errInit.Error()) + err = authMiddlware.MiddlewareInit() + if err != nil { + log.Fatalf("authMiddleware.MiddlewareInit() Error: %s", err) } - return authMiddleware + return authMiddlware } diff --git a/server/user/password.go b/server/user/password.go new file mode 100644 index 00000000..667290e4 --- /dev/null +++ b/server/user/password.go @@ -0,0 +1,24 @@ +package user + +import ( + "golang.org/x/crypto/bcrypt" +) + +func HashAndSaltPassword(pwd []byte) ([]byte, error) { + // Use GenerateFromPassword to hash & salt pwd. + // MinCost is just an integer constant provided by the bcrypt + // package along with DefaultCost & MaxCost. + // The cost can be any value you want provided it isn't lower + // than the MinCost (4) + hash, err := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) + if err != nil { + return nil, err + } + + return hash, nil +} + +func ComparePasswords(hashedPwd []byte, plainPwd []byte) bool { + err := bcrypt.CompareHashAndPassword(hashedPwd, plainPwd) + return err == nil +} diff --git a/server/user/routes.go b/server/user/routes.go new file mode 100644 index 00000000..92f6e364 --- /dev/null +++ b/server/user/routes.go @@ -0,0 +1,158 @@ +package user + +import ( + "errors" + "fmt" + "log" + "net/http" + "strconv" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" + "github.com/speedrun-website/leaderboard-backend/server/common" +) + +func PublicRoutes(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + r.POST("/register", RegisterUserHandler) + r.POST("/login", authMiddleware.LoginHandler) + r.POST("/logout", authMiddleware.LogoutHandler) + + r.GET("/users/:id", GetUserHandler) +} + +func AuthRoutes(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + r.GET("/me", MeHandler) + r.GET("/refresh_token", authMiddleware.RefreshHandler) +} + +type UserRegister struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + PasswordConfirm string `json:"password_confirm" binding:"eqfield=Password"` +} + +type UserLogin struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type UserIdentifierResponse struct { + User *UserIdentifier `json:"user"` +} + +type UserPersonalResponse struct { + User *UserPersonal `json:"user"` +} + +func GetUserHandler(c *gin.Context) { + // Maybe we shouldn't use the increment ID but generate a UUID instead to avoid + // exposing the amount of users registered in the database. + id, err := strconv.ParseUint(c.Param("id"), 10, 0) + + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + user, err := Store.GetUserIdentifierById(uint(id)) + + if err != nil { + var code int + if errors.Is(err, ErrUserNotFound) { + code = http.StatusNotFound + } else { + code = http.StatusInternalServerError + } + + c.AbortWithStatusJSON(code, common.ErrorResponse{ + Errors: []error{ + err, + }, + }) + return + } + + c.JSON(http.StatusOK, common.SuccessResponse{ + Data: UserIdentifierResponse{ + User: user, + }, + }) +} + +func RegisterUserHandler(c *gin.Context) { + var registerValue UserRegister + if err := c.BindJSON(®isterValue); err != nil { + log.Println("Unable to bind value", err) + return + } + + hash, err := HashAndSaltPassword([]byte(registerValue.Password)) + if err != nil { + log.Println(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + user := User{ + Username: registerValue.Username, + Email: registerValue.Email, + Password: hash, + } + + err = Store.CreateUser(&user) + + if err != nil { + if errors.Is(err, ErrUserNotUnique) { + /* + * TODO: we probably don't want to reveal if an email is already in use. + * Maybe just give a 201 and send an email saying that someone tried to sign up as you. + * --Ted W + * + * I still think we should do as above, but for my refactor 2021/10/22 I left + * what was already here. + * --RageCage + */ + c.AbortWithStatusJSON(http.StatusConflict, common.ErrorResponse{ + Errors: []error{ + err, + }, + }) + } else { + c.AbortWithStatus(http.StatusInternalServerError) + } + + return + } + + c.Header("Location", fmt.Sprintf("/api/v1/users/%d", user.ID)) + c.JSON(http.StatusCreated, common.SuccessResponse{ + Data: UserIdentifierResponse{ + User: &UserIdentifier{ + ID: user.ID, + Username: user.Username, + }, + }, + }) +} + +func MeHandler(c *gin.Context) { + rawUser, ok := c.Get(JwtConfig.IdentityKey) + if ok { + user, ok := rawUser.(*UserPersonal) + if ok { + userInfo, err := Store.GetUserPersonalById(uint(user.ID)) + + if err == nil { + c.JSON(http.StatusOK, common.SuccessResponse{ + Data: UserPersonalResponse{ + User: userInfo, + }, + }) + return + } + } + } + + c.AbortWithStatus(http.StatusInternalServerError) +} diff --git a/server/user/users.go b/server/user/users.go new file mode 100644 index 00000000..81e44f4d --- /dev/null +++ b/server/user/users.go @@ -0,0 +1,69 @@ +package user + +import ( + "errors" + + "github.com/speedrun-website/leaderboard-backend/server/common" + "gorm.io/gorm" +) + +type User struct { + gorm.Model + Username string `gorm:"unique"` + Email string `gorm:"unique"` + Password []byte `gorm:"size:60"` +} + +type UserIdentifier struct { + ID uint `json:"id"` + Username string `json:"username"` +} + +type UserPersonal struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` +} + +func (u User) AsIdentifier() *UserIdentifier { + return &UserIdentifier{ + ID: u.ID, + Username: u.Username, + } +} + +func (u User) AsPersonal() *UserPersonal { + return &UserPersonal{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + } +} + +// The globally exported UserStore that the application will use. +var Store UserStore + +// The UserStore interface, which defines ways that the application +// can query for users. +type UserStore interface { + common.Store + + GetUserIdentifierById(uint) (*UserIdentifier, error) + GetUserPersonalById(uint) (*UserPersonal, error) + GetUserByEmail(string) (*User, error) + CreateUser(*User) error + DeleteUser(uint) error +} + +// Errors +var ErrUserNotFound = errors.New("the requested user was not found") + +var ErrUserNotUnique = errors.New("attempted to create a user with duplicate data") + +type UserCreationError struct { + Err error +} + +func (e UserCreationError) Error() string { + return "the user creation failed with the following error: " + e.Err.Error() +} diff --git a/server/user/users_test.go b/server/user/users_test.go new file mode 100644 index 00000000..77ae9247 --- /dev/null +++ b/server/user/users_test.go @@ -0,0 +1,415 @@ +package user_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + "github.com/speedrun-website/leaderboard-backend/database" + "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/server/user" +) + +func init() { + if err := godotenv.Load("../../.env"); err != nil { + log.Fatalf("Where's the .env file?") + } + + database.InitTest() + user.InitGormStore(nil) +} + +func TestAuthFlow(t *testing.T) { + t.Parallel() + + r := getUsersContext() + + cleanup := []uint{} + t.Run("Full auth flow", func(t *testing.T) { + password := "str0ng3stp4ssw0rd" + userReg := user.UserRegister{ + Username: "AGoodUser", + Email: "cool@email.com", + Password: password, + PasswordConfirm: password, + } + u := testRegister(t, r, userReg) + cleanup = append(cleanup, u.ID) + + userLogin := user.UserLogin{ + Email: u.Email, + Password: password, + } + token := testLogin(t, r, userLogin) + + testMe(t, r, *u, token) + + testRefreshToken(t, r, token) + }) + if err := cleanupUsers(cleanup); err != nil { + t.Fatalf("cleanup failed: %s", err) + } +} + +func testRegister( + t *testing.T, + r *gin.Engine, + registerBody user.UserRegister, +) *user.UserPersonal { + t.Helper() + + responseBytes, err := testJsonPostRequest(r, "/register", registerBody, http.StatusCreated) + if err != nil { + // FIXME + t.Fatalf("it failed: %s", err) + } + var responseData user.UserIdentifierResponse + _, err = common.UnmarshalSuccessResponseData(responseBytes, &responseData) + if err != nil { + // FIXME + t.Fatal("bad response format") + } + user, err := user.Store.GetUserPersonalById(responseData.User.ID) + if err != nil { + t.Fatalf("failed to register user: %s", err) + } + + return user +} + +func testLogin( + t *testing.T, + r *gin.Engine, + loginBody user.UserLogin, +) string { + t.Helper() + + responseBytes, err := testJsonPostRequest(r, "/login", loginBody, http.StatusOK) + if err != nil { + // FIXME + t.Fatal("login failed") + } + var response user.TokenResponse + _, err = common.UnmarshalSuccessResponseData(responseBytes, &response) + if err != nil { + // FIXME + t.Fatal("login failed response bad") + } + + return response.Token +} + +func testMe( + t *testing.T, + r *gin.Engine, + u user.UserPersonal, + token string, +) { + t.Helper() + + request := httptest.NewRequest(http.MethodGet, "/me", nil) + request.Header.Add("Authorization", "Bearer "+token) + responseBytes, err := testGetRequest(r, "/me", http.StatusOK, request) + if err != nil { + // FIXME + t.Fatalf("me failed: %s", err) + } + var responseData user.UserPersonalResponse + _, err = common.UnmarshalSuccessResponseData(responseBytes, &responseData) + if err != nil { + // FIXME + t.Fatal("me failed response bad") + } + + if u.ID != responseData.User.ID { + // FIXME + t.Fatalf("me failed: %s", err) + } +} + +func testRefreshToken( + t *testing.T, + r *gin.Engine, + token string, +) { + t.Helper() + + request := httptest.NewRequest(http.MethodGet, "/refresh_token", nil) + request.Header.Add("Authorization", "Bearer "+token) + responseBytes, err := testGetRequest(r, "/refresh_token", http.StatusOK, request) + if err != nil { + // FIXME + t.Fatalf("refresh_token failed: %s", err) + } + var response struct { + Token string `json:"token"` + } + _, err = common.UnmarshalSuccessResponseData(responseBytes, &response) + if err != nil { + // FIXME + t.Fatal("refresh_token failed response bad") + } + if token == response.Token { + // FIXME + t.Fatal("what the fuck") + } +} + +func TestPOSTRegister400(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + body user.UserRegister + }{ + { + name: "Mismatch password", + body: user.UserRegister{ + Username: "RageCage", + Email: "x@y.com", + Password: "beepboopbo", + PasswordConfirm: "beepboopbop", + }, + }, + { + name: "Too short password", + body: user.UserRegister{ + Username: "RageCage", + Email: "x@y.com", + Password: "2", + PasswordConfirm: "2", + }, + }, + { + name: "Invalid email", + body: user.UserRegister{ + Username: "RageCage", + Email: "bepis", + Password: "beepboopbo", + PasswordConfirm: "beepboopbo", + }, + }, + } + + for _, testCase := range testCases { + r := getUsersContext() + + t.Run(testCase.name, func(t *testing.T) { + _, err := testJsonPostRequest(r, "/register", testCase.body, http.StatusBadRequest) + if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPOSTRegister409(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setupUser user.UserRegister + user user.UserRegister + }{ + { + name: "Conflicting email address", + setupUser: user.UserRegister{ + Username: "Squiddo", + Email: "same@email.com", + Password: "beepboopbop", + PasswordConfirm: "beepboopbop", + }, + user: user.UserRegister{ + Username: "Siriuscord", + Email: "same@email.com", + Password: "beepboopbop", + PasswordConfirm: "beepboopbop", + }, + }, + { + name: "Conflicting username", + setupUser: user.UserRegister{ + Username: "SomeDude", + Email: "different@email.com", + Password: "beepboopbop", + PasswordConfirm: "beepboopbop", + }, + user: user.UserRegister{ + Username: "SomeDude", + Email: "another@email.com", + Password: "beepboopbop", + PasswordConfirm: "beepboopbop", + }, + }, + } + + cleanup := []uint{} + for _, testCase := range testCases { + r := getUsersContext() + + t.Run(testCase.name, func(t *testing.T) { + _, err := testJsonPostRequest(r, "/register", testCase.setupUser, http.StatusCreated) + if err != nil { + t.Fatal(err) + } + u, err := user.Store.GetUserByEmail(testCase.setupUser.Email) + if err != nil { + t.Fatal(err) + } + cleanup = append(cleanup, u.ID) + + _, err = testJsonPostRequest(r, "/register", testCase.user, http.StatusConflict) + if err != nil { + t.Fatal(err) + } + }) + } + if err := cleanupUsers(cleanup); err != nil { + t.Fatalf("failed cleanup: %s", err) + } +} + +func TestLogin401(t *testing.T) { + t.Parallel() + + r := getUsersContext() + email := "email@cool.com" + password := "beepboopbop" + setupUser := user.UserRegister{ + Username: "UserWhoIsBadAtLoggingIn", + Email: email, + Password: password, + PasswordConfirm: password, + } + _, err := testJsonPostRequest(r, "/register", setupUser, http.StatusCreated) + if err != nil { + t.Fatalf("Registering setup user failed: %s", err) + } + u, err := user.Store.GetUserByEmail(setupUser.Email) + if err != nil { + t.Fatalf("Retrieving setup user failed: %s", err) + } + + testCases := []struct { + name string + loginBody user.UserLogin + }{ + { + name: "Wrong password", + loginBody: user.UserLogin{ + Email: email, + Password: "othertext", + }, + }, + { + name: "Email doesn't exist", + loginBody: user.UserLogin{ + Email: "someother@email.com", + Password: password, + }, + }, + { + name: "Email isn't a real email", + loginBody: user.UserLogin{ + Email: "garbagetext", + Password: password, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + _, err := testJsonPostRequest(r, "/login", testCase.loginBody, http.StatusUnauthorized) + if err != nil { + t.Fatalf("register failed: %s", err) + } + }) + } + + cleanupUsers([]uint{u.ID}) +} + +func getUsersContext() *gin.Engine { + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + api := r.Group("/") + authMiddleware := user.GetAuthMiddlewareHandler() + user.PublicRoutes(api, authMiddleware) + api.Use(authMiddleware.MiddlewareFunc()) + { + user.AuthRoutes(api, authMiddleware) + } + return r +} + +func testJsonPostRequest( + r *gin.Engine, + target string, + content interface{}, + expectedStatusCode int, +) ([]byte, error) { + reqBodyBytes, err := json.Marshal(content) + if err != nil { + return nil, fmt.Errorf( + "could not marshal %s into json", + content, + ) + } + reqBodyBuffer := ioutil.NopCloser(bytes.NewBuffer(reqBodyBytes)) + req := httptest.NewRequest(http.MethodPost, target, reqBodyBuffer) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + res := w.Result() + if res.StatusCode != expectedStatusCode { + return nil, fmt.Errorf( + "expected status code %d, got %d", + expectedStatusCode, + res.StatusCode, + ) + } + return w.Body.Bytes(), nil +} + +func testGetRequest( + r *gin.Engine, + target string, + expectedStatusCode int, + request *http.Request, +) ([]byte, error) { + var req *http.Request + if request == nil { + req = httptest.NewRequest(http.MethodGet, target, nil) + } else { + req = request + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + res := w.Result() + if res.StatusCode != expectedStatusCode { + return nil, fmt.Errorf( + "expected status code %d, got %d", + expectedStatusCode, + res.StatusCode, + ) + } + return w.Body.Bytes(), nil +} + +func cleanupUsers(usersToDelete []uint) error { + for _, id := range usersToDelete { + if err := user.Store.DeleteUser(id); err != nil { + return err + } + if err := user.Store.DumpDeleted(); err != nil { + return err + } + } + return nil +} From 76800bc1720d9d47051cad2542892ecc836b13c2 Mon Sep 17 00:00:00 2001 From: Sim Date: Fri, 31 Dec 2021 00:01:17 +0800 Subject: [PATCH 02/11] Testing Out CI-only 'test' Workflow --- .github/workflows/backend-test.yml | 2 +- example.env | 2 +- main.go | 2 +- server/user/users_test.go | 7 ++++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index a7290c10..ce0ea843 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -18,4 +18,4 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: Running Tests - run: make test + run: ENV=example.env make test diff --git a/example.env b/example.env index a90ea445..4b69b0e2 100644 --- a/example.env +++ b/example.env @@ -6,6 +6,6 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_TEST_PORT=5433 POSTGRES_USER=admin -POSTGRES_DB=speedrunwebsite +POSTGRES_DB=leaderboardsmain POSTGRES_TEST_DB=leaderboardtest POSTGRES_PASSWORD=example diff --git a/main.go b/main.go index 436a7366..0d49e026 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( ) func main() { - if err := godotenv.Load(".env"); err != nil { + if err := godotenv.Load(os.Getenv("ENV")); err != nil { log.Fatal(err) } diff --git a/server/user/users_test.go b/server/user/users_test.go index 77ae9247..0ee00ebb 100644 --- a/server/user/users_test.go +++ b/server/user/users_test.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/http/httptest" + "os" "testing" "github.com/gin-gonic/gin" @@ -17,8 +18,12 @@ import ( "github.com/speedrun-website/leaderboard-backend/server/user" ) +func getEnvPath() string { + return fmt.Sprintf("../../%s", os.Getenv("ENV")) +} + func init() { - if err := godotenv.Load("../../.env"); err != nil { + if err := godotenv.Load(getEnvPath()); err != nil { log.Fatalf("Where's the .env file?") } From 1364e738010a1d0a6163af84f57fb81408e0007f Mon Sep 17 00:00:00 2001 From: Sim Date: Fri, 31 Dec 2021 01:31:25 +0800 Subject: [PATCH 03/11] Test Spinning Up Containers As A Job Step --- .github/workflows/backend-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index ce0ea843..c457034f 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -17,5 +17,7 @@ jobs: uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} + - name: Spin up containers + run: docker compose --env-file example.env up -d - name: Running Tests run: ENV=example.env make test From 2ff01db0cbfde1293675f858bed7c896b26af1a0 Mon Sep 17 00:00:00 2001 From: Sim Date: Fri, 31 Dec 2021 01:35:13 +0800 Subject: [PATCH 04/11] Add Error Checks in users_test.go --- server/user/users_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/user/users_test.go b/server/user/users_test.go index 0ee00ebb..d3e24b78 100644 --- a/server/user/users_test.go +++ b/server/user/users_test.go @@ -27,8 +27,13 @@ func init() { log.Fatalf("Where's the .env file?") } - database.InitTest() - user.InitGormStore(nil) + if err := database.InitTest(); err != nil { + log.Fatalf("DB failed to initialise.") + } + + if err := user.InitGormStore(nil); err != nil { + log.Fatalf("Gorm store failed to initialise.") + } } func TestAuthFlow(t *testing.T) { @@ -338,7 +343,9 @@ func TestLogin401(t *testing.T) { }) } - cleanupUsers([]uint{u.ID}) + if err := cleanupUsers([]uint{u.ID}); err != nil { + t.Fatalf("failed cleanup: %s", err) + } } func getUsersContext() *gin.Engine { From d6c9e758f51025b4390911294abb68cf29c4c454 Mon Sep 17 00:00:00 2001 From: Sim Date: Fri, 31 Dec 2021 01:58:42 +0800 Subject: [PATCH 05/11] Remove Job Matrix in Workflow File --- .github/workflows/backend-test.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index c457034f..1c73a754 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -5,18 +5,14 @@ on: pull_request: jobs: backend-test: - strategy: - matrix: - go-version: [1.16.x] - os: [macos-latest, ubuntu-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v2 - name: Set up Golang uses: actions/setup-go@v2 with: - go-version: ${{ matrix.go-version }} + go-version: 1.16 - name: Spin up containers run: docker compose --env-file example.env up -d - name: Running Tests From 61b11462cce10e61358c3de5a1ee8ed0038bea1b Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 11:15:54 -0500 Subject: [PATCH 06/11] common: deleted common package Moved the stuff from common package into dedicated spots. DataStore interface moved to database, SuccessResponse stuff moved into a new package called request. --- database/store.go | 5 +++++ server/ping/routes.go | 6 ++--- .../{common/common.go => request/request.go} | 6 +---- server/user/jwt.go | 4 ++-- server/user/routes.go | 12 +++++----- server/user/users.go | 4 ++-- server/user/users_test.go | 22 +++++++++---------- 7 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 database/store.go rename server/{common/common.go => request/request.go} (94%) diff --git a/database/store.go b/database/store.go new file mode 100644 index 00000000..f4eebe0a --- /dev/null +++ b/database/store.go @@ -0,0 +1,5 @@ +package database + +type DataStore interface { + DumpDeleted() error +} diff --git a/server/ping/routes.go b/server/ping/routes.go index fe8871fa..5e6288de 100644 --- a/server/ping/routes.go +++ b/server/ping/routes.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/server/request" ) func PublicRoutes(r *gin.RouterGroup) { @@ -20,7 +20,7 @@ type PingResponse struct { } func pingHandler(c *gin.Context) { - c.JSON(http.StatusOK, common.SuccessResponse{ + c.JSON(http.StatusOK, request.SuccessResponse{ Data: PingResponse{ Message: "pong", }, @@ -28,7 +28,7 @@ func pingHandler(c *gin.Context) { } func authPingHandler(c *gin.Context) { - c.JSON(http.StatusOK, common.SuccessResponse{ + c.JSON(http.StatusOK, request.SuccessResponse{ Data: PingResponse{ Message: "authenticated pong", }, diff --git a/server/common/common.go b/server/request/request.go similarity index 94% rename from server/common/common.go rename to server/request/request.go index 8f756738..d859b3ca 100644 --- a/server/common/common.go +++ b/server/request/request.go @@ -1,4 +1,4 @@ -package common +package request import ( "encoding/json" @@ -39,7 +39,3 @@ func UnmarshalSuccessResponseData( type ErrorResponse struct { Errors []error `json:"errors"` } - -type Store interface { - DumpDeleted() error -} diff --git a/server/user/jwt.go b/server/user/jwt.go index a808d193..10268d5d 100644 --- a/server/user/jwt.go +++ b/server/user/jwt.go @@ -8,7 +8,7 @@ import ( jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/server/request" ) const identityKey = "id" @@ -61,7 +61,7 @@ var JwtConfig = &jwt.GinJWTMiddleware{ return user.AsPersonal(), nil }, LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) { - c.JSON(http.StatusOK, common.SuccessResponse{ + c.JSON(http.StatusOK, request.SuccessResponse{ Data: TokenResponse{ Token: token, Expiry: expire.Format(time.RFC3339), diff --git a/server/user/routes.go b/server/user/routes.go index 92f6e364..98cd0bae 100644 --- a/server/user/routes.go +++ b/server/user/routes.go @@ -9,7 +9,7 @@ import ( jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/server/request" ) func PublicRoutes(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { @@ -65,7 +65,7 @@ func GetUserHandler(c *gin.Context) { code = http.StatusInternalServerError } - c.AbortWithStatusJSON(code, common.ErrorResponse{ + c.AbortWithStatusJSON(code, request.ErrorResponse{ Errors: []error{ err, }, @@ -73,7 +73,7 @@ func GetUserHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, common.SuccessResponse{ + c.JSON(http.StatusOK, request.SuccessResponse{ Data: UserIdentifierResponse{ User: user, }, @@ -113,7 +113,7 @@ func RegisterUserHandler(c *gin.Context) { * what was already here. * --RageCage */ - c.AbortWithStatusJSON(http.StatusConflict, common.ErrorResponse{ + c.AbortWithStatusJSON(http.StatusConflict, request.ErrorResponse{ Errors: []error{ err, }, @@ -126,7 +126,7 @@ func RegisterUserHandler(c *gin.Context) { } c.Header("Location", fmt.Sprintf("/api/v1/users/%d", user.ID)) - c.JSON(http.StatusCreated, common.SuccessResponse{ + c.JSON(http.StatusCreated, request.SuccessResponse{ Data: UserIdentifierResponse{ User: &UserIdentifier{ ID: user.ID, @@ -144,7 +144,7 @@ func MeHandler(c *gin.Context) { userInfo, err := Store.GetUserPersonalById(uint(user.ID)) if err == nil { - c.JSON(http.StatusOK, common.SuccessResponse{ + c.JSON(http.StatusOK, request.SuccessResponse{ Data: UserPersonalResponse{ User: userInfo, }, diff --git a/server/user/users.go b/server/user/users.go index 81e44f4d..3d3cd47a 100644 --- a/server/user/users.go +++ b/server/user/users.go @@ -3,7 +3,7 @@ package user import ( "errors" - "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/database" "gorm.io/gorm" ) @@ -46,7 +46,7 @@ var Store UserStore // The UserStore interface, which defines ways that the application // can query for users. type UserStore interface { - common.Store + database.DataStore GetUserIdentifierById(uint) (*UserIdentifier, error) GetUserPersonalById(uint) (*UserPersonal, error) diff --git a/server/user/users_test.go b/server/user/users_test.go index d3e24b78..2c268f60 100644 --- a/server/user/users_test.go +++ b/server/user/users_test.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/speedrun-website/leaderboard-backend/database" - "github.com/speedrun-website/leaderboard-backend/server/common" + "github.com/speedrun-website/leaderboard-backend/server/request" "github.com/speedrun-website/leaderboard-backend/server/user" ) @@ -81,7 +81,7 @@ func testRegister( t.Fatalf("it failed: %s", err) } var responseData user.UserIdentifierResponse - _, err = common.UnmarshalSuccessResponseData(responseBytes, &responseData) + _, err = request.UnmarshalSuccessResponseData(responseBytes, &responseData) if err != nil { // FIXME t.Fatal("bad response format") @@ -107,7 +107,7 @@ func testLogin( t.Fatal("login failed") } var response user.TokenResponse - _, err = common.UnmarshalSuccessResponseData(responseBytes, &response) + _, err = request.UnmarshalSuccessResponseData(responseBytes, &response) if err != nil { // FIXME t.Fatal("login failed response bad") @@ -124,15 +124,15 @@ func testMe( ) { t.Helper() - request := httptest.NewRequest(http.MethodGet, "/me", nil) - request.Header.Add("Authorization", "Bearer "+token) - responseBytes, err := testGetRequest(r, "/me", http.StatusOK, request) + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Add("Authorization", "Bearer "+token) + responseBytes, err := testGetRequest(r, "/me", http.StatusOK, req) if err != nil { // FIXME t.Fatalf("me failed: %s", err) } var responseData user.UserPersonalResponse - _, err = common.UnmarshalSuccessResponseData(responseBytes, &responseData) + _, err = request.UnmarshalSuccessResponseData(responseBytes, &responseData) if err != nil { // FIXME t.Fatal("me failed response bad") @@ -151,9 +151,9 @@ func testRefreshToken( ) { t.Helper() - request := httptest.NewRequest(http.MethodGet, "/refresh_token", nil) - request.Header.Add("Authorization", "Bearer "+token) - responseBytes, err := testGetRequest(r, "/refresh_token", http.StatusOK, request) + req := httptest.NewRequest(http.MethodGet, "/refresh_token", nil) + req.Header.Add("Authorization", "Bearer "+token) + responseBytes, err := testGetRequest(r, "/refresh_token", http.StatusOK, req) if err != nil { // FIXME t.Fatalf("refresh_token failed: %s", err) @@ -161,7 +161,7 @@ func testRefreshToken( var response struct { Token string `json:"token"` } - _, err = common.UnmarshalSuccessResponseData(responseBytes, &response) + _, err = request.UnmarshalSuccessResponseData(responseBytes, &response) if err != nil { // FIXME t.Fatal("refresh_token failed response bad") From 2e0306cb588fd8cee8d40b25a83bbccd247aa42d Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 11:23:32 -0500 Subject: [PATCH 07/11] project: reformat all files with line endings --- .editorconfig | 2 +- .gitattributes | 2 +- .gitignore | 2 +- CONTRIBUTING.md | 49 +- COPYING | 2 +- README.md | 67 ++- docker-compose.yml | 49 +- docs/openapi.yml | 552 ++++++++++---------- server/user/{gorm_store.go => gormstore.go} | 0 9 files changed, 374 insertions(+), 351 deletions(-) rename server/user/{gorm_store.go => gormstore.go} (100%) diff --git a/.editorconfig b/.editorconfig index ec16e1ba..1341dd65 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,4 +12,4 @@ trim_trailing_whitespace = true indent_style = tab [*.md] -trim_tailing_whitespace = false \ No newline at end of file +trim_tailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 59f697c3..aaed3ca6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,4 +13,4 @@ Makefile text *.sh text eol=lf # Binary files that should not be modified -# Nothing yet \ No newline at end of file +# Nothing yet diff --git a/.gitignore b/.gitignore index a3d83f26..29ebe73a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ __debug_bin vendor # other -.env \ No newline at end of file +.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f34f381..cccb563c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,19 @@ # Contributing to leaderboard-backend + We appreciate your help! ## Before filing an issue + If you are unsure whether you have found a bug, please consider asking in [our discord](https://discord.gg/TZvfau25Vb) first. Similarly, if you have a question about a potential feature, [the discord](https://discord.gg/TZvfau25Vb) can be a fantastic resource for first comments. ## Filing issues + Filing issues is as simple as going to [the issue tracker](https://github.com/speedrun-website/leaderboard-backend/issues), and adding an issue using one of the below templates. ### Feature Request / Task + ``` {Feature Request/Task}: {short description} --- @@ -23,6 +27,7 @@ Filing issues is as simple as going to [the issue tracker](https://github.com/sp ``` ### Bugs + ``` Bug: {short description} --- @@ -39,7 +44,9 @@ Bug: {short description} ``` ## Contributing code + ### Example code contribution flow + 1. Make a fork of this repo. 1. Name a branch on your fork something descriptive for this change (eg. `UpdateMakefile`). 1. Commit your changes (Tip! Please read our [Style guide](#style-guide) to help the pull request process go smoothly). @@ -51,27 +58,31 @@ Bug: {short description} 1. Celebrate your amazing changes! 🎉 ## Style guide + ### General -- Be inclusive, this is a project for everyone. -- Be descriptive, it can be hard to understand abbreviations or short-hand. + +- Be inclusive, this is a project for everyone. +- Be descriptive, it can be hard to understand abbreviations or short-hand. ### GoLang -- Add tests for any new feature or bug fix, to ensure things continue to work. -- Comments should be full sentences, starting with a capital letter and ending with punctuation. -- Comments above a func or struct should start with the name of the thing being described. -- Wrap errors before returning with `oops.Wrapf(` to help build useful stacks for debugging. -- Early returns are great, they help reduce nesting! -- Avoid `interface{}` where possible, if we need such a generic please add a comment explaining what the real type is. + +- Add tests for any new feature or bug fix, to ensure things continue to work. +- Comments should be full sentences, starting with a capital letter and ending with punctuation. +- Comments above a func or struct should start with the name of the thing being described. +- Wrap errors before returning with `oops.Wrapf(` to help build useful stacks for debugging. +- Early returns are great, they help reduce nesting! +- Avoid `interface{}` where possible, if we need such a generic please add a comment explaining what the real type is. ### Git -- Try to have an informative branch name for others eg. `LB-{issue number}-{ghusername}`. - - Do not make pull requests from `main`. - - Do not include slashes in your branch name. - - Nested paths can act strange when other people start looking at your branch. -- Try to keep commit line length below 80 characters. -- All commit titles should be of the format `{area} {optional sub-area}: commit description`. - - This will help people reading through commits quickly find the relevant ones. - - Some examples might include: - - `go data: add support for sorting by high-score` - - `makefile: add docker build commands` -- Commits should be as [atomic](https://www.freshconsulting.com/insights/blog/atomic-commits/) as possible. \ No newline at end of file + +- Try to have an informative branch name for others eg. `LB-{issue number}-{ghusername}`. + - Do not make pull requests from `main`. + - Do not include slashes in your branch name. + - Nested paths can act strange when other people start looking at your branch. +- Try to keep commit line length below 80 characters. +- All commit titles should be of the format `{area} {optional sub-area}: commit description`. + - This will help people reading through commits quickly find the relevant ones. + - Some examples might include: + - `go data: add support for sorting by high-score` + - `makefile: add docker build commands` +- Commits should be as [atomic](https://www.freshconsulting.com/insights/blog/atomic-commits/) as possible. diff --git a/COPYING b/COPYING index e72bfdda..f288702d 100644 --- a/COPYING +++ b/COPYING @@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. \ No newline at end of file +. diff --git a/README.md b/README.md index 3354f78f..6e41d93d 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,66 @@ # leaderboard-backend + An open-source community-driven leaderboard backend for the gaming community. ## Links -- Website: https://leaderboards.gg -- Other Repos: https://github.com/leaderboardsgg -- Discord: https://discord.gg/TZvfau25Vb + +- Website: https://leaderboards.gg +- Other Repos: https://github.com/leaderboardsgg +- Discord: https://discord.gg/TZvfau25Vb # Tech-Stack Information -- This repository only contains the backend, and not the UI for the website. -- GoLang is used for implementing the backend. -- JSON API with JWT Authentication + +- This repository only contains the backend, and not the UI for the website. +- GoLang is used for implementing the backend. +- JSON API with JWT Authentication # Developing + ## Requirements -- [Go](https://golang.org/doc/install) 1.16+. -- [Make](https://www.gnu.org/software/make/) to run build scripts. -- [Docker](https://hub.docker.com/search?q=&type=edition&offering=community) to run the database and admin interface. + +- [Go](https://golang.org/doc/install) 1.16+. +- [Make](https://www.gnu.org/software/make/) to run build scripts. +- [Docker](https://hub.docker.com/search?q=&type=edition&offering=community) to run the database and admin interface. + ## Optional -- [golangci-lint](https://golangci-lint.run/usage/install/) to run CI linting on your machine. -- [staticcheck](https://staticcheck.io/docs/install) for linting that will integrate with your editor. -- [gcc](https://gcc.gnu.org/) to run race detection on unit tests. + +- [golangci-lint](https://golangci-lint.run/usage/install/) to run CI linting on your machine. +- [staticcheck](https://staticcheck.io/docs/install) for linting that will integrate with your editor. +- [gcc](https://gcc.gnu.org/) to run race detection on unit tests. ## Useful links -- [VSCode](https://code.visualstudio.com/download) is a pretty good editor with helpful GoLang plugins. -- [GoLand](https://www.jetbrains.com/go/) is JetBrains' Go offering and is very fully featured. -- [A Tour of Go](https://tour.golang.org/welcome/1) is a great place to learn the basics of how to use GoLang. -- [Effective Go](https://golang.org/doc/effective_go) is the best place to check to learn recommended best practices. -- [Set up git](https://docs.github.com/en/get-started/quickstart/set-up-git) is GitHub's guide on how to set up and begin using git. -- [How to Contribute to an Open Source Project](https://opensource.guide/how-to-contribute/#opening-a-pull-request) is a useful guide showing some of the steps involved in opening a Pull Request. + +- [VSCode](https://code.visualstudio.com/download) is a pretty good editor with helpful GoLang plugins. +- [GoLand](https://www.jetbrains.com/go/) is JetBrains' Go offering and is very fully featured. +- [A Tour of Go](https://tour.golang.org/welcome/1) is a great place to learn the basics of how to use GoLang. +- [Effective Go](https://golang.org/doc/effective_go) is the best place to check to learn recommended best practices. +- [Set up git](https://docs.github.com/en/get-started/quickstart/set-up-git) is GitHub's guide on how to set up and begin using git. +- [How to Contribute to an Open Source Project](https://opensource.guide/how-to-contribute/#opening-a-pull-request) is a useful guide showing some of the steps involved in opening a Pull Request. ## How to run + To start the postgres docker container -- `docker-compose up -d` -- Go to `localhost:1337` for an Adminer interface + +- `docker-compose up -d` +- Go to `localhost:1337` for an Adminer interface To test HTTP endpoints: -- `make run` or `make build` and run the binary -- Make requests to `localhost:3000/api/v1` (or whatever port from .env) + +- `make run` or `make build` and run the binary +- Make requests to `localhost:3000/api/v1` (or whatever port from .env) Running tests: -- `go test ./...` + +- `go test ./...` Running tests with coverage: -- `make test` + +- `make test` Running tests with race detection (requires `gcc`): -- `make test_race` + +- `make test_race` Running benchmarks: -- `make bench` + +- `make bench` diff --git a/docker-compose.yml b/docker-compose.yml index 7c1d24e4..962df05a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,29 @@ # Use postgres/example user/password credentials -version: '3.1' +version: "3.1" services: + db: + image: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - ${POSTGRES_PORT}:5432 - db: - image: postgres - restart: always - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - ${POSTGRES_PORT}:5432 + db-test: + image: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_TEST_DB} + ports: + - ${POSTGRES_TEST_PORT}:5432 - db-test: - image: postgres - restart: always - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_TEST_DB} - ports: - - ${POSTGRES_TEST_PORT}:5432 - - adminer: - image: adminer - restart: always - ports: - - ${ADMINER_PORT}:8080 \ No newline at end of file + adminer: + image: adminer + restart: always + ports: + - ${ADMINER_PORT}:8080 diff --git a/docs/openapi.yml b/docs/openapi.yml index eaadecf6..f5c1d194 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -1,299 +1,297 @@ openapi: 3.0.2 info: - title: Leaderboards.gg API - description: This is the docs for the Leaderboards.gg API version 1. - version: "1" + title: Leaderboards.gg API + description: This is the docs for the Leaderboards.gg API version 1. + version: "1" servers: - - url: https://leaderboards.gg/api/v1 + - url: https://leaderboards.gg/api/v1 paths: - /register: - post: - summary: Registers a user. - requestBody: - description: A [UserRegister](#/components/schemas/UserRegister) object. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserRegister' - responses: - '201': - $ref: '#/components/responses/UserRegister201' - '409': - $ref: '#/components/responses/UserRegister409' - '500': - description: Server error. - /login: - post: - summary: Logs a user in. - requestBody: - description: 'A UserLogin object. It has two required properties: `username` and `password`.' - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserLogin' - responses: - '200': - $ref: '#/components/responses/UserLogin200' - '401': - $ref: '#/components/responses/UserLogin401' - '500': - $ref: '#/components/responses/UserLogin500' - /logout: - post: - summary: Logs a user out. - responses: - '200': - description: User logged out successfully. - /refresh_token: - get: - summary: Refreshes the JWT for the currently logged-in user. The token still needs to be valid on refresh. - responses: - '200': - $ref: '#/components/responses/RefreshToken200' - /ping: - get: - summary: A simple check. A de facto health check endpoint. - responses: - '200': - $ref: '#/components/responses/Ping200' - /users/{id}: - get: - summary: Returns a user by ID. - parameters: - - in: path - name: id - required: true - schema: - type: number - format: uint64 - minimum: 1 - responses: - '200': - $ref: '#/components/responses/GetUser200' - '400': - description: Bad request. `id` must be an integer and be larger than 0. - '404': - $ref: '#/components/responses/GetUser404' - '500': - $ref: '#/components/responses/GetUser500' - /me: - get: - summary: Gets the currently logged-in user. - responses: - '200': - $ref: '#/components/responses/UserPersonal200' - '500': - description: Server error. + /register: + post: + summary: Registers a user. + requestBody: + description: A [UserRegister](#/components/schemas/UserRegister) object. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserRegister" + responses: + "201": + $ref: "#/components/responses/UserRegister201" + "409": + $ref: "#/components/responses/UserRegister409" + "500": + description: Server error. + /login: + post: + summary: Logs a user in. + requestBody: + description: "A UserLogin object. It has two required properties: `username` and `password`." + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserLogin" + responses: + "200": + $ref: "#/components/responses/UserLogin200" + "401": + $ref: "#/components/responses/UserLogin401" + "500": + $ref: "#/components/responses/UserLogin500" + /logout: + post: + summary: Logs a user out. + responses: + "200": + description: User logged out successfully. + /refresh_token: + get: + summary: Refreshes the JWT for the currently logged-in user. The token still needs to be valid on refresh. + responses: + "200": + $ref: "#/components/responses/RefreshToken200" + /ping: + get: + summary: A simple check. A de facto health check endpoint. + responses: + "200": + $ref: "#/components/responses/Ping200" + /users/{id}: + get: + summary: Returns a user by ID. + parameters: + - in: path + name: id + required: true + schema: + type: number + format: uint64 + minimum: 1 + responses: + "200": + $ref: "#/components/responses/GetUser200" + "400": + description: Bad request. `id` must be an integer and be larger than 0. + "404": + $ref: "#/components/responses/GetUser404" + "500": + $ref: "#/components/responses/GetUser500" + /me: + get: + summary: Gets the currently logged-in user. + responses: + "200": + $ref: "#/components/responses/UserPersonal200" + "500": + description: Server error. components: - schemas: - email: - type: string - format: email - example: johnsmithruns@leaderboards.gg - password: - type: string - format: password - minLength: 8 - example: "password" - userId: - type: integer - format: uint64 - minimum: 1 - example: 1 - username: - type: string - minLength: 2 - example: "JohnSmithRuns" - UserIdentifier: - type: object - required: - - id - - username - properties: - id: - $ref: '#/components/schemas/userId' - username: - $ref: '#/components/schemas/username' - UserPersonal: - allOf: - - $ref: '#/components/schemas/UserIdentifier' - - type: object - required: - - email - properties: - email: - $ref: '#/components/schemas/email' - UserLogin: - type: object - required: - - username - - password - properties: - username: - $ref: '#/components/schemas/username' + schemas: + email: + type: string + format: email + example: johnsmithruns@leaderboards.gg password: - $ref: '#/components/schemas/password' - UserRegister: - allOf: - - type: object - required: - - email - - password - - password_confirm - - username - properties: - email: - $ref: '#/components/schemas/email' - password: - $ref: '#/components/schemas/password' - password_confirm: - $ref: '#/components/schemas/password' - username: - $ref: '#/components/schemas/username' - UserLoginErrorResponseBody: - type: object - required: - - code - - message - properties: - code: - type: number - minimum: 400 - maxItems: 599 - message: - type: string - ErrorResponseBody: - type: object - required: - - message - properties: - message: - type: string - responses: - GetUser200: - description: "User was found. The response will be in the form `{\"user\": }`." - content: - application/json: - schema: + type: string + format: password + minLength: 8 + example: "password" + userId: + type: integer + format: uint64 + minimum: 1 + example: 1 + username: + type: string + minLength: 2 + example: "JohnSmithRuns" + UserIdentifier: type: object required: - - data + - id + - username properties: - data: - $ref: '#/components/schemas/UserIdentifier' - GetUser404: - description: No user with `id` could be found. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponseBody' - GetUser500: - description: Server error. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponseBody' - UserRegister201: - description: An object with a "data" key mapped to a [UserIdentifier](#/components/schemas/UserIdentifier). - content: - application/json: - schema: + id: + $ref: "#/components/schemas/userId" + username: + $ref: "#/components/schemas/username" + UserPersonal: + allOf: + - $ref: "#/components/schemas/UserIdentifier" + - type: object + required: + - email + properties: + email: + $ref: "#/components/schemas/email" + UserLogin: type: object required: - - data - properties: - data: - $ref: '#/components/schemas/UserIdentifier' - headers: - Location: - description: The slug to the user. - schema: - type: string - example: /api/v1/users/1 - UserRegister409: - description: The user cannot be created as the post request body contains a username and/or an email address that already exist(s) in the database. - content: - application/json: - schema: - type: object + - username + - password properties: - errors: - type: array - maxItems: 1 - items: - type: object + username: + $ref: "#/components/schemas/username" + password: + $ref: "#/components/schemas/password" + UserRegister: + allOf: + - type: object + required: + - email + - password + - password_confirm + - username properties: - constraint: - type: string - message: - type: string - UserPersonal200: - description: The user was found. - content: - application/json: - schema: + email: + $ref: "#/components/schemas/email" + password: + $ref: "#/components/schemas/password" + password_confirm: + $ref: "#/components/schemas/password" + username: + $ref: "#/components/schemas/username" + UserLoginErrorResponseBody: type: object required: - - data + - code + - message properties: - data: - $ref: '#/components/schemas/UserPersonal' - UserLogin401: - description: User is unauthorized to log in. This can either be because authentication or JWT creation failed. - content: - application/json: - schema: - $ref: '#/components/schemas/UserLoginErrorResponseBody' - example: { - "code": 401, - "message": "missing Username or Password" - } - UserLogin500: - description: Server error. - content: - application/json: - schema: - $ref: '#/components/schemas/UserLoginErrorResponseBody' - example: { - "code": 500, - "message": "Internal server error" - } - UserLogin200: - description: User logged in successfully. A JWT that lasts for an hour will be returned. - content: - application/json: - schema: + code: + type: number + minimum: 400 + maxItems: 599 + message: + type: string + ErrorResponseBody: type: object required: - - token - properties: - token: - type: string - format: jwt - RefreshToken200: - description: 'Token was refreshed successfully. A new token will be returned under `{"token": }`.' - content: - application/json: - schema: - type: object - properties: - token: - type: string - format: jwt - example: { "token": "" } - Ping200: - description: "The server's running. A `{\"message\": \"pong\"}` will be returned." - content: - application/json: - schema: - type: object + - message properties: - message: - type: string - example: - message: pong + message: + type: string + responses: + GetUser200: + description: 'User was found. The response will be in the form `{"user": }`.' + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/UserIdentifier" + GetUser404: + description: No user with `id` could be found. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponseBody" + GetUser500: + description: Server error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponseBody" + UserRegister201: + description: An object with a "data" key mapped to a [UserIdentifier](#/components/schemas/UserIdentifier). + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/UserIdentifier" + headers: + Location: + description: The slug to the user. + schema: + type: string + example: /api/v1/users/1 + UserRegister409: + description: The user cannot be created as the post request body contains a username and/or an email address that already exist(s) in the database. + content: + application/json: + schema: + type: object + properties: + errors: + type: array + maxItems: 1 + items: + type: object + properties: + constraint: + type: string + message: + type: string + UserPersonal200: + description: The user was found. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/UserPersonal" + UserLogin401: + description: User is unauthorized to log in. This can either be because authentication or JWT creation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/UserLoginErrorResponseBody" + example: + { + "code": 401, + "message": "missing Username or Password", + } + UserLogin500: + description: Server error. + content: + application/json: + schema: + $ref: "#/components/schemas/UserLoginErrorResponseBody" + example: { "code": 500, "message": "Internal server error" } + UserLogin200: + description: User logged in successfully. A JWT that lasts for an hour will be returned. + content: + application/json: + schema: + type: object + required: + - token + properties: + token: + type: string + format: jwt + RefreshToken200: + description: 'Token was refreshed successfully. A new token will be returned under `{"token": }`.' + content: + application/json: + schema: + type: object + properties: + token: + type: string + format: jwt + example: { "token": "" } + Ping200: + description: 'The server''s running. A `{"message": "pong"}` will be returned.' + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: + message: pong diff --git a/server/user/gorm_store.go b/server/user/gormstore.go similarity index 100% rename from server/user/gorm_store.go rename to server/user/gormstore.go From e8d7fd2704b94b51cbd32ac9542f35d8e1d9c6d9 Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 11:26:00 -0500 Subject: [PATCH 08/11] database: rename methods for purpose clarity --- database/db.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/database/db.go b/database/db.go index e35d1299..f6157de9 100644 --- a/database/db.go +++ b/database/db.go @@ -19,7 +19,7 @@ type dbConfig struct { password string } -func getConfig() dbConfig { +func getDbConfig() dbConfig { return dbConfig{ os.Getenv("POSTGRES_HOST"), os.Getenv("POSTGRES_PORT"), @@ -29,7 +29,7 @@ func getConfig() dbConfig { } } -func getTestConfig() dbConfig { +func getTestDbConfig() dbConfig { return dbConfig{ os.Getenv("POSTGRES_HOST"), os.Getenv("POSTGRES_TEST_PORT"), @@ -45,8 +45,8 @@ func getDns(config dbConfig) string { config.host, config.port, config.user, config.dbname, config.password) } -func Init() error { - config := getConfig() +func InitGlobalConnection() error { + config := getDbConfig() dns := getDns(config) db, err := gorm.Open(postgres.Open(dns), &gorm.Config{}) if err != nil { @@ -56,8 +56,8 @@ func Init() error { return nil } -func InitTest() error { - config := getTestConfig() +func InitGlobalTestConnection() error { + config := getTestDbConfig() dns := getDns(config) db, err := gorm.Open(postgres.Open(dns), &gorm.Config{}) if err != nil { From 4f5b6715417a12b029cba3166a1bb96da8cad73d Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 11:27:36 -0500 Subject: [PATCH 09/11] example.env: reorganize elements --- example.env | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/example.env b/example.env index 4b69b0e2..7d7e6bef 100644 --- a/example.env +++ b/example.env @@ -1,11 +1,13 @@ # copy and rename this file to '.env' to take effect -BACKEND_PORT=3000 ADMINER_PORT=1337 -USE_DB=false +BACKEND_PORT=3000 + POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_TEST_PORT=5433 POSTGRES_USER=admin +POSTGRES_PASSWORD=example + POSTGRES_DB=leaderboardsmain +POSTGRES_PORT=5432 + POSTGRES_TEST_DB=leaderboardtest -POSTGRES_PASSWORD=example +POSTGRES_TEST_PORT=5433 From ccd32768bb9fbadb8513d7a110866bbc36859868 Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 11:30:29 -0500 Subject: [PATCH 10/11] ping: delete package The original purpose of the package, to serve as an example for other package layouts, is largely obsoleted by the existence of the users package. --- server/ping/ping_test.go | 57 ---------------------------------------- server/ping/routes.go | 36 ------------------------- server/server.go | 3 --- 3 files changed, 96 deletions(-) delete mode 100644 server/ping/ping_test.go delete mode 100644 server/ping/routes.go diff --git a/server/ping/ping_test.go b/server/ping/ping_test.go deleted file mode 100644 index 74bf9f1b..00000000 --- a/server/ping/ping_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package ping_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/server/ping" -) - -func getPublicContext() (*gin.Engine, *httptest.ResponseRecorder) { - w := httptest.NewRecorder() - _, r := gin.CreateTestContext(w) - api := r.Group("/") - ping.PublicRoutes(api) - return r, w -} - -func getAuthContext() (*gin.Engine, *httptest.ResponseRecorder) { - w := httptest.NewRecorder() - _, r := gin.CreateTestContext(w) - api := r.Group("/") - ping.AuthRoutes(api) - return r, w -} - -func TestGETPing(t *testing.T) { - r, w := getPublicContext() - - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - r.ServeHTTP(w, req) - var res ping.PingResponse - err := json.Unmarshal(w.Body.Bytes(), &res) - if err != nil { - t.Fatalf("could not unmarshal response: %s", err) - } - if res.Message == "pong" { - t.Fatalf("response mismatch: expected %s, got %s", "pong", res.Message) - } -} - -func TestGETAuthPing(t *testing.T) { - r, w := getAuthContext() - - req := httptest.NewRequest(http.MethodGet, "/authPing", nil) - r.ServeHTTP(w, req) - var res ping.PingResponse - err := json.Unmarshal(w.Body.Bytes(), &res) - if err != nil { - t.Fatalf("could not unmarshal response: %s", err) - } - if res.Message == "authenticated pong" { - t.Fatalf("response mismatch: expected %s, got %s", "pong", res.Message) - } -} diff --git a/server/ping/routes.go b/server/ping/routes.go deleted file mode 100644 index 5e6288de..00000000 --- a/server/ping/routes.go +++ /dev/null @@ -1,36 +0,0 @@ -package ping - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/speedrun-website/leaderboard-backend/server/request" -) - -func PublicRoutes(r *gin.RouterGroup) { - r.GET("/ping", pingHandler) -} - -func AuthRoutes(r *gin.RouterGroup) { - r.GET("/authPing", authPingHandler) -} - -type PingResponse struct { - Message string `json:"message"` -} - -func pingHandler(c *gin.Context) { - c.JSON(http.StatusOK, request.SuccessResponse{ - Data: PingResponse{ - Message: "pong", - }, - }) -} - -func authPingHandler(c *gin.Context) { - c.JSON(http.StatusOK, request.SuccessResponse{ - Data: PingResponse{ - Message: "authenticated pong", - }, - }) -} diff --git a/server/server.go b/server/server.go index df306e42..9f9efc2b 100644 --- a/server/server.go +++ b/server/server.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" cors "github.com/rs/cors/wrapper/gin" - "github.com/speedrun-website/leaderboard-backend/server/ping" "github.com/speedrun-website/leaderboard-backend/server/user" ) @@ -27,12 +26,10 @@ func Init(router *gin.Engine) { authMiddleware := user.GetAuthMiddlewareHandler() api := router.Group("/api/v1") - ping.PublicRoutes(api) user.PublicRoutes(api, authMiddleware) api.Use(authMiddleware.MiddlewareFunc()) { - ping.AuthRoutes(api) user.AuthRoutes(api, authMiddleware) } } From 1082c3fdbcd56b11dd0b8b025c154a64af3357d6 Mon Sep 17 00:00:00 2001 From: BraydonKains Date: Sat, 8 Jan 2022 13:42:24 -0500 Subject: [PATCH 11/11] main, server: saved database function rename When I did the project-wide rename of the functions in the database package, I didn't save the files that had stuff renamed. --- main.go | 2 +- server/user/users_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 0d49e026..bd6f4f03 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,7 @@ func main() { log.Fatal(err) } - if err := database.Init(); err != nil { + if err := database.InitGlobalConnection(); err != nil { log.Fatal(err) } diff --git a/server/user/users_test.go b/server/user/users_test.go index 2c268f60..29059915 100644 --- a/server/user/users_test.go +++ b/server/user/users_test.go @@ -27,7 +27,7 @@ func init() { log.Fatalf("Where's the .env file?") } - if err := database.InitTest(); err != nil { + if err := database.InitGlobalTestConnection(); err != nil { log.Fatalf("DB failed to initialise.") }