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)
- [2024/10/03](#20241003)
- [2024/10/04](#20241004)
- [2024/10/06](#20241006)
- [Workflow](#workflow)
- [2024/10/07](#20241007)
<!--toc:end-->
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
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
tags:
- name: user
- name: session
paths:
/user/signup:
/user/create:
post:
tags:
- user
description: Sign up as a new user
description: Create a new user
requestBody:
description: Sign up
description: Create a new user
content:
application/json:
schema:
$ref: '#/components/schemas/UserSignUpRequest'
$ref: '#/components/schemas/UserCreateRequest'
responses:
'200':
description: Successful operation
'400':
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:
application/json:
schema:
$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':
description: Server side error
content:
@ -68,7 +119,7 @@ paths:
components:
schemas:
UserSignUpRequest:
UserCreateRequest:
type: object
properties:
email:
@ -88,12 +139,24 @@ components:
- fist_name
- last_name
- password
SessionCreateRequest:
type: object
properties:
email:
type: string
example: bruce@wayne.com
password:
type: string
example: verystrongpassword
required:
- email
- password
ErrResponse:
type: object
properties:
code:
type: string
example: FailedOperation.UserAlreadyExists
example: InternalError
message:
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
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gosuri/uitable v0.0.4
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/pflag v1.0.5
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.27.0
golang.org/x/net v0.25.0
)
@ -58,7 +61,6 @@ require (
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.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/sys v0.25.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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
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/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/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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"github.com/asaskevich/govalidator"
"github.com/gin-gonic/gin"
)
// User is the user controller interface, it describes all the handlers
// that need to be implemented for the /user endpoint
type User interface {
Signup(core.Context)
Create(core.Context)
UpdateInfo(*gin.Context)
Login(*gin.Context)
Logout(*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
if err := ctx.Bind(&params); err != nil {
@ -66,9 +65,15 @@ func (uc *UserController) Signup(ctx core.Context) {
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 {
core.WriteResponse(ctx, err, nil)
return
@ -80,11 +85,5 @@ func (uc *UserController) Signup(ctx core.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) {
}

View File

@ -52,7 +52,7 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
{
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
type User struct {
ID int `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"password"`
Email string `json:"email" valid:"email"`
FirstName string `json:"first_name" valid:"required"`
LastName string `json:"last_name" valid:"required"`
Password string `json:"password" valid:"required"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

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

View File

@ -25,12 +25,22 @@ package usecase
import (
"context"
"fmt"
"net/http"
"regexp"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"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"
"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 {
userRepo repo.UserRepository
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) {
// 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(
ctx,
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)
if err != nil {
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
if match {
return nil, UserExisted
}
return nil, err
}

View File

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