Have you been looking forward to a production application built with Golang and React? This is one.
This application has an API Backend and a Frontend that consumes the API.
The application has two repositories:
- https://github.com/victorsteven/Forum-App-Go-Backend (Backend)
- https://github.com/victorsteven/Forum-App-React-Frontend (Frontend)
This is live version of the App. You can interact with it.
Technologies
Backend Technologies:
- Golang
- Gin Framework
- GORM
- PostgreSQL/MySQL
Frontend Technologies:
- React
- React Hooks
- Redux
Devops Technologies
- Linux
- Nginx
- Docker
While the above may seem overwhelming, you will see how they all work in sync.
You might also like to check out my other articles about go, docker, kubernetes here
SECTION 1: Buiding the Backend
This is backend session wired up with Golang
Here, I will give a step by step approach to what was done.
Step 1: Basic Setup
a. The base directory
Create the forum directory on any path of your choice in your computer and switch to that directory:
```mkdir forum && cd forum```
b. Go Modules
Initialize go module. This takes care of our dependency management. In the root directory run:
go mod init github.com/victorsteven/forum
As seen, I used github url, my username, and the app root directory name. You can use any convention you want.
c. Basic Installations
We will be using third party packages in this application. If you have never installed them before, you can run the following commands:
go get github.com/badoux/checkmail
go get github.com/jinzhu/gorm
go get golang.org/x/crypto/bcrypt
go get github.com/dgrijalva/jwt-go
go get github.com/jinzhu/gorm/dialects/postgres
go get github.com/joho/godotenv
go get gopkg.in/go-playground/assert.v1
go get github.com/gin-contrib/cors
go get github.com/gin-gonic/contrib
go get github.com/gin-gonic/gin
go get github.com/aws/aws-sdk-go
go get github.com/sendgrid/sendgrid-go
go get github.com/stretchr/testify
go get github.com/twinj/uuid
github.com/matcornic/hermes/v2
d. .env file
Create and set up a .env file in the root directory.
touch .env
The .env file contains the database configuration details and other details that you want to key secret. You can use the .env.example file(from the repo) as a guide.
This is a sample .env file:
APP_ENV=local | |
API_PORT=8888 | |
DB_HOST=forum-postgres # RUNNING THE APP WITH DOCKER | |
# DB_HOST=127.0.0.1 # RUNNING THE APP WITHOUT DOCKER | |
DB_DRIVER=postgres | |
API_SECRET=98hbun98h | |
DB_USER=steven | |
DB_PASSWORD=password | |
DB_NAME=forum_db | |
DB_PORT=5432 | |
#TEST_DB_HOST=forum-postgres-test # RUNNING THE TEST WITH DOCKER | |
TEST_DB_HOST=127.0.0.1 # RUNNING THE TEST WITHOUT DOCKER | |
TEST_DB_DRIVER=postgres | |
TEST_API_SECRET=98hbun98h | |
TEST_DB_USER=steven | |
TEST_DB_PASSWORD=password | |
TEST_DB_NAME=forum_db_test | |
TEST_DB_PORT=5432 |
e. api and tests directories
Create an api and tests directories in the root directory.
mkdir api && mkdir tests
Thus far, our folder structure looks like this:
forum
├── api
├── tests
├── .env
└── go.mod
Step 2: Wiring up the Models
We will be needing about five models in this forum app:
a. User
b. Post
c. Like
d. Comment
e. ResetPassword
a. User Model
Inside the API directory, create the models directory:
cd api && mkdir models
Inside the models directory, create the User.go file:
cd models && touch User.go
A user can:
i. Signup
ii. Login
iii. Update his details
iv. Shutdown his account
package models | |
import ( | |
"errors" | |
"html" | |
"log" | |
"os" | |
"strings" | |
"time" | |
"github.com/victorsteven/forum/api/security" | |
"github.com/badoux/checkmail" | |
"github.com/jinzhu/gorm" | |
) | |
type User struct { | |
ID uint32 `gorm:"primary_key;auto_increment" json:"id"` | |
Username string `gorm:"size:255;not null;unique" json:"username"` | |
Email string `gorm:"size:100;not null;unique" json:"email"` | |
Password string `gorm:"size:100;not null;" json:"password"` | |
AvatarPath string `gorm:"size:255;null;" json:"avatar_path"` | |
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` | |
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` | |
} | |
func (u *User) BeforeSave() error { | |
hashedPassword, err := security.Hash(u.Password) | |
if err != nil { | |
return err | |
} | |
u.Password = string(hashedPassword) | |
return nil | |
} | |
func (u *User) Prepare() { | |
u.Username = html.EscapeString(strings.TrimSpace(u.Username)) | |
u.Email = html.EscapeString(strings.TrimSpace(u.Email)) | |
u.CreatedAt = time.Now() | |
u.UpdatedAt = time.Now() | |
} | |
func (u *User) AfterFind() (err error) { | |
if err != nil { | |
return err | |
} | |
if u.AvatarPath != "" { | |
u.AvatarPath = os.Getenv("DO_SPACES_URL") + u.AvatarPath | |
} | |
//dont return the user password | |
// u.Password = "" | |
return nil | |
} | |
func (u *User) Validate(action string) map[string]string { | |
var errorMessages = make(map[string]string) | |
var err error | |
switch strings.ToLower(action) { | |
case "update": | |
if u.Email == "" { | |
err = errors.New("Required Email") | |
errorMessages["Required_email"] = err.Error() | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
err = errors.New("Invalid Email") | |
errorMessages["Invalid_email"] = err.Error() | |
} | |
} | |
case "login": | |
if u.Password == "" { | |
err = errors.New("Required Password") | |
errorMessages["Required_password"] = err.Error() | |
} | |
if u.Email == "" { | |
err = errors.New("Required Email") | |
errorMessages["Required_email"] = err.Error() | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
err = errors.New("Invalid Email") | |
errorMessages["Invalid_email"] = err.Error() | |
} | |
} | |
case "forgotpassword": | |
if u.Email == "" { | |
err = errors.New("Required Email") | |
errorMessages["Required_email"] = err.Error() | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
err = errors.New("Invalid Email") | |
errorMessages["Invalid_email"] = err.Error() | |
} | |
} | |
default: | |
if u.Username == "" { | |
err = errors.New("Required Username") | |
errorMessages["Required_username"] = err.Error() | |
} | |
if u.Password == "" { | |
err = errors.New("Required Password") | |
errorMessages["Required_password"] = err.Error() | |
} | |
if u.Password != "" && len(u.Password) < 6 { | |
err = errors.New("Password should be atleast 6 characters") | |
errorMessages["Invalid_password"] = err.Error() | |
} | |
if u.Email == "" { | |
err = errors.New("Required Email") | |
errorMessages["Required_email"] = err.Error() | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
err = errors.New("Invalid Email") | |
errorMessages["Invalid_email"] = err.Error() | |
} | |
} | |
} | |
return errorMessages | |
} | |
func (u *User) SaveUser(db *gorm.DB) (*User, error) { | |
var err error | |
err = db.Debug().Create(&u).Error | |
if err != nil { | |
return &User{}, err | |
} | |
return u, nil | |
} | |
// THE ONLY PERSON THAT NEED TO DO THIS IS THE ADMIN, SO I HAVE COMMENTED THE ROUTES, SO SOMEONE ELSE DONT VIEW THIS DETAILS. | |
func (u *User) FindAllUsers(db *gorm.DB) (*[]User, error) { | |
var err error | |
users := []User{} | |
err = db.Debug().Model(&User{}).Limit(100).Find(&users).Error | |
if err != nil { | |
return &[]User{}, err | |
} | |
return &users, err | |
} | |
func (u *User) FindUserByID(db *gorm.DB, uid uint32) (*User, error) { | |
var err error | |
err = db.Debug().Model(User{}).Where("id = ?", uid).Take(&u).Error | |
if err != nil { | |
return &User{}, err | |
} | |
if gorm.IsRecordNotFoundError(err) { | |
return &User{}, errors.New("User Not Found") | |
} | |
return u, err | |
} | |
func (u *User) UpdateAUser(db *gorm.DB, uid uint32) (*User, error) { | |
if u.Password != "" { | |
// To hash the password | |
err := u.BeforeSave() | |
if err != nil { | |
log.Fatal(err) | |
} | |
db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).UpdateColumns( | |
map[string]interface{}{ | |
"password": u.Password, | |
"email": u.Email, | |
"update_at": time.Now(), | |
}, | |
) | |
} | |
db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).UpdateColumns( | |
map[string]interface{}{ | |
"email": u.Email, | |
"update_at": time.Now(), | |
}, | |
) | |
if db.Error != nil { | |
return &User{}, db.Error | |
} | |
// This is the display the updated user | |
err := db.Debug().Model(&User{}).Where("id = ?", uid).Take(&u).Error | |
if err != nil { | |
return &User{}, err | |
} | |
return u, nil | |
} | |
func (u *User) UpdateAUserAvatar(db *gorm.DB, uid uint32) (*User, error) { | |
db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).UpdateColumns( | |
map[string]interface{}{ | |
"avatar_path": u.AvatarPath, | |
"update_at": time.Now(), | |
}, | |
) | |
if db.Error != nil { | |
return &User{}, db.Error | |
} | |
// This is the display the updated user | |
err := db.Debug().Model(&User{}).Where("id = ?", uid).Take(&u).Error | |
if err != nil { | |
return &User{}, err | |
} | |
return u, nil | |
} | |
func (u *User) DeleteAUser(db *gorm.DB, uid uint32) (int64, error) { | |
db = db.Debug().Model(&User{}).Where("id = ?", uid).Take(&User{}).Delete(&User{}) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} | |
func (u *User) UpdatePassword(db *gorm.DB) error { | |
// To hash the password | |
err := u.BeforeSave() | |
if err != nil { | |
log.Fatal(err) | |
} | |
db = db.Debug().Model(&User{}).Where("email = ?", u.Email).Take(&User{}).UpdateColumns( | |
map[string]interface{}{ | |
"password": u.Password, | |
"update_at": time.Now(), | |
}, | |
) | |
if db.Error != nil { | |
return db.Error | |
} | |
return nil | |
} | |
b. Post Model
A post can be:
i. Created
ii. Updated
iii. Deleted
In the models directory, create a Post.go file:
touch Post.go
package models | |
import ( | |
"errors" | |
"html" | |
"strings" | |
"time" | |
"github.com/jinzhu/gorm" | |
) | |
type Post struct { | |
ID uint64 `gorm:"primary_key;auto_increment" json:"id"` | |
Title string `gorm:"size:255;not null;unique" json:"title"` | |
Content string `gorm:"text;not null;" json:"content"` | |
Author User `json:"author"` | |
AuthorID uint32 `gorm:"not null" json:"author_id"` | |
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` | |
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` | |
} | |
func (p *Post) Prepare() { | |
p.Title = html.EscapeString(strings.TrimSpace(p.Title)) | |
p.Content = html.EscapeString(strings.TrimSpace(p.Content)) | |
p.Author = User{} | |
p.CreatedAt = time.Now() | |
p.UpdatedAt = time.Now() | |
} | |
func (p *Post) Validate() map[string]string { | |
var err error | |
var errorMessages = make(map[string]string) | |
if p.Title == "" { | |
err = errors.New("Required Title") | |
errorMessages["Required_title"] = err.Error() | |
} | |
if p.Content == "" { | |
err = errors.New("Required Content") | |
errorMessages["Required_content"] = err.Error() | |
} | |
if p.AuthorID < 1 { | |
err = errors.New("Required Author") | |
errorMessages["Required_author"] = err.Error() | |
} | |
return errorMessages | |
} | |
func (p *Post) SavePost(db *gorm.DB) (*Post, error) { | |
var err error | |
err = db.Debug().Model(&Post{}).Create(&p).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
if p.ID != 0 { | |
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
} | |
return p, nil | |
} | |
func (p *Post) FindAllPosts(db *gorm.DB) (*[]Post, error) { | |
var err error | |
posts := []Post{} | |
err = db.Debug().Model(&Post{}).Limit(100).Order("created_at desc").Find(&posts).Error | |
if err != nil { | |
return &[]Post{}, err | |
} | |
if len(posts) > 0 { | |
for i, _ := range posts { | |
err := db.Debug().Model(&User{}).Where("id = ?", posts[i].AuthorID).Take(&posts[i].Author).Error | |
if err != nil { | |
return &[]Post{}, err | |
} | |
} | |
} | |
return &posts, nil | |
} | |
func (p *Post) FindPostByID(db *gorm.DB, pid uint64) (*Post, error) { | |
var err error | |
err = db.Debug().Model(&Post{}).Where("id = ?", pid).Take(&p).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
if p.ID != 0 { | |
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
} | |
return p, nil | |
} | |
func (p *Post) UpdateAPost(db *gorm.DB) (*Post, error) { | |
var err error | |
err = db.Debug().Model(&Post{}).Where("id = ?", p.ID).Updates(Post{Title: p.Title, Content: p.Content, UpdatedAt: time.Now()}).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
if p.ID != 0 { | |
err = db.Debug().Model(&User{}).Where("id = ?", p.AuthorID).Take(&p.Author).Error | |
if err != nil { | |
return &Post{}, err | |
} | |
} | |
return p, nil | |
} | |
func (p *Post) DeleteAPost(db *gorm.DB) (int64, error) { | |
db = db.Debug().Model(&Post{}).Where("id = ?", p.ID).Take(&Post{}).Delete(&Post{}) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} | |
func (p *Post) FindUserPosts(db *gorm.DB, uid uint32) (*[]Post, error) { | |
var err error | |
posts := []Post{} | |
err = db.Debug().Model(&Post{}).Where("author_id = ?", uid).Limit(100).Order("created_at desc").Find(&posts).Error | |
if err != nil { | |
return &[]Post{}, err | |
} | |
if len(posts) > 0 { | |
for i, _ := range posts { | |
err := db.Debug().Model(&User{}).Where("id = ?", posts[i].AuthorID).Take(&posts[i].Author).Error | |
if err != nil { | |
return &[]Post{}, err | |
} | |
} | |
} | |
return &posts, nil | |
} | |
//When a user is deleted, we also delete the post that the user had | |
func (c *Post) DeleteUserPosts(db *gorm.DB, uid uint32) (int64, error) { | |
posts := []Post{} | |
db = db.Debug().Model(&Post{}).Where("author_id = ?", uid).Find(&posts).Delete(&posts) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} |
c. Like Model
Posts can be liked or unliked.
A like can be:
i. Created
ii. Deleted
Create the Like.go file:
touch Like.go
package models | |
import ( | |
"errors" | |
"fmt" | |
"time" | |
"github.com/jinzhu/gorm" | |
) | |
type Like struct { | |
ID uint64 `gorm:"primary_key;auto_increment" json:"id"` | |
UserID uint32 `gorm:"not null" json:"user_id"` | |
PostID uint64 `gorm:"not null" json:"post_id"` | |
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` | |
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` | |
} | |
func (l *Like) SaveLike(db *gorm.DB) (*Like, error) { | |
// Check if the auth user has liked this post before: | |
err := db.Debug().Model(&Like{}).Where("post_id = ? AND user_id = ?", l.PostID, l.UserID).Take(&l).Error | |
if err != nil { | |
if err.Error() == "record not found" { | |
// The user has not liked this post before, so lets save incomming like: | |
err = db.Debug().Model(&Like{}).Create(&l).Error | |
if err != nil { | |
return &Like{}, err | |
} | |
} | |
} else { | |
// The user has liked it before, so create a custom error message | |
err = errors.New("double like") | |
return &Like{}, err | |
} | |
return l, nil | |
} | |
func (l *Like) DeleteLike(db *gorm.DB) (*Like, error) { | |
var err error | |
var deletedLike *Like | |
err = db.Debug().Model(Like{}).Where("id = ?", l.ID).Take(&l).Error | |
if err != nil { | |
return &Like{}, err | |
} else { | |
//If the like exist, save it in deleted like and delete it | |
deletedLike = l | |
db = db.Debug().Model(&Like{}).Where("id = ?", l.ID).Take(&Like{}).Delete(&Like{}) | |
if db.Error != nil { | |
fmt.Println("cant delete like: ", db.Error) | |
return &Like{}, db.Error | |
} | |
} | |
return deletedLike, nil | |
} | |
func (l *Like) GetLikesInfo(db *gorm.DB, pid uint64) (*[]Like, error) { | |
likes := []Like{} | |
err := db.Debug().Model(&Like{}).Where("post_id = ?", pid).Find(&likes).Error | |
if err != nil { | |
return &[]Like{}, err | |
} | |
return &likes, err | |
} | |
//When a post is deleted, we also delete the likes that the post had | |
func (l *Like) DeleteUserLikes(db *gorm.DB, uid uint32) (int64, error) { | |
likes := []Like{} | |
db = db.Debug().Model(&Like{}).Where("user_id = ?", uid).Find(&likes).Delete(&likes) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} | |
//When a post is deleted, we also delete the likes that the post had | |
func (l *Like) DeletePostLikes(db *gorm.DB, pid uint64) (int64, error) { | |
likes := []Like{} | |
db = db.Debug().Model(&Like{}).Where("post_id = ?", pid).Find(&likes).Delete(&likes) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} |
d. Comment Model
A post can have comments.
Comment can be:
i. Created
ii. Updated
iii. Deleted
Create the Comment.go file
touch Comment.go
package models | |
import ( | |
"errors" | |
"fmt" | |
"html" | |
"strings" | |
"time" | |
"github.com/jinzhu/gorm" | |
) | |
type Comment struct { | |
ID uint64 `gorm:"primary_key;auto_increment" json:"id"` | |
UserID uint32 `gorm:"not null" json:"user_id"` | |
PostID uint64 `gorm:"not null" json:"post_id"` | |
Body string `gorm:"text;not null;" json:"body"` | |
User User `json:"user"` | |
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` | |
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` | |
} | |
func (c *Comment) Prepare() { | |
c.ID = 0 | |
c.Body = html.EscapeString(strings.TrimSpace(c.Body)) | |
c.User = User{} | |
c.CreatedAt = time.Now() | |
c.UpdatedAt = time.Now() | |
} | |
func (c *Comment) Validate(action string) map[string]string { | |
var errorMessages = make(map[string]string) | |
var err error | |
switch strings.ToLower(action) { | |
case "update": | |
if c.Body == "" { | |
err = errors.New("Required Comment") | |
errorMessages["Required_body"] = err.Error() | |
} | |
default: | |
if c.Body == "" { | |
err = errors.New("Required Comment") | |
errorMessages["Required_body"] = err.Error() | |
} | |
} | |
return errorMessages | |
} | |
func (c *Comment) SaveComment(db *gorm.DB) (*Comment, error) { | |
err := db.Debug().Create(&c).Error | |
if err != nil { | |
return &Comment{}, err | |
} | |
if c.ID != 0 { | |
err = db.Debug().Model(&User{}).Where("id = ?", c.UserID).Take(&c.User).Error | |
if err != nil { | |
return &Comment{}, err | |
} | |
} | |
return c, nil | |
} | |
func (c *Comment) GetComments(db *gorm.DB, pid uint64) (*[]Comment, error) { | |
comments := []Comment{} | |
err := db.Debug().Model(&Comment{}).Where("post_id = ?", pid).Order("created_at desc").Find(&comments).Error | |
if err != nil { | |
return &[]Comment{}, err | |
} | |
if len(comments) > 0 { | |
for i, _ := range comments { | |
err := db.Debug().Model(&User{}).Where("id = ?", comments[i].UserID).Take(&comments[i].User).Error | |
if err != nil { | |
return &[]Comment{}, err | |
} | |
} | |
} | |
return &comments, err | |
} | |
func (c *Comment) UpdateAComment(db *gorm.DB) (*Comment, error) { | |
var err error | |
err = db.Debug().Model(&Comment{}).Where("id = ?", c.ID).Updates(Comment{Body: c.Body, UpdatedAt: time.Now()}).Error | |
if err != nil { | |
return &Comment{}, err | |
} | |
fmt.Println("this is the comment body: ", c.Body) | |
if c.ID != 0 { | |
err = db.Debug().Model(&User{}).Where("id = ?", c.UserID).Take(&c.User).Error | |
if err != nil { | |
return &Comment{}, err | |
} | |
} | |
return c, nil | |
} | |
func (c *Comment) DeleteAComment(db *gorm.DB) (int64, error) { | |
db = db.Debug().Model(&Comment{}).Where("id = ?", c.ID).Take(&Comment{}).Delete(&Comment{}) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} | |
//When a user is deleted, we also delete the comments that the user had | |
func (c *Comment) DeleteUserComments(db *gorm.DB, uid uint32) (int64, error) { | |
comments := []Comment{} | |
db = db.Debug().Model(&Comment{}).Where("user_id = ?", uid).Find(&comments).Delete(&comments) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} | |
//When a post is deleted, we also delete the comments that the post had | |
func (c *Comment) DeletePostComments(db *gorm.DB, pid uint64) (int64, error) { | |
comments := []Comment{} | |
db = db.Debug().Model(&Comment{}).Where("post_id = ?", pid).Find(&comments).Delete(&comments) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} |
e. ResetPassword Model
A user might forget his/her password. When this happens, they can request to change to a new one. A notification will be sent to their email address with instructions to create a new password.
In the models directory, create the ResetPassword.go file:
touch ResetPassword.go
package models | |
import ( | |
"html" | |
"strings" | |
"github.com/jinzhu/gorm" | |
) | |
type ResetPassword struct { | |
gorm.Model | |
Email string `gorm:"size:100;not null;" json:"email"` | |
Token string `gorm:"size:255;not null;" json:"token"` | |
} | |
func (resetPassword *ResetPassword) Prepare() { | |
resetPassword.Token = html.EscapeString(strings.TrimSpace(resetPassword.Token)) | |
resetPassword.Email = html.EscapeString(strings.TrimSpace(resetPassword.Email)) | |
} | |
func (resetPassword *ResetPassword) SaveDatails(db *gorm.DB) (*ResetPassword, error) { | |
var err error | |
err = db.Debug().Create(&resetPassword).Error | |
if err != nil { | |
return &ResetPassword{}, err | |
} | |
return resetPassword, nil | |
} | |
func (resetPassword *ResetPassword) DeleteDatails(db *gorm.DB) (int64, error) { | |
db = db.Debug().Model(&ResetPassword{}).Where("id = ?", resetPassword.ID).Take(&ResetPassword{}).Delete(&ResetPassword{}) | |
if db.Error != nil { | |
return 0, db.Error | |
} | |
return db.RowsAffected, nil | |
} |
Step 3: Security
a. Password Security
Observe in the User.go file, that before a password is saved in our database, it must first be hashed. We called a function to help us do that. Let's wire it up.
In the api directory(the path: /forum-backend/api/), create the security directory:
mkdir security
Inside the security directory, create the password.go file:
cd security && touch password.go
package security | |
import "golang.org/x/crypto/bcrypt" | |
func Hash(password string) ([]byte, error) { | |
return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | |
} | |
func VerifyPassword(hashedPassword, password string) error { | |
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) | |
} |
b. Token Creation for ResetPassword
This is the scenario: when a user requests to change his password, a token is sent to that user's email. A function is written to hash the token. This function will be used when we wire up the ResetPassword controller file.
Inside the security directory, create the tokenhash.go file:
touch tokenhash.go
package security | |
import ( | |
"crypto/md5" | |
"encoding/hex" | |
"github.com/twinj/uuid" | |
) | |
func TokenHash(text string) string { | |
hasher := md5.New() | |
hasher.Write([]byte(text)) | |
theHash := hex.EncodeToString(hasher.Sum(nil)) | |
//also use uuid | |
u := uuid.NewV4() | |
theToken := theHash + u.String() | |
return theToken | |
} |
Step 4: Seeder
I think is a good idea to have data to experiment with. We will be seeding the users and posts table when we eventually wire the database.
In the api directory (in the path: /forum/api/), create a seed directory:
mkdir seed
Inside the seed directory, create the seeder file seeder.go
touch seeder.go
package seed | |
import ( | |
"log" | |
"github.com/jinzhu/gorm" | |
"github.com/victorsteven/forum/api/models" | |
) | |
var users = []models.User{ | |
models.User{ | |
Username: "steven", | |
Email: "steven@example.com", | |
Password: "password", | |
}, | |
models.User{ | |
Username: "martin", | |
Email: "luther@example.com", | |
Password: "password", | |
}, | |
} | |
var posts = []models.Post{ | |
models.Post{ | |
Title: "Title 1", | |
Content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", | |
}, | |
models.Post{ | |
Title: "Title 2", | |
Content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", | |
}, | |
} | |
func Load(db *gorm.DB) { | |
err := db.Debug().DropTableIfExists(&models.Post{}, &models.User{}, &models.Like{}, &models.Comment{}).Error | |
if err != nil { | |
log.Fatalf("cannot drop table: %v", err) | |
} | |
err = db.Debug().AutoMigrate(&models.User{}, &models.Post{}).Error | |
if err != nil { | |
log.Fatalf("cannot migrate table: %v", err) | |
} | |
err = db.Debug().Model(&models.Post{}).AddForeignKey("author_id", "users(id)", "cascade", "cascade").Error | |
if err != nil { | |
log.Fatalf("attaching foreign key error: %v", err) | |
} | |
for i, _ := range users { | |
err = db.Debug().Model(&models.User{}).Create(&users[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed users table: %v", err) | |
} | |
posts[i].AuthorID = users[i].ID | |
err = db.Debug().Model(&models.Post{}).Create(&posts[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed posts table: %v", err) | |
} | |
} | |
} |
Step 5: Using JWT for Authentication
This app will require authentication for several things such as creating a post, liking a post, updating a profile, commenting on a post, and so on. We need to put in place an authentication system.
Inside the api directory, create the auth directory:
mkdir auth
Inside the auth directory, create the token.go file:
cd auth && touch token.go
package auth | |
import ( | |
"encoding/json" | |
"fmt" | |
"log" | |
"net/http" | |
"os" | |
"strconv" | |
"strings" | |
"github.com/dgrijalva/jwt-go" | |
) | |
func CreateToken(id uint32) (string, error) { | |
claims := jwt.MapClaims{} | |
claims["authorized"] = true | |
claims["id"] = id | |
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
return token.SignedString([]byte(os.Getenv("API_SECRET"))) | |
} | |
func TokenValid(r *http.Request) error { | |
tokenString := ExtractToken(r) | |
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { | |
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) | |
} | |
return []byte(os.Getenv("API_SECRET")), nil | |
}) | |
if err != nil { | |
return err | |
} | |
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { | |
Pretty(claims) | |
} | |
return nil | |
} | |
func ExtractToken(r *http.Request) string { | |
keys := r.URL.Query() | |
token := keys.Get("token") | |
if token != "" { | |
return token | |
} | |
bearerToken := r.Header.Get("Authorization") | |
if len(strings.Split(bearerToken, " ")) == 2 { | |
return strings.Split(bearerToken, " ")[1] | |
} | |
return "" | |
} | |
func ExtractTokenID(r *http.Request) (uint32, error) { | |
tokenString := ExtractToken(r) | |
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { | |
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) | |
} | |
return []byte(os.Getenv("API_SECRET")), nil | |
}) | |
if err != nil { | |
return 0, err | |
} | |
claims, ok := token.Claims.(jwt.MapClaims) | |
if ok && token.Valid { | |
uid, err := strconv.ParseUint(fmt.Sprintf("%.0f", claims["id"]), 10, 32) | |
if err != nil { | |
return 0, err | |
} | |
return uint32(uid), nil | |
} | |
return 0, nil | |
} | |
//Pretty display the claims licely in the terminal | |
func Pretty(data interface{}) { | |
b, err := json.MarshalIndent(data, "", " ") | |
if err != nil { | |
log.Println(err) | |
return | |
} | |
fmt.Println(string(b)) | |
} |
Step 6: Protect App with Middlewares
We created authentication in step 5. Middlewares are like the Police. They will ensure that the auth rules are not broken.
The CORS middleware will allow us to interact with the React Client that we will be wiring up in section 2.
In the api directory, create the middlewares directory
mkdir middlewares
Then create the middlewares.go file inside the middlewares directory.
cd middlewares && touch middlewares.go
package middlewares | |
import ( | |
"net/http" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
) | |
func TokenAuthMiddleware() gin.HandlerFunc { | |
errList := make(map[string]string) | |
return func(c *gin.Context) { | |
err := auth.TokenValid(c.Request) | |
if err != nil { | |
errList["unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
c.Abort() | |
return | |
} | |
c.Next() | |
} | |
} | |
// This enables us interact with the React Frontend | |
func CORSMiddleware() gin.HandlerFunc { | |
return func(c *gin.Context) { | |
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | |
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | |
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | |
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") | |
if c.Request.Method == "OPTIONS" { | |
c.AbortWithStatus(204) | |
return | |
} | |
c.Next() | |
} | |
} |
Step 7: Utilities
a. Error Formatting
We will like to handle errors nicely when they occur.
The ORM(Object-Relational Mapping) that is used in the app is GORM. There are some error messages that are not displayed nicely, especially those that occurred when the database is hit.
For instance, when a user inputs someone else email that is already in our database, in an attempt to sign up, we need to prevent such action and politely tell the user that he can't use that email.
In the api directory, create a the utils directory
mkdir utils
Inside the utils directory, create a formaterror directory:
cd utils && mkdir formaterror
Then create the formaterror.go file:
cd formaterror && touch formaterror.go
package formaterror | |
import ( | |
"strings" | |
) | |
var errorMessages = make(map[string]string) | |
var err error | |
func FormatError(errString string) map[string]string { | |
if strings.Contains(errString, "username") { | |
errorMessages["Taken_username"] = "Username Already Taken" | |
} | |
if strings.Contains(errString, "email") { | |
errorMessages["Taken_email"] = "Email Already Taken" | |
} | |
if strings.Contains(errString, "title") { | |
errorMessages["Taken_title"] = "Title Already Taken" | |
} | |
if strings.Contains(errString, "hashedPassword") { | |
errorMessages["Incorrect_password"] = "Incorrect Password" | |
} | |
if strings.Contains(errString, "record not found") { | |
errorMessages["No_record"] = "No Record Found" | |
} | |
if strings.Contains(errString, "double like") { | |
errorMessages["Double_like"] = "You cannot like this post twice" | |
} | |
if len(errorMessages) > 0 { | |
return errorMessages | |
} | |
if len(errorMessages) == 0 { | |
errorMessages["Incorrect_details"] = "Incorrect Details" | |
return errorMessages | |
} | |
return nil | |
} |
b. File Formatting
A user will need to update his profile(including adding an image) when he does, we will need to make sure that we image added has a unique name.
In the utils directory(path: /forum-backend/api/utils), create the fileformat directory.
mkdir fileformat
Then create the fileformat.go file inside the fileformat directory:
cd fileformat && touch fileformat.go
package fileformat | |
import ( | |
"path" | |
"strings" | |
"github.com/twinj/uuid" | |
) | |
func UniqueFormat(fn string) string { | |
//path.Ext() get the extension of the file | |
fileName := strings.TrimSuffix(fn, path.Ext(fn)) | |
extension := path.Ext(fn) | |
u := uuid.NewV4() | |
newFileName := fileName + "-" + u.String() + extension | |
return newFileName | |
} |
Step 8: Emails
Remember when we were wiring up the models, we had the ResetPassword model. Well, when a user wishes to change his password, an email is sent to him with instructions to do so. Let set up that email file.
The emails are handled using Sendgrid service.
In the api directory, create a mailer directory
mkdir mailer
Inside the mailer directory create the forgot_password_mail.go file.
cd mailer && touch forgot_password_mail.go
package mailer | |
import ( | |
"net/http" | |
"os" | |
"github.com/matcornic/hermes/v2" | |
"github.com/sendgrid/sendgrid-go" | |
"github.com/sendgrid/sendgrid-go/helpers/mail" | |
) | |
type sendMail struct {} | |
type SendMailer interface { | |
SendResetPassword(string, string, string, string, string) (*EmailResponse, error) | |
} | |
var ( | |
SendMail SendMailer = &sendMail{} //this is useful when we start testing | |
) | |
type EmailResponse struct { | |
Status int | |
RespBody string | |
} | |
func (s *sendMail) SendResetPassword(ToUser string, FromAdmin string, Token string, Sendgridkey string, AppEnv string) (*EmailResponse, error) { | |
h := hermes.Hermes{ | |
Product: hermes.Product{ | |
Name: "SeamFlow", | |
Link: "https://seamflow.com", | |
}, | |
} | |
var forgotUrl string | |
if os.Getenv("APP_ENV") == "production" { | |
forgotUrl = "https://seamflow.com/resetpassword/" + Token //this is the url of the frontend app | |
} else { | |
forgotUrl = "http://127.0.0.1:3000/resetpassword/" + Token //this is the url of the local frontend app | |
} | |
email := hermes.Email{ | |
Body: hermes.Body{ | |
Name: ToUser, | |
Intros: []string{ | |
"Welcome to SeamFlow! Good to have you here.", | |
}, | |
Actions: []hermes.Action{ | |
{ | |
Instructions: "Click this link to reset your password", | |
Button: hermes.Button{ | |
Color: "#FFFFFF", | |
Text: "Reset Password", | |
Link: forgotUrl, | |
}, | |
}, | |
}, | |
Outros: []string{ | |
"Need help, or have questions? Just reply to this email, we'd love to help.", | |
}, | |
}, | |
} | |
emailBody, err := h.GenerateHTML(email) | |
if err != nil { | |
return nil, err | |
} | |
from := mail.NewEmail("SeamFlow", FromAdmin) | |
subject := "Reset Password" | |
to := mail.NewEmail("Reset Password", ToUser) | |
message := mail.NewSingleEmail(from, subject, to, emailBody, emailBody) | |
client := sendgrid.NewSendClient(Sendgridkey) | |
_, err = client.Send(message) | |
if err != nil { | |
return nil, err | |
} | |
return &EmailResponse{ | |
Status: http.StatusOK, | |
RespBody: "Success, Please click on the link provided in your email", | |
}, nil | |
} |
Step 9: Wiring Up Controllers and Routes
I perceive you might be have been thinking how all these things connect right? Well, perish the thought, because we are finally there.
This step was purposely skipped until now because it calls most of the functions and methods we defined above.
In the api directory(path: /forum-backend/api/), create the controllers directory.
mkdir controllers
You might need to pay close attention to this directory.
a. The base file
This file will have our database connection information, call our routes, and start our server:
Inside the controllers directory, create the base.go file:
cd controllers && touch base.go
package controllers | |
import ( | |
"fmt" | |
"log" | |
"net/http" | |
"github.com/victorsteven/forum/api/middlewares" | |
"github.com/gin-gonic/gin" | |
"github.com/jinzhu/gorm" | |
_ "github.com/jinzhu/gorm/dialects/mysql" //mysql database driver | |
_ "github.com/jinzhu/gorm/dialects/postgres" //postgres database driver | |
"github.com/victorsteven/forum/api/models" | |
) | |
type Server struct { | |
DB *gorm.DB | |
Router *gin.Engine | |
} | |
var errList = make(map[string]string) | |
func (server *Server) Initialize(Dbdriver, DbUser, DbPassword, DbPort, DbHost, DbName string) { | |
var err error | |
// If you are using mysql, i added support for you here(dont forgot to edit the .env file) | |
if Dbdriver == "mysql" { | |
DBURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", DbUser, DbPassword, DbHost, DbPort, DbName) | |
server.DB, err = gorm.Open(Dbdriver, DBURL) | |
if err != nil { | |
fmt.Printf("Cannot connect to %s database", Dbdriver) | |
log.Fatal("This is the error:", err) | |
} else { | |
fmt.Printf("We are connected to the %s database", Dbdriver) | |
} | |
} else if Dbdriver == "postgres" { | |
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", DbHost, DbPort, DbUser, DbName, DbPassword) | |
server.DB, err = gorm.Open(Dbdriver, DBURL) | |
if err != nil { | |
fmt.Printf("Cannot connect to %s database", Dbdriver) | |
log.Fatal("This is the error connecting to postgres:", err) | |
} else { | |
fmt.Printf("We are connected to the %s database", Dbdriver) | |
} | |
} else { | |
fmt.Println("Unknown Driver") | |
} | |
//database migration | |
server.DB.Debug().AutoMigrate( | |
&models.User{}, | |
&models.Post{}, | |
&models.ResetPassword{}, | |
&models.Like{}, | |
&models.Comment{}, | |
) | |
server.Router = gin.Default() | |
server.Router.Use(middlewares.CORSMiddleware()) | |
server.initializeRoutes() | |
} | |
func (server *Server) Run(addr string) { | |
log.Fatal(http.ListenAndServe(addr, server.Router)) | |
} |
b. Users Controller
Inside the controllers directory, create the users_controller.go file
touch users_controller.go
package controllers | |
import ( | |
"bytes" | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"log" | |
"net/http" | |
"os" | |
"strconv" | |
"strings" | |
"github.com/joho/godotenv" | |
"golang.org/x/crypto/bcrypt" | |
"github.com/aws/aws-sdk-go/aws" | |
"github.com/aws/aws-sdk-go/aws/credentials" | |
"github.com/aws/aws-sdk-go/aws/session" | |
"github.com/aws/aws-sdk-go/service/s3" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/security" | |
"github.com/victorsteven/forum/api/utils/fileformat" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
) | |
func (server *Server) CreateUser(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
user := models.User{} | |
err = json.Unmarshal(body, &user) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
user.Prepare() | |
errorMessages := user.Validate("") | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
userCreated, err := user.SaveUser(server.DB) | |
if err != nil { | |
formattedError := formaterror.FormatError(err.Error()) | |
errList = formattedError | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusCreated, gin.H{ | |
"status": http.StatusCreated, | |
"response": userCreated, | |
}) | |
} | |
func (server *Server) GetUsers(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
user := models.User{} | |
users, err := user.FindAllUsers(server.DB) | |
if err != nil { | |
errList["No_user"] = "No User Found" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": users, | |
}) | |
} | |
func (server *Server) GetUser(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
userID := c.Param("id") | |
uid, err := strconv.ParseUint(userID, 10, 32) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
user := models.User{} | |
userGotten, err := user.FindUserByID(server.DB, uint32(uid)) | |
if err != nil { | |
errList["No_user"] = "No User Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": userGotten, | |
}) | |
} | |
//IF YOU ARE USING AMAZON S3 | |
// func SaveProfileImage(s *session.Session, file *multipart.FileHeader) (string, error) { | |
// size := file.Size | |
// buffer := make([]byte, size) | |
// f, err := file.Open() | |
// if err != nil { | |
// fmt.Println("This is the error: ") | |
// fmt.Println(err) | |
// } | |
// defer f.Close() | |
// filePath := "/profile-photos/" + fileformat.UniqueFormat(file.Filename) | |
// f.Read(buffer) | |
// fileBytes := bytes.NewReader(buffer) | |
// fileType := http.DetectContentType(buffer) | |
// _, err = s3.New(s).PutObject(&s3.PutObjectInput{ | |
// ACL: aws.String("public-read"), | |
// Body: fileBytes, | |
// Bucket: aws.String("chodapibucket"), | |
// ContentLength: aws.Int64(size), | |
// ContentType: aws.String(fileType), | |
// Key: aws.String(filePath), | |
// }) | |
// if err != nil { | |
// return "", err | |
// } | |
// return filePath, err | |
// } | |
func (server *Server) UpdateAvatar(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
var err error | |
err = godotenv.Load() | |
if err != nil { | |
log.Fatalf("Error getting env, %v", err) | |
} | |
userID := c.Param("id") | |
// Check if the user id is valid | |
uid, err := strconv.ParseUint(userID, 10, 32) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Get user id from the token for valid tokens | |
tokenID, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If the id is not the authenticated user id | |
if tokenID != 0 && tokenID != uint32(uid) { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
file, err := c.FormFile("file") | |
if err != nil { | |
errList["Invalid_file"] = "Invalid File" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
f, err := file.Open() | |
if err != nil { | |
errList["Invalid_file"] = "Invalid File" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
defer f.Close() | |
size := file.Size | |
//The image should not be more than 500KB | |
if size > int64(512000) { | |
errList["Too_large"] = "Sorry, Please upload an Image of 500KB or less" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
buffer := make([]byte, size) | |
f.Read(buffer) | |
fileBytes := bytes.NewReader(buffer) | |
fileType := http.DetectContentType(buffer) | |
//if the image is valid | |
if !strings.HasPrefix(fileType, "image") { | |
errList["Not_Image"] = "Please Upload a valid image" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
filePath := fileformat.UniqueFormat(file.Filename) | |
path := "/profile-photos/" + filePath | |
params := &s3.PutObjectInput{ | |
Bucket: aws.String("chodapi"), | |
Key: aws.String(path), | |
Body: fileBytes, | |
ContentLength: aws.Int64(size), | |
ContentType: aws.String(fileType), | |
ACL: aws.String("public-read"), | |
} | |
s3Config := &aws.Config{ | |
Credentials: credentials.NewStaticCredentials( | |
os.Getenv("DO_SPACES_KEY"), os.Getenv("DO_SPACES_SECRET"), os.Getenv("DO_SPACES_TOKEN")), | |
Endpoint: aws.String(os.Getenv("DO_SPACES_ENDPOINT")), | |
Region: aws.String(os.Getenv("DO_SPACES_REGION")), | |
} | |
newSession := session.New(s3Config) | |
s3Client := s3.New(newSession) | |
_, err = s3Client.PutObject(params) | |
if err != nil { | |
fmt.Println(err.Error()) | |
return | |
} | |
//IF YOU PREFER TO USE AMAZON S3 | |
//s, err := session.NewSession(&aws.Config{Too_large | |
// Region: aws.String("us-east-1"), | |
// Credentials: credentials.NewStaticCredentials( | |
// os.Getenv("AWS_KEY"), | |
// os.Getenv("AWS_SECRET"), | |
// os.Getenv("AWS_TOKEN"), | |
// ), | |
//}) | |
//if err != nil { | |
// fmt.Printf("Could not upload file first error: %s\n", err) | |
//} | |
//fileName, err := SaveProfileImage(s, file) | |
//if err != nil { | |
// fmt.Printf("Could not upload file %s\n", err) | |
//} else { | |
// fmt.Printf("Image uploaded: %s\n", fileName) | |
//} | |
//Save the image path to the database | |
user := models.User{} | |
user.AvatarPath = filePath | |
user.Prepare() | |
updatedUser, err := user.UpdateAUserAvatar(server.DB, uint32(uid)) | |
if err != nil { | |
errList["Cannot_Save"] = "Cannot Save Image, Pls try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": updatedUser, | |
}) | |
} | |
func (server *Server) UpdateUser(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
userID := c.Param("id") | |
// Check if the user id is valid | |
uid, err := strconv.ParseUint(userID, 10, 32) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Get user id from the token for valid tokens | |
tokenID, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If the id is not the authenticated user id | |
if tokenID != 0 && tokenID != uint32(uid) { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Start processing the request | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
requestBody := map[string]string{} | |
err = json.Unmarshal(body, &requestBody) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
// Check for previous details | |
formerUser := models.User{} | |
err = server.DB.Debug().Model(models.User{}).Where("id = ?", uid).Take(&formerUser).Error | |
if err != nil { | |
errList["User_invalid"] = "The user is does not exist" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
newUser := models.User{} | |
//When current password has content. | |
if requestBody["current_password"] == "" && requestBody["new_password"] != "" { | |
errList["Empty_current"] = "Please Provide current password" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
if requestBody["current_password"] != "" && requestBody["new_password"] == "" { | |
errList["Empty_new"] = "Please Provide new password" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
if requestBody["current_password"] != "" && requestBody["new_password"] != "" { | |
//Also check if the new password | |
if len(requestBody["new_password"]) < 6 { | |
errList["Invalid_password"] = "Password should be atleast 6 characters" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
//if they do, check that the former password is correct | |
err = security.VerifyPassword(formerUser.Password, requestBody["current_password"]) | |
if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { | |
errList["Password_mismatch"] = "The password not correct" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
//update both the password and the email | |
newUser.Username = formerUser.Username //remember, you cannot update the username | |
newUser.Email = requestBody["email"] | |
newUser.Password = requestBody["new_password"] | |
} | |
//The password fields not entered, so update only the email | |
newUser.Username = formerUser.Username | |
newUser.Email = requestBody["email"] | |
newUser.Prepare() | |
errorMessages := newUser.Validate("update") | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
updatedUser, err := newUser.UpdateAUser(server.DB, uint32(uid)) | |
if err != nil { | |
errList := formaterror.FormatError(err.Error()) | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": updatedUser, | |
}) | |
} | |
func (server *Server) DeleteUser(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
var tokenID uint32 | |
userID := c.Param("id") | |
// Check if the user id is valid | |
uid, err := strconv.ParseUint(userID, 10, 32) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Get user id from the token for valid tokens | |
tokenID, err = auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If the id is not the authenticated user id | |
if tokenID != 0 && tokenID != uint32(uid) { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
user := models.User{} | |
_, err = user.DeleteAUser(server.DB, uint32(uid)) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
// Also delete the posts, likes and the comments that this user created if any: | |
comment := models.Comment{} | |
like := models.Like{} | |
post := models.Post{} | |
_, err = post.DeleteUserPosts(server.DB, uint32(uid)) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": err, | |
}) | |
return | |
} | |
_, err = comment.DeleteUserComments(server.DB, uint32(uid)) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": err, | |
}) | |
return | |
} | |
_, err = like.DeleteUserLikes(server.DB, uint32(uid)) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": err, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": "User deleted", | |
}) | |
} |
From the above file, you can observe that we sent a photo upload to either DigitalOceanSpaces or AWS S3 Bucket
If you wish to practice along, You will need to create an Amazon S3 bucket or DigitalOcean Spaces object to store the images.
Also, update your .env file:
DO_SPACES_KEY=your_do_key
DO_SPACES_SECRET=your_do_secret
DO_SPACES_TOKEN=your_do_token
DO_SPACES_ENDPOINT=your_do_endpoint
DO_SPACES_REGION=your_do_region
DO_SPACES_URL=your_do_url
# OR USING S3:
AWS_KEY=your_aws_key
AWS_SECRET=your_aws_secret
AWS_TOKEN=
c. Posts Controller
Inside the controllers directory, create the posts_controller.go file:
touch posts_controller.go
package controllers | |
import ( | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"net/http" | |
"strconv" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
) | |
func (server *Server) CreatePost(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
post := models.Post{} | |
err = json.Unmarshal(body, &post) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the user exist: | |
user := models.User{} | |
err = server.DB.Debug().Model(models.User{}).Where("id = ?", uid).Take(&user).Error | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
post.AuthorID = uid //the authenticated user is the one creating the post | |
post.Prepare() | |
errorMessages := post.Validate() | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
postCreated, err := post.SavePost(server.DB) | |
if err != nil { | |
errList := formaterror.FormatError(err.Error()) | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusCreated, gin.H{ | |
"status": http.StatusCreated, | |
"response": postCreated, | |
}) | |
} | |
func (server *Server) GetPosts(c *gin.Context) { | |
post := models.Post{} | |
posts, err := post.FindAllPosts(server.DB) | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": posts, | |
}) | |
} | |
func (server *Server) GetPost(c *gin.Context) { | |
postID := c.Param("id") | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
post := models.Post{} | |
postReceived, err := post.FindPostByID(server.DB, pid) | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": postReceived, | |
}) | |
} | |
func (server *Server) UpdatePost(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
postID := c.Param("id") | |
// Check if the post id is valid | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
//CHeck if the auth token is valid and get the user id from it | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
//Check if the post exist | |
origPost := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&origPost).Error | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
if uid != origPost.AuthorID { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Read the data posted | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
// Start processing the request data | |
post := models.Post{} | |
err = json.Unmarshal(body, &post) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
post.ID = origPost.ID //this is important to tell the model the post id to update, the other update field are set above | |
post.AuthorID = origPost.AuthorID | |
post.Prepare() | |
errorMessages := post.Validate() | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
postUpdated, err := post.UpdateAPost(server.DB) | |
if err != nil { | |
errList := formaterror.FormatError(err.Error()) | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": postUpdated, | |
}) | |
} | |
func (server *Server) DeletePost(c *gin.Context) { | |
postID := c.Param("id") | |
// Is a valid post id given to us? | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
fmt.Println("this is delete post sir") | |
// Is this user authenticated? | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Check if the post exist | |
post := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&post).Error | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
// Is the authenticated user, the owner of this post? | |
if uid != post.AuthorID { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If all the conditions are met, delete the post | |
_, err = post.DeleteAPost(server.DB) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
comment := models.Comment{} | |
like := models.Like{} | |
// Also delete the likes and the comments that this post have: | |
_, err = comment.DeletePostComments(server.DB, pid) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
_, err = like.DeletePostLikes(server.DB, pid) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": "Post deleted", | |
}) | |
} | |
func (server *Server) GetUserPosts(c *gin.Context) { | |
userID := c.Param("id") | |
// Is a valid user id given to us? | |
uid, err := strconv.ParseUint(userID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
post := models.Post{} | |
posts, err := post.FindUserPosts(server.DB, uint32(uid)) | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": posts, | |
}) | |
} |
c. Login Controller
Request that update a user, create a post, delete a post, and so on, need authentication.
Inside the controllers directory, create the login_controller.go file:
touch login_controller.go
package controllers | |
import ( | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"net/http" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/security" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
"golang.org/x/crypto/bcrypt" | |
) | |
func (server *Server) Login(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"first error": "Unable to get request", | |
}) | |
return | |
} | |
user := models.User{} | |
err = json.Unmarshal(body, &user) | |
if err != nil { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": "Cannot unmarshal body", | |
}) | |
return | |
} | |
user.Prepare() | |
errorMessages := user.Validate("login") | |
if len(errorMessages) > 0 { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errorMessages, | |
}) | |
return | |
} | |
userData, err := server.SignIn(user.Email, user.Password) | |
if err != nil { | |
formattedError := formaterror.FormatError(err.Error()) | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": formattedError, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": userData, | |
}) | |
} | |
func (server *Server) SignIn(email, password string) (map[string]interface{}, error) { | |
var err error | |
userData := make(map[string]interface{}) | |
user := models.User{} | |
err = server.DB.Debug().Model(models.User{}).Where("email = ?", email).Take(&user).Error | |
if err != nil { | |
fmt.Println("this is the error getting the user: ", err) | |
return nil, err | |
} | |
err = security.VerifyPassword(user.Password, password) | |
if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { | |
fmt.Println("this is the error hashing the password: ", err) | |
return nil, err | |
} | |
token, err := auth.CreateToken(user.ID) | |
if err != nil { | |
fmt.Println("this is the error creating the token: ", err) | |
return nil, err | |
} | |
userData["token"] = token | |
userData["id"] = user.ID | |
userData["email"] = user.Email | |
userData["avatar_path"] = user.AvatarPath | |
userData["username"] = user.Username | |
return userData, nil | |
} |
c. Likes Controller
An authenticated user can like a post or unliked already liked post.
Inside the controllers directory, create likes_controller.go file
touch likes_controller.go
package controllers | |
import ( | |
"fmt" | |
"net/http" | |
"strconv" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
) | |
func (server *Server) LikePost(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
postID := c.Param("id") | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the user exist: | |
user := models.User{} | |
err = server.DB.Debug().Model(models.User{}).Where("id = ?", uid).Take(&user).Error | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the post exist: | |
post := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&post).Error | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
like := models.Like{} | |
like.UserID = user.ID | |
like.PostID = post.ID | |
likeCreated, err := like.SaveLike(server.DB) | |
if err != nil { | |
formattedError := formaterror.FormatError(err.Error()) | |
errList = formattedError | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusCreated, gin.H{ | |
"status": http.StatusCreated, | |
"response": likeCreated, | |
}) | |
} | |
func (server *Server) GetLikes(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
postID := c.Param("id") | |
// Is a valid post id given to us? | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
fmt.Println("this is the error: ", err) | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Check if the post exist: | |
post := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&post).Error | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
like := models.Like{} | |
likes, err := like.GetLikesInfo(server.DB, pid) | |
if err != nil { | |
errList["No_likes"] = "No Likes found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": likes, | |
}) | |
} | |
func (server *Server) UnLikePost(c *gin.Context) { | |
likeID := c.Param("id") | |
// Is a valid like id given to us? | |
lid, err := strconv.ParseUint(likeID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Is this user authenticated? | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Check if the post exist | |
like := models.Like{} | |
err = server.DB.Debug().Model(models.Like{}).Where("id = ?", lid).Take(&like).Error | |
if err != nil { | |
errList["No_like"] = "No Like Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
// Is the authenticated user, the owner of this post? | |
if uid != like.UserID { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If all the conditions are met, delete the post | |
_, err = like.DeleteLike(server.DB) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": "Like deleted", | |
}) | |
} |
d. Comments Controller
The authenticated user can create/update/delete a comment for a particular post.
touch comments_controller.go
package controllers | |
import ( | |
"encoding/json" | |
"io/ioutil" | |
"net/http" | |
"strconv" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/auth" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
) | |
func (server *Server) CreateComment(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
postID := c.Param("id") | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// check the token | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the user exists; | |
user := models.User{} | |
err = server.DB.Debug().Model(models.User{}).Where("id = ?", uid).Take(&user).Error | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the post exist: | |
post := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&post).Error | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
comment := models.Comment{} | |
err = json.Unmarshal(body, &comment) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
// enter the userid and the postid. The comment body is automatically passed | |
comment.UserID = uid | |
comment.PostID = pid | |
comment.Prepare() | |
errorMessages := comment.Validate("") | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
commentCreated, err := comment.SaveComment(server.DB) | |
if err != nil { | |
formattedError := formaterror.FormatError(err.Error()) | |
errList = formattedError | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusCreated, gin.H{ | |
"status": http.StatusCreated, | |
"response": commentCreated, | |
}) | |
} | |
func (server *Server) GetComments(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
postID := c.Param("id") | |
// Is a valid post id given to us? | |
pid, err := strconv.ParseUint(postID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// check if the post exist: | |
post := models.Post{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&post).Error | |
if err != nil { | |
errList["No_post"] = "No post found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
comment := models.Comment{} | |
comments, err := comment.GetComments(server.DB, pid) | |
if err != nil { | |
errList["No_comments"] = "No comments found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": comments, | |
}) | |
} | |
func (server *Server) UpdateComment(c *gin.Context) { | |
//clear previous error if any | |
errList = map[string]string{} | |
commentID := c.Param("id") | |
// Check if the post id is valid | |
pid, err := strconv.ParseUint(commentID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
//CHeck if the auth token is valid and get the user id from it | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
//Check if the post exist | |
origComment := models.Comment{} | |
err = server.DB.Debug().Model(models.Post{}).Where("id = ?", pid).Take(&origComment).Error | |
if err != nil { | |
errList["No_comment"] = "No Comment Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
if uid != origComment.UserID { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Read the data posted | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
// Start processing the request data | |
comment := models.Comment{} | |
err = json.Unmarshal(body, &comment) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
comment.Prepare() | |
errorMessages := comment.Validate("") | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
comment.ID = origComment.ID //this is important to tell the model the post id to update, the other update field are set above | |
comment.UserID = origComment.UserID | |
comment.PostID = origComment.PostID | |
commentUpdated, err := comment.UpdateAComment(server.DB) | |
if err != nil { | |
formattedError := formaterror.FormatError(err.Error()) | |
errList = formattedError | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": err, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": commentUpdated, | |
}) | |
} | |
func (server *Server) DeleteComment(c *gin.Context) { | |
commentID := c.Param("id") | |
// Is a valid post id given to us? | |
cid, err := strconv.ParseUint(commentID, 10, 64) | |
if err != nil { | |
errList["Invalid_request"] = "Invalid Request" | |
c.JSON(http.StatusBadRequest, gin.H{ | |
"status": http.StatusBadRequest, | |
"error": errList, | |
}) | |
return | |
} | |
// Is this user authenticated? | |
uid, err := auth.ExtractTokenID(c.Request) | |
if err != nil { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// Check if the comment exist | |
comment := models.Comment{} | |
err = server.DB.Debug().Model(models.Comment{}).Where("id = ?", cid).Take(&comment).Error | |
if err != nil { | |
errList["No_post"] = "No Post Found" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
// Is the authenticated user, the owner of this post? | |
if uid != comment.UserID { | |
errList["Unauthorized"] = "Unauthorized" | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": errList, | |
}) | |
return | |
} | |
// If all the conditions are met, delete the post | |
_, err = comment.DeleteAComment(server.DB) | |
if err != nil { | |
errList["Other_error"] = "Please try again later" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": "Comment deleted", | |
}) | |
} |
e. ResetPassword Controller
A user can request to reset their password peradventure the password is forgotten:
touch resetpassword_controller.go
package controllers | |
import ( | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"net/http" | |
"os" | |
"github.com/gin-gonic/gin" | |
"github.com/victorsteven/forum/api/mailer" | |
"github.com/victorsteven/forum/api/models" | |
"github.com/victorsteven/forum/api/security" | |
"github.com/victorsteven/forum/api/utils/formaterror" | |
) | |
func (server *Server) ForgotPassword(c *gin.Context) { | |
//remove any possible error, because the frontend dont reload | |
errList = map[string]string{} | |
// Start processing the request | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
user := models.User{} | |
err = json.Unmarshal(body, &user) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
user.Prepare() | |
errorMessages := user.Validate("forgotpassword") | |
if len(errorMessages) > 0 { | |
errList = errorMessages | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
err = server.DB.Debug().Model(models.User{}).Where("email = ?", user.Email).Take(&user).Error | |
if err != nil { | |
errList["No_email"] = "Sorry, we do not recognize this email" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
resetPassword := models.ResetPassword{} | |
resetPassword.Prepare() | |
//generate the token: | |
token := security.TokenHash(user.Email) | |
resetPassword.Email = user.Email | |
resetPassword.Token = token | |
resetDetails, err := resetPassword.SaveDatails(server.DB) | |
if err != nil { | |
errList = formaterror.FormatError(err.Error()) | |
c.JSON(http.StatusInternalServerError, gin.H{ | |
"status": http.StatusInternalServerError, | |
"error": errList, | |
}) | |
return | |
} | |
fmt.Println("THIS OCCURRED HERE") | |
//Send welcome mail to the user: | |
response, err := mailer.SendMail.SendResetPassword(resetDetails.Email, os.Getenv("SENDGRID_FROM"), resetDetails.Token, os.Getenv("SENDGRID_API_KEY"), os.Getenv("APP_ENV")) | |
if err != nil { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": err, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": response.RespBody, | |
}) | |
} | |
func (server *Server) ResetPassword(c *gin.Context) { | |
//remove any possible error, because the frontend dont reload | |
errList = map[string]string{} | |
// Start processing the request | |
body, err := ioutil.ReadAll(c.Request.Body) | |
if err != nil { | |
errList["Invalid_body"] = "Unable to get request" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
requestBody := map[string]string{} | |
err = json.Unmarshal(body, &requestBody) | |
if err != nil { | |
errList["Unmarshal_error"] = "Cannot unmarshal body" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
user := models.User{} | |
resetPassword := models.ResetPassword{} | |
err = server.DB.Debug().Model(models.ResetPassword{}).Where("token = ?", requestBody["token"]).Take(&resetPassword).Error | |
if err != nil { | |
errList["Invalid_token"] = "Invalid link. Try requesting again" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
if requestBody["new_password"] == "" || requestBody["retype_password"] == "" { | |
errList["Empty_passwords"] = "Please ensure both field are entered" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
if requestBody["new_password"] != "" && requestBody["retype_password"] != "" { | |
//Also check if the new password | |
if len(requestBody["new_password"]) < 6 || len(requestBody["retype_password"]) < 6 { | |
errList["Invalid_Passwords"] = "Password should be atleast 6 characters" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
if requestBody["new_password"] != requestBody["retype_password"] { | |
errList["Password_unequal"] = "Passwords provided do not match" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
//Note this password will be hashed before it is saved in the model | |
user.Password = requestBody["new_password"] | |
user.Email = resetPassword.Email | |
//update the password | |
user.Prepare() | |
err := user.UpdatePassword(server.DB) | |
if err != nil { | |
fmt.Println("this is the error: ", err) | |
errList["Cannot_save"] = "Cannot Save, Pls try again later" | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"status": http.StatusUnprocessableEntity, | |
"error": errList, | |
}) | |
return | |
} | |
//Delete the token record so is not used again: | |
_, err = resetPassword.DeleteDatails(server.DB) | |
if err != nil { | |
errList["Cannot_delete"] = "Cannot Delete record, Pls try again later" | |
c.JSON(http.StatusNotFound, gin.H{ | |
"status": http.StatusNotFound, | |
"error": errList, | |
}) | |
return | |
} | |
c.JSON(http.StatusOK, gin.H{ | |
"status": http.StatusOK, | |
"response": "Success", | |
}) | |
} | |
} |
f. Routes
All controller methods are used here.
Still, in the controllers directory, create the routes.go file:
touch routes.go
package controllers | |
import ( | |
"github.com/victorsteven/forum/api/middlewares" | |
) | |
func (s *Server) initializeRoutes() { | |
v1 := s.Router.Group("/api/v1") | |
{ | |
// Login Route | |
v1.POST("/login", s.Login) | |
// Reset password: | |
v1.POST("/password/forgot", s.ForgotPassword) | |
v1.POST("/password/reset", s.ResetPassword) | |
//Users routes | |
v1.POST("/users", s.CreateUser) | |
v1.GET("/users", s.GetUsers) | |
v1.GET("/users/:id", s.GetUser) | |
v1.PUT("/users/:id", middlewares.TokenAuthMiddleware(), s.UpdateUser) | |
v1.PUT("/avatar/users/:id", middlewares.TokenAuthMiddleware(), s.UpdateAvatar) | |
v1.DELETE("/users/:id", middlewares.TokenAuthMiddleware(), s.DeleteUser) | |
//Posts routes | |
v1.POST("/posts", middlewares.TokenAuthMiddleware(), s.CreatePost) | |
v1.GET("/posts", s.GetPosts) | |
v1.GET("/posts/:id", s.GetPost) | |
v1.PUT("/posts/:id", middlewares.TokenAuthMiddleware(), s.UpdatePost) | |
v1.DELETE("/posts/:id", middlewares.TokenAuthMiddleware(), s.DeletePost) | |
v1.GET("/user_posts/:id", s.GetUserPosts) | |
//Like route | |
v1.GET("/likes/:id", s.GetLikes) | |
v1.POST("/likes/:id", middlewares.TokenAuthMiddleware(), s.LikePost) | |
v1.DELETE("/likes/:id", middlewares.TokenAuthMiddleware(), s.UnLikePost) | |
//Comment routes | |
v1.POST("/comments/:id", middlewares.TokenAuthMiddleware(), s.CreateComment) | |
v1.GET("/comments/:id", s.GetComments) | |
v1.PUT("/comments/:id", middlewares.TokenAuthMiddleware(), s.UpdateComment) | |
v1.DELETE("/comments/:id", middlewares.TokenAuthMiddleware(), s.DeleteComment) | |
} | |
} |
Step 10: Create the Server File
In the server.go file, we open a connection to the database, provide a port the app listens to from the .env file.
Inside the api directory(in the path: forum-backend/api/) create the server.go file
touch server.go
package api | |
import ( | |
"fmt" | |
"log" | |
"os" | |
"github.com/joho/godotenv" | |
"github.com/victorsteven/forum/api/controllers" | |
) | |
var server = controllers.Server{} | |
func init() { | |
// loads values from .env into the system | |
if err := godotenv.Load(); err != nil { | |
log.Print("sad .env file found") | |
} | |
} | |
func Run() { | |
var err error | |
err = godotenv.Load() | |
if err != nil { | |
log.Fatalf("Error getting env, %v", err) | |
} else { | |
fmt.Println("We are getting values") | |
} | |
server.Initialize(os.Getenv("DB_DRIVER"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_PORT"), os.Getenv("DB_HOST"), os.Getenv("DB_NAME")) | |
// This is for testing, when done, do well to comment | |
// seed.Load(server.DB) | |
apiPort := fmt.Sprintf(":%s", os.Getenv("API_PORT")) | |
fmt.Printf("Listening to port %s", apiPort) | |
server.Run(apiPort) | |
} |
Step 11: Run the App
Let's now see some output from our labor thus far.
Create the main.go file in the root directory of the app, and call the Run method defined in server.go file above.
In the path /forum-backend/,
touch main.go
package main | |
import ( | |
"github.com/victorsteven/forum/api" | |
) | |
func main() { | |
api.Run() | |
} |
Confirm that your directory structure looks like this:
Running Without Docker
If you just want to run this API without docker, make sure you have this in your .env file:
DB_HOST=127.0.0.1
Also that your database is created, the username, password, and every other thing are in place.
Open the Terminal, in the root directory, run:
go run main.go
Your terminal output should look like this:
Running With Docker
a. Edit your .env file like this:
DB_HOST=forum-postgres
b. Create the Dockerfile for development:
In the project root (path: /forum-backend/), create the Dockerfile
touch Dockerfile
You can rename the example-Dockerfile.dev(from the repo) to Dockerfile
FROM golang:1.12-alpine | |
# Install git | |
RUN apk update && apk add --no-cache git | |
# Where our file will be in the docker container | |
WORKDIR /usr/src/app | |
COPY go.mod go.sum ./ | |
RUN go mod download | |
# Copy the source from the current directory to the working Directory inside the container | |
COPY . . | |
EXPOSE 8888 | |
# Install CompileDaemon which is used for hot reload each time a file is changed | |
RUN go get github.com/githubnemo/CompileDaemon | |
# The ENTRYPOINT defines the command that will be ran when the container starts up | |
# The "go build" command here build from the current directory | |
# We will also execute the binary so that the server starts up. CompileDaemon handles the rest - anytime any .go file changes in the directory | |
ENTRYPOINT CompileDaemon -log-prefix=false -build="go build ." -command="./forum" |
c. Create the docker-compose.yml file for development
In the project root (path: /forum/), create the docker-compose.yml
touch docker-compose.yml
You can also rename the example-docker-compose.dev.yml to docker-compose.yml
version: '3' | |
services: | |
app: | |
container_name: full_app | |
build: | |
context: . | |
dockerfile: ./Dockerfile.dev | |
ports: | |
- 8888:8888 | |
restart: on-failure | |
volumes: # without this volume mapping to the directory of our project, live reloading wont happen | |
- .:/usr/src/app | |
depends_on: | |
- forum-postgres # This service depends on postgres. Start that first. | |
# - forum-mysql # This service depends on mysql. Start that first. | |
networks: | |
- forum | |
forum-postgres: | |
image: postgres:latest | |
container_name: full_db_postgres | |
environment: | |
- POSTGRES_USER=${DB_USER} | |
- POSTGRES_PASSWORD=${DB_PASSWORD} | |
- POSTGRES_DB=${DB_NAME} | |
- DATABASE_HOST=${DB_HOST} | |
ports: | |
- '5432:5432' | |
volumes: | |
- database_postgres:/var/lib/postgresql/data | |
networks: | |
- forum | |
pgadmin: | |
image: dpage/pgadmin4 | |
container_name: pgadmin_container | |
environment: | |
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} | |
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} | |
depends_on: | |
- forum-postgres | |
ports: | |
- "5050:80" | |
networks: | |
- forum | |
restart: unless-stopped | |
# forum-mysql: | |
# image: mysql:5.7 | |
# container_name: full_db_mysql | |
# ports: | |
# - 3306:3306 | |
# environment: | |
# - MYSQL_ROOT_HOST=${DB_HOST} | |
# - MYSQL_USER=${DB_USER} | |
# - MYSQL_PASSWORD=${DB_PASSWORD} | |
# - MYSQL_DATABASE=${DB_NAME} | |
# - MYSQL_ROOT_PASSWORD=${DB_PASSWORD} | |
# volumes: | |
# - database_mysql:/var/lib/mysql | |
# networks: | |
# - forum | |
# phpmyadmin: | |
# image: phpmyadmin/phpmyadmin | |
# container_name: phpmyadmin_container | |
# depends_on: | |
# - forum-mysql | |
# environment: | |
# - PMA_HOST=mysql # Note the "mysql". Must be the name of the what you used as the mysql service. | |
# - PMA_USER=${DB_USER} | |
# - PMA_PORT=${DB_PORT} | |
# - PMA_PASSWORD=${DB_PASSWORD} | |
# ports: | |
# - 9090:80 | |
# restart: always | |
# networks: | |
# - forum | |
volumes: | |
api: | |
database_postgres: | |
# database_mysql: | |
# Networks to be created to facilitate communication between containers | |
networks: | |
forum: | |
driver: bridge | |
d. Run the app:
Open the terminal and run:
docker-compose up --build
e. You can use pgadmin to view your database.
Look up this article I wrote for a guide here
Step 13: Writing Unit and Integration Tests
The API is 99.9% tested.
Golang has a beautiful term called Table Testing.
That term might not sound familiar if you are coming from the NodeJS/PHP/Python/Ruby world.
Table testing in Go, give the Developer the privilege of testing all edge cases of a particular functionality just with one test function.
This is what I mean, Imagine a user signing up. What could possibly go wrong?
- The user might input an invalid email
- The user might input a password that does not meet the requirement
- The user might input an email that belongs to someone else in our database.
- and so on.
With the power of Table Tests, you can test all the cases with one test function, instead of writing multiple functions with more lines of code to worry about.
Tests Set up
Remember, we created a tests directory at the start of the project.
Inside the tests directory, create the setup_test.go
touch setup_test.go
package tests | |
import ( | |
"fmt" | |
"log" | |
"os" | |
"testing" | |
"github.com/jinzhu/gorm" | |
"github.com/victorsteven/forum/api/controllers" | |
"github.com/victorsteven/forum/api/models" | |
) | |
var server = controllers.Server{} | |
var userInstance = models.User{} | |
var postInstance = models.Post{} | |
var likeInstance = models.Like{} | |
var commentInstance = models.Comment{} | |
func TestMain(m *testing.M) { | |
// UNCOMMENT THIS WHILE TESTING ON LOCAL(WITHOUT USING CIRCLE CI), BUT LEAVE IT COMMENTED IF YOU ARE USING CIRCLE CI | |
// var err error | |
// err = godotenv.Load(os.ExpandEnv("./../.env")) | |
// if err != nil { | |
// log.Fatalf("Error getting env %v\n", err) | |
// } | |
Database() | |
os.Exit(m.Run()) | |
} | |
func Database() { | |
var err error | |
////////////////////////////////// UNCOMMENT THIS WHILE TESTING ON LOCAL(WITHOUT USING CIRCLE CI) /////////////////////// | |
// TestDbDriver := os.Getenv("TEST_DB_DRIVER") | |
// if TestDbDriver == "mysql" { | |
// DBURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", os.Getenv("TEST_DB_USER"), os.Getenv("TEST_DB_PASSWORD"), os.Getenv("TEST_DB_HOST"), os.Getenv("TEST_DB_PORT"), os.Getenv("TEST_DB_NAME")) | |
// server.DB, err = gorm.Open(TestDbDriver, DBURL) | |
// if err != nil { | |
// fmt.Printf("Cannot connect to %s database\n", TestDbDriver) | |
// log.Fatal("This is the error:", err) | |
// } else { | |
// fmt.Printf("We are connected to the %s database\n", TestDbDriver) | |
// } | |
// } | |
// if TestDbDriver == "postgres" { | |
// DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", os.Getenv("TEST_DB_HOST"), os.Getenv("TEST_DB_PORT"), os.Getenv("TEST_DB_USER"), os.Getenv("TEST_DB_NAME"), os.Getenv("TEST_DB_PASSWORD")) | |
// server.DB, err = gorm.Open(TestDbDriver, DBURL) | |
// if err != nil { | |
// fmt.Printf("Cannot connect to %s database\n", TestDbDriver) | |
// log.Fatal("This is the error:", err) | |
// } else { | |
// fmt.Printf("We are connected to the %s database\n", TestDbDriver) | |
// } | |
// } | |
///////////////////////////////// END OF LOCAL TEST DATABASE SETUP /////////////////////////////////////////////////// | |
////////////////////////////////// COMMENT THIS WHILE TESTING ON LOCAL(WITHOUT USING CIRCLE CI) ////////////////////// | |
// WE HAVE TO INPUT TESTING DATA MANUALLY BECAUSE CIRCLECI, CANNOT READ THE ".env" FILE WHICH, WE WOULD HAVE ADDED THE TEST CONFIG THERE | |
// SO MANUALLY ADD THE NAME OF THE DATABASE, THE USER AND THE PASSWORD, AS SEEN BELOW: | |
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", "127.0.0.1", "5432", "steven", "forum_db_test", "password") | |
server.DB, err = gorm.Open("postgres", DBURL) | |
if err != nil { | |
fmt.Printf("Cannot connect to %s database\n", "postgres") | |
log.Fatal("This is the error:", err) | |
} else { | |
fmt.Printf("We are connected to the %s database\n", "postgres") | |
} | |
//////////////////////////////// END OF USING CIRCLE CI //////////////////////////////////////////////////////////////// | |
} | |
func refreshUserTable() error { | |
err := server.DB.DropTableIfExists(&models.User{}).Error | |
if err != nil { | |
return err | |
} | |
err = server.DB.AutoMigrate(&models.User{}).Error | |
if err != nil { | |
return err | |
} | |
log.Printf("Successfully refreshed table") | |
return nil | |
} | |
func seedOneUser() (models.User, error) { | |
user := models.User{ | |
Username: "Pet", | |
Email: "pet@example.com", | |
Password: "password", | |
} | |
err := server.DB.Model(&models.User{}).Create(&user).Error | |
if err != nil { | |
return models.User{}, err | |
} | |
return user, nil | |
} | |
func seedUsers() ([]models.User, error) { | |
var err error | |
if err != nil { | |
return nil, err | |
} | |
users := []models.User{ | |
models.User{ | |
Username: "Steven", | |
Email: "steven@example.com", | |
Password: "password", | |
}, | |
models.User{ | |
Username: "Kenny", | |
Email: "kenny@example.com", | |
Password: "password", | |
}, | |
} | |
for i, _ := range users { | |
err := server.DB.Model(&models.User{}).Create(&users[i]).Error | |
if err != nil { | |
return []models.User{}, err | |
} | |
} | |
return users, nil | |
} | |
func refreshUserAndPostTable() error { | |
err := server.DB.DropTableIfExists(&models.User{}, &models.Post{}).Error | |
if err != nil { | |
return err | |
} | |
err = server.DB.AutoMigrate(&models.User{}, &models.Post{}).Error | |
if err != nil { | |
return err | |
} | |
log.Printf("Successfully refreshed tables") | |
return nil | |
} | |
func seedOneUserAndOnePost() (models.User, models.Post, error) { | |
user := models.User{ | |
Username: "Sam", | |
Email: "sam@example.com", | |
Password: "password", | |
} | |
err := server.DB.Model(&models.User{}).Create(&user).Error | |
if err != nil { | |
return models.User{}, models.Post{}, err | |
} | |
post := models.Post{ | |
Title: "This is the title sam", | |
Content: "This is the content sam", | |
AuthorID: user.ID, | |
} | |
err = server.DB.Model(&models.Post{}).Create(&post).Error | |
if err != nil { | |
return models.User{}, models.Post{}, err | |
} | |
return user, post, nil | |
} | |
func seedUsersAndPosts() ([]models.User, []models.Post, error) { | |
var err error | |
if err != nil { | |
return []models.User{}, []models.Post{}, err | |
} | |
var users = []models.User{ | |
models.User{ | |
Username: "Steven", | |
Email: "steven@example.com", | |
Password: "password", | |
}, | |
models.User{ | |
Username: "Magu", | |
Email: "magu@example.com", | |
Password: "password", | |
}, | |
} | |
var posts = []models.Post{ | |
models.Post{ | |
Title: "Title 1", | |
Content: "Hello world 1", | |
}, | |
models.Post{ | |
Title: "Title 2", | |
Content: "Hello world 2", | |
}, | |
} | |
for i, _ := range users { | |
err = server.DB.Model(&models.User{}).Create(&users[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed users table: %v", err) | |
} | |
posts[i].AuthorID = users[i].ID | |
err = server.DB.Model(&models.Post{}).Create(&posts[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed posts table: %v", err) | |
} | |
} | |
return users, posts, nil | |
} | |
func refreshUserPostAndLikeTable() error { | |
err := server.DB.DropTableIfExists(&models.User{}, &models.Post{}, &models.Like{}).Error | |
if err != nil { | |
return err | |
} | |
err = server.DB.AutoMigrate(&models.User{}, &models.Post{}, &models.Like{}).Error | |
if err != nil { | |
return err | |
} | |
log.Printf("Successfully refreshed user, post and like tables") | |
return nil | |
} | |
func seedUsersPostsAndLikes() (models.Post, []models.User, []models.Like, error) { | |
// The idea here is: two users can like one post | |
var err error | |
var users = []models.User{ | |
models.User{ | |
Username: "Steven", | |
Email: "steven@example.com", | |
Password: "password", | |
}, | |
models.User{ | |
Username: "Magu", | |
Email: "magu@example.com", | |
Password: "password", | |
}, | |
} | |
post := models.Post{ | |
Title: "This is the title", | |
Content: "This is the content", | |
} | |
err = server.DB.Model(&models.Post{}).Create(&post).Error | |
if err != nil { | |
log.Fatalf("cannot seed post table: %v", err) | |
} | |
var likes = []models.Like{ | |
models.Like{ | |
UserID: 1, | |
PostID: post.ID, | |
}, | |
models.Like{ | |
UserID: 2, | |
PostID: post.ID, | |
}, | |
} | |
for i, _ := range users { | |
err = server.DB.Model(&models.User{}).Create(&users[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed users table: %v", err) | |
} | |
err = server.DB.Model(&models.Like{}).Create(&likes[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed likes table: %v", err) | |
} | |
} | |
return post, users, likes, nil | |
} | |
func refreshUserPostAndCommentTable() error { | |
err := server.DB.DropTableIfExists(&models.User{}, &models.Post{}, &models.Comment{}).Error | |
if err != nil { | |
return err | |
} | |
err = server.DB.AutoMigrate(&models.User{}, &models.Post{}, &models.Comment{}).Error | |
if err != nil { | |
return err | |
} | |
log.Printf("Successfully refreshed user, post and comment tables") | |
return nil | |
} | |
func seedUsersPostsAndComments() (models.Post, []models.User, []models.Comment, error) { | |
// The idea here is: two users can comment one post | |
var err error | |
var users = []models.User{ | |
models.User{ | |
Username: "Steven", | |
Email: "steven@example.com", | |
Password: "password", | |
}, | |
models.User{ | |
Username: "Magu", | |
Email: "magu@example.com", | |
Password: "password", | |
}, | |
} | |
post := models.Post{ | |
Title: "This is the title", | |
Content: "This is the content", | |
} | |
err = server.DB.Model(&models.Post{}).Create(&post).Error | |
if err != nil { | |
log.Fatalf("cannot seed post table: %v", err) | |
} | |
var comments = []models.Comment{ | |
models.Comment{ | |
Body: "user 1 made this comment", | |
UserID: 1, | |
PostID: post.ID, | |
}, | |
models.Comment{ | |
Body: "user 2 made this comment", | |
UserID: 2, | |
PostID: post.ID, | |
}, | |
} | |
for i, _ := range users { | |
err = server.DB.Model(&models.User{}).Create(&users[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed users table: %v", err) | |
} | |
err = server.DB.Model(&models.Like{}).Create(&comments[i]).Error | |
if err != nil { | |
log.Fatalf("cannot seed comments table: %v", err) | |
} | |
} | |
return post, users, comments, nil | |
} | |
func refreshUserAndResetPasswordTable() error { | |
err := server.DB.DropTableIfExists(&models.User{}, &models.ResetPassword{}).Error | |
if err != nil { | |
return err | |
} | |
err = server.DB.AutoMigrate(&models.User{}, &models.ResetPassword{}).Error | |
if err != nil { | |
return err | |
} | |
log.Printf("Successfully refreshed user and resetpassword tables") | |
return nil | |
} | |
// Seed the reset password table with the token | |
func seedResetPassword() (models.ResetPassword, error) { | |
resetDetails := models.ResetPassword{ | |
Token: "awesometoken", | |
Email: "pet@example.com", | |
} | |
err := server.DB.Model(&models.ResetPassword{}).Create(&resetDetails).Error | |
if err != nil { | |
return models.ResetPassword{}, err | |
} | |
return resetDetails, nil | |
} |
Since you will be running this tests in your local, let your TestMain and Database functions look like this:
func TestMain(m *testing.M) {
var err error
err = godotenv.Load(os.ExpandEnv("./../.env"))
if err != nil {
log.Fatalf("Error getting env %v\n", err)
}
Database()
os.Exit(m.Run())
}
func Database() {
var err error
TestDbDriver := os.Getenv("TEST_DB_DRIVER")
if TestDbDriver == "mysql" {
DBURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", os.Getenv("TEST_DB_USER"), os.Getenv("TEST_DB_PASSWORD"), os.Getenv("TEST_DB_HOST"), os.Getenv("TEST_DB_PORT"), os.Getenv("TEST_DB_NAME"))
server.DB, err = gorm.Open(TestDbDriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database\n", TestDbDriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database\n", TestDbDriver)
}
}
if TestDbDriver == "postgres" {
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", os.Getenv("TEST_DB_HOST"), os.Getenv("TEST_DB_PORT"), os.Getenv("TEST_DB_USER"), os.Getenv("TEST_DB_NAME"), os.Getenv("TEST_DB_PASSWORD"))
server.DB, err = gorm.Open(TestDbDriver, DBURL)
if err != nil {
fmt.Printf("Cannot connect to %s database\n", TestDbDriver)
log.Fatal("This is the error:", err)
} else {
fmt.Printf("We are connected to the %s database\n", TestDbDriver)
}
}
}
...
I had to modify the repository because Circle CI could not detect the .env file that has the test database details. Please take note of this. The rest of the functions in the setup_test.go remain unchanged.
The setup_test.go file has functionalities that:
- Initializes our testing database
- Refresh the database before each test
- Seed the database with relevant data before each test. This file is very handy because it will be used throughout the tests. Do well to study it.
Model Tests.
a. User Model Tests
In the tests directory, create the model_users_test.go file
touch model_users_test.go
package tests | |
import ( | |
"log" | |
"testing" | |
_ "github.com/jinzhu/gorm/dialects/mysql" //mysql driver | |
_ "github.com/jinzhu/gorm/dialects/postgres" //postgres driver | |
"github.com/stretchr/testify/assert" | |
"github.com/victorsteven/forum/api/models" | |
) | |
func TestFindAllUsers(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, err = seedUsers() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, err := userInstance.FindAllUsers(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the users: %v\n", err) | |
return | |
} | |
assert.Equal(t, len(*users), 2) | |
} | |
func TestSaveUser(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
newUser := models.User{ | |
ID: 1, | |
Email: "test@example.com", | |
Username: "test", | |
Password: "password", | |
} | |
savedUser, err := newUser.SaveUser(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the users: %v\n", err) | |
return | |
} | |
assert.Equal(t, newUser.ID, savedUser.ID) | |
assert.Equal(t, newUser.Email, savedUser.Email) | |
assert.Equal(t, newUser.Username, savedUser.Username) | |
} | |
func TestFindUserByID(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatalf("cannot seed users table: %v", err) | |
} | |
foundUser, err := userInstance.FindUserByID(server.DB, user.ID) | |
if err != nil { | |
t.Errorf("this is the error getting one user: %v\n", err) | |
return | |
} | |
assert.Equal(t, foundUser.ID, user.ID) | |
assert.Equal(t, foundUser.Email, user.Email) | |
assert.Equal(t, foundUser.Username, user.Username) | |
} | |
func TestUpdateAUser(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatalf("Cannot seed user: %v\n", err) | |
} | |
userUpdate := models.User{ | |
ID: 1, | |
Username: "modiUpdate", | |
Email: "modiupdate@example.com", | |
Password: "password", | |
} | |
updatedUser, err := userUpdate.UpdateAUser(server.DB, user.ID) | |
if err != nil { | |
t.Errorf("this is the error updating the user: %v\n", err) | |
return | |
} | |
assert.Equal(t, updatedUser.ID, userUpdate.ID) | |
assert.Equal(t, updatedUser.Email, userUpdate.Email) | |
assert.Equal(t, updatedUser.Username, userUpdate.Username) | |
} | |
func TestDeleteAUser(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatalf("Cannot seed user: %v\n", err) | |
} | |
isDeleted, err := userInstance.DeleteAUser(server.DB, user.ID) | |
if err != nil { | |
t.Errorf("this is the error updating the user: %v\n", err) | |
return | |
} | |
assert.Equal(t, isDeleted, int64(1)) | |
} |
After ensuring that your test database is created, the right user and password set and all files saved, You can go ahead and run this test. Launch your terminal in the path: /forum-backend/tests and run:
go test -v
The v flag is for verbose output.
To run individual tests in the model_users_test.go file, Say for instance I want to run the TestSaveUser, run:
go test -v --run TestSaveUser
b. Post Model Tests
In the tests directory, create the model_posts_test.go file
touch model_posts_test.go
package tests | |
import ( | |
"log" | |
"testing" | |
_ "github.com/jinzhu/gorm/dialects/mysql" | |
"github.com/stretchr/testify/assert" | |
"github.com/victorsteven/forum/api/models" | |
) | |
func TestFindAllPosts(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user and post table %v\n", err) | |
} | |
_, _, err = seedUsersAndPosts() | |
if err != nil { | |
log.Fatalf("Error seeding user and post table %v\n", err) | |
} | |
//Where postInstance is an instance of the post initialize in setup_test.go | |
posts, err := postInstance.FindAllPosts(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the posts: %v\n", err) | |
return | |
} | |
assert.Equal(t, len(*posts), 2) | |
} | |
func TestSavePost(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error user and post refreshing table %v\n", err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatalf("Cannot seed user %v\n", err) | |
} | |
newPost := models.Post{ | |
ID: 1, | |
Title: "This is the title", | |
Content: "This is the content", | |
AuthorID: user.ID, | |
} | |
savedPost, err := newPost.SavePost(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the post: %v\n", err) | |
return | |
} | |
assert.Equal(t, newPost.ID, savedPost.ID) | |
assert.Equal(t, newPost.Title, savedPost.Title) | |
assert.Equal(t, newPost.Content, savedPost.Content) | |
assert.Equal(t, newPost.AuthorID, savedPost.AuthorID) | |
} | |
func TestFindPostByID(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user and post table: %v\n", err) | |
} | |
_, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Error Seeding table") | |
} | |
foundPost, err := post.FindPostByID(server.DB, post.ID) | |
if err != nil { | |
t.Errorf("this is the error getting one user: %v\n", err) | |
return | |
} | |
assert.Equal(t, foundPost.ID, post.ID) | |
assert.Equal(t, foundPost.Title, post.Title) | |
assert.Equal(t, foundPost.Content, post.Content) | |
} | |
func TestUpdateAPost(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user and post table: %v\n", err) | |
} | |
_, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Error Seeding table") | |
} | |
postUpdate := models.Post{ | |
ID: 1, | |
Title: "modiUpdate", | |
Content: "modiupdate@example.com", | |
AuthorID: post.AuthorID, | |
} | |
updatedPost, err := postUpdate.UpdateAPost(server.DB) | |
if err != nil { | |
t.Errorf("this is the error updating the user: %v\n", err) | |
return | |
} | |
assert.Equal(t, updatedPost.ID, postUpdate.ID) | |
assert.Equal(t, updatedPost.Title, postUpdate.Title) | |
assert.Equal(t, updatedPost.Content, postUpdate.Content) | |
assert.Equal(t, updatedPost.AuthorID, postUpdate.AuthorID) | |
} | |
func TestDeleteAPost(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user and post table: %v\n", err) | |
} | |
_, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Error Seeding tables") | |
} | |
isDeleted, err := post.DeleteAPost(server.DB) | |
if err != nil { | |
t.Errorf("this is the error updating the user: %v\n", err) | |
return | |
} | |
assert.Equal(t, isDeleted, int64(1)) | |
} | |
func TestDeleteUserPosts(t *testing.T) { | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user and post table: %v\n", err) | |
} | |
user, _, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Error Seeding tables") | |
} | |
numberDeleted, err := postInstance.DeleteUserPosts(server.DB, user.ID) | |
if err != nil { | |
t.Errorf("this is the error deleting the post: %v\n", err) | |
return | |
} | |
assert.Equal(t, numberDeleted, int64(1)) | |
} |
c. Like Model Tests
In the tests directory, create the model_likes_test.go file
touch model_likes_test.go
package tests | |
import ( | |
"log" | |
"testing" | |
"github.com/stretchr/testify/assert" | |
"github.com/victorsteven/forum/api/models" | |
) | |
func TestSaveALike(t *testing.T) { | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and like table %v\n", err) | |
} | |
user, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Cannot seed user and post %v\n", err) | |
} | |
newLike := models.Like{ | |
ID: 1, | |
UserID: user.ID, | |
PostID: post.ID, | |
} | |
savedLike, err := newLike.SaveLike(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, newLike.ID, savedLike.ID) | |
assert.Equal(t, newLike.UserID, savedLike.UserID) | |
assert.Equal(t, newLike.PostID, savedLike.PostID) | |
} | |
func TestGetLikeInfoForAPost(t *testing.T) { | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and like table %v\n", err) | |
} | |
post, users, likes, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and like table %v\n", err) | |
} | |
//Where likeInstance is an instance of the post initialize in setup_test.go | |
_, err = likeInstance.GetLikesInfo(server.DB, post.ID) | |
if err != nil { | |
t.Errorf("this is the error getting the likes: %v\n", err) | |
return | |
} | |
assert.Equal(t, len(likes), 2) | |
assert.Equal(t, len(users), 2) //two users like the post | |
} | |
func TestDeleteALike(t *testing.T) { | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and like table %v\n", err) | |
} | |
_, _, likes, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and like table %v\n", err) | |
} | |
// Delete the first like | |
for _, v := range likes { | |
if v.ID == 2 { | |
continue | |
} | |
likeInstance.ID = v.ID //likeInstance is defined in setup_test.go | |
} | |
deletedLike, err := likeInstance.DeleteLike(server.DB) | |
if err != nil { | |
t.Errorf("this is the error deleting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, deletedLike.ID, likeInstance.ID) | |
} | |
// When a post is deleted, delete its likes | |
func TestDeleteLikesForAPost(t *testing.T) { | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and like table %v\n", err) | |
} | |
post, _, _, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and like table %v\n", err) | |
} | |
numberDeleted, err := likeInstance.DeletePostLikes(server.DB, post.ID) | |
if err != nil { | |
t.Errorf("this is the error deleting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, numberDeleted, int64(2)) | |
} | |
// When a user is deleted, delete its likes | |
func TestDeleteLikesForAUser(t *testing.T) { | |
var userID uint32 | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and like table %v\n", err) | |
} | |
_, users, likes, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and like table %v\n", err) | |
} | |
for _, v := range likes { | |
if v.ID == 2 { | |
continue | |
} | |
likeInstance.ID = v.ID //likeInstance is defined in setup_test.go | |
} | |
// get the first user, this user has one like | |
for _, v := range users { | |
if v.ID == 2 { | |
continue | |
} | |
userID = v.ID | |
} | |
numberDeleted, err := likeInstance.DeleteUserLikes(server.DB, userID) | |
if err != nil { | |
t.Errorf("this is the error deleting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, numberDeleted, int64(1)) | |
} |
d. Comment Model Tests
In the tests directory, create the model_comments_test.go file
touch model_comments_test.go
package tests | |
import ( | |
"log" | |
"testing" | |
"github.com/stretchr/testify/assert" | |
"github.com/victorsteven/forum/api/models" | |
) | |
func TestCreateComment(t *testing.T) { | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and comment table %v\n", err) | |
} | |
user, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatalf("Cannot seed user and post %v\n", err) | |
} | |
newComment := models.Comment{ | |
ID: 1, | |
Body: "This is the comment body", | |
UserID: user.ID, | |
PostID: post.ID, | |
} | |
savedComment, err := newComment.SaveComment(server.DB) | |
if err != nil { | |
t.Errorf("this is the error getting the comment: %v\n", err) | |
return | |
} | |
assert.Equal(t, newComment.ID, savedComment.ID) | |
assert.Equal(t, newComment.UserID, savedComment.UserID) | |
assert.Equal(t, newComment.PostID, savedComment.PostID) | |
assert.Equal(t, newComment.Body, "This is the comment body") | |
} | |
func TestCommentsForAPost(t *testing.T) { | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and comment table %v\n", err) | |
} | |
post, users, comments, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and comment table %v\n", err) | |
} | |
//Where commentInstance is an instance of the post initialize in setup_test.go | |
_, err = commentInstance.GetComments(server.DB, post.ID) | |
if err != nil { | |
t.Errorf("this is the error getting the comments: %v\n", err) | |
return | |
} | |
assert.Equal(t, len(comments), 2) | |
assert.Equal(t, len(users), 2) //two users like the post | |
} | |
func TestDeleteAComment(t *testing.T) { | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and comment table %v\n", err) | |
} | |
_, _, comments, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and comment table %v\n", err) | |
} | |
// Delete the first comment | |
for _, v := range comments { | |
if v.ID == 2 { | |
continue | |
} | |
commentInstance.ID = v.ID //commentInstance is defined in setup_test.go | |
} | |
isDeleted, err := commentInstance.DeleteAComment(server.DB) | |
if err != nil { | |
t.Errorf("this is the error deleting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, isDeleted, int64(1)) | |
} | |
func TestDeleteCommentsForAPost(t *testing.T) { | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and comment table %v\n", err) | |
} | |
post, _, _, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and comment table %v\n", err) | |
} | |
numberDeleted, err := commentInstance.DeletePostComments(server.DB, post.ID) | |
if err != nil { | |
t.Errorf("this is the error deleting the like: %v\n", err) | |
return | |
} | |
assert.Equal(t, numberDeleted, int64(2)) | |
} | |
func TestDeleteCommentsForAUser(t *testing.T) { | |
var userID uint32 | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatalf("Error refreshing user, post and comment table %v\n", err) | |
} | |
_, users, _, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Error seeding user, post and comment table %v\n", err) | |
} | |
// get the first user. When you delete this user, also delete his comment | |
for _, v := range users { | |
if v.ID == 2 { | |
continue | |
} | |
userID = v.ID | |
} | |
numberDeleted, err := commentInstance.DeleteUserComments(server.DB, userID) | |
if err != nil { | |
t.Errorf("this is the error deleting the comment: %v\n", err) | |
return | |
} | |
assert.Equal(t, numberDeleted, int64(1)) | |
} |
Controller Tests.
a. Login Controller Test
Observe in the login_controller.go file that, the Login method depends on the SignIn method.
In the tests directory, create the controller_login_test.go file.
touch controller_login_test.go
package tests | |
import ( | |
"bytes" | |
"encoding/json" | |
"errors" | |
"fmt" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
func TestSignIn(t *testing.T) { | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
fmt.Printf("This is the error %v\n", err) | |
} | |
samples := []struct { | |
email string | |
password string | |
errorMessage string | |
}{ | |
{ | |
email: user.Email, | |
password: "password", //Note the password has to be this, not the hashed one from the database | |
errorMessage: "", | |
}, | |
{ | |
email: user.Email, | |
password: "Wrong password", | |
errorMessage: "crypto/bcrypt: hashedPassword is not the hash of the given password", | |
}, | |
{ | |
email: "Wrong email", | |
password: "password", | |
errorMessage: "record not found", | |
}, | |
} | |
for _, v := range samples { | |
loginDetails, err := server.SignIn(v.email, v.password) | |
if err != nil { | |
assert.Equal(t, err, errors.New(v.errorMessage)) | |
} else { | |
assert.NotEqual(t, loginDetails, "") | |
} | |
} | |
} | |
func TestLogin(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
fmt.Printf("This is the error %v\n", err) | |
} | |
samples := []struct { | |
inputJSON string | |
statusCode int | |
username string | |
email string | |
password string | |
}{ | |
{ | |
inputJSON: `{"email": "pet@example.com", "password": "password"}`, | |
statusCode: 200, | |
username: user.Username, | |
email: user.Email, | |
}, | |
{ | |
inputJSON: `{"email": "pet@example.com", "password": "wrong password"}`, | |
statusCode: 422, | |
}, | |
{ | |
// this record does not exist | |
inputJSON: `{"email": "frank@example.com", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"email": "kanexample.com", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"email": "", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"email": "kan@example.com", "password": ""}`, | |
statusCode: 422, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.POST("/login", server.Login) | |
req, err := http.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(v.inputJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
fmt.Printf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
//casting the interface to map: | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["username"], v.username) | |
assert.Equal(t, responseMap["email"], v.email) | |
} | |
if v.statusCode == 422 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Incorrect_password"] != nil { | |
assert.Equal(t, responseMap["Incorrect_password"], "Incorrect Password") | |
} | |
if responseMap["Incorrect_details"] != nil { | |
assert.Equal(t, responseMap["Incorrect_details"], "Incorrect Details") | |
} | |
if responseMap["Invalid_email"] != nil { | |
assert.Equal(t, responseMap["Invalid_email"], "Invalid Email") | |
} | |
if responseMap["Required_password"] != nil { | |
assert.Equal(t, responseMap["Required_password"], "Required Password") | |
} | |
} | |
} | |
} |
b. Users Controller Test
Each method in the users' controller call at least one method from somewhere else. The methods that each Users Controller Method called are tested in the Unit Tests session.
In the tests directory, create the controller_users_test.go file.
touch controller_users_test.go
package tests | |
import ( | |
"bytes" | |
"encoding/json" | |
"fmt" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"strconv" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
func TestCreateUser(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
samples := []struct { | |
inputJSON string | |
statusCode int | |
username string | |
email string | |
}{ | |
{ | |
inputJSON: `{"username":"Pet", "email": "pet@example.com", "password": "password"}`, | |
statusCode: 201, | |
username: "Pet", | |
email: "pet@example.com", | |
}, | |
{ | |
inputJSON: `{"username":"Frank", "email": "pet@example.com", "password": "password"}`, | |
statusCode: 500, | |
}, | |
{ | |
inputJSON: `{"username":"Pet", "email": "grand@example.com", "password": "password"}`, | |
statusCode: 500, | |
}, | |
{ | |
inputJSON: `{"username":"Kan", "email": "kanexample.com", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"username": "", "email": "kan@example.com", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"username": "Kan", "email": "", "password": "password"}`, | |
statusCode: 422, | |
}, | |
{ | |
inputJSON: `{"username": "Kan", "email": "kan@example.com", "password": ""}`, | |
statusCode: 422, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.POST("/users", server.CreateUser) | |
req, err := http.NewRequest(http.MethodPost, "/users", bytes.NewBufferString(v.inputJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 201 { | |
//casting the interface to map: | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["username"], v.username) | |
assert.Equal(t, responseMap["email"], v.email) | |
} | |
if v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Taken_email"] != nil { | |
assert.Equal(t, responseMap["Taken_email"], "Email Already Taken") | |
} | |
if responseMap["Taken_username"] != nil { | |
assert.Equal(t, responseMap["Taken_username"], "Username Already Taken") | |
} | |
if responseMap["Invalid_email"] != nil { | |
assert.Equal(t, responseMap["Invalid_email"], "Invalid Email") | |
} | |
if responseMap["Required_username"] != nil { | |
assert.Equal(t, responseMap["Required_username"], "Required Username") | |
} | |
if responseMap["Required_email"] != nil { | |
assert.Equal(t, responseMap["Required_email"], "Required Email") | |
} | |
if responseMap["Required_password"] != nil { | |
assert.Equal(t, responseMap["Required_password"], "Required Password") | |
} | |
} | |
} | |
} | |
func TestGetUsers(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, err = seedUsers() | |
if err != nil { | |
log.Fatal(err) | |
} | |
r := gin.Default() | |
r.GET("/users", server.GetUsers) | |
req, err := http.NewRequest(http.MethodGet, "/users", nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
usersMap := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &usersMap) | |
if err != nil { | |
log.Fatalf("Cannot convert to json: %v\n", err) | |
} | |
// This is so that we can get the length of the users: | |
theUsers := usersMap["response"].([]interface{}) | |
assert.Equal(t, rr.Code, http.StatusOK) | |
assert.Equal(t, len(theUsers), 2) | |
} | |
func TestGetUserByID(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatal(err) | |
} | |
userSample := []struct { | |
id string | |
statusCode int | |
username string | |
email string | |
}{ | |
{ | |
id: strconv.Itoa(int(user.ID)), | |
statusCode: 200, | |
username: user.Username, | |
email: user.Email, | |
}, | |
{ | |
id: "unknwon", | |
statusCode: 400, | |
}, | |
{ | |
id: strconv.Itoa(12322), //an id that does not exist | |
statusCode: 404, | |
}, | |
} | |
for _, v := range userSample { | |
req, _ := http.NewRequest("GET", "/users/"+v.id, nil) | |
rr := httptest.NewRecorder() | |
r := gin.Default() | |
r.GET("/users/:id", server.GetUser) | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["username"], v.username) | |
assert.Equal(t, responseMap["email"], v.email) | |
} | |
if v.statusCode == 400 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["No_user"] != nil { | |
assert.Equal(t, responseMap["No_user"], "No User Found") | |
} | |
} | |
} | |
} | |
func TestUpdateUser(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var AuthEmail, AuthPassword, AuthUsername string | |
var AuthID uint32 | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, err := seedUsers() //we need atleast two users to properly check the update | |
if err != nil { | |
log.Fatalf("Error seeding user: %v\n", err) | |
} | |
// Get only the first user | |
for _, user := range users { | |
if user.ID == 2 { | |
continue | |
} | |
AuthID = user.ID | |
AuthEmail = user.Email | |
AuthUsername = user.Username | |
AuthPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(AuthEmail, AuthPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
samples := []struct { | |
id string | |
updateJSON string | |
statusCode int | |
username string | |
updateEmail string | |
tokenGiven string | |
}{ | |
{ | |
// In this particular test case, we changed the user's password to "newpassword". Very important to note | |
// Convert int32 to int first before converting to string | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "grand@example.com", "current_password": "password", "new_password": "newpassword"}`, | |
statusCode: 200, | |
username: AuthUsername, //the username does not change, even if a new name is provided, it will be ignored | |
updateEmail: "grand@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// An attempt to change the username, will not work, the old name is still retained. | |
// Remember, the "current_password" is now "newpassword", changed in test 1 | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"username": "new_name", "email": "grand@example.com", "current_password": "newpassword", "new_password": "newpassword"}`, | |
statusCode: 200, | |
username: AuthUsername, //irrespective of the username inputed above, the old one is still used | |
updateEmail: "grand@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// The user can update only his email address | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "fred@example.com"}`, | |
statusCode: 200, | |
username: AuthUsername, | |
updateEmail: "fred@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "alex@example.com", "current_password": "", "new_password": ""}`, | |
statusCode: 200, | |
username: AuthUsername, | |
updateEmail: "alex@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When password the "current_password" is given and does not match with the one in the database | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "alex@example.com", "current_password": "wrongpassword", "new_password": "password"}`, | |
statusCode: 422, | |
updateEmail: "alex@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When password the "current_password" is correct but the "new_password" field is not given | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "alex@example.com", "current_password": "newpassword", "new_password": ""}`, | |
statusCode: 422, | |
updateEmail: "alex@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When password the "current_password" is correct but the "new_password" field is not up to 6 characters | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "alex@example.com", "current_password": "newpassword", "new_password": "pass"}`, | |
statusCode: 422, | |
updateEmail: "alex@example.com", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When no token was passed (when the user is not authenticated) | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "man@example.com", "current_password": "newpassword", "new_password": "password"}`, | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
// When incorrect token was passed | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "man@example.com", "current_password": "newpassword", "new_password": "password"}`, | |
statusCode: 401, | |
tokenGiven: "This is incorrect token", | |
}, | |
{ | |
// Remember "kenny@example.com" belongs to user 2, so, user 1 cannot use some else email that is in our database | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "kenny@example.com", "current_password": "newpassword", "new_password": "password"}`, | |
statusCode: 500, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When the email provided is invalid | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "notexample.com", "current_password": "newpassword", "new_password": "password"}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// If the email field is empty | |
id: strconv.Itoa(int(AuthID)), | |
updateJSON: `{"email": "", "current_password": "newpassword", "new_password": "password"}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// when invalid is provided | |
id: "unknwon", | |
tokenGiven: tokenString, | |
statusCode: 400, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.PUT("/users/:id", server.UpdateUser) | |
req, err := http.NewRequest(http.MethodPut, "/users/"+v.id, bytes.NewBufferString(v.updateJSON)) | |
req.Header.Set("Authorization", v.tokenGiven) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
//casting the interface to map: | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["email"], v.updateEmail) | |
// assert.Equal(t, responseMap["username"], v.username) | |
} | |
if v.statusCode == 401 || v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
fmt.Println("this is the response error: ", responseMap) | |
if responseMap["Password_mismatch"] != nil { | |
assert.Equal(t, responseMap["Password_mismatch"], "The password not correct") | |
} | |
if responseMap["Empty_new"] != nil { | |
assert.Equal(t, responseMap["Empty_new"], "Please Provide new password") | |
} | |
if responseMap["Empty_current"] != nil { | |
assert.Equal(t, responseMap["Empty_current"], "Please Provide current password") | |
} | |
if responseMap["Invalid_password"] != nil { | |
assert.Equal(t, responseMap["Invalid_password"], "Password should be atleast 6 characters") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["Taken_email"] != nil { | |
assert.Equal(t, responseMap["Taken_email"], "Email Already Taken") | |
} | |
if responseMap["Invalid_email"] != nil { | |
assert.Equal(t, responseMap["Invalid_email"], "Invalid Email") | |
} | |
if responseMap["Required_email"] != nil { | |
assert.Equal(t, responseMap["Required_email"], "Required Email") | |
} | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
} | |
} | |
} | |
func TestDeleteUser(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatal(err) | |
} | |
// Note: the value of the user password before it was hashed is "password". so: | |
password := "password" | |
tokenInterface, err := server.SignIn(user.Email, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
userSample := []struct { | |
id string | |
tokenGiven string | |
statusCode int | |
}{ | |
{ | |
// Convert int32 to int first before converting to string | |
id: strconv.Itoa(int(user.ID)), | |
tokenGiven: tokenString, | |
statusCode: 200, | |
}, | |
{ | |
// When no token is given | |
id: strconv.Itoa(int(user.ID)), | |
tokenGiven: "", | |
statusCode: 401, | |
}, | |
{ | |
// When incorrect token is given | |
id: strconv.Itoa(int(user.ID)), | |
tokenGiven: "This is an incorrect token", | |
statusCode: 401, | |
}, | |
{ | |
// When bad request data is given: | |
id: "unknwon", | |
tokenGiven: tokenString, | |
statusCode: 400, | |
}, | |
} | |
for _, v := range userSample { | |
r := gin.Default() | |
r.DELETE("/users/:id", server.DeleteUser) | |
req, _ := http.NewRequest(http.MethodDelete, "/users/"+v.id, nil) | |
req.Header.Set("Authorization", v.tokenGiven) | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
assert.Equal(t, responseInterface["response"], "User deleted") | |
} | |
if v.statusCode == 400 || v.statusCode == 401 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
} | |
} | |
} |
c. Posts Controller Test
In the tests directory, create the controller_posts_test.go file.
touch controller_posts_test.go
package tests | |
import ( | |
"bytes" | |
"encoding/json" | |
"fmt" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"strconv" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
func TestCreatePost(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
user, err := seedOneUser() | |
if err != nil { | |
log.Fatalf("Cannot seed user %v\n", err) | |
} | |
// Note: the value of the user password before it was hashed is "password". so: | |
password := "password" | |
tokenInterface, err := server.SignIn(user.Email, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
// Note that the author id is obtained from the token, so we dont pass it | |
samples := []struct { | |
inputJSON string | |
statusCode int | |
title string | |
content string | |
tokenGiven string | |
}{ | |
{ | |
inputJSON: `{"title":"The title", "content": "the content"}`, | |
statusCode: 201, | |
tokenGiven: tokenString, | |
title: "The title", | |
content: "the content", | |
}, | |
{ | |
// When the post title already exist | |
inputJSON: `{"title":"The title", "content": "the content"}`, | |
statusCode: 500, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When no token is passed | |
inputJSON: `{"title":"When no token is passed", "content": "the content"}`, | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
// When incorrect token is passed | |
inputJSON: `{"title":"When incorrect token is passed", "content": "the content"}`, | |
statusCode: 401, | |
tokenGiven: "This is an incorrect token", | |
}, | |
{ | |
inputJSON: `{"title": "", "content": "The content"}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
inputJSON: `{"title": "This is a title", "content": ""}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.POST("/posts", server.CreatePost) | |
req, err := http.NewRequest(http.MethodPost, "/posts", bytes.NewBufferString(v.inputJSON)) | |
req.Header.Set("Authorization", v.tokenGiven) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 201 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["title"], v.title) | |
assert.Equal(t, responseMap["content"], v.content) | |
} | |
if v.statusCode == 401 || v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["Taken_title"] != nil { | |
assert.Equal(t, responseMap["Taken_title"], "Title Already Taken") | |
} | |
if responseMap["Required_title"] != nil { | |
assert.Equal(t, responseMap["Required_title"], "Required Title") | |
} | |
if responseMap["Required_content"] != nil { | |
assert.Equal(t, responseMap["Required_content"], "Required Content") | |
} | |
} | |
} | |
} | |
func TestGetPosts(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, _, err = seedUsersAndPosts() | |
if err != nil { | |
log.Fatal(err) | |
} | |
r := gin.Default() | |
r.GET("/posts", server.GetUsers) | |
req, err := http.NewRequest(http.MethodGet, "/posts", nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
postsInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &postsInterface) | |
if err != nil { | |
log.Fatalf("Cannot convert to json: %v\n", err) | |
} | |
// This is so that we can get the length of the posts: | |
thePosts := postsInterface["response"].([]interface{}) | |
assert.Equal(t, rr.Code, http.StatusOK) | |
assert.Equal(t, len(thePosts), 2) | |
} | |
func TestGetPostByID(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, post, err := seedOneUserAndOnePost() | |
if err != nil { | |
log.Fatal(err) | |
} | |
postSample := []struct { | |
id string | |
statusCode int | |
title string | |
content string | |
author_id uint32 | |
}{ | |
{ | |
id: strconv.Itoa(int(post.ID)), | |
statusCode: 200, | |
title: post.Title, | |
content: post.Content, | |
author_id: post.AuthorID, | |
}, | |
{ | |
id: "unknwon", | |
statusCode: 400, | |
}, | |
{ | |
id: strconv.Itoa(12322), //an id that does not exist | |
statusCode: 404, | |
}, | |
} | |
for _, v := range postSample { | |
req, _ := http.NewRequest("GET", "/posts/"+v.id, nil) | |
rr := httptest.NewRecorder() | |
r := gin.Default() | |
r.GET("/posts/:id", server.GetPost) | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["title"], v.title) | |
assert.Equal(t, responseMap["content"], v.content) | |
assert.Equal(t, responseMap["author_id"], float64(v.author_id)) | |
} | |
if v.statusCode == 400 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["No_post"] != nil { | |
assert.Equal(t, responseMap["No_post"], "No Post Found") | |
} | |
} | |
} | |
} | |
func TestUpdatePost(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var PostUserEmail, PostUserPassword string | |
// var AuthID uint32 | |
var AuthPostID uint64 | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, posts, err := seedUsersAndPosts() | |
if err != nil { | |
log.Fatal(err) | |
} | |
// Get only the first user | |
for _, user := range users { | |
if user.ID == 2 { | |
continue | |
} | |
PostUserEmail = user.Email | |
PostUserPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
// Get only the first post | |
for _, post := range posts { | |
if post.ID == 2 { | |
continue | |
} | |
AuthPostID = post.ID | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(PostUserEmail, PostUserPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
samples := []struct { | |
id string | |
updateJSON string | |
statusCode int | |
title string | |
content string | |
tokenGiven string | |
}{ | |
{ | |
// Convert int64 to int first before converting to string | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"The updated post", "content": "This is the updated content"}`, | |
statusCode: 200, | |
title: "The updated post", | |
content: "This is the updated content", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When no token is provided | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"This is still another title", "content": "This is the updated content"}`, | |
tokenGiven: "", | |
statusCode: 401, | |
}, | |
{ | |
// When incorrect token is provided | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"This is still another title", "content": "This is the updated content"}`, | |
tokenGiven: "this is an incorrect token", | |
statusCode: 401, | |
}, | |
{ | |
//Note: "Title 2" belongs to post 2, and title must be unique | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"Title 2", "content": "This is the updated content"}`, | |
statusCode: 500, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When title is not given | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"", "content": "This is the updated content"}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When content is not given | |
id: strconv.Itoa(int(AuthPostID)), | |
updateJSON: `{"title":"Awesome title", "content": ""}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When invalid post id is given | |
id: "unknwon", | |
statusCode: 400, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.PUT("/posts/:id", server.UpdatePost) | |
req, err := http.NewRequest(http.MethodPut, "/posts/"+v.id, bytes.NewBufferString(v.updateJSON)) | |
req.Header.Set("Authorization", v.tokenGiven) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
//casting the interface to map: | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["title"], v.title) | |
assert.Equal(t, responseMap["content"], v.content) | |
} | |
if v.statusCode == 400 || v.statusCode == 401 || v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Taken_title"] != nil { | |
assert.Equal(t, responseMap["Taken_title"], "Title Already Taken") | |
} | |
if responseMap["Required_title"] != nil { | |
assert.Equal(t, responseMap["Required_title"], "Required Title") | |
} | |
if responseMap["Required_content"] != nil { | |
assert.Equal(t, responseMap["Required_content"], "Required Content") | |
} | |
} | |
} | |
} | |
func TestDeletePost(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var PostUserEmail, PostUserPassword string | |
// var AuthID uint32 | |
var AuthPostID uint64 | |
err := refreshUserAndPostTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, posts, err := seedUsersAndPosts() | |
if err != nil { | |
log.Fatal(err) | |
} | |
// Get only the second user | |
for _, user := range users { | |
if user.ID == 1 { | |
continue | |
} | |
PostUserEmail = user.Email | |
PostUserPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
// Get only the second post | |
for _, post := range posts { | |
if post.ID == 1 { | |
continue | |
} | |
AuthPostID = post.ID | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(PostUserEmail, PostUserPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
postSample := []struct { | |
id string | |
tokenGiven string | |
statusCode int | |
errorMessage string | |
}{ | |
{ | |
// Convert int64 to int first before converting to string | |
id: strconv.Itoa(int(AuthPostID)), | |
tokenGiven: tokenString, | |
statusCode: 200, | |
}, | |
{ | |
// When empty token is passed | |
id: strconv.Itoa(int(AuthPostID)), | |
tokenGiven: "", | |
statusCode: 401, | |
}, | |
{ | |
// When incorrect token is passed | |
id: strconv.Itoa(int(AuthPostID)), | |
tokenGiven: "This is an incorrect token", | |
statusCode: 401, | |
}, | |
{ | |
id: "unknwon", | |
tokenGiven: tokenString, | |
statusCode: 400, | |
}, | |
{ | |
id: strconv.Itoa(int(1)), | |
statusCode: 401, | |
errorMessage: "Unauthorized", | |
}, | |
} | |
for _, v := range postSample { | |
r := gin.Default() | |
r.DELETE("/posts/:id", server.DeletePost) | |
req, _ := http.NewRequest(http.MethodDelete, "/posts/"+v.id, nil) | |
req.Header.Set("Authorization", v.tokenGiven) | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
assert.Equal(t, responseInterface["response"], "Post deleted") | |
} | |
if v.statusCode == 400 || v.statusCode == 401 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
} | |
} | |
} |
d. Likes Controller Test
In the tests directory, create the controller_likes_test.go file.
touch controller_likes_test.go
package tests | |
import ( | |
"encoding/json" | |
"fmt" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"strconv" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
func TestLikePost(t *testing.T) { | |
var firstUserEmail, secondUserEmail string | |
var firstPostID uint64 | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, posts, err := seedUsersAndPosts() | |
if err != nil { | |
log.Fatalf("Cannot seed user %v\n", err) | |
} | |
for _, user := range users { | |
if user.ID == 1 { | |
firstUserEmail = user.Email | |
} | |
if user.ID == 2 { | |
secondUserEmail = user.Email | |
} | |
} | |
// Get only the first post, which belongs to first user | |
for _, post := range posts { | |
if post.ID == 2 { | |
continue | |
} | |
firstPostID = post.ID | |
} | |
// Login both users | |
// user 1 and user 2 password are the same, you can change if you want (Note by the time they are hashed and saved in the db, they are different) | |
// Note: the value of the user password before it was hashed is "password". so: | |
password := "password" | |
// Login First User | |
tokenInterface1, err := server.SignIn(firstUserEmail, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token1 := tokenInterface1["token"] //get only the token | |
firstUserToken := fmt.Sprintf("Bearer %v", token1) | |
// Login Second User | |
tokenInterface2, err := server.SignIn(secondUserEmail, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token2 := tokenInterface2["token"] //get only the token | |
secondUserToken := fmt.Sprintf("Bearer %v", token2) | |
samples := []struct { | |
postIDString string | |
statusCode int | |
userID uint32 | |
postID uint64 | |
tokenGiven string | |
}{ | |
{ | |
// User 1 can like his post | |
postIDString: strconv.Itoa(int(firstPostID)), //we need the id as a string | |
statusCode: 201, | |
userID: 1, | |
postID: firstPostID, | |
tokenGiven: firstUserToken, | |
}, | |
{ | |
// User 2 can also like user 1 post | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 201, | |
userID: 2, | |
postID: firstPostID, | |
tokenGiven: secondUserToken, | |
}, | |
{ | |
// An authenticated user cannot like a post more than once | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 500, | |
tokenGiven: firstUserToken, | |
}, | |
{ | |
// Not authenticated (No token provided) | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
// Wrong Token | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 401, | |
tokenGiven: "This is an incorrect token", | |
}, | |
} | |
for _, v := range samples { | |
gin.SetMode(gin.TestMode) | |
r := gin.Default() | |
r.POST("/likes/:id", server.LikePost) | |
req, err := http.NewRequest(http.MethodPost, "/likes/"+v.postIDString, nil) | |
req.Header.Set("Authorization", v.tokenGiven) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 201 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["post_id"], float64(v.postID)) | |
assert.Equal(t, responseMap["user_id"], float64(v.userID)) | |
} | |
if v.statusCode == 401 || v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["Double_like"] != nil { | |
assert.Equal(t, responseMap["Double_like"], "You cannot like this post twice") | |
} | |
} | |
} | |
} | |
func TestGetLikes(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
post, users, likes, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Cannot seed tables %v\n", err) | |
} | |
likesSample := []struct { | |
postID string | |
usersLength int | |
likesLength int | |
statusCode int | |
}{ | |
{ | |
postID: strconv.Itoa(int(post.ID)), | |
statusCode: 200, | |
usersLength: len(users), | |
likesLength: len(likes), | |
}, | |
{ | |
postID: "unknwon", | |
statusCode: 400, | |
}, | |
{ | |
postID: strconv.Itoa(12322), //an id that does not exist | |
statusCode: 404, | |
}, | |
} | |
for _, v := range likesSample { | |
r := gin.Default() | |
r.GET("/likes/:id", server.GetLikes) | |
req, err := http.NewRequest(http.MethodGet, "/likes/"+v.postID, nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"].([]interface{}) | |
assert.Equal(t, len(responseMap), v.likesLength) | |
assert.Equal(t, v.usersLength, 2) | |
} | |
if v.statusCode == 400 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["No_post"] != nil { | |
assert.Equal(t, responseMap["No_post"], "No Post Found") | |
} | |
} | |
} | |
} | |
func TestDeleteLike(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var secondUserEmail, secondUserPassword string | |
var secondLike uint64 | |
err := refreshUserPostAndLikeTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, users, likes, err := seedUsersPostsAndLikes() | |
if err != nil { | |
log.Fatalf("Cannot seed tables %v\n", err) | |
} | |
// Get only the second user | |
for _, user := range users { | |
if user.ID == 1 { | |
continue | |
} | |
secondUserEmail = user.Email | |
secondUserPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
// Get only the second like | |
for _, like := range likes { | |
if like.ID == 1 { | |
continue | |
} | |
secondLike = like.ID | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(secondUserEmail, secondUserPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
likesSample := []struct { | |
likeID string | |
usersLength int | |
tokenGiven string | |
likesLength int | |
statusCode int | |
}{ | |
{ | |
likeID: strconv.Itoa(int(secondLike)), | |
statusCode: 200, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//an id that does not exist | |
likeID: strconv.Itoa(12322), | |
statusCode: 404, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//When the user is not authenticated | |
likeID: strconv.Itoa(int(secondLike)), | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
//When wrong token is passed | |
likeID: strconv.Itoa(int(secondLike)), | |
statusCode: 401, | |
tokenGiven: "this is a wrong token", | |
}, | |
{ | |
// When id passed is invalid | |
likeID: "unknwon", | |
statusCode: 400, | |
}, | |
} | |
for _, v := range likesSample { | |
r := gin.Default() | |
r.GET("/likes/:id", server.UnLikePost) | |
req, err := http.NewRequest(http.MethodGet, "/likes/"+v.likeID, nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
req.Header.Set("Authorization", v.tokenGiven) | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"] | |
assert.Equal(t, responseMap, "Like deleted") | |
} | |
if v.statusCode == 400 || v.statusCode == 401 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["No_like"] != nil { | |
assert.Equal(t, responseMap["No_like"], "No Like Found") | |
} | |
} | |
} | |
} |
e. Comments Controller Test
In the tests directory, create the controller_comments_test.go file.
touch controller_comments_test.go
package tests | |
import ( | |
"bytes" | |
"encoding/json" | |
"fmt" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"strconv" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
func TestCommentPost(t *testing.T) { | |
var firstUserEmail, secondUserEmail string | |
var firstPostID uint64 | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
users, posts, err := seedUsersAndPosts() | |
if err != nil { | |
log.Fatalf("Cannot seed user %v\n", err) | |
} | |
for _, user := range users { | |
if user.ID == 1 { | |
firstUserEmail = user.Email | |
} | |
if user.ID == 2 { | |
secondUserEmail = user.Email | |
} | |
} | |
// Get only the first post, which belongs to first user | |
for _, post := range posts { | |
if post.ID == 2 { | |
continue | |
} | |
firstPostID = post.ID | |
} | |
// Login both users | |
// user 1 and user 2 password are the same, you can change if you want (Note by the time they are hashed and saved in the db, they are different) | |
// Note: the value of the user password before it was hashed is "password". so: | |
password := "password" | |
// Login First User | |
tokenInterface1, err := server.SignIn(firstUserEmail, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token1 := tokenInterface1["token"] //get only the token | |
firstUserToken := fmt.Sprintf("Bearer %v", token1) | |
// Login Second User | |
tokenInterface2, err := server.SignIn(secondUserEmail, password) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token2 := tokenInterface2["token"] //get only the token | |
secondUserToken := fmt.Sprintf("Bearer %v", token2) | |
fmt.Println("this is the second user token: ", secondUserToken) | |
samples := []struct { | |
postIDString string | |
inputJSON string | |
statusCode int | |
userID uint32 | |
postID uint64 | |
Body string | |
tokenGiven string | |
}{ | |
{ | |
// User 1 can comment on his post | |
postIDString: strconv.Itoa(int(firstPostID)), //we need the id as a string | |
inputJSON: `{"body": "comment from user 1"}`, | |
statusCode: 201, | |
userID: 1, | |
postID: firstPostID, | |
Body: "comment from user 1", | |
tokenGiven: firstUserToken, | |
}, | |
{ | |
// User 2 can also comment on user 1 post | |
postIDString: strconv.Itoa(int(firstPostID)), | |
inputJSON: `{"body":"comment from user 2"}`, | |
statusCode: 201, | |
userID: 2, | |
postID: firstPostID, | |
Body: "comment from user 2", | |
tokenGiven: secondUserToken, | |
}, | |
{ | |
// When no body is provided: | |
postIDString: strconv.Itoa(int(firstPostID)), | |
inputJSON: `{"body":""}`, | |
statusCode: 422, | |
postID: firstPostID, | |
tokenGiven: secondUserToken, | |
}, | |
{ | |
// Not authenticated (No token provided) | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
// Wrong Token | |
postIDString: strconv.Itoa(int(firstPostID)), | |
statusCode: 401, | |
tokenGiven: "This is an incorrect token", | |
}, | |
{ | |
// When invalid post id is given | |
postIDString: "unknwon", | |
statusCode: 400, | |
}, | |
} | |
for _, v := range samples { | |
gin.SetMode(gin.TestMode) | |
r := gin.Default() | |
r.POST("/comments/:id", server.CreateComment) | |
req, err := http.NewRequest(http.MethodPost, "/comments/"+v.postIDString, bytes.NewBufferString(v.inputJSON)) | |
req.Header.Set("Authorization", v.tokenGiven) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 201 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["post_id"], float64(v.postID)) | |
assert.Equal(t, responseMap["user_id"], float64(v.userID)) | |
assert.Equal(t, responseMap["body"], v.Body) | |
} | |
if v.statusCode == 401 || v.statusCode == 422 || v.statusCode == 500 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["Required_body"] != nil { | |
assert.Equal(t, responseMap["Required_body"], "Required Comment") | |
} | |
} | |
} | |
} | |
func TestGetComments(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
post, users, comments, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Cannot seed tables %v\n", err) | |
} | |
commentsSample := []struct { | |
postID string | |
usersLength int | |
commentsLength int | |
statusCode int | |
}{ | |
{ | |
postID: strconv.Itoa(int(post.ID)), | |
statusCode: 200, | |
usersLength: len(users), | |
commentsLength: len(comments), | |
}, | |
{ | |
postID: "unknwon", | |
statusCode: 400, | |
}, | |
{ | |
postID: strconv.Itoa(12322), //an id that does not exist | |
statusCode: 404, | |
}, | |
} | |
for _, v := range commentsSample { | |
r := gin.Default() | |
r.GET("/comments/:id", server.GetComments) | |
req, err := http.NewRequest(http.MethodGet, "/comments/"+v.postID, nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"].([]interface{}) | |
assert.Equal(t, len(responseMap), v.commentsLength) | |
assert.Equal(t, v.usersLength, 2) | |
} | |
if v.statusCode == 400 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["No_post"] != nil { | |
assert.Equal(t, responseMap["No_post"], "No post found") | |
} | |
} | |
} | |
} | |
func TestUpdateComment(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var secondUserEmail, secondUserPassword string | |
var secondUserID uint32 | |
var secondCommentID uint64 | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
post, users, comments, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Cannot seed tables %v\n", err) | |
} | |
// Get only the second user | |
for _, user := range users { | |
if user.ID == 1 { | |
continue | |
} | |
secondUserID = user.ID | |
secondUserEmail = user.Email | |
secondUserPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
// Get only the second comment | |
for _, comment := range comments { | |
if comment.ID == 1 { | |
continue | |
} | |
secondCommentID = comment.ID | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(secondUserEmail, secondUserPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
commentsSample := []struct { | |
commentID string | |
updateJSON string | |
Body string | |
tokenGiven string | |
statusCode int | |
}{ | |
{ | |
commentID: strconv.Itoa(int(secondCommentID)), | |
updateJSON: `{"Body":"This is the update body"}`, | |
statusCode: 200, | |
Body: "This is the update body", | |
tokenGiven: tokenString, | |
}, | |
{ | |
// When the body field is empty | |
commentID: strconv.Itoa(int(secondCommentID)), | |
updateJSON: `{"Body":""}`, | |
statusCode: 422, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//an id that does not exist | |
commentID: strconv.Itoa(12322), | |
statusCode: 404, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//When the user is not authenticated | |
commentID: strconv.Itoa(int(secondCommentID)), | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
//When wrong token is passed | |
commentID: strconv.Itoa(int(secondCommentID)), | |
statusCode: 401, | |
tokenGiven: "this is a wrong token", | |
}, | |
{ | |
// When id passed is invalid | |
commentID: "unknwon", | |
statusCode: 400, | |
}, | |
} | |
for _, v := range commentsSample { | |
r := gin.Default() | |
r.PUT("/comments/:id", server.UpdateComment) | |
req, err := http.NewRequest(http.MethodPut, "/comments/"+v.commentID, bytes.NewBufferString(v.updateJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
req.Header.Set("Authorization", v.tokenGiven) | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"].(map[string]interface{}) | |
assert.Equal(t, responseMap["post_id"], float64(post.ID)) | |
assert.Equal(t, responseMap["user_id"], float64(secondUserID)) | |
assert.Equal(t, responseMap["body"], v.Body) | |
} | |
if v.statusCode == 400 || v.statusCode == 401 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["No_comment"] != nil { | |
assert.Equal(t, responseMap["No_comment"], "No Comment Found") | |
} | |
} | |
} | |
} | |
func TestDeleteComment(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
var secondUserEmail, secondUserPassword string | |
// var secondUserID uint32 | |
var secondCommentID uint64 | |
err := refreshUserPostAndCommentTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, users, comments, err := seedUsersPostsAndComments() | |
if err != nil { | |
log.Fatalf("Cannot seed tables %v\n", err) | |
} | |
// Get only the second user | |
for _, user := range users { | |
if user.ID == 1 { | |
continue | |
} | |
// secondUserID = user.ID | |
secondUserEmail = user.Email | |
secondUserPassword = "password" //Note the password in the database is already hashed, we want unhashed | |
} | |
// Get only the second comment | |
for _, comment := range comments { | |
if comment.ID == 1 { | |
continue | |
} | |
secondCommentID = comment.ID | |
} | |
//Login the user and get the authentication token | |
tokenInterface, err := server.SignIn(secondUserEmail, secondUserPassword) | |
if err != nil { | |
log.Fatalf("cannot login: %v\n", err) | |
} | |
token := tokenInterface["token"] //get only the token | |
tokenString := fmt.Sprintf("Bearer %v", token) | |
commentsSample := []struct { | |
commentID string | |
usersLength int | |
tokenGiven string | |
commentsLength int | |
statusCode int | |
}{ | |
{ | |
commentID: strconv.Itoa(int(secondCommentID)), | |
statusCode: 200, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//an id that does not exist | |
commentID: strconv.Itoa(12322), | |
statusCode: 404, | |
tokenGiven: tokenString, | |
}, | |
{ | |
//When the user is not authenticated | |
commentID: strconv.Itoa(int(secondCommentID)), | |
statusCode: 401, | |
tokenGiven: "", | |
}, | |
{ | |
//When wrong token is passed | |
commentID: strconv.Itoa(int(secondCommentID)), | |
statusCode: 401, | |
tokenGiven: "this is a wrong token", | |
}, | |
{ | |
// When id passed is invalid | |
commentID: "unknwon", | |
statusCode: 400, | |
}, | |
} | |
for _, v := range commentsSample { | |
r := gin.Default() | |
r.DELETE("/comments/:id", server.DeleteComment) | |
req, err := http.NewRequest(http.MethodDelete, "/comments/"+v.commentID, nil) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
req.Header.Set("Authorization", v.tokenGiven) | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json here: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"] | |
assert.Equal(t, responseMap, "Comment deleted") | |
} | |
if v.statusCode == 400 || v.statusCode == 401 || v.statusCode == 404 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_request"] != nil { | |
assert.Equal(t, responseMap["Invalid_request"], "Invalid Request") | |
} | |
if responseMap["Unauthorized"] != nil { | |
assert.Equal(t, responseMap["Unauthorized"], "Unauthorized") | |
} | |
if responseMap["No_comment"] != nil { | |
assert.Equal(t, responseMap["No_comment"], "No Comment Found") | |
} | |
} | |
} | |
} |
f. ResetPassword Controller Test
In the tests directory, create the controller_reset_password_test.go file.
touch controller_reset_password_test.go
package tests | |
import ( | |
"bytes" | |
"encoding/json" | |
"github.com/victorsteven/forum/api/mailer" | |
"log" | |
"net/http" | |
"net/http/httptest" | |
"testing" | |
"github.com/gin-gonic/gin" | |
"github.com/stretchr/testify/assert" | |
) | |
var ( | |
sendMailFunc func(ToUser string, FromAdmin string, Token string, Sendgridkey string, AppEnv string) (*mailer.EmailResponse, error) | |
) | |
type sendMailMock struct {} | |
func (sm *sendMailMock) SendResetPassword(ToUser string, FromAdmin string, Token string, Sendgridkey string, AppEnv string) (*mailer.EmailResponse, error) { | |
return sendMailFunc(ToUser, FromAdmin, Token, Sendgridkey, AppEnv) | |
} | |
func TestForgotPasswordSuccess(t *testing.T) { | |
//In this test, we will simulate sending mail | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndResetPasswordTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, err = seedOneUser() | |
if err != nil { | |
log.Fatal(err) | |
} | |
//Since we are mocking sending the email, we are going to call the fake mail function: | |
mailer.SendMail = &sendMailMock{} //this is where the magic happen, to deceive the app that we are sending real email | |
//We send the mail and tell it the response we want | |
sendMailFunc = func(ToUser string, FromAdmin string, Token string, Sendgridkey string, AppEnv string) (*mailer.EmailResponse, error) { | |
return &mailer.EmailResponse{ | |
Status: http.StatusOK, | |
RespBody: "Success, Please click on the link provided in your email", | |
}, nil | |
} | |
inputJSON := `{"email": "pet@example.com"}` //the seeded user | |
r := gin.Default() | |
r.POST("/password/forgot", server.ForgotPassword) | |
req, err := http.NewRequest(http.MethodPost, "/password/forgot", bytes.NewBufferString(inputJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
message := responseInterface["response"] | |
status := responseInterface["status"] | |
assert.Equal(t, rr.Code, int(status.(float64))) //we convert interface to string. | |
assert.EqualValues(t, "Success, Please click on the link provided in your email", message) | |
} | |
func TestForgotPasswordFailures(t *testing.T) { | |
//In this test, we dont need to mock the email because we will never call the send mail method | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndResetPasswordTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, err = seedOneUser() | |
if err != nil { | |
log.Fatal(err) | |
} | |
//Since we are mocking sending the email, we are going to call the fake mail function: | |
mailer.SendMail = &sendMailMock{} //this is where the magic happen, to deceive the app that we are sending real email | |
samples := []struct { | |
id string | |
inputJSON string | |
statusCode int | |
}{ | |
{ | |
// When the user input invalid email: | |
inputJSON: `{"email": "petexample.com"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the email given dont exist in our database: | |
inputJSON: `{"email": "raman@example.com"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the email field is empty: | |
inputJSON: `{"email": ""}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the number or any other input that is not string: | |
inputJSON: `{"email": 123}`, | |
statusCode: 422, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.POST("/password/forgot", server.ForgotPassword) | |
req, err := http.NewRequest(http.MethodPost, "/password/forgot", bytes.NewBufferString(v.inputJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 422 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_email"] != nil { | |
assert.Equal(t, responseMap["Invalid_email"], "Invalid Email") | |
} | |
if responseMap["No_email"] != nil { | |
assert.Equal(t, responseMap["No_email"], "Sorry, we do not recognize this email") | |
} | |
if responseMap["Required_email"] != nil { | |
assert.Equal(t, responseMap["Required_email"], "Required Email") | |
} | |
if responseMap["Unmarshal_error"] != nil { | |
assert.Equal(t, responseMap["Unmarshal_error"], "Cannot unmarshal body") | |
} | |
} | |
} | |
} | |
func TestResetPassword(t *testing.T) { | |
gin.SetMode(gin.TestMode) | |
err := refreshUserAndResetPasswordTable() | |
if err != nil { | |
log.Fatal(err) | |
} | |
// This is important when we want to update the user password | |
_, err = seedOneUser() | |
if err != nil { | |
log.Fatal(err) | |
} | |
_, err = seedResetPassword() | |
if err != nil { | |
log.Fatal(err) | |
} | |
samples := []struct { | |
inputJSON string | |
statusCode int | |
}{ | |
{ | |
// When no token is passed: | |
inputJSON: `{"token": ""}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the token is tampered with: | |
inputJSON: `{"token": "23423498398rwnef9sd8fjsdf"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When passwords "new_password" and "retype_password" provided are not up to 6 characters: | |
inputJSON: `{"token": "awesometoken", "new_password": "pass", "retype_password":"pass"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the "new_password" is empty: | |
inputJSON: `{"token": "awesometoken", "new_password": "", "retype_password":"password"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the "retype_password" is empty: | |
inputJSON: `{"token": "awesometoken", "new_password": "password", "retype_password":""}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the two password fields dont match | |
inputJSON: `{"token": "awesometoken", "new_password": "password", "retype_password":"newpassword"}`, | |
statusCode: 422, | |
}, | |
{ | |
// When the token and the password fields are correct, and the password updated | |
inputJSON: `{"token": "awesometoken", "new_password": "password", "retype_password":"password"}`, | |
statusCode: 200, | |
}, | |
} | |
for _, v := range samples { | |
r := gin.Default() | |
r.POST("/password/reset", server.ResetPassword) | |
req, err := http.NewRequest(http.MethodPost, "/password/reset", bytes.NewBufferString(v.inputJSON)) | |
if err != nil { | |
t.Errorf("this is the error: %v\n", err) | |
} | |
rr := httptest.NewRecorder() | |
r.ServeHTTP(rr, req) | |
responseInterface := make(map[string]interface{}) | |
err = json.Unmarshal([]byte(rr.Body.String()), &responseInterface) | |
if err != nil { | |
t.Errorf("Cannot convert to json: %v", err) | |
} | |
assert.Equal(t, rr.Code, v.statusCode) | |
if v.statusCode == 200 { | |
responseMap := responseInterface["response"] | |
assert.Equal(t, responseMap, "Success") | |
} | |
if v.statusCode == 422 { | |
responseMap := responseInterface["error"].(map[string]interface{}) | |
if responseMap["Invalid_token"] != nil { | |
assert.Equal(t, responseMap["Invalid_token"], "Invalid link. Try requesting again") | |
} | |
if responseMap["No_email"] != nil { | |
assert.Equal(t, responseMap["No_email"], "Sorry, we do not recognize this email") | |
} | |
if responseMap["Invalid_Passwords"] != nil { | |
assert.Equal(t, responseMap["Invalid_Passwords"], "Password should be atleast 6 characters") | |
} | |
if responseMap["Empty_passwords"] != nil { | |
assert.Equal(t, responseMap["Empty_passwords"], "Please ensure both field are entered") | |
} | |
if responseMap["Password_unequal"] != nil { | |
assert.Equal(t, responseMap["Password_unequal"], "Passwords provided do not match") | |
} | |
} | |
} | |
} |
As mentioned earlier, You can run any test in the tests directory. No test function depends on another to pass. All test functions run independently.
To run the entire test suite use:
go test -v
You can also run tests from the main directory of the app that is, outside the tests directory(path: /forum-backend/) using:
go test -v ./...
Running Tests with Docker
If you wish to run the tests with docker, do the following:
a. Dockerfile.test file
In the root directory, create a Dockerfile.test
touch Dockerfile.test
You can rename the example.Dockerfile.test(from the repo) to Dockerfile.test
FROM golang:1.12-alpine | |
# Install git | |
RUN apk update && apk add --no-cache git | |
WORKDIR /usr/src/app | |
COPY go.mod go.sum ./ | |
RUN go mod download | |
# Copy the source from the current directory to the working Directory inside the container | |
COPY . . | |
# Run tests | |
CMD CGO_ENABLED=0 go test -v ./tests/... |
b. docker-compose.test.yml file
In the root directory, create a docker-compose.test.yml
touch docker-compose.test.yml
You can rename the example.docker-compose.test.yml(from the repo) to docker-compose.test.yml
version: '3' | |
services: | |
app_test: | |
container_name: full_app_test | |
build: | |
context: . | |
dockerfile: ./Dockerfile.test | |
depends_on: | |
- forum-postgres-test | |
# - mysql_test | |
networks: | |
- forum_test | |
forum-postgres-test: | |
image: postgres:latest | |
container_name: full_db_test_postgress | |
environment: | |
- POSTGRES_USER=${TEST_DB_USER} | |
- POSTGRES_PASSWORD=${TEST_DB_PASSWORD} | |
- POSTGRES_DB=${TEST_DB_NAME} | |
- DATABASE_HOST=${TEST_DB_HOST} | |
ports: | |
- '5555:5432' | |
volumes: | |
- database_postgres_test:/var/lib/postgresql/data | |
networks: | |
- forum_test | |
# mysql_test: | |
# image: mysql:5.7 | |
# container_name: full_db_test_mysql | |
# ports: | |
# - 3333:3306 | |
# environment: | |
# - MYSQL_DATABASE=${TEST_DB_NAME} | |
# - MYSQL_USER=${TEST_DB_USER} | |
# - MYSQL_PASSWORD=${TEST_DB_PASSWORD} | |
# - MYSQL_ROOT_PASSWORD=${TEST_DB_PASSWORD} | |
# - DATABASE_HOST=${TEST_DB_HOST} | |
# volumes: | |
# - database_mysql_test:/var/lib/mysql | |
# networks: | |
# - forum_test | |
volumes: | |
api_test: | |
database_postgres_test: | |
# database_mysql_test: | |
networks: | |
forum_test: | |
driver: bridge |
c. Run the tests suite:
Ensure that the test database details are provided in the .env file and the Test_Host_DB is set as such:
TEST_DB_HOST=forum-postgres-test
From the project root directory, run:
docker-compose -f docker-compose.test.yml up --build
Step 14: Continuous Integration Tools
Circle CI is used as the CI tool in this API. Another option you might consider is Travis CI.
Steps to Integrate CircleCI:
a. config.yml
In the root directory(path: /forum-backend/), create the .circleci
mkdir .circleci
Create the config.yml file inside the .circleci directory
cd .circleci && touch config.yml
version: 2 # use CircleCI 2.0 | |
jobs: # basic units of work in a run | |
build: # runs not using Workflows must have a `build` job as entry point | |
docker: # run the steps with Docker | |
- image: circleci/golang:1.12 | |
- image: circleci/postgres:9.6-alpine | |
environment: # environment variables for primary container | |
POSTGRES_USER: steven | |
POSTGRES_DB: forum_db_test | |
environment: # environment variables for the build itself | |
GO111MODULE: "on" #we don't rely on GOPATH | |
working_directory: ~/usr/src/app # Go module is used, so we dont need to worry about GOPATH | |
steps: # steps that comprise the `build` job | |
- checkout # check out source code to working directory | |
- run: | |
name: "Fetch dependencies" | |
command: go mod download | |
# Wait for Postgres to be ready before proceeding | |
- run: | |
name: Waiting for Postgres to be ready | |
command: dockerize -wait tcp://localhost:5432 -timeout 1m | |
- run: | |
name: Run unit tests | |
environment: # environment variables for the database url and path to migration files | |
FORUM_DB_URL: "postgres://steven@localhost:5432/forum_db_test?sslmode=disable" | |
command: go test -v ./tests/... # our test is inside the "tests" folder, so target only that | |
workflows: | |
version: 2 | |
build-workflow: | |
jobs: | |
- build |
b. Connect the repository
Since you have following this tutorial on your local, you can now create a github/bitbucket repository and push the code.
Login to Circle CI and choose the repo to build.
Click on start building.
After the build process, you will be notified if it succeeds or fails. For failure, check the logs in the CI environment to know why.
Go to the settings, copy the badge and add it to the README.md of your repo
For a successful build, your badge should look like mine:
Step 15: Deployment
I deployed a dockerized version of the app to digitalocean. The job can also be done with Amazon AWS.
This deployment process is worth a full-blown article. If you be interested in the step by step process, do well to comment, I will spin up a different article for that.
Get the Repository for the backend here
Section 2: Buiding the Frontend
You might have been waiting for the session.
This is a where you will appreciate the backend work done in Section 1
We will be using React. I would have as well decided to use Vue(which is also cool).
This frontend has zero class definition. React Hooks are used 100%.
Redux is used for state management.
The repository for the frontend is this:
https://github.com/victorsteven/Forum-App-React-Frontend
Step 1: Basic Step Up
a. Installation
To follow along from scratch, create a new React Project. Note that this project should be created outside the backend. You can create it in your Desktop, Documents, or your dedicated frontend directory.
npx create-react-app forum-frontend
Follow the instructions in the terminal after the project is created.
Change to the forum-frontend directory:
cd forum-frontend
And start the app:
npm start
Visit on the browser:
http://localhost:3000
Please note that I will be as concise as possible.
b. Install External Packages.
We installed packages like axios, moment, and so on.
To be brief, use the content in the project package.json file:
{ | |
"name": "forum-frontend", | |
"version": "0.1.0", | |
"private": true, | |
"dependencies": { | |
"autoprefixer": "^9.6.5", | |
"axios": "^0.19.0", | |
"bootstrap": "^4.3.1", | |
"dayjs": "^1.8.16", | |
"jsonwebtoken": "^8.5.1", | |
"lodash": "^4.17.15", | |
"moment": "^2.24.0", | |
"postcss-cli": "^6.1.3", | |
"prop-types": "^15.7.2", | |
"react": "^16.10.2", | |
"react-dom": "^16.10.2", | |
"react-icons": "^3.7.0", | |
"react-moment": "^0.9.6", | |
"react-redux": "^7.1.1", | |
"react-router": "^5.1.2", | |
"react-router-dom": "^5.1.2", | |
"react-router-redux": "^4.0.8", | |
"react-scripts": "3.1.1", | |
"react-thunk": "^1.0.0", | |
"reactstrap": "^8.0.1", | |
"redux": "^4.0.4", | |
"redux-react-hook": "^3.4.0", | |
"redux-thunk": "^2.3.0", | |
"tailwindcss": "^1.1.2" | |
}, | |
"scripts": { | |
"start": "react-scripts start", | |
"build": "react-scripts build", | |
"test": "react-scripts test", | |
"eject": "react-scripts eject" | |
}, | |
"eslintConfig": { | |
"extends": "react-app" | |
}, | |
"browserslist": { | |
"production": [ | |
">0.2%", | |
"not dead", | |
"not op_mini all" | |
], | |
"development": [ | |
"last 1 chrome version", | |
"last 1 firefox version", | |
"last 1 safari version" | |
] | |
}, | |
"devDependencies": { | |
"@testing-library/jest-dom": "^4.1.2", | |
"@testing-library/react": "^9.3.0", | |
"check-prop-types": "^1.1.2", | |
"enzyme": "^3.10.0", | |
"enzyme-adapter-react-16": "^1.15.1", | |
"enzyme-to-json": "^3.4.2", | |
"husky": "^3.0.9", | |
"jest": "^24.8.0", | |
"jest-enzyme": "^7.1.1", | |
"moxios": "^0.4.0", | |
"redux-mock-store": "^1.5.3" | |
} | |
} |
Then run:
npm update
c. API Url
The Backend is totally standalone from the Frontend
So a means of communication is needed.
Inside the src directory, create the apiRoute.js file:
cd src && touch apiRoute.js
let API_ROUTE | |
process.env.NODE_ENV === 'development' | |
? API_ROUTE = 'http://127.0.0.1:8888/api/v1' | |
: API_ROUTE = 'https://chodapi.com/api/v1' | |
export default API_ROUTE |
Not, from the above file, the production URL for the forum app is used, you can as well change it to yours if you have hosted a backend somewhere.
d. Authorization
Authenticated will be needed for some requests in the app.
Take, for instance, a user needs to be authenticated to create a post.
Since axios is used for api calls(sending requests to the backend), we need to send the authenticated user's authorization token to each request they make. Instead of adding the authorization token manually, let's do it automatically.
Inside the src directory, create the authorization directory:
mkdir authorization
Create the authorization.js file inside the authorization directory
cd authorization && touch authorization.js
e. History
We may need to call redirection from our redux action.
This is what I mean: When a user creates a post, redirect him to the list of posts available.
To achieve this, we will use the createBrowserHistory function from the history package.
Inside the src directory, create the history.js file:
touch history.js
import { createBrowserHistory } from 'history'; | |
export const history = createBrowserHistory(); | |
f. Assets
For each newly registered user, a default avatar is used as their display image.
Inside the src directory, create the assets directory:
mkdir assets
Add the avatar below in the assets directory. You can rename it to Default.png
Step 2: Wiring up our Store
As said earlier, we will be using redux for state management. And I think it is best the store is fired up before we start calling components that we will create later.
Inside the src directory, create the store directory:
cd src && mkdir store
Inside the store directory, create the modules directory:
cd store && mkdir modules
a. The Authentication Store
Inside the modules directory, create the auth directory:
cd modules && mkdir auth
Inside the auth directory, create these directories and files as shown in the image below:
i. auth/actions/authActions.js
import API_ROUTE from "../../../../apiRoute"; | |
import axios from 'axios' | |
import setAuthorizationToken from "../../../../authorization/authorization"; | |
import { BEFORE_STATE, SIGNUP_SUCCESS, SIGNUP_ERROR, LOGIN_SUCCESS, LOGIN_ERROR, LOGOUT_SUCCESS, UPDATE_USER_AVATAR, UPDATE_USER_SUCCESS, UPDATE_USER_ERROR, UPDATE_USER_AVATAR_ERROR, BEFORE_AVATAR_STATE, BEFORE_USER_STATE, FORGOT_PASSWORD_SUCCESS, FORGOT_PASSWORD_ERROR, RESET_PASSWORD_SUCCESS, RESET_PASSWORD_ERROR, DELETE_USER_SUCCESS, DELETE_USER_ERROR } from '../authTypes' | |
import {history} from '../../../../history' | |
export const SignIn = (credentials) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE }) | |
try { | |
const res = await axios.post(`${API_ROUTE}/login`, credentials) | |
let userData = res.data.response | |
localStorage.setItem("token", userData.token) | |
localStorage.setItem('user_data', JSON.stringify(userData)); | |
setAuthorizationToken(userData.token) | |
dispatch({ type: LOGIN_SUCCESS, payload: userData }) | |
} catch(err) { | |
dispatch({ type: LOGIN_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const SignOut = () => { | |
return (dispatch) => { | |
localStorage.removeItem("token") | |
setAuthorizationToken(false) | |
dispatch({ type: LOGOUT_SUCCESS }) | |
window.localStorage.clear(); //update the localstorage | |
history.push('/login'); | |
} | |
} | |
export const SignUp = (newUser) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE }) | |
try { | |
await axios.post(`${API_ROUTE}/users`, newUser); | |
dispatch({ type: SIGNUP_SUCCESS }) | |
history.push('/login'); | |
} catch(err) { | |
dispatch({ type: SIGNUP_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const updateUserAvatar = (updateUserAvatar) => { | |
return async (dispatch, getState) => { | |
dispatch({ type: BEFORE_AVATAR_STATE }) | |
const { id } = getState().Auth.currentUser | |
try { | |
const res = await axios.put(`${API_ROUTE}/avatar/users/${id}`, updateUserAvatar, { | |
headers: { | |
'Content-Type': 'multipart/form-data' | |
}, | |
}); | |
let updatedUser = res.data.response | |
window.localStorage.setItem('user_data', JSON.stringify(updatedUser)); //update the localstorage | |
dispatch({ type: UPDATE_USER_AVATAR, payload: updatedUser }) | |
} catch (err) { | |
dispatch({ type: UPDATE_USER_AVATAR_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const updateUser = (updateUser, clearInput) => { | |
return async (dispatch, getState) => { | |
dispatch({ type: BEFORE_USER_STATE }) | |
const { currentUser } = getState().Auth | |
try { | |
const res = await axios.put(`${API_ROUTE}/users/${currentUser.id}`, updateUser); | |
let updatedUser = res.data.response | |
dispatch({ type: UPDATE_USER_SUCCESS, payload: updatedUser }) | |
window.localStorage.setItem('user_data', JSON.stringify(updatedUser)); //update the localstorages | |
clearInput() | |
} catch (err) { | |
dispatch({ type: UPDATE_USER_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const deleteUser = (id) => { | |
return async dispatch => { | |
dispatch({ type: BEFORE_STATE }) | |
try { | |
const res = await axios.delete(`${API_ROUTE}/users/${id}`); | |
let deleteMessage = res.data.response | |
dispatch({ type: DELETE_USER_SUCCESS, payload: deleteMessage }) | |
window.localStorage.clear(); //update the localstorage | |
window.location.href = "/" | |
} catch (err) { | |
dispatch({ type: DELETE_USER_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const ForgotPassword = (userEmail, clearInput) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE }) | |
try { | |
const res = await axios.post(`${API_ROUTE}/password/forgot`, userEmail); | |
let passwordRequest = res.data.response | |
dispatch({ type: FORGOT_PASSWORD_SUCCESS, payload: passwordRequest }) | |
clearInput() | |
} catch (err) { | |
dispatch({ type: FORGOT_PASSWORD_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const ResetPassword = (details, clearInput) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE }) | |
try { | |
const res = await axios.post(`${API_ROUTE}/password/reset`, details); | |
let passwordRequest = res.data.response | |
dispatch({ type: RESET_PASSWORD_SUCCESS, payload: passwordRequest }) | |
clearInput() | |
} catch (err) { | |
dispatch({ type: RESET_PASSWORD_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
ii. auth/authTypes/index.js
export const SIGNUP_SUCCESS = "SIGNUP_SUCCESS" | |
export const SIGNUP_ERROR = "SIGNUP_ERROR" | |
export const LOGIN_SUCCESS = "LOGIN_SUCCESS" | |
export const LOGOUT_SUCCESS = "LOGOUT_SUCCESS" | |
export const LOGIN_ERROR = "LOGIN_ERROR" | |
export const SET_CURRENT_USER = "SET_CURRENT_USER" | |
export const CREATE_POST_SUCCESS = "CREATE_POST_SUCCESS" | |
export const CREATE_POST_ERROR = "CREATE_POST_ERROR" | |
export const CREATE_POST = "CREATE_POST" | |
export const FETCH_POSTS = "FETCH_POSTS" | |
export const UPDATE_USER_SUCCESS = "UPDATE_USER_SUCCESS" | |
export const UPDATE_USER_ERROR = "UPDATE_USER_ERROR" | |
export const UPDATE_USER_AVATAR = "UPDATE_USER_AVATAR" | |
export const UPDATE_USER_AVATAR_ERROR = "UPDATE_USER_AVATAR_ERROR" | |
export const BEFORE_STATE = "BEFORE_STATE" | |
export const BEFORE_AVATAR_STATE = "BEFORE_AVATAR_STATE" | |
export const BEFORE_USER_STATE = "BEFORE_USER_STATE" | |
export const FORGOT_PASSWORD_SUCCESS = "FORGOT_PASSWORD_SUCCESS" | |
export const FORGOT_PASSWORD_ERROR = "FORGOT_PASSWORD_ERROR" | |
export const RESET_PASSWORD_SUCCESS = "RESET_PASSWORD_SUCCESS" | |
export const RESET_PASSWORD_ERROR = "RESET_PASSWORD_ERROR" | |
iii. auth/reducer/authReducer.js
import { SIGNUP_SUCCESS, SIGNUP_ERROR, LOGIN_SUCCESS, LOGIN_ERROR, LOGOUT_SUCCESS, UPDATE_USER_AVATAR, UPDATE_USER_SUCCESS, UPDATE_USER_ERROR, BEFORE_STATE, UPDATE_USER_AVATAR_ERROR, BEFORE_AVATAR_STATE, BEFORE_USER_STATE, FORGOT_PASSWORD_SUCCESS, FORGOT_PASSWORD_ERROR, RESET_PASSWORD_SUCCESS, RESET_PASSWORD_ERROR, DELETE_USER_SUCCESS, DELETE_USER_ERROR } from '../authTypes' | |
import isEmpty from 'lodash/isEmpty'; | |
export const initState = { | |
isAuthenticated: false, | |
currentUser: {}, | |
isLoading: false, | |
isLoadingAvatar: false, | |
isUpdatingUser: false, | |
authError: null, | |
authSuccess: null | |
} | |
const authReducer = (state = initState, action) => { | |
switch(action.type) { | |
// This is the state to set when the button is click and we are waiting for response | |
case BEFORE_STATE: | |
return { | |
...state, | |
authError: null, | |
isLoading: true, | |
} | |
case BEFORE_AVATAR_STATE: | |
return { | |
...state, | |
avatarError: null, | |
isLoadingAvatar: true, | |
} | |
case BEFORE_USER_STATE: | |
return { | |
...state, | |
userError: null, | |
isUpdatingUser: true, | |
} | |
case SIGNUP_SUCCESS: | |
return { | |
...state, | |
isLoading: false, | |
signupError: null, | |
loginError: null | |
} | |
case SIGNUP_ERROR: | |
return { | |
...state, | |
isLoading: false, | |
signupError: action.payload, | |
loginError: null | |
} | |
case LOGIN_SUCCESS: | |
return { | |
...state, | |
isLoading: false, | |
currentUser: action.payload, | |
isAuthenticated: !isEmpty(action.payload), | |
loginError: null, | |
signupError: null, | |
} | |
case LOGIN_ERROR: | |
return { | |
...state, | |
isLoading: false, | |
loginError: action.payload, | |
signupError: null, | |
} | |
case LOGOUT_SUCCESS: | |
return { | |
...state, | |
isAuthenticated: false, | |
currentUser: {}, | |
logoutError: null, | |
isLoading: false, | |
signupError: null, | |
loginError: null, | |
} | |
case UPDATE_USER_AVATAR: | |
return { | |
...state, | |
isLoadingAvatar: false, | |
currentUser: action.payload, | |
avatarError: null, | |
authSuccessImage: "Image Uploaded" | |
} | |
case UPDATE_USER_AVATAR_ERROR: | |
return { | |
...state, | |
isLoadingAvatar: false, | |
avatarError: action.payload, | |
} | |
case UPDATE_USER_SUCCESS: | |
return { | |
...state, | |
isUpdatingUser: false, | |
currentUser: action.payload, | |
userError: null, | |
authSuccessUser: "Details Updated" | |
} | |
case UPDATE_USER_ERROR: | |
return { | |
...state, | |
isUpdatingUser: false, | |
userError: action.payload | |
} | |
case DELETE_USER_SUCCESS: | |
return { | |
...state, | |
isAuthenticated: false, | |
currentUser: {}, | |
isLoading: false, | |
authSuccessUser: "User Deleted" | |
} | |
case DELETE_USER_ERROR: | |
return { | |
...state, | |
isLoading: false, | |
userError: action.payload | |
} | |
case FORGOT_PASSWORD_SUCCESS: | |
return { | |
...state, | |
isLoading: false, | |
forgotError: null, | |
successMessage: "Mesage sent to the email provided. Please check the spam folder" | |
} | |
case FORGOT_PASSWORD_ERROR: | |
return { | |
...state, | |
isLoading: false, | |
forgotError: action.payload | |
} | |
case RESET_PASSWORD_SUCCESS: | |
return { | |
...state, | |
isLoading: false, | |
resetError: null, | |
successMessage: "Success! Password Reset" | |
} | |
case RESET_PASSWORD_ERROR: | |
return { | |
...state, | |
isLoading: false, | |
resetError: action.payload | |
} | |
default: | |
return state; | |
} | |
} | |
export default authReducer |
b. The Posts Store
Inside the modules directory, create the posts directory:
mkdir posts
Inside the posts directory, create these directories and files as shown in the image below:
i. posts/actions/postsActions.js
import API_ROUTE from "../../../../apiRoute"; | |
import axios from 'axios' | |
import { BEFORE_STATE_POST, FETCH_POSTS, FETCH_POSTS_ERROR, GET_POST_SUCCESS, GET_POST_ERROR, CREATE_POST_SUCCESS, CREATE_POST_ERROR, UPDATE_POST_SUCCESS, UPDATE_POST_ERROR, DELETE_POST_SUCCESS, DELETE_POST_ERROR, FETCH_AUTH_POSTS, FETCH_AUTH_POSTS_ERROR } from '../postsTypes' | |
import {history} from '../../../../history' | |
export const fetchPosts = () => { | |
return (dispatch) => { | |
axios.get(`${API_ROUTE}/posts`).then(res => { | |
dispatch({ type: FETCH_POSTS, payload: res.data.response }) | |
}).catch(err => { | |
dispatch({ type: FETCH_POSTS_ERROR, payload: err.response ? err.respons.data.error : "" }) | |
}) | |
} | |
} | |
export const fetchPost = id => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_POST }) | |
try { | |
const res = await axios.get(`${API_ROUTE}/posts/${id}`) | |
dispatch({ type: GET_POST_SUCCESS, payload: res.data.response }) | |
} catch(err){ | |
dispatch({ type: GET_POST_ERROR, payload: err.response.data.error }) | |
history.push('/'); //incase the user manually enter the param that dont exist | |
} | |
} | |
} | |
export const fetchAuthPosts = id => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_POST }) | |
try { | |
const res = await axios.get(`${API_ROUTE}/user_posts/${id}`) | |
dispatch({ type: FETCH_AUTH_POSTS, payload: res.data.response }) | |
} catch(err){ | |
dispatch({ type: FETCH_AUTH_POSTS_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const createPost = (createPost) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_POST }) | |
try { | |
const res = await axios.post(`${API_ROUTE}/posts`, createPost) | |
dispatch({ | |
type: CREATE_POST_SUCCESS, | |
payload: res.data.response | |
}) | |
history.push('/'); | |
} catch(err) { | |
dispatch({ type: CREATE_POST_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const updatePost = (updateDetails, updateSuccess) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_POST }) | |
try { | |
const res = await axios.put(`${API_ROUTE}/posts/${updateDetails.id}`, updateDetails) | |
dispatch({ | |
type: UPDATE_POST_SUCCESS, | |
payload: res.data.response | |
}) | |
updateSuccess() | |
} catch(err) { | |
dispatch({ type: UPDATE_POST_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const deletePost = (id) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_POST }) | |
try { | |
const res = await axios.delete(`${API_ROUTE}/posts/${id}`) | |
dispatch({ | |
type: DELETE_POST_SUCCESS, | |
payload: { | |
deletedID: id, | |
message: res.data.response | |
} | |
}) | |
history.push('/'); | |
} catch(err) { | |
dispatch({ type: DELETE_POST_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} |
ii. posts/postsTypes/index.js
export const CREATE_POST_SUCCESS = "CREATE_POST_SUCCESS" | |
export const CREATE_POST_ERROR = "CREATE_POST_ERROR" | |
export const FETCH_POSTS = "FETCH_POSTS" | |
export const FETCH_POSTS_ERROR = "FETCH_POSTS_ERROR" | |
export const BEFORE_STATE_POST = "BEFORE_STATE_POST" | |
export const BEFORE_AVATAR_STATE = "BEFORE_AVATAR_STATE" | |
export const FORGOT_PASSWORD_SUCCESS = "FORGOT_PASSWORD_SUCCESS" | |
export const FORGOT_PASSWORD_ERROR = "FORGOT_PASSWORD_ERROR" | |
export const RESET_PASSWORD_SUCCESS = "RESET_PASSWORD_SUCCESS" | |
export const RESET_PASSWORD_ERROR = "RESET_PASSWORD_ERROR" | |
export const SINGLE_POST_SUCCESS = "SINGLE_POST_SUCCESS" | |
export const GET_POST_SUCCESS = "GET_POST_SUCCESS" | |
export const GET_POST_ERROR = "GET_POST_ERROR" | |
export const UPDATE_POST_SUCCESS = "UPDATE_POST_SUCCESS" | |
export const UPDATE_POST_ERROR = "UPDATE_POST_ERROR" | |
export const DELETE_POST_SUCCESS = "DELETE_POST_SUCCESS" | |
export const DELETE_POST_ERROR = "DELETE_POST_ERROR" | |
export const FETCH_AUTH_POSTS = "FETCH_AUTH_POSTS" | |
export const FETCH_AUTH_POSTS_ERROR = "FETCH_AUTH_POSTS_ERROR" | |
iii. posts/reducer/postsReducer.js
import { BEFORE_STATE_POST, FETCH_POSTS, FETCH_POSTS_ERROR, CREATE_POST_SUCCESS, UPDATE_POST_SUCCESS, CREATE_POST_ERROR, UPDATE_POST_ERROR, GET_POST_SUCCESS, GET_POST_ERROR, DELETE_POST_SUCCESS, DELETE_POST_ERROR, FETCH_AUTH_POSTS, FETCH_AUTH_POSTS_ERROR } from '../postsTypes' | |
export const initState = { | |
posts: [], | |
authPosts: [], | |
post: {}, | |
postsError: null, | |
isLoading: false, | |
} | |
export const postsState = (state = initState, action) => { | |
const { payload, type } = action | |
switch(type) { | |
case BEFORE_STATE_POST: | |
return { | |
...state, | |
postsError: null, | |
isLoading: true, | |
} | |
case FETCH_POSTS: | |
return { | |
...state, | |
posts: payload, | |
isLoading: false, | |
} | |
case FETCH_POSTS_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
case FETCH_AUTH_POSTS: | |
return { | |
...state, | |
authPosts: payload, | |
isLoading: false, | |
} | |
case FETCH_AUTH_POSTS_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
case GET_POST_SUCCESS: | |
return { | |
...state, | |
post: payload, | |
postsError: null, | |
isLoading: false | |
} | |
case GET_POST_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
case CREATE_POST_SUCCESS: | |
return { | |
...state, | |
posts: [payload, ...state.posts], | |
authPosts: [payload, ...state.authPosts], | |
postsError: null, | |
isLoading: false | |
} | |
case CREATE_POST_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
case UPDATE_POST_SUCCESS: | |
return { | |
...state, | |
posts: state.posts.map(post => | |
post.id === payload.id ? | |
{...post, title: payload.title, content: payload.content } : post | |
), | |
authPosts: state.authPosts.map(post => | |
post.id === payload.id ? | |
{...post, title: payload.title, content: payload.content } : post | |
), | |
post: payload, | |
postsError: null, | |
isLoading: false | |
} | |
case UPDATE_POST_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
case DELETE_POST_SUCCESS: | |
return { | |
...state, | |
posts: state.posts.filter(post => post.id !== payload.deletedID), | |
authPosts: state.authPosts.filter(post => post.id !== payload.deletedID), | |
postsError: null, | |
isLoading: false | |
} | |
case DELETE_POST_ERROR: | |
return { | |
...state, | |
postsError: payload, | |
isLoading: false | |
} | |
default: | |
return state | |
} | |
} |
c. The Likes Store
Inside the modules directory, create the likes directory:
mkdir likes
Inside the likes directory, create these directories and files as shown in the image below:
i. likes/actions/likesActions.js
import API_ROUTE from "../../../../apiRoute"; | |
import axios from 'axios' | |
import { LIKE_CREATE_SUCCESS, LIKE_CREATE_ERROR, GET_LIKES_SUCCESS, GET_LIKES_ERROR, LIKE_DELETE_SUCCESS, LIKE_DELETE_ERROR } from '../likeTypes' | |
export const fetchLikes = id => { | |
return async dispatch => { | |
try { | |
const res = await axios.get(`${API_ROUTE}/likes/${id}`) | |
dispatch({ | |
type: GET_LIKES_SUCCESS, | |
payload: { | |
postID: id, | |
likes: res.data.response, | |
} | |
}) | |
} catch(err) { | |
dispatch({ type: GET_LIKES_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const createLike = id => { | |
return async (dispatch) => { | |
try { | |
const res = await axios.post(`${API_ROUTE}/likes/${id}`) | |
dispatch({ | |
type: LIKE_CREATE_SUCCESS, | |
payload: { | |
postID: id, | |
oneLike: res.data.response, | |
} | |
}) | |
} catch(err){ | |
dispatch({ type: LIKE_CREATE_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const deleteLike = details => { | |
return async (dispatch) => { | |
try { | |
await axios.delete(`${API_ROUTE}/likes/${details.id}`) | |
dispatch({ | |
type: LIKE_DELETE_SUCCESS, | |
payload: { | |
likeID: details.id, | |
postID: details.postID, | |
} | |
}) | |
} catch(err){ | |
dispatch({ type: LIKE_DELETE_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} |
ii. likes/likeTypes/index.js
export const GET_LIKES_SUCCESS = "GET_LIKES_SUCCESS" | |
export const GET_LIKES_ERROR = "GET_LIKES_ERROR" | |
export const LIKE_CREATE_SUCCESS = "LIKE_CREATE_SUCCESS" | |
export const LIKE_CREATE_ERROR = "LIKE_CREATE_ERROR" | |
export const LIKE_DELETE_SUCCESS = "LIKE_DELETE_SUCCESS" | |
export const LIKE_DELETE_ERROR = "LIKE_DELETE_ERROR" | |
iii. likes/reducer/likesReducer.js
import { LIKE_CREATE_SUCCESS, LIKE_CREATE_ERROR, GET_LIKES_SUCCESS, GET_LIKES_ERROR, LIKE_DELETE_SUCCESS, LIKE_DELETE_ERROR } from '../likeTypes' | |
export const initState = { | |
likeItems : [], | |
likesError: null | |
} | |
export const likesState = (state = initState, action) => { | |
const { payload, type } = action; | |
switch(type) { | |
case GET_LIKES_SUCCESS: | |
return { | |
...state, | |
likeItems: [...state.likeItems, { postID: payload.postID, likes: payload.likes } ], | |
likesError: null | |
} | |
case GET_LIKES_ERROR: | |
return { | |
...state, | |
likesError: payload, | |
likeItems : [], | |
} | |
case LIKE_CREATE_SUCCESS: | |
return { | |
...state, | |
likeItems: state.likeItems.map(likeItem => | |
likeItem.postID === payload.postID ? | |
{...likeItem, likes: [...likeItem.likes, payload.oneLike]} : likeItem | |
), | |
likesError: null | |
} | |
case LIKE_CREATE_ERROR: | |
return { | |
...state, | |
likesError: payload | |
} | |
case LIKE_DELETE_SUCCESS: | |
return { | |
...state, | |
likeItems: state.likeItems.map(likeItem => | |
Number(likeItem.postID) === payload.postID ? | |
{...likeItem, likes: likeItem.likes.filter(({id}) => id !== payload.likeID) } : likeItem | |
) | |
} | |
case LIKE_DELETE_ERROR: | |
return { | |
...state, | |
likesError: payload | |
} | |
default: | |
return state | |
} | |
} | |
d. The comments Store
Inside the modules directory, create the comments directory:
mkdir comments
Inside the comments directory, create these directories and files as shown in the image below:
i. comments/actions/commentsActions.js
import API_ROUTE from "../../../../apiRoute"; | |
import axios from 'axios' | |
import { COMMENT_CREATE_SUCCESS, COMMENT_CREATE_ERROR, GET_COMMENTS_SUCCESS, GET_COMMENTS_ERROR, COMMENT_DELETE_SUCCESS, COMMENT_DELETE_ERROR, COMMENT_UPDATE_SUCCESS, COMMENT_UPDATE_ERROR, BEFORE_STATE_COMMENT } from '../commentTypes' | |
import {history} from '../../../../history' | |
export const fetchComments = id => { | |
return async dispatch => { | |
dispatch({ type: BEFORE_STATE_COMMENT }) | |
try { | |
const res = await axios.get(`${API_ROUTE}/comments/${id}`) | |
dispatch({ | |
type: GET_COMMENTS_SUCCESS, | |
payload: { | |
postID: id, | |
comments: res.data.response, | |
} | |
}) | |
} catch(err) { | |
dispatch({ type: GET_COMMENTS_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const createComment = (details, commentSuccess) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_COMMENT }) | |
try { | |
const res = await axios.post(`${API_ROUTE}/comments/${details.post_id}`, details) | |
dispatch({ | |
type: COMMENT_CREATE_SUCCESS, | |
payload: { | |
postID: details.post_id, | |
comment: res.data.response, | |
} | |
}) | |
commentSuccess() | |
history.push(`/posts/${details.post_id}`); | |
} catch(err){ | |
dispatch({ type: COMMENT_CREATE_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const updateComment = (updateDetails, updateSuccess) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_COMMENT }) | |
try { | |
const res = await axios.put(`${API_ROUTE}/comments/${updateDetails.id}`, updateDetails) | |
dispatch({ | |
type: COMMENT_UPDATE_SUCCESS, | |
payload: { | |
comment: res.data.response | |
} | |
}) | |
updateSuccess() | |
} catch(err) { | |
dispatch({ type: COMMENT_UPDATE_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} | |
export const deleteComment = (details, deleteSuccess) => { | |
return async (dispatch) => { | |
dispatch({ type: BEFORE_STATE_COMMENT }) | |
try { | |
await axios.delete(`${API_ROUTE}/comments/${details.id}`) | |
dispatch({ | |
type: COMMENT_DELETE_SUCCESS, | |
payload: { | |
id: details.id, | |
postID: details.postID, | |
} | |
}) | |
deleteSuccess() | |
} catch(err) { | |
dispatch({ type: COMMENT_DELETE_ERROR, payload: err.response.data.error }) | |
} | |
} | |
} |
ii. comments/commentTypes/index.js
export const BEFORE_STATE_COMMENT = "BEFORE_STATE_COMMENT" | |
export const GET_COMMENTS_SUCCESS = "GET_COMMENTS_SUCCESS" | |
export const GET_COMMENTS_ERROR = "GET_COMMENTS_ERROR" | |
export const COMMENT_CREATE_SUCCESS = "COMMENT_CREATE_SUCCESS" | |
export const COMMENT_CREATE_ERROR = "COMMENT_CREATE_ERROR" | |
export const COMMENT_DELETE_SUCCESS = "COMMENT_DELETE_SUCCESS" | |
export const COMMENT_DELETE_ERROR = "COMMENT_DELETE_ERROR" | |
export const COMMENT_UPDATE_SUCCESS = "COMMENT_UPDATE_SUCCESS" | |
export const COMMENT_UPDATE_ERROR = "COMMENT_UPDATE_ERROR" | |
iii. comments/reducer/commentsReducer.js
import { BEFORE_STATE_COMMENT, COMMENT_CREATE_SUCCESS, COMMENT_CREATE_ERROR, GET_COMMENTS_SUCCESS, GET_COMMENTS_ERROR, COMMENT_DELETE_SUCCESS, COMMENT_DELETE_ERROR, COMMENT_UPDATE_SUCCESS, COMMENT_UPDATE_ERROR } from '../commentTypes' | |
export const initState = { | |
commentItems : [], | |
isLoading: false, | |
commentSuccess: false | |
} | |
export const commentsState = (state = initState, action) => { | |
const { payload, type } = action; | |
switch(type) { | |
case BEFORE_STATE_COMMENT: | |
return { | |
...state, | |
commentsError: null, | |
isLoading: true, | |
commentSuccess: false | |
} | |
case GET_COMMENTS_SUCCESS: | |
return { | |
...state, | |
commentItems: [...state.commentItems, { postID: payload.postID, comments: payload.comments } ], | |
isLoading: false, | |
commentsError: null, | |
} | |
case GET_COMMENTS_ERROR: | |
return { | |
...state, | |
commentError: payload, | |
isLoading: false, | |
} | |
case COMMENT_CREATE_SUCCESS: | |
return { | |
...state, | |
commentItems: state.commentItems.map(commentItem => | |
Number(commentItem.postID) === payload.postID ? | |
{...commentItem, comments: [payload.comment, ...commentItem.comments]} : commentItem | |
), | |
message: "The comment is added", | |
isLoading: false, | |
commentSuccess: true | |
} | |
case COMMENT_CREATE_ERROR: | |
return { | |
...state, | |
commentsError: payload, | |
isLoading: false, | |
commentSuccess: false | |
} | |
case COMMENT_UPDATE_SUCCESS: | |
return { | |
...state, | |
commentItems: state.commentItems.map(commentItem => | |
Number(commentItem.postID) === payload.comment.post_id ? | |
{...commentItem, comments: commentItem.comments.map(comment => comment.id === payload.comment.id ? | |
{...comment, body: payload.comment.body } : comment ) } : commentItem | |
), | |
commentsError: null, | |
isLoading: false, | |
commentSuccess: true, | |
} | |
case COMMENT_UPDATE_ERROR: | |
return { | |
...state, | |
commentsError: payload, | |
isLoading: false, | |
commentSuccess: false | |
} | |
case COMMENT_DELETE_SUCCESS: | |
return { | |
...state, | |
commentItems: state.commentItems.map(commentItem => | |
Number(commentItem.postID) === payload.postID ? | |
{...commentItem, comments: commentItem.comments.filter(({id}) => id !== payload.id ) } : commentItem | |
), | |
commentsError: null, | |
isLoading: false, | |
commentSuccess: true, | |
} | |
case COMMENT_DELETE_ERROR: | |
return { | |
...state, | |
commentsError: payload, | |
isLoading: false, | |
commentSuccess: false | |
} | |
default: | |
return state | |
} | |
} | |
e. The Combined Reducer
We will need to combine the reducers from each of the stores defined above.
Inside the modules directory(path: /src/store/modules/), create the index.js file.
touch index.js
import { combineReducers } from "redux" | |
import authReducer from './auth/reducer/authReducer' | |
import { postsState } from "./posts/reducer/postsReducer"; | |
import { likesState } from './likes/reducer/likesReducer' | |
import { commentsState } from './comments/reducer/commentsReducer' | |
const reducer = combineReducers({ | |
Auth: authReducer, | |
PostsState: postsState, | |
LikesState: likesState, | |
CommentsState: commentsState | |
}) | |
export default reducer | |
f. The Store File
This is the file that a kind of wraps up the store.
- The combined reducer is called
- We applied the thunk middleware
- Enabled Redux DevTools
In the store directory(path: /src/store/), create the index.js file.
touch index.js
import { createStore, applyMiddleware, compose } from "redux"; | |
import thunk from "redux-thunk"; | |
import reducer from './modules/index' | |
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; | |
const store = createStore( | |
reducer, | |
composeEnhancers( | |
applyMiddleware(thunk) | |
) | |
); | |
export default store; | |
Step 3: Wiring Up The Components
Inside the src directory, create the components directory
cd src && mkdir components
Navigation Component
This component takes us wherever we want in the app.
a. Navigation
Inside the components directory, create the Navigation component
cd components && touch Navigation.js
b. Navigation.css
Inside the components directory, create the Navigation.css file
Utils Component
Inside the components directory, create the utils directory
mkdir utils
a. Message: This is the notification component.
Create a Message.js file inside the utils directory:
cd utils && touch Message.js
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
const Message = ({ msg }) => { | |
return ( | |
<div className='alert alert-info alert-dismissible fade show' role='alert'> | |
{msg} | |
<button | |
type='button' | |
className='close' | |
data-dismiss='alert' | |
aria-label='Close' | |
> | |
</button> | |
</div> | |
); | |
}; | |
Message.propTypes = { | |
msg: PropTypes.string.isRequired | |
}; | |
export default Message; |
Auth Component
This is the component that will house our authentication.
Inside the components directory, create the auth directory
mkdir auth
a. Signup: A user can register on the app.
Create a Register.js file inside the auth directory:
cd auth && touch Register.js
import React, { useState } from "react"; | |
import { Label, Input, FormGroup, Button, Card, CardHeader, CardBody } from "reactstrap"; | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Redirect, Link } from 'react-router-dom'; | |
import "./Auth.css"; | |
import Navigation from '../Navigation' | |
import { SignUp } from '../../store/modules/auth/actions/authAction'; | |
const Register = () => { | |
const currentState = useSelector((state) => state.Auth); | |
const [user, setUser] = useState({ | |
username:'', | |
email: '', | |
password: '' | |
}); | |
const dispatch = useDispatch() | |
const addUser = (credentials) => dispatch(SignUp(credentials)) | |
const handleChange = e => { | |
setUser({ | |
...user, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const submitUser = (e) => { | |
e.preventDefault() | |
addUser({ | |
username: user.username, | |
email: user.email, | |
password: user.password | |
}); | |
} | |
if(currentState.isAuthenticated){ | |
return <Redirect to='/' /> | |
} | |
return ( | |
<div className="App"> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="container Auth"> | |
<Card className="card-style"> | |
<CardHeader>Welcome To SeamFlow</CardHeader> | |
<CardBody> | |
<form onSubmit={submitUser}> | |
<FormGroup> | |
<Label>User Name</Label> | |
<Input type="text" name="username" placeholder="Enter username" onChange={handleChange}/> | |
{ currentState.signupError && currentState.signupError.Required_username ? ( | |
<small className="color-red">{currentState.signupError.Required_username}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.signupError && currentState.signupError.Taken_username ? ( | |
<small className="color-red">{ currentState.signupError.Taken_username }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
<Label>Email</Label> | |
<Input type="email" name="email" placeholder="Enter email" onChange={handleChange} /> | |
{ currentState.signupError && currentState.signupError.Required_email ? ( | |
<small className="color-red">{currentState.signupError.Required_email}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.signupError && currentState.signupError.Invalid_email ? ( | |
<small className="color-red">{ currentState.signupError.Invalid_email }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.signupError && currentState.signupError.Taken_email ? ( | |
<small className="color-red">{ currentState.signupError.Taken_email }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
<Label>Password</Label> | |
<Input type="password" name="password" placeholder="Enter password" onChange={handleChange}/> | |
{ currentState.signupError && currentState.signupError.Required_password ? ( | |
<small className="color-red">{ currentState.signupError.Required_password }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.signupError && currentState.signupError.Invalid_password ? ( | |
<small className="color-red">{ currentState.signupError.Invalid_password }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
{ currentState.isLoading ? ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled | |
> | |
Registering... | |
</Button> | |
) : ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled={ user.username === "" || user.email === "" || user.password === "" } | |
> | |
Register | |
</Button> | |
)} | |
</form> | |
<div className="mt-2"> | |
<small>Have an account? <Link to="/login">Please login</Link></small> | |
</div> | |
</CardBody> | |
</Card> | |
</div> | |
</div> | |
); | |
} | |
export default Register |
b. Login: A user can log in.
Create a Login.js file inside the auth directory:
touch Login.js
import React, { useState } from "react"; | |
import { Label, Input, FormGroup, Button, Card, CardHeader, CardBody } from "reactstrap"; | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Redirect, Link } from 'react-router-dom'; | |
import "./Auth.css"; | |
import Navigation from '../Navigation' | |
import { SignIn } from '../../store/modules/auth/actions/authAction'; | |
const Login = () => { | |
const currentState = useSelector((state) => state.Auth); | |
const [user, setUser] = useState({ | |
email: '', | |
password: '' | |
}); | |
const dispatch = useDispatch() | |
const userLogin = (credentials) => dispatch(SignIn(credentials)) | |
const handleChange = e => { | |
setUser({ | |
...user, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const submitUser = (e) => { | |
e.preventDefault() | |
userLogin({ | |
email: user.email, | |
password: user.password | |
}); | |
} | |
if(currentState.isAuthenticated){ | |
return <Redirect to='/' /> | |
} | |
return ( | |
<div className="App"> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="container Auth"> | |
<Card className="card-style"> | |
<CardHeader>Login</CardHeader> | |
<CardBody> | |
<form onSubmit={submitUser}> | |
<div className="mb-2"> | |
{ currentState.loginError && currentState.loginError.Incorrect_details ? ( | |
<small className="color-red">{currentState.loginError.Incorrect_details}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.loginError && currentState.loginError.No_record ? ( | |
<small className="color-red">{currentState.loginError.No_record}</small> | |
) : ( | |
"" | |
)} | |
</div> | |
<FormGroup> | |
<Label>Email</Label> | |
<Input type="email" name="email" placeholder="Enter email" onChange={handleChange} /> | |
{ currentState.loginError && currentState.loginError.Required_email ? ( | |
<small className="color-red">{currentState.loginError.Required_email}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.loginError && currentState.loginError.Invalid_email ? ( | |
<small className="color-red">{ currentState.loginError.Invalid_email }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
<Label>Password</Label> | |
<Input type="password" name="password" placeholder="Enter password" onChange={handleChange}/> | |
{ currentState.loginError && currentState.loginError.Required_password ? ( | |
<small className="color-red">{ currentState.loginError.Required_password }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.loginError && currentState.loginError.Invalid_password ? ( | |
<small className="color-red">{ currentState.loginError.Invalid_password }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.loginError && currentState.loginError.Incorrect_password ? ( | |
<small className="color-red">{ currentState.loginError.Incorrect_password }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
{ currentState.isLoading ? ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled | |
> | |
Login... | |
</Button> | |
) : ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled={ user.email === "" || user.password === "" } | |
> | |
Login | |
</Button> | |
)} | |
</form> | |
<div className="mt-2" style={{display: "flex", justifyContent: "space-between"}}> | |
<div> | |
<small><Link to="/signup">Sign Up</Link></small> | |
</div> | |
<div> | |
<small><Link to="/forgotpassword">Forgot Password?</Link></small> | |
</div> | |
</div> | |
</CardBody> | |
</Card> | |
</div> | |
</div> | |
); | |
} | |
export default Login | |
c. Auth.css Add styling to auth files.
Create a Auth.css file inside the auth directory:
touch Auth.css
@media all and (min-width: 480px) { | |
.Auth { | |
padding: 60px 0; | |
} | |
.Auth .card-style { | |
margin: 0 auto; | |
max-width: 320px; | |
} | |
.Auth .login-style { | |
margin: 0 auto; | |
max-width: 320px; | |
} | |
} |
Users Component
The user can update his profile picture, change his email address, request to change his password, and so on.
Inside the components directory, create the users directory
mkdir users
a. Profile: A user can update his profile.
Inside the users directory, create the Profile.js component:
cd users && touch Profile.js
import React, { Fragment, useState } from 'react' | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Redirect } from 'react-router-dom'; | |
import { Label, Input, FormGroup, Button, CardBody, Col, Row, Form, CustomInput, Modal, ModalHeader, ModalFooter, ModalBody } from "reactstrap"; | |
import { updateUserAvatar, updateUser, deleteUser } from '../../store/modules/auth/actions/authAction'; | |
import Default from '../../Assets/default.png' | |
import './Profile.css' | |
import Message from '../utils/Message'; | |
import Navigation from "../Navigation" | |
const Profile = () => { | |
const [modal, setModal] = useState(false); | |
const toggle = (e) => { | |
setModal(!modal); | |
} | |
const currentUserState = useSelector((state) => state.Auth); | |
const AuthID = currentUserState.currentUser ? currentUserState.currentUser.id : "" | |
const dispatch = useDispatch() | |
const userAvatarUpdate = (userDetails) => dispatch(updateUserAvatar(userDetails)) | |
const userUpdate = (userDetails) => dispatch(updateUser(userDetails, clearInput)) | |
const deleteAccount = id => dispatch(deleteUser(id)) | |
const [file, setFile] = useState(); | |
const [uploadedFile, setUploadedFile] = useState(); | |
const [user, setUser] = useState({ | |
email: currentUserState.currentUser.email, | |
current_password: '', | |
new_password: '', | |
}) | |
const clearInput = () => { | |
setUser({ | |
...user, | |
current_password: "", | |
new_password: "" | |
}) | |
} | |
const handleChange = e => { | |
setUser({ | |
...user, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const handleImageChange = (e) => { | |
e.preventDefault(); | |
let reader = new FileReader(); | |
let thefile = e.target.files[0]; | |
reader.onloadend = () => { | |
setFile(thefile) | |
setUploadedFile(reader.result) | |
} | |
reader.readAsDataURL(thefile) | |
} | |
let imagePreview = null; | |
if(currentUserState.currentUser.avatar_path && !uploadedFile){ | |
imagePreview = (<img className="img_style" src={currentUserState.currentUser.avatar_path} alt="profile"/>); | |
} | |
else if(uploadedFile) { | |
imagePreview = (<img className="img_style" src={uploadedFile} alt="profile"/>); | |
} else { | |
imagePreview = (<img className="img_style" src={Default} alt="profile"/>); | |
} | |
//incase someone visits the route manually | |
if(!currentUserState.isAuthenticated){ | |
return <Redirect to='/login' /> | |
} | |
const submitUserAvatar = (e) => { | |
e.preventDefault() | |
const formData = new FormData(); | |
formData.append('file', file); | |
userAvatarUpdate(formData) | |
} | |
const submitUser = (e) => { | |
e.preventDefault() | |
userUpdate({ | |
email: user.email, | |
current_password: user.current_password, | |
new_password: user.new_password | |
}) | |
} | |
const shutDown = (e) => { | |
e.preventDefault() | |
deleteAccount(AuthID) | |
} | |
return ( | |
<Fragment> | |
<Navigation /> | |
<div className="post-style container"> | |
<div className="card-style"> | |
<div className="text-center"> | |
<h4>Update Profile</h4> | |
</div> | |
<Row className="mt-1"> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
{ currentUserState.authSuccessImage != null && currentUserState.avatarError == null ? ( | |
<Message msg={currentUserState.authSuccessImage} /> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
<CardBody> | |
<div className="text-center mb-3"> | |
{imagePreview} | |
</div> | |
<Form onSubmit={submitUserAvatar} encType="multipart/form-data"> | |
<div> | |
<FormGroup className="style_file_input"> | |
<CustomInput type="file" accept="image/*" id="exampleCustomFileBrowser" onChange={(e)=> handleImageChange(e)} /> | |
{ currentUserState.avatarError && currentUserState.avatarError.Too_large ? ( | |
<small className="color-red">{currentUserState.avatarError.Too_large}</small> | |
) : ( | |
"" | |
)} | |
{ currentUserState.avatarError && currentUserState.avatarError.Not_Image ? ( | |
<small className="color-red">{ currentUserState.avatarError.Not_Image }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</div> | |
{ currentUserState.isLoadingAvatar ? ( | |
<Button className="style_photo_button" | |
color="primary" | |
type="submit" | |
disabled | |
> | |
Updating... | |
</Button> | |
) : ( | |
<Button className="style_photo_button" | |
color="primary" | |
type="submit" | |
disabled={ uploadedFile == null || file == null } | |
> | |
Update Photo | |
</Button> | |
)} | |
</Form> | |
<Row> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<div style={{margin: "10px 0px 10px"}}>Username: <strong>{currentUserState.currentUser.username}</strong></div> | |
</Col> | |
</Row> | |
<Form onSubmit={submitUser}> | |
<Row> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
<Label for="exampleAddress">Email</Label> | |
<Input type="text" name="email" value={user.email} onChange={handleChange} /> | |
{ currentUserState.userError && currentUserState.userError.Required_email ? ( | |
<small className="color-red">{currentUserState.userError.Required_email}</small> | |
) : ( | |
"" | |
)} | |
{ currentUserState.userError && currentUserState.userError.Invalid_email ? ( | |
<small className="color-red">{ currentUserState.userError.Invalid_email }</small> | |
) : ( | |
"" | |
)} | |
{ currentUserState.userError && currentUserState.userError.Taken_email ? ( | |
<small className="color-red">{ currentUserState.userError.Taken_email }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
<Row> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
<Label for="exampleAddress">Current Password</Label> | |
<Input type="password" name="current_password" value={user.current_password} onChange={handleChange}/> | |
{ currentUserState.userError && currentUserState.userError.Password_mismatch ? ( | |
<small className="color-red">{currentUserState.userError.Password_mismatch}</small> | |
) : ( | |
"" | |
)} | |
{ currentUserState.userError && currentUserState.userError.Empty_current ? ( | |
<small className="color-red">{ currentUserState.userError.Empty_current }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
<Row> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
<Label for="exampleAddress">New Password</Label> | |
<Input type="password" name="new_password" value={user.new_password} onChange={handleChange}/> | |
{ currentUserState.userError && currentUserState.userError.Invalid_password ? ( | |
<small className="color-red">{ currentUserState.userError.Invalid_password }</small> | |
) : ( | |
"" | |
)} | |
{ currentUserState.userError && currentUserState.userError.Empty_new ? ( | |
<small className="color-red">{ currentUserState.userError.Empty_new }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
<Row className="mt-4"> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
{ currentUserState.authSuccessUser != null && currentUserState.userError == null ? ( | |
<Message msg={currentUserState.authSuccessUser} /> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
<Row className="mt-3"> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
{ currentUserState.isUpdatingUser ? ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled | |
> | |
Updating... | |
</Button> | |
) : ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
> | |
Update | |
</Button> | |
)} | |
</FormGroup> | |
</Col> | |
</Row> | |
</Form> | |
<Row className="mt-3"> | |
<Col sm="12" md={{ size: 10, offset: 1 }}> | |
<FormGroup> | |
<Button onClick={toggle} | |
color="danger" | |
type="submit" | |
block | |
> | |
Deactivate Account | |
</Button> | |
</FormGroup> | |
</Col> | |
</Row> | |
</CardBody> | |
<Modal isOpen={modal} toggle={toggle}> | |
<ModalHeader toggle={toggle} className="text-center">Are you sure you want to delete your account?</ModalHeader> | |
<ModalBody toggle={toggle} className="text-center">This will also delete your posts, likes and comments if you created any.</ModalBody> | |
<ModalFooter> | |
{ currentUserState.isLoading ? ( | |
<button className="btn btn-danger" | |
disabled | |
> | |
Deleting... | |
</button> | |
) : ( | |
<button className="btn btn-danger" | |
onClick={shutDown} | |
type="submit" | |
> | |
Delete | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</div> | |
</div> | |
</Fragment> | |
) | |
} | |
export default Profile |
b. Profile.css. Add the profile css file.
Inside the users directory, create the Profile.css file:
touch Profile.css
.img_style { | |
border-radius: 50%; | |
height: 120px; | |
width: 120px; | |
margin-top: 30px; | |
} | |
@media (min-width: 768px){ | |
.style_file_input { | |
width: 50%; | |
margin: auto; | |
} | |
.style_photo_button { | |
margin-top: 20px; | |
margin-bottom: 20px; | |
margin-left: 35%; | |
} | |
} | |
@media (max-width: 768px){ | |
.style_photo_button { | |
margin-top: 5px; | |
margin-bottom: 5px; | |
margin-left: 25%; | |
} | |
} | |
c. ForgotPassword: A user can request to change their forgotten password.
Inside the users directory, create the ForgotPassword.js component:
touch ForgotPassword.js
import React, { useState } from "react"; | |
import { Label, FormGroup, Card, CardHeader, CardBody } from "reactstrap"; | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Redirect, Link } from 'react-router-dom'; | |
import Navigation from '../Navigation' | |
import { ForgotPassword } from '../../store/modules/auth/actions/authAction'; | |
import Message from '../utils/Message'; | |
const PasswordForgot = () => { | |
const currentState = useSelector((state) => state.Auth); | |
const [email, setEmail] = useState(''); | |
const dispatch = useDispatch() | |
const forgotPass = (userEmail) => dispatch(ForgotPassword(userEmail, clearInput)) | |
const handleChange = e => { | |
setEmail(e.target.value) | |
} | |
const clearInput = () => { | |
setEmail('') | |
} | |
const submitRequest = (e) => { | |
e.preventDefault() | |
forgotPass({ | |
}); | |
} | |
if(currentState.isAuthenticated){ | |
return <Redirect to='/' /> | |
} | |
return ( | |
<div className="App"> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="container Auth"> | |
<Card className="card-style"> | |
<CardHeader>Forgot Password</CardHeader> | |
<CardBody> | |
<FormGroup> | |
{ currentState.successMessage != null && currentState.forgotError == null ? ( | |
<span> | |
<Message msg={currentState.successMessage} /> | |
</span> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<form onSubmit={submitRequest}> | |
<FormGroup> | |
<Label>Email</Label> | |
<input type="email" name="email" className="form-control" data-test="inputEmail" placeholder="Enter email" value={email} onChange={handleChange} /> | |
{ currentState.forgotError && currentState.forgotError.Required_email ? ( | |
<small className="color-red">{ currentState.forgotError.Required_email }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.forgotError && currentState.forgotError.No_email ? ( | |
<small className="color-red">{currentState.forgotError.No_email}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.forgotError && currentState.forgotError.Invalid_email ? ( | |
<small className="color-red">{ currentState.forgotError.Invalid_email }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
{ currentState.isLoading ? ( | |
<button | |
className="btn btn-primary w-100" | |
color="primary" | |
type="submit" | |
disabled | |
> | |
Sending Request... | |
</button> | |
) : ( | |
<button | |
data-test='resetButton' | |
className="btn btn-primary w-100" | |
color="primary" | |
type="submit" | |
disabled={ email === ""} | |
> | |
Reset Password | |
</button> | |
)} | |
</form> | |
<div className="mt-2" style={{display: "flex", justifyContent: "space-between"}}> | |
<div> | |
<small><Link to="/signup">Sign Up</Link></small> | |
</div> | |
<div> | |
<small><Link to="/login">Login</Link></small> | |
</div> | |
</div> | |
</CardBody> | |
</Card> | |
</div> | |
</div> | |
); | |
} | |
export default PasswordForgot |
d. ResetPassword: A user can reset their password.
Inside the users directory, create the ResetPassword.js component:
touch ResetPassword.js
import React, { useState } from "react"; | |
import { Label, Input, FormGroup, Button, Card, CardHeader, CardBody } from "reactstrap"; | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Redirect, Link } from 'react-router-dom'; | |
import Navigation from '../Navigation' | |
import { ResetPassword } from '../../store/modules/auth/actions/authAction'; | |
import Message from '../utils/Message'; | |
const PasswordReset = (props) => { | |
const currentState = useSelector((state) => state.Auth); | |
const [resetDetails, setResetDetails] = useState({ | |
token: props.match.params.token, | |
new_password: '', | |
retype_password: '' | |
}); | |
const dispatch = useDispatch() | |
const resetPass = (details) => dispatch(ResetPassword(details, clearInput)) | |
const [showLogin, setShowLogin] = useState(false) | |
const clearInput = () => { | |
setShowLogin(true) | |
setResetDetails({ | |
token: '', | |
new_password: '', | |
retype_password: '' | |
}) | |
} | |
const handleChange = e => { | |
setResetDetails({ | |
...resetDetails, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const submitRequest = (e) => { | |
e.preventDefault() | |
resetPass({ | |
token: resetDetails.token, | |
new_password: resetDetails.new_password, | |
retype_password: resetDetails.retype_password | |
}); | |
} | |
if(currentState.isAuthenticated){ | |
return <Redirect to='/' /> | |
} | |
return ( | |
<div className="App"> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="container Auth"> | |
<Card className="card-style"> | |
<CardHeader>Reset Password</CardHeader> | |
<CardBody> | |
<FormGroup> | |
{ currentState.successMessage != null && currentState.resetError == null ? ( | |
<span> | |
<Message msg={currentState.successMessage} /> | |
</span> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
{ currentState.resetError && currentState.resetError.Invalid_token ? ( | |
<span> | |
<small className="color-red">{currentState.resetError.Invalid_token}</small> | |
<small className="ml-2"><Link to="/forgotpassword">here </Link></small> | |
</span> | |
) : ( | |
"" | |
)} | |
{ currentState.resetError && currentState.resetError.Empty_passwords ? ( | |
<small className="color-red">{currentState.resetError.Empty_passwords}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.resetError && currentState.resetError.Invalid_Passwords ? ( | |
<small className="color-red">{ currentState.resetError.Invalid_Passwords }</small> | |
) : ( | |
"" | |
)} | |
{ currentState.resetError && currentState.resetError.Password_unequal ? ( | |
<small className="color-red">{ currentState.resetError.Password_unequal }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
{showLogin ? ( | |
<a href="/login" className="btn btn-primary form-control" | |
> | |
Login | |
</a> | |
) : ( | |
<form onSubmit={submitRequest}> | |
<FormGroup> | |
<Label>New Password</Label> | |
<Input type="password" name="new_password" value={resetDetails.new_password} onChange={handleChange} /> | |
</FormGroup> | |
<FormGroup> | |
<Label>Retype Password</Label> | |
<Input type="password" name="retype_password" value={resetDetails.retype_password} onChange={handleChange} /> | |
</FormGroup> | |
{ currentState.isLoading ? ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled | |
> | |
Reseting... | |
</Button> | |
) : ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled={ resetDetails.new_password === "" || resetDetails.retype_password === "" } | |
> | |
Save Password | |
</Button> | |
)} | |
</form> | |
)} | |
</CardBody> | |
</Card> | |
</div> | |
</div> | |
); | |
} | |
export default PasswordReset |
Posts Component
An authenticated user can create/edit/delete posts they created.
Inside the components directory, create the posts directory
mkdir posts
a. Posts: A user can view all posts.
Inside the posts directory, create the Posts.js component:
cd posts && touch Posts.js
import React, { useEffect } from 'react' | |
import { Link } from 'react-router-dom' | |
import { useSelector, useDispatch } from "react-redux"; | |
import './Posts.css'; | |
import { fetchPosts } from '../../store/modules/posts/actions/postsAction'; | |
import Post from './Post' | |
const Posts = () => { | |
const postsSelector = useSelector((state) => state.PostsState); | |
const dispatch = useDispatch(); | |
// console.log("this is the post state: ", postsSelector) | |
const getPosts = () => dispatch(fetchPosts()); | |
useEffect(() => { | |
getPosts(); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []) | |
let posts = postsSelector.posts.map((post) => { | |
return ( | |
<div className="mt-2 style-card" key={post.id}> | |
<Link to={'/posts/' + post.id} key={post.id}> | |
<Post post={post} key={post.id} /> | |
</Link> | |
</div> | |
); | |
}) | |
return ( | |
<div className="container">{posts}</div> | |
) | |
} | |
export default Posts |
b. Post: This a single component inside the Posts component
Inside the posts directory, create the Post.js component:
touch Post.js
import React from 'react' | |
import Moment from 'react-moment'; | |
import { useSelector } from 'react-redux' | |
import { Card, CardText, CardBody, CardTitle } from 'reactstrap'; | |
import './Posts.css'; | |
import Default from '../../Assets/default.png' | |
import Likes from '../likes/Likes' | |
import Comments from '../comments/Comments' | |
import EditPost from './EditPost'; | |
import DeletePost from './DeletePost' | |
const Post = ({ post }) => { | |
const currentState = useSelector(state => state) | |
const authID = currentState.Auth.currentUser ? currentState.Auth.currentUser.id : "" | |
let $imagePreview = null; | |
if(post.author.avatar_path){ | |
$imagePreview = (<img className="img_style_post" src={post.author.avatar_path} alt="no one"/>); | |
} else { | |
$imagePreview = (<img className="img_style_post" src={Default} alt="no one 2"/>); | |
} | |
return ( | |
<Card className="style-card-main"> | |
<CardBody className="style-card-body"> | |
<CardTitle> | |
<span> | |
<span className="mr-2"> | |
{$imagePreview} | |
</span> | |
<span href="" style={{fontWeight: 'bold'}}>{post.author.username}</span> | |
</span> | |
<span style={{float: 'right'}}> | |
<Moment fromNow>{post.created_at}</Moment> | |
</span> | |
</CardTitle> | |
<CardTitle>{post.title}</CardTitle> | |
<CardText>{post.content}</CardText> | |
<div className="style-fav"> | |
<> | |
<Likes postID={post.id} /> | |
<Comments postID={post.id} /> | |
</> | |
{ authID === post.author_id ? ( | |
<div className="ml-auto"> | |
<span style={{marginRight: "20px"}}> | |
<EditPost post={post} /> | |
</span> | |
<span> | |
<DeletePost postID={post.id} /> | |
</span> | |
</div> | |
) : ""} | |
</div> | |
</CardBody> | |
</Card> | |
) | |
} | |
export default Post |
c. PostDetails: A user can visit a particular post.
Inside the posts directory, create the PostDetails.js component:
touch PostDetails.js
import React, { useEffect } from 'react' | |
import Moment from 'react-moment'; | |
import { useSelector, useDispatch } from "react-redux"; | |
import { Card, CardText, CardBody, CardTitle } from 'reactstrap'; | |
import Default from '../../Assets/default.png' | |
import { fetchPost } from '../../store/modules/posts/actions/postsAction' | |
import Navigation from '../Navigation' | |
import Likes from '../likes/Likes' | |
import Comments from '../comments/Comments' | |
import Comment from '../comments/Comment' | |
import EditPost from './EditPost'; | |
import DeletePost from './DeletePost' | |
const PostDetails = (props) => { | |
const postID = props.match.params.id | |
const dispatch = useDispatch() | |
const singlePost = id => dispatch(fetchPost(id)) | |
const currentState = useSelector(state => state) | |
const post = currentState.PostsState.post | |
const postComments = currentState.CommentsState | |
const authID = currentState.Auth.currentUser ? currentState.Auth.currentUser.id : "" | |
//Get the avatar of the author of the post | |
let imagePreview = null; | |
let avatarPathPost = post.author ? post.author.avatar_path : null | |
if(avatarPathPost){ | |
imagePreview = (<img className="img_style_post" src={avatarPathPost} alt="profile"/>); | |
} else { | |
imagePreview = (<img className="img_style_post" src={Default} alt="profile"/>); | |
} | |
useEffect(() => { | |
singlePost(postID) | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []) | |
let singlePostComments = [] | |
if(postComments){ | |
// eslint-disable-next-line array-callback-return | |
postComments.commentItems.map(eachItem => { | |
if(eachItem.postID === postID){ | |
singlePostComments = eachItem.comments | |
} | |
}) | |
} | |
return ( | |
<div> | |
<Navigation /> | |
<div className="container"> | |
<div className="mt-5 style-card"> | |
<Card> | |
<CardBody style={{paddingBottom: "0px"}}> | |
<CardTitle> | |
<span> | |
<span className="mr-2"> | |
{imagePreview} | |
</span> | |
<span href="" style={{fontWeight: 'bold'}}>{post.author ? post.author.username : ""}</span> | |
</span> | |
<span style={{float: 'right'}}> | |
<Moment fromNow> | |
{post ? post.created_at : ""} | |
</Moment> | |
</span> | |
</CardTitle> | |
<CardTitle>{post.title}</CardTitle> | |
<CardText>{post.content}</CardText> | |
<div className="style-fav"> | |
<Likes postID={Number(postID)} /> | |
<Comments postID={postID} /> | |
{ authID === post.author_id ? ( | |
<div className="ml-auto"> | |
<span style={{marginRight: "20px"}}> | |
<EditPost post={post} /> | |
</span> | |
<span> | |
<DeletePost postID={post.id} /> | |
</span> | |
</div> | |
) : ""} | |
</div> | |
</CardBody> | |
</Card> | |
</div> | |
<div className="mt-3 style-card-comment"> | |
{singlePostComments ? singlePostComments.map(comment => { | |
return ( | |
<Comment comment={comment} key={comment.id} /> | |
) | |
}) | |
: "" | |
} | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default PostDetails | |
d. CreatePost: An authenticated user can create a post.
Inside the posts directory, create the CreatePost.js component:
touch CreatePost.js
import React, { useState } from "react"; | |
import { Label, Input, FormGroup, Button, Card, CardHeader, CardBody } from "reactstrap"; | |
import { Redirect } from 'react-router-dom'; | |
import { useSelector, useDispatch } from "react-redux"; | |
import "./Posts.css"; | |
import Navigation from '../Navigation' | |
import { createPost } from '../../store/modules/posts/actions/postsAction'; | |
const CreatePost = () => { | |
const currentState = useSelector((state) => state); | |
const [post, setPost] = useState({ | |
title:'', | |
content: '', | |
}); | |
const dispatch = useDispatch() | |
const addPost = (postDetails) => dispatch(createPost(postDetails)) | |
const handleChange = e => { | |
setPost({ | |
...post, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const submitUser = (e) => { | |
e.preventDefault() | |
addPost({ | |
title: post.title, | |
content: post.content, | |
}); | |
} | |
if(!currentState.Auth.isAuthenticated){ | |
return <Redirect to='/login' /> | |
} | |
return ( | |
<div> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="post-style container App"> | |
<Card className="card-style"> | |
<CardHeader>Create Post</CardHeader> | |
<CardBody> | |
<form onSubmit={submitUser}> | |
<FormGroup> | |
<Label>Title</Label> | |
<Input type="text" name="title" placeholder="Enter title" onChange={handleChange}/> | |
{ currentState.PostsState.postsError && currentState.PostsState.postsError.Required_title ? ( | |
<small className="color-red">{currentState.PostsState.postsError.Required_title}</small> | |
) : ( | |
"" | |
)} | |
{ currentState.PostsState.postsError && currentState.PostsState.postsError.Taken_title ? ( | |
<small className="color-red">{ currentState.PostsState.postsError.Taken_title }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
<Label>Content</Label> | |
<Input type="textarea" cols="30" rows="6" name="content" id="" placeholder="Enter a short description" onChange={handleChange} /> | |
{ currentState.PostsState.postsError && currentState.PostsState.postsError.Required_content ? ( | |
<small className="color-red">{currentState.PostsState.postsError.Required_content}</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
{ currentState.PostsState.isLoading ? ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
disabled | |
> | |
Creating... | |
</Button> | |
) : ( | |
<Button | |
color="primary" | |
type="submit" | |
block | |
> | |
Create Post | |
</Button> | |
)} | |
</form> | |
</CardBody> | |
</Card> | |
</div> | |
</div> | |
); | |
} | |
export default CreatePost |
e. EditPost: An authenticated user can edit their post.
Inside the posts directory, create the EditPost.js component:
touch EditPost.js
import React, { useState, useEffect } from 'react'; | |
import { useDispatch, useSelector } from 'react-redux' | |
import { Button, Modal, ModalHeader, ModalBody, ModalFooter, FormGroup } from 'reactstrap'; | |
import { FaPencilAlt } from 'react-icons/fa' | |
import { updatePost } from '../../store/modules/posts/actions/postsAction' | |
const EditPost = ({ post, className }) => { | |
const [modal, setModal] = useState(false); | |
const [postUpdate, setPostUpdate] = useState("") | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const authID = currentState.Auth.currentUser.id | |
const theUpdate = details => dispatch(updatePost(details, updateSuccess)) | |
const updateSuccess = () => { | |
setModal(!modal); | |
} | |
useEffect(() => { | |
setPostUpdate(post) | |
}, [post]); | |
const toggle = (e) => { | |
e.preventDefault() | |
setModal(!modal); | |
setPostUpdate(post) | |
} | |
const handleChange = e => { | |
setPostUpdate({ | |
...postUpdate, | |
[e.target.name]: e.target.value | |
}) | |
} | |
const submitPost = (e) => { | |
e.preventDefault() | |
theUpdate({ | |
id: post.id, | |
title: postUpdate.title, | |
content: postUpdate.content, | |
author_id: authID | |
}) | |
} | |
return ( | |
<span> | |
<FaPencilAlt className="style-edit " onClick={toggle}/> | |
<Modal isOpen={modal} toggle={toggle} className={className}> | |
<ModalHeader toggle={toggle}>Edit Post</ModalHeader> | |
<ModalBody> | |
<FormGroup> | |
<label>Title hello</label> | |
<input className="form-control" type="text" name="title" defaultValue={postUpdate.title} onChange={handleChange}/> | |
{ currentState.PostsState.postsError && currentState.PostsState.postsError.Required_title ? ( | |
<small className="color-red">{currentState.PostsState.postsError.Required_title}</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
<FormGroup> | |
<label>Content</label> | |
<textarea className="form-control" name="content" style={{ width: "100%", height: "150px" }} defaultValue={postUpdate.content} onChange={handleChange}></textarea> | |
{ currentState.PostsState.postsError && currentState.PostsState.postsError.Required_content ? ( | |
<small className="color-red">{currentState.PostsState.postsError.Required_content}</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</ModalBody> | |
<ModalFooter> | |
{ currentState.PostsState.isLoading ? ( | |
<button className="btn btn-primary" | |
disabled | |
> | |
Updating... | |
</button> | |
) : ( | |
<button className="btn btn-primary" | |
onClick={submitPost} | |
type="submit" | |
> | |
Update | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</span> | |
); | |
} | |
export default EditPost; |
f. DeletePost: An authenticated user can delete the post they created.
Inside the posts directory, create the DeletePost.js component:
touch DeletePost.js
import React, { useState } from 'react'; | |
import { useDispatch, useSelector } from 'react-redux' | |
import { Button, Modal, ModalHeader, ModalFooter } from 'reactstrap'; | |
import { FaRegTrashAlt } from 'react-icons/fa' | |
import { deletePost } from '../../store/modules/posts/actions/postsAction' | |
const DeletePost = ({ postID, className }) => { | |
const [modal, setModal] = useState(false); | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const removePost = id => dispatch(deletePost(id)) | |
const toggle = (e) => { | |
e.preventDefault(); | |
setModal(!modal); | |
} | |
const submitDelete = (e) => { | |
e.preventDefault() | |
let id = postID | |
removePost(id) | |
} | |
return ( | |
<span> | |
<FaRegTrashAlt className="style-delete" onClick={toggle}/> | |
<Modal isOpen={modal} toggle={toggle} className={className}> | |
<ModalHeader toggle={toggle} className="text-center">Delete Post?</ModalHeader> | |
<ModalFooter> | |
{ currentState.CommentsState.isLoading ? ( | |
<button className="btn btn-primary" | |
disabled | |
> | |
Deleting... | |
</button> | |
) : ( | |
<button className="btn btn-primary" | |
onClick={submitDelete} | |
type="submit" | |
> | |
Delete | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</span> | |
); | |
} | |
export default DeletePost; |
g. AuthPosts: An authenticated user view all the posts they created.
Inside the posts directory, create the AuthPosts.js component:
touch AuthPosts.js
import React, { useEffect } from 'react' | |
import { Link, Redirect } from 'react-router-dom' | |
import { useSelector, useDispatch } from "react-redux"; | |
import { FaFilter } from 'react-icons/fa' | |
import { fetchAuthPosts } from '../../store/modules/posts/actions/postsAction'; | |
import AuthPost from './AuthPost' | |
import Navigation from '../Navigation' | |
import './Posts.css'; | |
const AuthPosts = () => { | |
const currentState = useSelector((state) => state.Auth); | |
const authID = currentState.currentUser.id | |
const postsSelector = useSelector((state) => state.PostsState); | |
const dispatch = useDispatch(); | |
const getAuthPosts = id => dispatch(fetchAuthPosts(id)); | |
useEffect(() => { | |
getAuthPosts(authID); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []) | |
//incase someone visits the route manually | |
if(!currentState.isAuthenticated){ | |
return <Redirect to='/login' /> | |
} | |
let authPosts = postsSelector.authPosts.map(post => { | |
return ( | |
<div className="mt-2 style-card" key={post.id}> | |
<Link to={'/posts/' + post.id} key={post.id}> | |
<AuthPost post={post} key={post.id} /> | |
</Link> | |
</div> | |
); | |
}) | |
return ( | |
<div className="App"> | |
<div> | |
<Navigation /> | |
</div> | |
<div className="container"> | |
{ authPosts.length > 0 ? ( | |
<div className="container">{authPosts}</div> | |
) : ( | |
<div className="text-center mt-4"> | |
<div style={{fontSize: "100px"}}><FaFilter /></div> | |
<p className="mt-2">It seems you have not created any posts yet.</p> | |
<p>Click the button the button below to create one</p> | |
<div className="mt-4"> | |
<Link to="/createpost" className="btn btn-primary">Create Post</Link> | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export default AuthPosts |
h. AuthPost: This is a single component inside the AuthPosts component.
Inside the posts directory, create the AuthPost.js component:
touch AuthPost.js
import React from 'react' | |
import Moment from 'react-moment'; | |
import { useSelector } from 'react-redux' | |
import { Card, CardText, CardBody, CardTitle } from 'reactstrap'; | |
import './Posts.css'; | |
import Default from '../../Assets/default.png' | |
import Likes from '../likes/Likes' | |
import Comments from '../comments/Comments' | |
import EditPost from './EditPost'; | |
import DeletePost from './DeletePost' | |
const AuthPost = ({ post }) => { | |
const currentState = useSelector(state => state) | |
const authID = currentState.Auth.currentUser.id | |
let $imagePreview = null; | |
if(post.author.avatar_path){ | |
$imagePreview = (<img className="img_style_post" src={post.author.avatar_path} alt="no one"/>); | |
} else { | |
$imagePreview = (<img className="img_style_post" src={Default} alt="no one 2"/>); | |
} | |
return ( | |
<Card className="style-card-main"> | |
<CardBody className="style-card-body"> | |
<CardTitle> | |
<span> | |
<span className="mr-2"> | |
{$imagePreview} | |
</span> | |
<span href="" style={{fontWeight: 'bold'}}>{post.author.username}</span> | |
</span> | |
<span style={{float: 'right'}}> | |
<Moment fromNow>{post.created_at}</Moment> | |
</span> | |
</CardTitle> | |
<CardTitle>{post.title}</CardTitle> | |
<CardText>{post.content}</CardText> | |
<div className="style-fav"> | |
{ authID ? ( | |
<> | |
<Likes postID={post.id} /> | |
<Comments postID={post.id} /> | |
</> | |
) : ""} | |
{ authID === post.author_id ? ( | |
<div className="ml-auto"> | |
<span style={{marginRight: "20px"}}> | |
<EditPost post={post} /> | |
</span> | |
<span> | |
<DeletePost postID={post.id} /> | |
</span> | |
</div> | |
) : ""} | |
</div> | |
</CardBody> | |
</Card> | |
) | |
} | |
export default AuthPost |
i. Posts.css: This is CSS file for the above components.
@media (min-width: 768px){ | |
.style-card { | |
margin-right: 200px; | |
margin-left: 200px; | |
} | |
.style-card-comment { | |
margin-right: 200px; | |
margin-left: 240px; | |
} | |
.img_style_post { | |
border-radius: 50%; | |
height: 50px; | |
width: 50px; | |
} | |
.dropdown-menu { | |
position: absolute; | |
left: -6.8rem; | |
top: 100%; | |
} | |
} | |
@media (max-width: 768px){ | |
.img_style_post { | |
border-radius: 50%; | |
height: 30px; | |
width: 30px; | |
} | |
.dropdown-menu { | |
position: absolute; | |
left: -10rem; | |
top: 100%; | |
} | |
} | |
a { | |
color: #000; | |
text-decoration: none; | |
} | |
a:hover{ | |
color: #000; | |
text-decoration: none; | |
} | |
.style-card-body:hover{ | |
background: #f2f2f2; | |
} | |
.style-card-body { | |
padding-bottom: 0px; | |
} | |
.style-heart:hover { | |
color: pink; | |
} | |
.style-auth { | |
color: red; | |
font-size: 18px; | |
} | |
.style-heart { | |
font-size: 18px | |
} | |
.style-delete { | |
cursor: pointer; | |
font-size: 18px | |
} | |
.style-delete:hover { | |
color: red; | |
} | |
.style-edit { | |
cursor: pointer; | |
font-size: 18px | |
} | |
.style-edit:hover { | |
color: cyan; | |
} | |
.style-fav { | |
display: flex; | |
justify-content: left; | |
} | |
@media all and (min-width: 480px) { | |
.post-style { | |
padding: 60px 0; | |
} | |
.post-style .card-style { | |
margin: 0 auto; | |
max-width: 500px; | |
} | |
} | |
.toggle-style { | |
background: #fff; | |
padding: 0px 5px 5px 5px; | |
border: none; | |
} | |
.toggle-style:hover { | |
background-color:#d3d3d3; | |
} | |
.style-dropdown { | |
width: 10px; | |
} | |
.dropdown-menu { | |
padding: 2px; | |
} | |
button.dropdown-item { | |
padding: 2px 0px 0px 10px; | |
} | |
.navbar-expand-md .navbar-nav .dropdown-menu { | |
width: 75px; | |
} | |
Likes Component
Inside the components directory, create the likes directory
mkdir likes
a. Likes: An authenticated user can like a post or unlike already liked post.
Inside the likes directory, create the Likes.js component:
cd likes && touch Likes.js
import React, { useEffect } from 'react' | |
import { useSelector, useDispatch } from 'react-redux' | |
import { FaRegHeart, FaHeart } from 'react-icons/fa' | |
import '../posts/Posts.css'; | |
import { createLike, deleteLike, fetchLikes } from '../../store/modules/likes/actions/likesAction'; | |
import { history } from '../../history' | |
const Likes = ({ postID }) => { | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const postLikes = currentState.LikesState | |
const authID = currentState.Auth.currentUser ? currentState.Auth.currentUser.id : "" | |
let postLike = 0 | |
let likeID = null | |
let authLiked = false | |
if(postLikes){ | |
// eslint-disable-next-line array-callback-return | |
postLikes.likeItems.map(eachItem => { | |
if(eachItem.postID === postID){ | |
postLike = eachItem.likes.length | |
// eslint-disable-next-line array-callback-return | |
eachItem.likes.map(eachLike => { | |
if(eachLike.user_id === authID){ | |
authLiked = true | |
likeID = eachLike.id | |
} | |
}) | |
} | |
}) | |
} | |
const getPostLikes = id => dispatch(fetchLikes(id)); | |
const addLike = id => dispatch(createLike(id)) | |
const removeLike = details => dispatch(deleteLike(details)) | |
useEffect(() => { | |
getPostLikes(postID); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []) | |
const unLike = (e) => { | |
e.preventDefault() | |
let id = likeID | |
removeLike({id, postID}) | |
} | |
const saveLike = (e) => { | |
e.preventDefault() | |
addLike(postID) | |
} | |
const likeToggle = (e) => { | |
e.preventDefault() | |
authLiked ? unLike(e) : saveLike(e) | |
} | |
const noAuth = (e) => { | |
e.preventDefault() | |
history.push('/login'); | |
} | |
return ( | |
<div className="style-fav"> | |
<div className="style-heart-outer"> | |
<span className="mr-4"> | |
{ authID ? ( | |
<span onClick={likeToggle}> | |
{ authLiked ? | |
<FaHeart className="style-auth"/> | |
: | |
<FaRegHeart className="style-heart"/> | |
} | |
<span className="ml-2"> | |
{postLike} | |
</span> | |
</span> | |
) : ( | |
<span onClick={noAuth}> | |
<FaRegHeart className="style-heart"/> | |
<span className="ml-2"> | |
{postLike} | |
</span> | |
</span> | |
)} | |
</span> | |
</div> | |
</div> | |
) | |
} | |
export default Likes | |
Comments Component
An authenticated user can create/edit/delete comments they created.
Inside the components directory, create the comments directory
mkdir comments
a. Comments: A user can view all comments for a post.
Inside the comments directory, create the Comments.js component:
cd comments && touch Comments.js
import React, { useEffect } from 'react' | |
import { useSelector, useDispatch } from 'react-redux' | |
import '../posts/Posts.css'; | |
import { fetchComments } from '../../store/modules/comments/actions/commentsAction'; | |
import CreateComment from './CreateComment' | |
import { history } from '../../history' | |
const Comments = ({ postID }) => { | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const authID = currentState.Auth.currentUser ? currentState.Auth.currentUser.id : "" | |
const postComments = currentState.CommentsState | |
const getPostComments = id => dispatch(fetchComments(id)) | |
let singlePostComments = [] | |
if(postComments){ | |
// eslint-disable-next-line array-callback-return | |
postComments.commentItems.map(eachItem => { | |
if(eachItem.postID === postID){ | |
singlePostComments = eachItem.comments | |
} | |
}) | |
} | |
const noAuth = (e) => { | |
e.preventDefault() | |
history.push('/login'); | |
} | |
useEffect(() => { | |
getPostComments(postID); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []) | |
return ( | |
<div className="style-heart-outer"> | |
<span className="mr-4"> | |
{ authID ? | |
<span> | |
<CreateComment postID={postID} /> | |
</span> | |
: | |
<span onClick={noAuth}> | |
<CreateComment /> | |
</span> | |
} | |
<span className="ml-2"> | |
{singlePostComments.length} | |
</span> | |
<div></div> | |
</span> | |
</div> | |
) | |
} | |
export default Comments | |
b. Comment: This a single component inside the Comments component.
Inside the comments directory, create the Comment.js component:
touch Comment.js
import React from 'react' | |
import Moment from 'react-moment'; | |
import { useSelector } from 'react-redux' | |
import { | |
Card, CardText, CardBody, | |
CardTitle | |
} from 'reactstrap'; | |
import '../posts/Posts.css'; | |
import Default from '../../Assets/default.png' | |
import EditComment from './EditComment' | |
import DeleteComment from './DeleteComment' | |
const Comment = ({ comment }) => { | |
const currentState = useSelector(state => state) | |
const authID = currentState.Auth.currentUser.id | |
let commentAvatar = comment.user.avatar_path | |
let imagePreview = null | |
if(commentAvatar){ | |
imagePreview = (<img className="img_style_post" src={commentAvatar} alt="profile"/>); | |
} else { | |
imagePreview = (<img className="img_style_post" src={Default} alt="profile"/>); | |
} | |
return ( | |
<div className="mt-3"> | |
<Card> | |
<CardBody> | |
<CardTitle> | |
{comment.user ? | |
<span> | |
<span> | |
<span className="mr-2"> | |
{imagePreview} | |
</span> | |
<span href="" style={{fontWeight: 'bold'}}>{comment.user.username}</span> | |
</span> | |
<span style={{float: 'right'}}> | |
<Moment fromNow> | |
{comment.created_at} | |
</Moment> | |
</span> | |
</span> | |
: "" } | |
</CardTitle> | |
<CardText>{comment.body}</CardText> | |
{ authID === comment.user.id ? ( | |
<div style={{float: "right"}}> | |
<span style={{marginRight: "20px"}}> | |
<EditComment comment={comment} /> | |
</span> | |
<span> | |
<DeleteComment comment={comment} /> | |
</span> | |
</div> | |
) : ""} | |
</CardBody> | |
</Card> | |
</div> | |
) | |
} | |
export default Comment |
c. CreateComment: An authenticated user can create a comment.
Inside the comments directory, create the CreateComment.js component:
touch CreateComment.js
import React, { useState } from 'react'; | |
import { useDispatch, useSelector } from 'react-redux' | |
import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; | |
import { FaRegComment } from 'react-icons/fa' | |
import { createComment } from '../../store/modules/comments/actions/commentsAction' | |
const CreateComment = ({ postID, className }) => { | |
const [modal, setModal] = useState(false); | |
const [body, setBody] = useState("") | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const addComment = details => dispatch(createComment(details, commentSuccess)) | |
const commentSuccess = () => { | |
setModal(!modal); | |
} | |
const toggle = (e) => { | |
e.preventDefault() | |
setModal(!modal); | |
} | |
const handleChange = e => { | |
setBody(e.target.value) | |
} | |
const submitComment = (e) => { | |
e.preventDefault() | |
addComment({ | |
post_id: Number(postID), | |
body | |
}) | |
} | |
return ( | |
<span> | |
<FaRegComment className="style-heart " onClick={toggle}/> | |
<Modal isOpen={modal} toggle={toggle} className={className}> | |
<ModalHeader toggle={toggle}>Comment</ModalHeader> | |
<ModalBody> | |
<textarea name="body" style={{ width: "100%", height: "150px" }} onChange={handleChange}></textarea> | |
{ currentState.CommentsState.commentsError && currentState.CommentsState.commentsError.Required_body ? ( | |
<small className="color-red">{currentState.CommentsState.commentsError.Required_body}</small> | |
) : ( | |
"" | |
)} | |
</ModalBody> | |
<ModalFooter> | |
{ currentState.CommentsState.isLoading ? ( | |
<button className="btn btn-primary" | |
disabled | |
> | |
Saving... | |
</button> | |
) : ( | |
<button className="btn btn-primary" | |
onClick={submitComment} | |
type="submit" | |
> | |
Comment | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</span> | |
); | |
} | |
export default CreateComment; |
d. EditComment: An authenticated user can edit their comment.
Inside the comments directory, create the EditComment.js component:
touch EditComment.js
import React, { useState, useEffect } from 'react'; | |
import { useDispatch, useSelector } from 'react-redux' | |
import { Button, Modal, ModalHeader, ModalBody, ModalFooter, FormGroup } from 'reactstrap'; | |
import { FaPencilAlt } from 'react-icons/fa' | |
import { updateComment } from '../../store/modules/comments/actions/commentsAction' | |
const EditComment = ({ comment, className }) => { | |
const [modal, setModal] = useState(false); | |
const [commentUpdate, setCommentUpdate] = useState("") | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const theUpdate = details => dispatch(updateComment(details, updateSuccess)) | |
const updateSuccess = () => { | |
setModal(!modal); | |
} | |
useEffect(() => { | |
setCommentUpdate(comment) | |
}, [comment]); | |
const toggle = (e) => { | |
e.preventDefault() | |
setModal(!modal); | |
setCommentUpdate(comment) | |
} | |
const handleChange = e => { | |
setCommentUpdate(e.target.value) //since is just one value | |
} | |
const submitComment = (e) => { | |
e.preventDefault() | |
theUpdate({ | |
id: comment.id, | |
body: commentUpdate, | |
}) | |
} | |
return ( | |
<span> | |
<FaPencilAlt className="style-edit " onClick={toggle}/> | |
<Modal isOpen={modal} toggle={toggle} className={className}> | |
<ModalHeader toggle={toggle}>Edit Comment</ModalHeader> | |
<ModalBody> | |
<FormGroup> | |
<label>Content</label> | |
<textarea className="form-control" name="body" style={{ width: "100%", height: "100px" }} defaultValue={commentUpdate.body} onChange={handleChange}></textarea> | |
{ currentState.CommentsState.commentsError && currentState.CommentsState.commentsError.Required_body ? ( | |
<small className="color-red">{currentState.CommentsState.commentsError.Required_body }</small> | |
) : ( | |
"" | |
)} | |
</FormGroup> | |
</ModalBody> | |
<ModalFooter> | |
{ currentState.CommentsState.isLoading ? ( | |
<button className="btn btn-primary" | |
disabled | |
> | |
Updating... | |
</button> | |
) : ( | |
<button className="btn btn-primary" | |
onClick={submitComment} | |
type="submit" | |
> | |
Update | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</span> | |
); | |
} | |
export default EditComment; |
e. DeleteComment: An authenticated user can delete their comment.
Inside the comments directory, create the DeleteComment.js component:
touch DeleteComment.js
import React, { useState } from 'react'; | |
import { useDispatch, useSelector } from 'react-redux' | |
import { Button, Modal, ModalHeader, ModalFooter } from 'reactstrap'; | |
import { FaRegTrashAlt } from 'react-icons/fa' | |
import { deleteComment } from '../../store/modules/comments/actions/commentsAction' | |
const DeleteComment = ({ comment, className }) => { | |
const [modal, setModal] = useState(false); | |
const dispatch = useDispatch() | |
const currentState = useSelector((state) => state); | |
const removeCommment = details => dispatch(deleteComment(details, deleteSuccess)) | |
const toggle = (e) => { | |
e.preventDefault(); | |
setModal(!modal); | |
} | |
//this callback should not listen for an event | |
const deleteSuccess = () => { | |
setModal(!modal); | |
} | |
const submitDelete = (e) => { | |
e.preventDefault() | |
removeCommment({ | |
id: comment.id, | |
postID: comment.post_id | |
}) | |
} | |
return ( | |
<span> | |
<FaRegTrashAlt className="style-delete" onClick={toggle}/> | |
<Modal isOpen={modal} toggle={toggle} className={className}> | |
<ModalHeader toggle={toggle} className="text-center">Delete Comment?</ModalHeader> | |
<ModalFooter> | |
{ currentState.CommentsState.isLoading ? ( | |
<button className="btn btn-primary" | |
disabled | |
> | |
Deleting... | |
</button> | |
) : ( | |
<button className="btn btn-primary" | |
onClick={submitDelete} | |
type="submit" | |
> | |
Delete | |
</button> | |
)} | |
<Button color="secondary" onClick={toggle}>Cancel</Button> | |
</ModalFooter> | |
</Modal> | |
</span> | |
); | |
} | |
export default DeleteComment; | |
Dashboard Component
This is the entry component of the application.
Inside the components directory, create the Dashboard.js component
touch Dashboard
import React from 'react'; | |
import Posts from './posts/Posts'; | |
import Navigation from './Navigation' | |
const Dashboard = () => { | |
return ( | |
<div> | |
<Navigation /> | |
<Posts /> | |
</div> | |
) | |
} | |
export default Dashboard; | |
Step 4: Wiring Up The Route
If routing is not in place, we cannot navigate to the different components we have.
In the src directory, create the Route.js file
touch Route.js
import React from 'react'; | |
import { Router, Switch, Route } from 'react-router-dom'; | |
import Login from './components/auth/Login'; | |
import Register from './components/auth/Register'; | |
import CreatePost from './components/posts/CreatePost'; | |
import Dashboard from './components/Dashboard'; | |
import { history } from './history' | |
import Profile from './components/users/Profile'; | |
import ForgotPassword from './components/users/ForgotPassword.js'; | |
import ResetPassword from './components/users/ResetPassword'; | |
import PostDetails from './components/posts/PostDetails' | |
import AuthPosts from './components/posts/AuthPosts' | |
const Routes = () => { | |
return ( | |
<Router history={history}> | |
<div className="App"> | |
<Switch> | |
<Route exact path='/' component={ Dashboard } /> | |
<Route path='/login' component={Login} /> | |
<Route path='/signup' component={Register} /> | |
<Route path='/createpost' component={CreatePost} /> | |
<Route path='/profile/:id' component={Profile} /> | |
<Route path='/forgotpassword' component={ForgotPassword} /> | |
<Route path='/resetpassword/:token' component={ResetPassword} /> | |
<Route path='/posts/:id' component={PostDetails} /> | |
<Route path='/authposts' component={AuthPosts} /> | |
</Switch> | |
</div> | |
</Router> | |
); | |
} | |
export default Routes; | |
Step 4: Wiring Up The App Main Entry
All that is done above, from the store* to the routing need to connect at some point.
This is done in the index.js file in the src directory.
Edit the index.js file in the src directory
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
import 'bootstrap/dist/css/bootstrap.min.css'; | |
import Routes from './Routes'; | |
import * as serviceWorker from './serviceWorker'; | |
import { Provider } from "react-redux" | |
import './index.css' | |
import store from './store/index' | |
import setAuthorizationToken from './authorization/authorization'; | |
import { LOGIN_SUCCESS } from './store/modules/auth/authTypes'; | |
//when the page reloads, the auth user is still set | |
if (localStorage.token){ | |
setAuthorizationToken(localStorage.token) | |
let userData = localStorage.getItem('user_data') == null ? null : JSON.parse(localStorage.getItem('user_data')) | |
store.dispatch({ type: LOGIN_SUCCESS, payload: userData}) //provided he has a valid token | |
} | |
ReactDOM.render(<Provider store={store}><Routes /></Provider>, document.getElementById('root')); | |
// If you want your app to work offline and load faster, you can change | |
// unregister() to register() below. Note this comes with some pitfalls. | |
// Learn more about service workers: https://bit.ly/CRA-PWA | |
serviceWorker.unregister(); | |
Also, edit the index.css file in the src directory. This file has just once CSS class color-red. This is used in all components that error is displayed
.color-red { | |
color: red; | |
} |
Fire up your terminal and run http://localhost:3000
Welcome to the App.
Step 4: Deployment
The frontend is deployed using Netlify
before you deploy, in the public directory(path: forum-frontend/public), create the _redirects file
touch _redirects
File content:
/* /index.html 200
Steps to deploy:
- Create a new github repo(different from the backend)
- Push the frontend code to the repo
- Login to your Netlify account and connect the frontend repo.
- Give it sometime to deploy.
Note the following:
- For the backend to work with the deployed frontend, it needs to be deployed also to a live server(digitalocean, aws, heroku, etc).
- Make sure that url for the backend is not just the ip address. you can get a domain name and make sure https is enabled
- You can update the apiRoute file and add your backend url
Conclusion
I tried as concise as possible to avoid a 2 hours or so read.
This is the visit the production application
https://seamflow.com
You can visit and try all that you learned in this article.
Also, get the github repositories
- https://github.com/victorsteven/Forum-App-Go-Backend (Backend)
- https://github.com/victorsteven/Forum-App-React-Frontend (Frontend)
Don't forget to drop a star.
You can ask me personal questions on questions on twitter
You might also like to check out my other articles about go, docker, kubernetes here
Thanks.
Top comments (5)
Greate Steven
Thanks Jose
this is huge man, thanks for the detailed explanation for all the steps 👍 looks like the deployment doesn't work already though, does it?
Nice post, Steven!
How can we port the front-end to React-Native?
This one realy help me alot to understand go web, thank you Steven