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
.PHONY: sqlc
sqlc:
@sqlc generate
.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`.
> [Kubernetes stores configuration data into etcd for service discovery and
cluster management; etcds consistency is crucial for correctly scheduling
and operating services. The Kubernetes API server persists cluster state
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/)
> cluster management; etcds consistency is crucial for correctly scheduling
> and operating services. The Kubernetes API server persists cluster state
> 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/)
#### Business logic
@ -113,8 +113,8 @@ critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
- init DBs (Redis, SQL, Kafka, etc.)
- init web service (http, https, gRPC, etc.)
- start async tasks like `watch kube-apiserver`; pull data from third-party
services; store, register `/metrics` and listen on some port; start kafka
consumer queue, etc.
services; store, register `/metrics` and listen on some port; start kafka
consumer queue, etc.
- Run specific business logic
- Stop the program
- others...
@ -166,26 +166,26 @@ that has several layers:
- Entities: the models of the product
- Use cases: the core business rule
- Interface Adapters: convert data-in to entities and convert data-out to
output ports.
output ports.
- Frameworks and drivers: Web server, DB.
Based on this logic, we create the following directories:
- `model`: entities
- `infra`: Provides the necessary functions to setup the infrastructure,
especially the DB (output-port), but also the router (input-port). Once
setup, we don't touch them anymore.
especially the DB (output-port), but also the router (input-port). Once
setup, we don't touch them anymore.
- `registry`: Provides a register function for the main to register a service.
It takes the pass to the output-port (ex.DBs) and gives back a pass
(controller) to the input-port
It takes the pass to the output-port (ex.DBs) and gives back a pass
(controller) to the input-port
- `adapter`: Controllers are one of the adapters, when they are called,
they parse the user input and parse them into models and run the usecase
rules. Then they send back the response(input-port). For the output-port
part, the `repo` is the implementation of interfaces defined in `usecase/repo`.
they parse the user input and parse them into models and run the usecase
rules. Then they send back the response(input-port). For the output-port
part, the `repo` is the implementation of interfaces defined in `usecase/repo`.
- `usecase`: with the input of adapter, do what have to be done, and answer
with the result. In the meantime, we may have to store things into DBs.
Here we use the Repository model to decouple the implementation of the repo
with the interface. Thus in `usecase/repo` we only define interfaces.
with the result. In the meantime, we may have to store things into DBs.
Here we use the Repository model to decouple the implementation of the repo
with the interface. Thus in `usecase/repo` we only define interfaces.
Then it comes the real design for the app.
@ -229,7 +229,7 @@ For the test-driven part,
(success, timeout, etc.). To test, we have to mock the
**database connection**.
- usecase: This is the core part to test, it's about the core business.
We provide the data input and we check the data output in a fake repository.
We provide the data input and we check the data output in a fake repository.
With this design, although it may seem overkill for this little project, fits
perfectly well with the TDD method.
@ -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
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
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`.
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.
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,
@ -330,3 +330,15 @@ the database model design.
![Core user story part 1](./docs/howmuch_us1.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:
tags:
- user
- session
description: Create a new session for a user
requestBody:
description: Create session

View File

@ -28,4 +28,6 @@ type AppController struct {
User interface{ User }
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
) VALUES ( $1, $2, $3, $4, $5, $6 )
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"
)
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
INSERT INTO "user" (

View File

@ -86,3 +86,25 @@ func (ur *userRepository) Create(
UpdatedAt: userDB.CreatedAt.Time,
}, 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) })
}
v1.POST("/session/create", func(ctx *gin.Context) { c.Session.Create(ctx) })
}
return engine

View File

@ -53,5 +53,6 @@ func (r *registry) NewAppController() controller.AppController {
return controller.AppController{
User: r.NewUserController(),
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 {
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
// SOFTWARE.
package repo
package usecase
import "context"

View File

@ -20,15 +20,18 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
package usecase
import (
"context"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
var UserTestDummyErr = errors.New("dummy error")
type TestUserRepository struct{}
func (tur *TestUserRepository) Create(
@ -46,3 +49,21 @@ func (tur *TestUserRepository) Create(
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 (
"context"
"errors"
"fmt"
"net/http"
"regexp"
@ -35,11 +36,23 @@ import (
"golang.org/x/crypto/bcrypt"
)
var UserExisted = &errno.Errno{
var (
UserExisted = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
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 {
userRepo repo.UserRepository
@ -48,6 +61,7 @@ type userUsecase struct {
type User interface {
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 {
@ -98,3 +112,24 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User,
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"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"github.com/stretchr/testify/assert"
)
func TestCreateUser(t *testing.T) {
t.Run("normal create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
input := &model.User{
Email: "a@b.c",
FirstName: "James",
@ -51,7 +50,7 @@ func TestCreateUser(t *testing.T) {
t.Run("duplicate create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
input := &model.User{
Email: "duplicate@error.com",
FirstName: "James",
@ -63,3 +62,38 @@ func TestCreateUser(t *testing.T) {
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
Bind(obj any) error
GetHeader(key string) string
// Response
JSON(code int, obj any)

View File

@ -26,6 +26,8 @@ import (
"os"
"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/zapcore"
)
@ -100,6 +102,26 @@ func NewLogger(opts *Options) *zapLogger {
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{}) {
z.z.Sugar().Fatalw(msg, keyValues...)
}

View File

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

View File

@ -24,33 +24,14 @@ package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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) {
r := gin.New()
r.Use(RequestID())
@ -58,21 +39,28 @@ func TestRequestID(t *testing.T) {
wanted := "123"
r.GET("/example", func(c *gin.Context) {
got = c.GetHeader(requestID)
got = c.GetHeader(XRequestID)
c.Status(http.StatusOK)
})
r.POST("/example", func(c *gin.Context) {
got = c.GetHeader(requestID)
got = c.GetHeader(XRequestID)
c.String(http.StatusAccepted, "ok")
})
// 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)
res := performRequest(r, "GET", "/example?a=100")
res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
assert.NotEqual(t, "", 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
}