Compare commits

...

7 Commits

Author SHA1 Message Date
Muyao CHEN
addddb152a fix: move testdb/testuser to usecase dir. repo should only be interface defs
All checks were successful
Build and test / Build (push) Successful in 2m23s
2024-10-12 18:38:55 +02:00
Muyao CHEN
3e09afd4b0 feat: add usecase to check if a user exists 2024-10-12 18:33:19 +02:00
Muyao CHEN
c312b4e2c8 fix(mkfile): fix make sqlc 2024-10-12 18:31:18 +02:00
Muyao CHEN
eee5084821 feat: update openapi 2024-10-12 17:11:16 +02:00
Muyao CHEN
ce3076047a feat: add t.Helper to request helper 2024-10-12 17:09:03 +02:00
Muyao CHEN
9b6282a101 feat: make the request test helper public 2024-10-12 17:07:24 +02:00
Muyao CHEN
a3c2ade9fb feat: create session. (also print the x-rid into the log) 2024-10-11 23:24:29 +02:00
21 changed files with 295 additions and 67 deletions

View File

@ -37,6 +37,7 @@ build: tidy sqlc # build.
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null @go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null
.PHONY: sqlc .PHONY: sqlc
sqlc:
@sqlc generate @sqlc generate
.PHONY: format .PHONY: format

View File

