Compare commits

..

4 Commits

Author SHA1 Message Date
Muyao CHEN
cb7a4bf5c5 fix: change API design to be more RESTful 2024-10-07 23:25:25 +02:00
Muyao CHEN
c1173b4bcc feat: add test case for usecase user.create 2024-10-06 23:21:54 +02:00
Muyao CHEN
b7697bc89b feat: hash the user password 2024-10-06 22:07:51 +02:00
Muyao CHEN
ba8570857d feat: add validate for user signup 2024-10-06 21:54:29 +02:00
10 changed files with 218 additions and 42 deletions

View File

@ -13,6 +13,9 @@
- [Version](#version) - [Version](#version)
- [2024/10/03](#20241003) - [2024/10/03](#20241003)
- [2024/10/04](#20241004) - [2024/10/04](#20241004)
- [2024/10/06](#20241006)
- [Workflow](#workflow)
- [2024/10/07](#20241007)
<!--toc:end--> <!--toc:end-->
A tricount like expense-sharing system written in Go A tricount like expense-sharing system written in Go
@ -241,3 +244,69 @@ rest, I just put unit tests aside for later.
That should be the correct workflow. But to save time, I will cut off the That should be the correct workflow. But to save time, I will cut off the
integration test part (the 2nd point). integration test part (the 2nd point).
### 2024/10/07
I rethought about the whole API design (even though I have only one yet). I
have created `/signup` and `/login` without thinking too much, but in fact
it is not quite *RESTful*.
**REST** is all about resources. While `/signup` and `/login` is quite
comprehensible, thus service-oriented, they don't follow the REST philosophy,
that is to say, **resource-oriented**.
If we rethink about `/signup`, what it does is to create a resource of `User`.
Thus, for a backend API, it'd better be named as `User.Create`. But what
about `/login`, it doesn't do anything about `User`. It would be strange to
declare it as a User-relevant method.
Instead, what `/login` really does, is to **create a session**.
In consequence, we have to create a new struct `Session` that can be created,
deleted, or updated.
It might seem overkill, and in real life, even in the official Pet store
example of OpenAPI, signup and login are under /user. But it just opened my
mind and forces me to **think and design RESTfully**!
That being said, for the user side, we shall still have `/signup` and `/login`,
because on the Front-end, we must be user-centered. We can even make this
2 functions on the same page with the same endpoint `/login`. The user enter
the email and the password, then clicks on `Login or Signup`. If the login
is successful, then he is logged in. Otherwise, if the user doesn't exist
yet, we open up 2 more inputs (first name and last name) for signup. They
can just provide the extra information and click again on `Signup`.
That, again, being said, I am thinking about doing some Front-end stuff just
to make the validation tests of the product simpler.
#### The choice of the front end framework
I have considered several choices.
If I didn't purposely make the backend code to provide a REST API, I might
choose server-side-rendering with `templ + htmx`, or even `template+vanilla
javascript`.
I can still write a rather static Go-frontend-server to serve HTMLs and call
my Go backend. *And it might be a good idea if they communicate on Go native
rpc.* It worth a try.
And I have moved on to `Svelte` which seems very simple by design and the
whole compile thing makes it really charm. But this is mainly a Go project,
to learn something new with a rather small community means potentially more
investment. I can learn it later.
Among `Angular`, `React` and `Vue`, I prefer `Vue`, for several reasons.
First, `Angular` is clearly overkill for this small demo project. Second,
`React` is good but I personally like the way of Vue doing things. And I
work with Vue at work, so I might have more technical help from my colleagues.
So the plan for this week is to have both the Front end part and Backend part
working, just for user signup and login.
I would like to directly put this stuff on a CI-pipeline for tests and
deployment, even I have barely nothing yet. It is always good to do this
preparation stuff at the early stage of the project. So we can benefit from
them all the way along.
Moreover, even I am not really finishing the project, it can still be
something representable that I can show to a future interviewer.

View File

@ -37,28 +37,79 @@ servers:
- url: https:/localhost:8000/v1 - url: https:/localhost:8000/v1
tags: tags:
- name: user - name: user
- name: session
paths: paths:
/user/signup: /user/create:
post: post:
tags: tags:
- user - user
description: Sign up as a new user description: Create a new user
requestBody: requestBody:
description: Sign up description: Create a new user
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserSignUpRequest' $ref: '#/components/schemas/UserCreateRequest'
responses: responses:
'200': '200':
description: Successful operation description: Successful operation
'400': '400':
description: Client side error description: Client side error
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: FailedOperation.UserExisted
message:
type: string
example: "Email already existed."
'500':
description: Server side error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrResponse' $ref: '#/components/schemas/ErrResponse'
/session/create:
post:
tags:
- user
description: Create a new session for a user
requestBody:
description: Create session
content:
application/json:
schema:
$ref: '#/components/schemas/SessionCreateRequest'
responses:
'200':
description: Successful operation
headers:
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/json:
schema:
type: string
'400':
description: Client side error
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: AuthFailure
message:
type: string
example: "wrong email password."
'500': '500':
description: Server side error description: Server side error
content: content:
@ -68,7 +119,7 @@ paths:
components: components:
schemas: schemas:
UserSignUpRequest: UserCreateRequest:
type: object type: object
properties: properties:
email: email:
@ -88,12 +139,24 @@ components:
- fist_name - fist_name
- last_name - last_name
- password - password
SessionCreateRequest:
type: object
properties:
email:
type: string
example: bruce@wayne.com
password:
type: string
example: verystrongpassword
required:
- email
- password
ErrResponse: ErrResponse:
type: object type: object
properties: properties:
code: code:
type: string type: string
example: FailedOperation.UserAlreadyExists example: InternalError
message: message:
type: string type: string
example: "User already exists." example: "Server internal error."

4
go.mod
View File

@ -3,17 +3,20 @@ module git.vinchent.xyz/vinchent/howmuch
go 1.23.1 go 1.23.1
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gosuri/uitable v0.0.4 github.com/gosuri/uitable v0.0.4
github.com/jackc/pgx/v5 v5.7.1 github.com/jackc/pgx/v5 v5.7.1
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.27.0
golang.org/x/net v0.25.0 golang.org/x/net v0.25.0
) )
@ -58,7 +61,6 @@ require (
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.18.0 // indirect

4
go.sum
View File

@ -1,3 +1,5 @@
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@ -84,6 +86,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View File

@ -29,16 +29,15 @@ import (
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core" "git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno" "git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"github.com/asaskevich/govalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// User is the user controller interface, it describes all the handlers // User is the user controller interface, it describes all the handlers
// that need to be implemented for the /user endpoint // that need to be implemented for the /user endpoint
type User interface { type User interface {
Signup(core.Context) Create(core.Context)
UpdateInfo(*gin.Context) UpdateInfo(*gin.Context)
Login(*gin.Context)
Logout(*gin.Context)
ChangePassword(*gin.Context) ChangePassword(*gin.Context)
} }
@ -58,7 +57,7 @@ func NewUserController(us usecase.User) User {
} }
} }
func (uc *UserController) Signup(ctx core.Context) { func (uc *UserController) Create(ctx core.Context) {
var params model.User var params model.User
if err := ctx.Bind(&params); err != nil { if err := ctx.Bind(&params); err != nil {
@ -66,9 +65,15 @@ func (uc *UserController) Signup(ctx core.Context) {
return return
} }
// TODO: check params validity (govalidator) _, err := govalidator.ValidateStruct(params)
if err != nil {
errno := UserParamsErr
errno.Message = err.Error()
core.WriteResponse(ctx, errno, nil)
return
}
_, err := uc.userUsecase.Create(ctx, &params) _, err = uc.userUsecase.Create(ctx, &params)
if err != nil { if err != nil {
core.WriteResponse(ctx, err, nil) core.WriteResponse(ctx, err, nil)
return return
@ -80,11 +85,5 @@ func (uc *UserController) Signup(ctx core.Context) {
func (uc *UserController) UpdateInfo(ctx *gin.Context) { func (uc *UserController) UpdateInfo(ctx *gin.Context) {
} }
func (uc *UserController) Login(ctx *gin.Context) {
}
func (uc *UserController) Logout(ctx *gin.Context) {
}
func (uc *UserController) ChangePassword(ctx *gin.Context) { func (uc *UserController) ChangePassword(ctx *gin.Context) {
} }

View File

@ -52,7 +52,7 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
{ {
userV1 := v1.Group("/user") userV1 := v1.Group("/user")
{ {
userV1.POST("/signup", func(ctx *gin.Context) { c.User.Signup(ctx) }) userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) })
} }
} }

View File

@ -27,10 +27,10 @@ import "time"
// User model // User model
type User struct { type User struct {
ID int `json:"id"` ID int `json:"id"`
Email string `json:"email"` Email string `json:"email" valid:"email"`
FirstName string `json:"first_name"` FirstName string `json:"first_name" valid:"required"`
LastName string `json:"last_name"` LastName string `json:"last_name" valid:"required"`
Password string `json:"password"` Password string `json:"password" valid:"required"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@ -26,6 +26,7 @@ import (
"context" "context"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/pkg/errors"
) )
type TestUserRepository struct{} type TestUserRepository struct{}
@ -38,5 +39,10 @@ func (tur *TestUserRepository) Create(
user := *u user := *u
user.ID = 123 user.ID = 123
if user.Email == "duplicate@error.com" {
return nil, errors.New("blabla (SQLSTATE 23505)")
}
return &user, nil return &user, nil
} }

View File

@ -25,12 +25,22 @@ package usecase
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"regexp"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log" "git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
"golang.org/x/crypto/bcrypt"
) )
var UserExisted = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
Message: "email already existed.",
}
type userUsecase struct { type userUsecase struct {
userRepo repo.UserRepository userRepo repo.UserRepository
dbRepo repo.DBRepository dbRepo repo.DBRepository
@ -48,15 +58,22 @@ func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
} }
func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User, error) { func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User, error) {
// Hash the password
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
if err != nil {
log.ErrorLog("encrypt password error", "err", err)
return nil, errno.InternalServerErr
}
u.Password = string(encrypted)
data, err := uuc.dbRepo.Transaction( data, err := uuc.dbRepo.Transaction(
ctx, ctx,
func(txCtx context.Context, tx interface{}) (interface{}, error) { func(txCtx context.Context, tx interface{}) (interface{}, error) {
// TODO: should check if the user exists
// DB will return an error since we have set email to UNIQUE.
// But we may not want to expose the exact db error.
u, err := uuc.userRepo.Create(txCtx, tx, u) u, err := uuc.userRepo.Create(txCtx, tx, u)
if err != nil { if err != nil {
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
if match {
return nil, UserExisted
}
return nil, err return nil, err
} }

View File

@ -32,18 +32,34 @@ import (
) )
func TestCreateUser(t *testing.T) { func TestCreateUser(t *testing.T) {
ctx := context.Background() t.Run("normal create", func(t *testing.T) {
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{}) ctx := context.Background()
input := &model.User{ userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
Email: "a@b.c", input := &model.User{
FirstName: "James", Email: "a@b.c",
LastName: "Bond", FirstName: "James",
Password: "verystrong", LastName: "Bond",
} Password: "verystrong",
want := input }
want.ID = 123 want := input
want.ID = 123
got, err := userUsecase.Create(ctx, input) got, err := userUsecase.Create(ctx, input)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, want, got) assert.Equal(t, want, got)
})
t.Run("duplicate create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
input := &model.User{
Email: "duplicate@error.com",
FirstName: "James",
LastName: "Bond",
Password: "verystrong",
}
_, err := userUsecase.Create(ctx, input)
assert.EqualError(t, err, UserExisted.Error())
})
} }