20
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:
- implement function to communicate with database for required action
- implement Gin handler which will use function from step 1
- 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
}
20