@ -102,10 +102,10 @@ The execution of the program is then just a command like `howmuch run`.
Moreover, in a distributed system, configs can be stored on `etcd`. Moreover, in a distributed system, configs can be stored on `etcd`.
> [Kubernetes stores configuration data into etcd for service discovery and > [Kubernetes stores configuration data into etcd for service discovery and
cluster management; etcds consistency is crucial for correctly scheduling > cluster management; etcds consistency is crucial for correctly scheduling
and operating services. The Kubernetes API server persists cluster state > and operating services. The Kubernetes API server persists cluster state
into etcd. It uses etcds watch API to monitor the cluster and roll out > into etcd. It uses etcds watch API to monitor the cluster and roll out
critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/) > critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
#### Business logic #### Business logic
@ -253,7 +253,7 @@ integration test part (the 2nd point).
I rethought about the whole API design (even though I have only one yet). I 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 have created `/signup` and `/login` without thinking too much, but in fact
it is not quite *RESTful*. it is not quite _RESTful_.
**REST** is all about resources. While `/signup` and `/login` is quite **REST** is all about resources. While `/signup` and `/login` is quite
comprehensible, thus service-oriented, they don't follow the REST philosophy, comprehensible, thus service-oriented, they don't follow the REST philosophy,
@ -292,8 +292,8 @@ choose server-side-rendering with `templ + htmx`, or even `template+vanilla
javascript`. javascript`.
I can still write a rather static Go-frontend-server to serve HTMLs and call 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 my Go backend. _And it might be a good idea if they communicate on Go native
rpc.* It worth a try. rpc._ It worth a try.
And I have moved on to `Svelte` which seems very simple by design and the 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, whole compile thing makes it really charm. But this is mainly a Go project,
@ -330,3 +330,15 @@ the database model design.
![Core user story part 1](./docs/howmuch_us1.drawio.png) ![Core user story part 1](./docs/howmuch_us1.drawio.png)
![Database model](./docs/howmuch.drawio.png) ![Database model](./docs/howmuch.drawio.png)
### 2024/10/11
I spent 2 days learning some basic of Vue. Learning Vue takes time. There
are a lot of concepts and it needs a lot of practice. Even though I may not
need a professional level web page, I don't want to copy one module from this
blog and another one from another tutorial. I might just put aside the
front-end for now and concentrate on my backend Go app.
For now, I will just test my backend with `curl`.
And today's job is to get the login part done!

View File

@ -77,6 +77,7 @@ paths:
post: post:
tags: tags:
- user - user
- session
description: Create a new session for a user description: Create a new session for a user
requestBody: requestBody:
description: Create session description: Create session

View File

@ -28,4 +28,6 @@ type AppController struct {
User interface{ User } User interface{ User }
Admin interface{ Admin } Admin interface{ Admin }
Session interface{ Session }
} }

View File

@ -0,0 +1,20 @@
package controller
import (
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
"github.com/gin-gonic/gin"
)
type Session interface {
Create(*gin.Context)
}
type SessionController struct{}
func NewSessionController() Session {
return &SessionController{}
}
func (sc *SessionController) Create(ctx *gin.Context) {
log.CtxLog(ctx).DebugLog("session create")
}

View File

@ -25,3 +25,8 @@ INSERT INTO "user" (
email, first_name, last_name, password, created_at, updated_at email, first_name, last_name, password, created_at, updated_at
) VALUES ( $1, $2, $3, $4, $5, $6 ) ) VALUES ( $1, $2, $3, $4, $5, $6 )
RETURNING *; RETURNING *;
-- name: GetUserByEmail :one
SELECT id, email, first_name, last_name, password, created_at, updated_at
FROM "user"
WHERE email = $1;

View File

@ -11,6 +11,27 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, first_name, last_name, password, created_at, updated_at
FROM "user"
WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.Password,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertUser = `-- name: InsertUser :one const insertUser = `-- name: InsertUser :one
INSERT INTO "user" ( INSERT INTO "user" (

View File

@ -86,3 +86,25 @@ func (ur *userRepository) Create(
UpdatedAt: userDB.CreatedAt.Time, UpdatedAt: userDB.CreatedAt.Time,
}, nil }, nil
} }
// GetByEmail if not found, return nil for user but not error.
func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
queries := sqlc.New(ur.db)
userDB, err := queries.GetUserByEmail(ctx, email)
if errors.Is(err, pgx.ErrNoRows) {
// No query error, but user not found
return nil, nil
} else if err != nil {
return nil, err
}
return &model.User{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,
LastName: userDB.LastName,
Password: userDB.Password,
CreatedAt: userDB.CreatedAt.Time,
UpdatedAt: userDB.CreatedAt.Time,
}, nil
}

View File

@ -54,6 +54,8 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
{ {
userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) }) userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) })
} }
v1.POST("/session/create", func(ctx *gin.Context) { c.Session.Create(ctx) })
} }
return engine return engine

View File

@ -53,5 +53,6 @@ func (r *registry) NewAppController() controller.AppController {
return controller.AppController{ return controller.AppController{
User: r.NewUserController(), User: r.NewUserController(),
Admin: r.NewAdminController(), Admin: r.NewAdminController(),
Session: r.NewSessionController(),
} }
} }

View File

@ -0,0 +1,9 @@
package registry
import "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
// NewSessionController returns a session controller's implementation
func (r *registry) NewSessionController() controller.Session {
// u := usecase.NewSessionUsecase(repo.NewSessionRepository(r.db), repo.NewDBRepository(r.db))
return controller.NewSessionController()
}

View File

@ -30,4 +30,5 @@ import (
type UserRepository interface { type UserRepository interface {
Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error) Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, error)
} }

View File

@ -20,7 +20,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
package repo package usecase
import "context" import "context"

View File

@ -20,15 +20,18 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
package repo package usecase
import ( import (
"context" "context"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/pkg/errors" "github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
) )
var UserTestDummyErr = errors.New("dummy error")
type TestUserRepository struct{} type TestUserRepository struct{}
func (tur *TestUserRepository) Create( func (tur *TestUserRepository) Create(
@ -46,3 +49,21 @@ func (tur *TestUserRepository) Create(
return &user, nil return &user, nil
} }
func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
switch email {
case "a@b.c":
return &model.User{
ID: 123,
Email: "a@b.c",
Password: string(hashedPwd),
}, nil
case "query@error.com":
return nil, UserTestDummyErr
case "inexist@error.com":
return nil, nil
}
return nil, UserTestDummyErr
}

View File

@ -24,6 +24,7 @@ package usecase
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
@ -35,11 +36,23 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var UserExisted = &errno.Errno{ var (
UserExisted = &errno.Errno{
HTTP: http.StatusBadRequest, HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"), Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
Message: "email already existed.", Message: "email already existed.",
} }
UserNotExist = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.ResourceNotFoundCode, "UserNotExist"),
Message: "user does not exists.",
}
UserWrongPassword = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.AuthFailureCode, "UserWrongPassword"),
Message: "wrong password.",
}
)
type userUsecase struct { type userUsecase struct {
userRepo repo.UserRepository userRepo repo.UserRepository
@ -48,6 +61,7 @@ type userUsecase struct {
type User interface { type User interface {
Create(ctx context.Context, u *model.User) (*model.User, error) Create(ctx context.Context, u *model.User) (*model.User, error)
Exist(ctx context.Context, u *model.User) (bool, error)
} }
func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User { func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
@ -98,3 +112,24 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User,
return user, nil return user, nil
} }
func (uuc *userUsecase) Exist(ctx context.Context, u *model.User) (bool, error) {
got, err := uuc.userRepo.GetByEmail(ctx, u.Email)
// Any query error?
if err != nil {
return false, err
}
// User exists?
if got == nil {
return false, UserNotExist
}
// Password correct?
err = bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(u.Password))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, UserWrongPassword
}
return true, nil
}

View File

@ -27,14 +27,13 @@ import (
"testing" "testing"
"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"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCreateUser(t *testing.T) { func TestCreateUser(t *testing.T) {
t.Run("normal create", func(t *testing.T) { t.Run("normal create", func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{}) userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
input := &model.User{ input := &model.User{
Email: "a@b.c", Email: "a@b.c",
FirstName: "James", FirstName: "James",
@ -51,7 +50,7 @@ func TestCreateUser(t *testing.T) {
t.Run("duplicate create", func(t *testing.T) { t.Run("duplicate create", func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{}) userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
input := &model.User{ input := &model.User{
Email: "duplicate@error.com", Email: "duplicate@error.com",
FirstName: "James", FirstName: "James",
@ -63,3 +62,38 @@ func TestCreateUser(t *testing.T) {
assert.EqualError(t, err, UserExisted.Error()) assert.EqualError(t, err, UserExisted.Error())
}) })
} }
func TestUserExist(t *testing.T) {
testCases := []struct {
Name string
User *model.User
ExpErr error
ExpRes bool
}{
{"user exists", &model.User{
Email: "a@b.c",
Password: "strongHashed",
}, nil, true},
{"query error", &model.User{
Email: "query@error.com",
Password: "strongHashed",
}, UserTestDummyErr, false},
{"user doesn not exist", &model.User{
Email: "inexist@error.com",
Password: "strongHashed",
}, UserNotExist, false},
{"wrong password", &model.User{
Email: "a@b.c",
Password: "wrongHashed",
}, UserWrongPassword, false},
}
for _, tst := range testCases {
ctx := context.Background()
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
got, err := userUsecase.Exist(ctx, tst.User)
assert.ErrorIs(t, err, tst.ExpErr)
assert.Equal(t, tst.ExpRes, got)
}
}

View File

@ -33,6 +33,7 @@ type Context interface {
// Request // Request
Bind(obj any) error Bind(obj any) error
GetHeader(key string) string
// Response // Response
JSON(code int, obj any) JSON(code int, obj any)

View File

@ -26,6 +26,8 @@ import (
"os" "os"
"sync" "sync"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
) )
@ -100,6 +102,26 @@ func NewLogger(opts *Options) *zapLogger {
return &zapLogger{z: z} return &zapLogger{z: z}
} }
// CtxLog writes context's information into the log
func CtxLog(ctx core.Context) *zapLogger {
return std.CtxLog(ctx)
}
func (z *zapLogger) CtxLog(ctx core.Context) *zapLogger {
zz := z.clone()
if rid := ctx.GetHeader(middleware.XRequestID); rid != "" {
zz.z = zz.z.With(zap.Any(middleware.XRequestID, rid))
}
return zz
}
func (z *zapLogger) clone() *zapLogger {
zz := *z
return &zz
}
func (z *zapLogger) FatalLog(msg string, keyValues ...interface{}) { func (z *zapLogger) FatalLog(msg string, keyValues ...interface{}) {
z.z.Sugar().Fatalw(msg, keyValues...) z.z.Sugar().Fatalw(msg, keyValues...)
} }

View File

@ -27,20 +27,20 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const requestID = "X-Request-Id" const XRequestID = "X-Request-Id"
func RequestID() gin.HandlerFunc { func RequestID() gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
var rid string var rid string
if rid = ctx.GetHeader(requestID); rid != "" { if rid = ctx.GetHeader(XRequestID); rid != "" {
ctx.Request.Header.Add(requestID, rid) ctx.Request.Header.Add(XRequestID, rid)
ctx.Next() ctx.Next()
} }
rid = uuid.NewString() rid = uuid.NewString()
ctx.Request.Header.Add(requestID, rid) ctx.Request.Header.Add(XRequestID, rid)
ctx.Header(requestID, rid) ctx.Header(XRequestID, rid)
ctx.Next() ctx.Next()
} }
} }

View File

@ -24,33 +24,14 @@ package middleware
import ( import (
"net/http" "net/http"
"net/http/httptest"
"testing" "testing"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
type header struct {
Key string
Value string
}
func performRequest(
r http.Handler,
method, path string,
headers ...header,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
for _, h := range headers {
req.Header.Add(h.Key, h.Value)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestRequestID(t *testing.T) { func TestRequestID(t *testing.T) {
r := gin.New() r := gin.New()
r.Use(RequestID()) r.Use(RequestID())
@ -58,21 +39,28 @@ func TestRequestID(t *testing.T) {
wanted := "123" wanted := "123"
r.GET("/example", func(c *gin.Context) { r.GET("/example", func(c *gin.Context) {
got = c.GetHeader(requestID) got = c.GetHeader(XRequestID)
c.Status(http.StatusOK) c.Status(http.StatusOK)
}) })
r.POST("/example", func(c *gin.Context) { r.POST("/example", func(c *gin.Context) {
got = c.GetHeader(requestID) got = c.GetHeader(XRequestID)
c.String(http.StatusAccepted, "ok") c.String(http.StatusAccepted, "ok")
}) })
// Test with Request ID // Test with Request ID
_ = performRequest(r, "GET", "/example?a=100", header{requestID, wanted}) _ = test.PerformRequest(
t,
r,
"GET",
"/example?a=100",
nil,
test.Header{Key: XRequestID, Value: wanted},
)
assert.Equal(t, "123", got) assert.Equal(t, "123", got)
res := performRequest(r, "GET", "/example?a=100") res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
assert.NotEqual(t, "", got) assert.NotEqual(t, "", got)
assert.NoError(t, uuid.Validate(got)) assert.NoError(t, uuid.Validate(got))
assert.Equal(t, res.Header()[requestID][0], got) assert.Equal(t, res.Header()[XRequestID][0], got)
} }

View File

@ -0,0 +1,30 @@
package test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
type Header struct {
Key string
Value string
}
func PerformRequest(
t testing.TB,
r http.Handler,
method, path string,
body io.Reader,
headers ...Header,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, body)
for _, h := range headers {
req.Header.Add(h.Key, h.Value)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}