React Gin Blog (14/19): Add user posts

With authentication in place, it is time to start using it. We will need authentication to create, read, update and delete user’s blog posts. Let’s start by adding new database migration which will create required data table with columns. Create new migration file migrations/2_addPostsTable.go:

package main

import (
  "fmt"

  "github.com/go-pg/migrations/v8"
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println("creating table posts...")
    _, err := db.Exec(`CREATE TABLE posts(
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      user_id INT REFERENCES users ON DELETE CASCADE
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println("dropping table posts...")
    _, err := db.Exec(`DROP TABLE posts`)
    return err
  })
}

And then run migrations with:

cd migrations/
go run *.go up

Now we create structure to hold post data. We will also add field constraints for Title and Content. Add new file internal/store/posts.go:

package store

import "time"

type Post struct {
  ID         int
  Title      string    `binding:"required,min=3,max=50"`
  Content    string    `binding:"required,min=5,max=5000"`
  CreatedAt  time.Time
  ModifiedAt time.Time
  UserID     int `json:"-"`
}

User can have multiple blog posts, so we have to add has-many relation to User struct. In internal/store/users.go, edit User struct:

type User struct {
  ID             int
  Username       string `binding:"required,min=5,max=30"`
  Password       string `pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte `json:"-"`
  Salt           []byte `json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
  Posts          []*Post `json:"-" pg:"fk:user_id,rel:has-many,on_delete:CASCADE"`
}

Function that can insert new post entry in database will be implemented in internal/store/posts.go:

func AddPost(user *User, post *Post) error {
  post.UserID = user.ID
  _, err := db.Model(post).Returning("*").Insert()
  if err != nil {
    log.Error().Err(err).Msg("Error inserting new post")
  }
  return err
}

To create post, we will add new handler which will call the function above. Create new file internal/server/post.go:

package server

import (
  "net/http"
  "rgb/internal/store"

  "github.com/gin-gonic/gin"
)

func createPost(ctx *gin.Context) {
  post := new(store.Post)
  if err := ctx.Bind(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  if err := store.AddPost(user, post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    "msg":  "Post created successfully.",
    "data": post,
  })
}

Post creation handler is ready, so let’s add new protected route for crating posts. In internal/server/router.go, we will create new group which will use authorization middleware we implemented in previous chapter. We will add route /posts with HTTP method POST to that protected group:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can't be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group("/api")
  {
    api.POST("/signup", signUp)
    api.POST("/signin", signIn)
  }

  authorized := api.Group("/")
  authorized.Use(authorization)
  {
    authorized.POST("/posts", createPost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

The recipe is same for all other CRUD (Create, Read, Update, Delete) methods:

  1. implement function to communicate with database for required action
  2. implement Gin handler which will use function from step 1
  3. add route with handler to router

We have Create part covered, so let’s move on to the next method, Read. We will implement function that will fetch all user’s posts from database in internal/store/posts.go:

func FetchUserPosts(user *User) error {
  err := db.Model(user).
    Relation("Posts", func(q *orm.Query) (*orm.Query, error) {
      return q.Order("id ASC"), nil
    }).
    Select()
  if err != nil {
    log.Error().Err(err).Msg("Error fetching user's posts")
  }
  return err
}

And in internal/server/post.go:

func indexPosts(ctx *gin.Context) {
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  if err := store.FetchUserPosts(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    "msg":  "Posts fetched successfully.",
    "data": user.Posts,
  })
}

To Update post, add these 2 functions to internal/store/posts.go:

func FetchPost(id int) (*Post, error) {
  post := new(Post)
  post.ID = id
  err := db.Model(post).WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg("Error fetching post")
    return nil, err
  }
  return post, nil
}

func UpdatePost(post *Post) error {
  _, err := db.Model(post).WherePK().UpdateNotZero()
  if err != nil {
    log.Error().Err(err).Msg("Error updating post")
  }
  return err
}

And in internal/server/post.go:

func updatePost(ctx *gin.Context) {
  jsonPost := new(store.Post)
  if err := ctx.Bind(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  dbPost, err := store.FetchPost(jsonPost.ID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  if user.ID != dbPost.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Not authorized."})
    return
  }
  jsonPost.ModifiedAt = time.Now()
  if err := store.UpdatePost(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    "msg":  "Post updated successfully.",
    "data": jsonPost,
  })
}

Finally, to Delete post add to internal/store.posts.go:

func DeletePost(post *Post) error {
  _, err := db.Model(post).WherePK().Delete()
  if err != nil {
    log.Error().Err(err).Msg("Error deleting post")
  }
  return err
}

And in internal/server/post.go:

func deletePost(ctx *gin.Context) {
  paramID := ctx.Param("id")
  id, err := strconv.Atoi(paramID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Not valid ID."})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  post, err := store.FetchPost(id)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  if user.ID != post.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Not authorized."})
    return
  }
  if err := store.DeletePost(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{"msg": "Post deleted successfully."})
}

One new thing that you can notice here is paramID := ctx.Param("id"). We are using that to extract ID param from URL path.

Let’s add all those handlers to router:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can't be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group("/api")
  {
    api.POST("/signup", signUp)
    api.POST("/signin", signIn)
  }

  authorized := api.Group("/")
  authorized.Use(authorization)
  {
    authorized.GET("/posts", indexPosts)
    authorized.POST("/posts", createPost)
    authorized.PUT("/posts", updatePost)
    authorized.DELETE("/posts/:id", deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

If user has no posts yet, User.Posts field will be nil by default. This complicates things for frontend since it must check for nil value, so it would be better to use empty slice. For that we will use AfterSelectHook which will be executed every time after Select() is executed for User. That hook will be added to internal/store/users.go:

var _ pg.AfterSelectHook = (*User)(nil)

func (user *User) AfterSelect(ctx context.Context) error {
  if user.Posts == nil {
    user.Posts = []*Post{}
  }
  return nil
}

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: