From 7dcca84b7d1a9c55b2c633ba87e0eec82cb5db9e Mon Sep 17 00:00:00 2001 From: Arthur Lefebvre Date: Wed, 15 Oct 2025 22:52:43 +0200 Subject: [PATCH] Refactor api code: split between packages --- internal/api/dto.go | 27 ---- internal/api/mapper.go | 42 ------ internal/api/routes.go | 138 ------------------ internal/appcontext/main.go | 13 ++ internal/dto/bookpostcreate.go | 6 + internal/dto/booksearchget.go | 6 + internal/dto/bookuserget.go | 7 + internal/dto/userlogin.go | 6 + internal/dto/usersignup.go | 6 + internal/mapper/bookpostcreate.go | 14 ++ internal/mapper/booksearchget.go | 13 ++ internal/mapper/bookuserget.go | 14 ++ internal/mapper/usersignup.go | 20 +++ .../myvalidator.go} | 14 +- internal/routes/bookpostcreate.go | 33 +++++ internal/routes/booksearchget.go | 22 +++ internal/routes/bookuserget.go | 26 ++++ internal/routes/userlogin.go | 47 ++++++ internal/routes/usersignup.go | 32 ++++ internal/setup/setup.go | 12 +- 20 files changed, 284 insertions(+), 214 deletions(-) delete mode 100644 internal/api/dto.go delete mode 100644 internal/api/mapper.go delete mode 100644 internal/api/routes.go create mode 100644 internal/dto/bookpostcreate.go create mode 100644 internal/dto/booksearchget.go create mode 100644 internal/dto/bookuserget.go create mode 100644 internal/dto/userlogin.go create mode 100644 internal/dto/usersignup.go create mode 100644 internal/mapper/bookpostcreate.go create mode 100644 internal/mapper/booksearchget.go create mode 100644 internal/mapper/bookuserget.go create mode 100644 internal/mapper/usersignup.go rename internal/{api/validator.go => myvalidator/myvalidator.go} (76%) create mode 100644 internal/routes/bookpostcreate.go create mode 100644 internal/routes/booksearchget.go create mode 100644 internal/routes/bookuserget.go create mode 100644 internal/routes/userlogin.go create mode 100644 internal/routes/usersignup.go diff --git a/internal/api/dto.go b/internal/api/dto.go deleted file mode 100644 index 0769797..0000000 --- a/internal/api/dto.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -type bookPostCreate struct { - Title string `json:"title" binding:"required,max=300"` - Author string `json:"author" binding:"max=100"` -} - -type userSignup struct { - Username string `json:"username" binding:"required,min=2,max=20"` - Password string `json:"password" binding:"required,min=6,max=100"` -} - -type userLogin struct { - Username string `json:"username" binding:"required,min=2,max=20"` - Password string `json:"password" binding:"required,min=6,max=100"` -} - -type bookUserGet struct { - Title string `json:"title" binding:"required,max=300"` - Author string `json:"author" binding:"max=100"` - Rating int `json:"rating" binding:"min=0,max=10"` -} - -type bookSearchGet struct { - Title string `json:"title" binding:"required,max=300"` - Author string `json:"author" binding:"max=100"` -} diff --git a/internal/api/mapper.go b/internal/api/mapper.go deleted file mode 100644 index c6bf244..0000000 --- a/internal/api/mapper.go +++ /dev/null @@ -1,42 +0,0 @@ -package api - -import ( - "git.artlef.fr/PersonalLibraryManager/internal/model" - "golang.org/x/crypto/bcrypt" -) - -func (b bookPostCreate) toBook(user *model.User) model.Book { - return model.Book{ - Title: b.Title, - Author: b.Author, - AddedBy: *user, - } -} - -func fromUserBookDb(b *model.UserBook) bookUserGet { - return bookUserGet{ - Title: b.Book.Title, - Author: b.Book.Author, - Rating: b.Rating, - } -} - -func (u userSignup) toUser() (model.User, error) { - user := model.User{ - Name: u.Username, - Password: "", - } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) - if err != nil { - return user, err - } - user.Password = string(hashedPassword) - return user, nil -} - -func fromBookDb(b *model.Book) bookSearchGet { - return bookSearchGet{ - Title: b.Title, - Author: b.Author, - } -} diff --git a/internal/api/routes.go b/internal/api/routes.go deleted file mode 100644 index 125b6e9..0000000 --- a/internal/api/routes.go +++ /dev/null @@ -1,138 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "git.artlef.fr/PersonalLibraryManager/internal/appcontext" - "git.artlef.fr/PersonalLibraryManager/internal/i18nresource" - "git.artlef.fr/PersonalLibraryManager/internal/jwtauth" - "git.artlef.fr/PersonalLibraryManager/internal/model" - "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -func GetMyBooksHanderl(ac appcontext.AppContext) { - var userbooks []model.UserBook - user, err := getAuthenticatedUser(ac) - if err != nil { - manageDefaultError(ac.C, err) - return - } - ac.Db.Preload("Book").Where("user_id = ?", user.ID).Find(&userbooks) - booksDto := make([]bookUserGet, 0) - for _, userbook := range userbooks { - booksDto = append(booksDto, fromUserBookDb(&userbook)) - } - ac.C.JSON(http.StatusOK, booksDto) -} - -func GetSearchBooksHandler(ac appcontext.AppContext) { - searchterm := ac.C.Param("searchterm") - var booksDb []model.Book - ac.Db.Where("LOWER(title) LIKE ?", "%"+strings.ToLower(searchterm)+"%").Find(&booksDb) - books := make([]bookSearchGet, 0) - for _, b := range booksDb { - books = append(books, fromBookDb(&b)) - } - ac.C.JSON(http.StatusOK, books) -} - -func PostBookHandler(ac appcontext.AppContext) { - var book bookPostCreate - err := ac.C.ShouldBindJSON(&book) - if err != nil { - manageBindingError(ac, err) - return - } - user, fetchUserErr := getAuthenticatedUser(ac) - if fetchUserErr != nil { - manageDefaultError(ac.C, err) - return - } - bookDb := book.toBook(&user) - err = ac.Db.Model(&model.Book{}).Save(&bookDb).Error - if err != nil { - manageDefaultError(ac.C, err) - return - } - ac.C.String(200, "Success") -} - -func PostSignupHandler(ac appcontext.AppContext) { - var user userSignup - err := ac.C.ShouldBindJSON(&user) - if err != nil { - manageBindingError(ac, err) - return - } - userDb, err := user.toUser() - if err != nil { - manageDefaultError(ac.C, err) - return - } - err = ac.Db.Model(&model.User{}).Save(&userDb).Error - if err != nil { - manageDefaultError(ac.C, err) - return - } - ac.C.String(200, "Success") -} - -func PostLoginHandler(ac appcontext.AppContext) { - var user userLogin - err := ac.C.ShouldBindJSON(&user) - if err != nil { - manageBindingError(ac, err) - return - } - - if !isUserAndPasswordOk(ac.Db, user.Username, user.Password) { - ac.C.JSON(http.StatusInternalServerError, - gin.H{"error": i18nresource.GetTranslatedMessage(ac, "InvalidCredentials")}) - return - } - - var jwtToken string - jwtToken, err = jwtauth.GenerateJwtToken(user.Username) - if err != nil { - ac.C.JSON(http.StatusUnauthorized, - gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)}) - return - } - ac.C.JSON(200, gin.H{"message": i18nresource.GetTranslatedMessage(ac, "AuthenticationSuccess"), "token": jwtToken}) -} - -func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool { - var user model.User - db.Where("name = ?", username).First(&user) - err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) - return err == nil -} - -func getAuthenticatedUser(ac appcontext.AppContext) (model.User, error) { - var user model.User - username, userIsInContext := ac.C.Get("user") - if !userIsInContext { - return user, errors.New("User not found in context") - } - res := ac.Db.Where("name = ?", username).First(&user) - return user, res.Error -} - -func manageBindingError(ac appcontext.AppContext, err error) { - var ve validator.ValidationErrors - if errors.As(err, &ve) { - ac.C.JSON(http.StatusBadRequest, getValidationErrors(ac, &ve)) - } else { - manageDefaultError(ac.C, err) - } -} - -func manageDefaultError(c *gin.Context, err error) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) -} diff --git a/internal/appcontext/main.go b/internal/appcontext/main.go index bc68582..f6435e0 100644 --- a/internal/appcontext/main.go +++ b/internal/appcontext/main.go @@ -1,6 +1,9 @@ package appcontext import ( + "errors" + + "git.artlef.fr/PersonalLibraryManager/internal/model" "github.com/gin-gonic/gin" "github.com/nicksnyder/go-i18n/v2/i18n" "gorm.io/gorm" @@ -11,3 +14,13 @@ type AppContext struct { Db *gorm.DB I18n *i18n.Bundle } + +func (ac AppContext) GetAuthenticatedUser() (model.User, error) { + var user model.User + username, userIsInContext := ac.C.Get("user") + if !userIsInContext { + return user, errors.New("User not found in context") + } + res := ac.Db.Where("name = ?", username).First(&user) + return user, res.Error +} diff --git a/internal/dto/bookpostcreate.go b/internal/dto/bookpostcreate.go new file mode 100644 index 0000000..3671ce2 --- /dev/null +++ b/internal/dto/bookpostcreate.go @@ -0,0 +1,6 @@ +package dto + +type BookPostCreate struct { + Title string `json:"title" binding:"required,max=300"` + Author string `json:"author" binding:"max=100"` +} diff --git a/internal/dto/booksearchget.go b/internal/dto/booksearchget.go new file mode 100644 index 0000000..68e854c --- /dev/null +++ b/internal/dto/booksearchget.go @@ -0,0 +1,6 @@ +package dto + +type BookSearchGet struct { + Title string `json:"title" binding:"required,max=300"` + Author string `json:"author" binding:"max=100"` +} diff --git a/internal/dto/bookuserget.go b/internal/dto/bookuserget.go new file mode 100644 index 0000000..67b4871 --- /dev/null +++ b/internal/dto/bookuserget.go @@ -0,0 +1,7 @@ +package dto + +type BookUserGet struct { + Title string `json:"title" binding:"required,max=300"` + Author string `json:"author" binding:"max=100"` + Rating int `json:"rating" binding:"min=0,max=10"` +} diff --git a/internal/dto/userlogin.go b/internal/dto/userlogin.go new file mode 100644 index 0000000..167ae14 --- /dev/null +++ b/internal/dto/userlogin.go @@ -0,0 +1,6 @@ +package dto + +type UserLogin struct { + Username string `json:"username" binding:"required,min=2,max=20"` + Password string `json:"password" binding:"required,min=6,max=100"` +} diff --git a/internal/dto/usersignup.go b/internal/dto/usersignup.go new file mode 100644 index 0000000..63d0688 --- /dev/null +++ b/internal/dto/usersignup.go @@ -0,0 +1,6 @@ +package dto + +type UserSignup struct { + Username string `json:"username" binding:"required,min=2,max=20"` + Password string `json:"password" binding:"required,min=6,max=100"` +} diff --git a/internal/mapper/bookpostcreate.go b/internal/mapper/bookpostcreate.go new file mode 100644 index 0000000..6267f1e --- /dev/null +++ b/internal/mapper/bookpostcreate.go @@ -0,0 +1,14 @@ +package mapper + +import ( + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/model" +) + +func BookWsToDb(b dto.BookPostCreate, user *model.User) model.Book { + return model.Book{ + Title: b.Title, + Author: b.Author, + AddedBy: *user, + } +} diff --git a/internal/mapper/booksearchget.go b/internal/mapper/booksearchget.go new file mode 100644 index 0000000..6e2dcb6 --- /dev/null +++ b/internal/mapper/booksearchget.go @@ -0,0 +1,13 @@ +package mapper + +import ( + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/model" +) + +func BookDbToWs(b *model.Book) dto.BookSearchGet { + return dto.BookSearchGet{ + Title: b.Title, + Author: b.Author, + } +} diff --git a/internal/mapper/bookuserget.go b/internal/mapper/bookuserget.go new file mode 100644 index 0000000..ffa2c64 --- /dev/null +++ b/internal/mapper/bookuserget.go @@ -0,0 +1,14 @@ +package mapper + +import ( + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/model" +) + +func UserBookDbToWs(b *model.UserBook) dto.BookUserGet { + return dto.BookUserGet{ + Title: b.Book.Title, + Author: b.Book.Author, + Rating: b.Rating, + } +} diff --git a/internal/mapper/usersignup.go b/internal/mapper/usersignup.go new file mode 100644 index 0000000..02d112b --- /dev/null +++ b/internal/mapper/usersignup.go @@ -0,0 +1,20 @@ +package mapper + +import ( + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "golang.org/x/crypto/bcrypt" +) + +func UserWsToDb(u dto.UserSignup) (model.User, error) { + user := model.User{ + Name: u.Username, + Password: "", + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return user, err + } + user.Password = string(hashedPassword) + return user, nil +} diff --git a/internal/api/validator.go b/internal/myvalidator/myvalidator.go similarity index 76% rename from internal/api/validator.go rename to internal/myvalidator/myvalidator.go index a7d9522..581ed18 100644 --- a/internal/api/validator.go +++ b/internal/myvalidator/myvalidator.go @@ -1,10 +1,13 @@ -package api +package myvalidator import ( + "errors" "fmt" + "net/http" "git.artlef.fr/PersonalLibraryManager/internal/appcontext" "git.artlef.fr/PersonalLibraryManager/internal/i18nresource" + "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" ) @@ -13,6 +16,15 @@ type apiValidationError struct { Err string `json:"error"` } +func ManageBindingError(ac appcontext.AppContext, err error) { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + ac.C.JSON(http.StatusBadRequest, getValidationErrors(ac, &ve)) + } else { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } +} + func getValidationErrors(ac appcontext.AppContext, ve *validator.ValidationErrors) []apiValidationError { errors := make([]apiValidationError, len(*ve)) for i, fe := range *ve { diff --git a/internal/routes/bookpostcreate.go b/internal/routes/bookpostcreate.go new file mode 100644 index 0000000..8d8f937 --- /dev/null +++ b/internal/routes/bookpostcreate.go @@ -0,0 +1,33 @@ +package routes + +import ( + "net/http" + + "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/mapper" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "git.artlef.fr/PersonalLibraryManager/internal/myvalidator" + "github.com/gin-gonic/gin" +) + +func PostBookHandler(ac appcontext.AppContext) { + var book dto.BookPostCreate + err := ac.C.ShouldBindJSON(&book) + if err != nil { + myvalidator.ManageBindingError(ac, err) + return + } + user, fetchUserErr := ac.GetAuthenticatedUser() + if fetchUserErr != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + bookDb := mapper.BookWsToDb(book, &user) + err = ac.Db.Model(&model.Book{}).Save(&bookDb).Error + if err != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ac.C.String(200, "Success") +} diff --git a/internal/routes/booksearchget.go b/internal/routes/booksearchget.go new file mode 100644 index 0000000..57f5cc6 --- /dev/null +++ b/internal/routes/booksearchget.go @@ -0,0 +1,22 @@ +package routes + +import ( + "net/http" + "strings" + + "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/mapper" + "git.artlef.fr/PersonalLibraryManager/internal/model" +) + +func GetSearchBooksHandler(ac appcontext.AppContext) { + searchterm := ac.C.Param("searchterm") + var booksDb []model.Book + ac.Db.Where("LOWER(title) LIKE ?", "%"+strings.ToLower(searchterm)+"%").Find(&booksDb) + books := make([]dto.BookSearchGet, 0) + for _, b := range booksDb { + books = append(books, mapper.BookDbToWs(&b)) + } + ac.C.JSON(http.StatusOK, books) +} diff --git a/internal/routes/bookuserget.go b/internal/routes/bookuserget.go new file mode 100644 index 0000000..82a27b1 --- /dev/null +++ b/internal/routes/bookuserget.go @@ -0,0 +1,26 @@ +package routes + +import ( + "net/http" + + "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/mapper" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "github.com/gin-gonic/gin" +) + +func GetMyBooksHanderl(ac appcontext.AppContext) { + var userbooks []model.UserBook + user, err := ac.GetAuthenticatedUser() + if err != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ac.Db.Preload("Book").Where("user_id = ?", user.ID).Find(&userbooks) + booksDto := make([]dto.BookUserGet, 0) + for _, userbook := range userbooks { + booksDto = append(booksDto, mapper.UserBookDbToWs(&userbook)) + } + ac.C.JSON(http.StatusOK, booksDto) +} diff --git a/internal/routes/userlogin.go b/internal/routes/userlogin.go new file mode 100644 index 0000000..a90a069 --- /dev/null +++ b/internal/routes/userlogin.go @@ -0,0 +1,47 @@ +package routes + +import ( + "fmt" + "net/http" + + "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/i18nresource" + "git.artlef.fr/PersonalLibraryManager/internal/jwtauth" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "git.artlef.fr/PersonalLibraryManager/internal/myvalidator" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func PostLoginHandler(ac appcontext.AppContext) { + var user dto.UserLogin + err := ac.C.ShouldBindJSON(&user) + if err != nil { + myvalidator.ManageBindingError(ac, err) + return + } + + if !isUserAndPasswordOk(ac.Db, user.Username, user.Password) { + ac.C.JSON(http.StatusInternalServerError, + gin.H{"error": i18nresource.GetTranslatedMessage(ac, "InvalidCredentials")}) + return + } + + var jwtToken string + jwtToken, err = jwtauth.GenerateJwtToken(user.Username) + if err != nil { + ac.C.JSON(http.StatusUnauthorized, + gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)}) + return + } + ac.C.JSON(200, gin.H{"message": i18nresource.GetTranslatedMessage(ac, "AuthenticationSuccess"), "token": jwtToken}) +} + +func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool { + var user model.User + db.Where("name = ?", username).First(&user) + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + return err == nil +} diff --git a/internal/routes/usersignup.go b/internal/routes/usersignup.go new file mode 100644 index 0000000..8926522 --- /dev/null +++ b/internal/routes/usersignup.go @@ -0,0 +1,32 @@ +package routes + +import ( + "net/http" + + "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/dto" + "git.artlef.fr/PersonalLibraryManager/internal/mapper" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "git.artlef.fr/PersonalLibraryManager/internal/myvalidator" + "github.com/gin-gonic/gin" +) + +func PostSignupHandler(ac appcontext.AppContext) { + var user dto.UserSignup + err := ac.C.ShouldBindJSON(&user) + if err != nil { + myvalidator.ManageBindingError(ac, err) + return + } + userDb, err := mapper.UserWsToDb(user) + if err != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + err = ac.Db.Model(&model.User{}).Save(&userDb).Error + if err != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ac.C.String(200, "Success") +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index fbe3fcc..34a1e69 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -4,13 +4,13 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - "git.artlef.fr/PersonalLibraryManager/internal/api" "git.artlef.fr/PersonalLibraryManager/internal/appcontext" "git.artlef.fr/PersonalLibraryManager/internal/config" "git.artlef.fr/PersonalLibraryManager/internal/db" i18nresource "git.artlef.fr/PersonalLibraryManager/internal/i18nresource" "git.artlef.fr/PersonalLibraryManager/internal/jwtauth" "git.artlef.fr/PersonalLibraryManager/internal/middleware" + "git.artlef.fr/PersonalLibraryManager/internal/routes" ) func Setup(config *config.Config) *gin.Engine { @@ -24,19 +24,19 @@ func Setup(config *config.Config) *gin.Engine { r.Use(middleware.Auth()) bundle := i18nresource.InitializeI18n() r.GET("/mybooks", func(c *gin.Context) { - api.GetMyBooksHanderl(appcontext.AppContext{C: c, Db: db, I18n: bundle}) + routes.GetMyBooksHanderl(appcontext.AppContext{C: c, Db: db, I18n: bundle}) }) r.GET("/search/:searchterm", func(c *gin.Context) { - api.GetSearchBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) + routes.GetSearchBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) }) r.POST("/book", func(c *gin.Context) { - api.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) + routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) }) r.POST("/auth/signup", func(c *gin.Context) { - api.PostSignupHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) + routes.PostSignupHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) }) r.POST("/auth/login", func(c *gin.Context) { - api.PostLoginHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) + routes.PostLoginHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle}) }) return r }