Compare commits

..

No commits in common. "addddb152af34c873e74c3f1260bd43d646f0af1" and "be7f57d5a1889d1bc29e1da28a1893f7c55a74a4" have entirely different histories.

21 changed files with 67 additions and 295 deletions

View File

@ -37,7 +37,6 @@ 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

@ -54,11 +54,11 @@ Next I need to design the API.
- add other users to that event
- A user can only view their own events, but not the events of other users'
- A user can add an expense to the event (reason, date, who payed how much,
who benefited how much)
who benefited how much)
- Users in the event can edit or delete one entry
- changes are sent to friends in the event
- User can get the money they spent themselves and the money they must pay
to each other
to each other
- User can also get the total amount or the histories.
That is what I thought of for now.
@ -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 @@ Moreover, in a distributed system, configs can be stored on `etcd`.
- 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.
@ -219,17 +219,17 @@ For the test-driven part,
- infra: routes and db connections, it works when it works. Nothing to test.
- registry: Just return some structs, no logic. **Not worth testing**
- adapter:
- input-port (controller) test: it is about testing parsing the input
- input-port (controller) test: it is about testing parsing the input
value, and the output results writing. The unit test of controller is to
**make sure that they behave as defined in the API documentation**. To
test, we have to mock the **business service**.
- output-port (repo) test: it is about testing converting business model
- output-port (repo) test: it is about testing converting business model
to database model and the interaction with the database. If we are going
to test them, it's about simulating different type of database behaviour
(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,15 +330,3 @@ 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,7 +77,6 @@ paths:
post:
tags:
- user
- session
description: Create a new session for a user
requestBody:
description: Create session

View File

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

View File

@ -1,20 +0,0 @@
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,8 +25,3 @@ 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,27 +11,6 @@ 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,25 +86,3 @@ 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,8 +54,6 @@ 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

@ -51,8 +51,7 @@ func NewRegistry(db *pgx.Conn) Registry {
// each domain.
func (r *registry) NewAppController() controller.AppController {
return controller.AppController{
User: r.NewUserController(),
Admin: r.NewAdminController(),
Session: r.NewSessionController(),
User: r.NewUserController(),
Admin: r.NewAdminController(),
}
}

View File

@ -1,9 +0,0 @@
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

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

View File

@ -20,18 +20,15 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package usecase
package repo
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(
@ -49,21 +46,3 @@ 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

@ -30,5 +30,4 @@ 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

@ -24,7 +24,6 @@ package usecase
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
@ -36,23 +35,11 @@ import (
"golang.org/x/crypto/bcrypt"
)
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.",
}
)
var UserExisted = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
Message: "email already existed.",
}
type userUsecase struct {
userRepo repo.UserRepository
@ -61,7 +48,6 @@ 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 {
@ -112,24 +98,3 @@ 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,13 +27,14 @@ 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(&TestUserRepository{}, &TestDBRepository{})
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
input := &model.User{
Email: "a@b.c",
FirstName: "James",
@ -50,7 +51,7 @@ func TestCreateUser(t *testing.T) {
t.Run("duplicate create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
input := &model.User{
Email: "duplicate@error.com",
FirstName: "James",
@ -62,38 +63,3 @@ 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,7 +33,6 @@ type Context interface {
// Request
Bind(obj any) error
GetHeader(key string) string
// Response
JSON(code int, obj any)

View File

@ -26,8 +26,6 @@ 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"
)
@ -102,26 +100,6 @@ 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 XRequestID = "X-Request-Id"
const requestID = "X-Request-Id"
func RequestID() gin.HandlerFunc {
return func(ctx *gin.Context) {
var rid string
if rid = ctx.GetHeader(XRequestID); rid != "" {
ctx.Request.Header.Add(XRequestID, rid)
if rid = ctx.GetHeader(requestID); rid != "" {
ctx.Request.Header.Add(requestID, rid)
ctx.Next()
}
rid = uuid.NewString()
ctx.Request.Header.Add(XRequestID, rid)
ctx.Header(XRequestID, rid)
ctx.Request.Header.Add(requestID, rid)
ctx.Header(requestID, rid)
ctx.Next()
}
}

View File

@ -24,14 +24,33 @@ 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())
@ -39,28 +58,21 @@ func TestRequestID(t *testing.T) {
wanted := "123"
r.GET("/example", func(c *gin.Context) {
got = c.GetHeader(XRequestID)
got = c.GetHeader(requestID)
c.Status(http.StatusOK)
})
r.POST("/example", func(c *gin.Context) {
got = c.GetHeader(XRequestID)
got = c.GetHeader(requestID)
c.String(http.StatusAccepted, "ok")
})
// Test with Request ID
_ = test.PerformRequest(
t,
r,
"GET",
"/example?a=100",
nil,
test.Header{Key: XRequestID, Value: wanted},
)
_ = performRequest(r, "GET", "/example?a=100", header{requestID, wanted})
assert.Equal(t, "123", got)
res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
res := performRequest(r, "GET", "/example?a=100")
assert.NotEqual(t, "", got)
assert.NoError(t, uuid.Validate(got))
assert.Equal(t, res.Header()[XRequestID][0], got)
assert.Equal(t, res.Header()[requestID][0], got)
}

View File

@ -1,30 +0,0 @@
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
}