DEV Community

Cover image for Forum App with Golang/Gin and React/Hooks
Steven Victor
Steven Victor

Posted on • Edited on

27 7

Forum App with Golang/Gin and React/Hooks

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:

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```
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
view raw Forum: .env hosted with ❤ by GitHub

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
}
view raw forum: User.go hosted with ❤ by GitHub

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
}
view raw forum: Post.go hosted with ❤ by GitHub

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
}
view raw forum: Like.go hosted with ❤ by GitHub

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))
}
view raw forum: token.go hosted with ❤ by GitHub

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))
}
view raw forum: base.go hosted with ❤ by GitHub

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=
Enter fullscreen mode Exit fullscreen mode

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()
}
view raw forum: main.go hosted with ❤ by GitHub

Confirm that your directory structure looks like this:

Alt Text

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

Enter fullscreen mode Exit fullscreen mode

Your terminal output should look like this:
Alt Text

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

Enter fullscreen mode Exit fullscreen mode

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

...

Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

From the project root directory, run:

docker-compose -f docker-compose.test.yml up --build
Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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

import axios from 'axios';
export default function setAuthorizationToken(token){
if (token) {
axios.defaults.headers.common['Authorization'] =`Bearer ${token}`;
} else {
delete axios.defaults.headers.common['Authorization'];
}
}

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

Alt Text

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:

Alt Text

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:

Alt Text

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:

Alt Text

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:

Alt Text

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

import React, { useState } from 'react';
import { NavLink, Link } from 'react-router-dom';
import { useSelector, useDispatch } from "react-redux";
import { SignOut } from '../store/modules/auth/actions/authAction';
import Default from '../Assets/default.png'
import './Navigation.css'
import {
  Collapse,
  Navbar,
  NavbarToggler,
  NavbarBrand,
  Nav,
  NavItem,
  UncontrolledDropdown,
  DropdownToggle,
  DropdownMenu,
  DropdownItem } from 'reactstrap';
const Navigation = () => {
const [isOpen, setIsOpen] = useState(false)
const currentState = useSelector((state) => state);
const { isAuthenticated, currentUser } = currentState.Auth;
const dispatch = useDispatch()
const logoutUser = () => dispatch(SignOut());
let imagePreview = null;
if(currentUser && currentUser.avatar_path){
imagePreview = (<img className="img_style_nav" src={currentUser.avatar_path} alt="profile 1"/>);
} else {
imagePreview = (<img className="img_style_nav" src={Default} alt="profile 2"/>);
}
const logout = (e) => {
e.preventDefault()
logoutUser()
}
const userProfile = isAuthenticated ? `/profile/${currentState.Auth.currentUser.id}` : ""
const SignedInLinks = (
              <React.Fragment>
<NavItem className="mt-2" style={{marginRight: "15px" }}>
<NavLink to="/createpost">Create Post</NavLink>
</NavItem>
<NavItem className="mt-2" style={{marginRight: "15px" }}>
<NavLink to="/authposts">My Posts</NavLink>
</NavItem>
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
{imagePreview}
</DropdownToggle>
<DropdownMenu right>
<DropdownItem>
<NavItem>
<NavLink to={userProfile}>Profile</NavLink>
</NavItem>
</DropdownItem>
<DropdownItem divider />
<DropdownItem>
<a onClick={logout}>Logout</a>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
</React.Fragment>
)
const SignedOutLinks = (
<React.Fragment>
<NavItem style={{marginRight: "20px" }}>
<Link to='/login'>Login</Link>
</NavItem>
<NavItem>
<Link to='/signup'>Signup</Link>
</NavItem>
</React.Fragment>
)
return (
<div className="mb-3">
<Navbar color="light" light expand="md">
<NavbarBrand className="mx-auto" href="/">Seamflow</NavbarBrand>
<NavbarToggler onClick={() => setIsOpen(!isOpen) } /> 
<Collapse isOpen={isOpen} navbar> 
<Nav className="ml-auto" navbar>
{ isAuthenticated ? SignedInLinks: SignedOutLinks }
</Nav>
</Collapse>
</Navbar>
</div>
);
}
export default Navigation

b. Navigation.css
Inside the components directory, create the Navigation.css file

.style-navbar {
width: 100%!important;
display: flex;
justify-content: right;
}
@media (min-width: 768px){
.img_style_nav {
border-radius: 50%;
height: 30px;
width: 30px;
}
}
@media (max-width: 768px){
.img_style_nav {
border-radius: 50%;
height: 30px;
width: 30px;
}
}
.style-dropdown {
cursor: pointer;
}
.navbar-expand-md .navbar-nav .dropdown-menu {
position: absolute;
width: 100px;
}
@media (min-width: 768px) {
.dropdown-menu {
min-width: 30px;
margin-left: 90px;
}
}
@media (max-width: 768px) {
.dropdown-menu {
min-width: 30px;
margin-left: 160px;
}
}

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
view raw forum: Login.js hosted with ❤ by GitHub

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;
}
}
view raw forum: Auth.css hosted with ❤ by GitHub

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%;
}
}
view raw Profile.css hosted with ❤ by GitHub

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({
email
});
}
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
view raw Forum: Posts.js hosted with ❤ by GitHub

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
view raw Forum: Post.js hosted with ❤ by GitHub

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
view raw Forum: Likes.js hosted with ❤ by GitHub

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;
view raw Route.js hosted with ❤ by GitHub

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();
view raw Forum: index.js hosted with ❤ by GitHub

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
Enter fullscreen mode Exit fullscreen mode

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

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.

Neon image

Build better on Postgres with AI-Assisted Development Practices

Compare top AI coding tools like Cursor and Windsurf with Neon's database integration. Generate synthetic data and manage databases with natural language.

Read more →

Top comments (5)

Collapse
 
giorgiootto profile image
Giorgio Jose Otto

Greate Steven

Collapse
 
stevensunflash profile image
Steven Victor

Thanks Jose

Collapse
 
fyodorio profile image
Fyodor

this is huge man, thanks for the detailed explanation for all the steps 👍 looks like the deployment doesn't work already though, does it?

Collapse
 
cells200 profile image
Cells

Nice post, Steven!
How can we port the front-end to React-Native?

Collapse
 
cag000 profile image
cag000

This one realy help me alot to understand go web, thank you Steven

Jetbrains image

Build Secure, Ship Fast

Discover best practices to secure CI/CD without slowing down your pipeline.

Read more

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay