diff --git a/README.md b/README.md index ba67d33..0bec8ff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The way to go with a cookbook 🍳 - [ ] [geek-design-patterns](https://time.geekbang.org/column/article/165114) - [ ] [golang101](https://gfw.go101.org/) - [ ] [master-to-go](https://github.com/aceld/golang) +- [ ] [The complete gRPC course](https://dev.to/techschoolguru/the-complete-grpc-course-protobuf-go-java-2af6) ## References diff --git a/devto-grpc/cmd/server/main.go b/devto-grpc/cmd/server/main.go index c33324c..850488b 100644 --- a/devto-grpc/cmd/server/main.go +++ b/devto-grpc/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -20,7 +21,10 @@ var imgdir = flag.String("imgdir", "./../tests/imgs", "the image store dir") func main() { flag.Parse() - server := grpc.NewServer() + server := grpc.NewServer( + grpc.UnaryInterceptor(unaryInterceptor), + grpc.StreamInterceptor(streamInterceptor), + ) laptopStore := store.NewMemoryLaptopStore() imageStore := store.NewDiskImageStore(*imgdir) rateStore := store.NewMemoryRateStore() @@ -37,3 +41,13 @@ func main() { log.Fatalf("start server failed: %v", err) } } + +func unaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + log.Printf("=> unary interceptor: %v", info.FullMethod) + return handler(ctx, req) +} + +func streamInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + log.Printf("=> stream interceptor: %v", info.FullMethod) + return handler(server, stream) +} diff --git a/devto-grpc/model/user.go b/devto-grpc/model/user.go new file mode 100644 index 0000000..ab49634 --- /dev/null +++ b/devto-grpc/model/user.go @@ -0,0 +1,42 @@ +package model + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// User represents request auth info +type User struct { + Username string + Password string + Role string +} + +// NewUser creates a new User instance with username and password +func NewUser(username, password, role string) (*User, error) { + cryptPass, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to generate crypto password: %v", err) + } + return &User{ + Role: role, + Username: username, + Password: string(cryptPass), + }, nil +} + +// Authed check whether the password is correct +func (u *User) Authed(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) + return err == nil +} + +// Clone returns a clone of this user +func (u *User) Clone() *User { + return &User{ + Username: u.Username, + Password: u.Password, + Role: u.Role, + } +} diff --git a/devto-grpc/pkg/jwtool/manager.go b/devto-grpc/pkg/jwtool/manager.go new file mode 100644 index 0000000..fcf2479 --- /dev/null +++ b/devto-grpc/pkg/jwtool/manager.go @@ -0,0 +1,43 @@ +package jwtool + +import ( + "time" + + "github.com/dgrijalva/jwt-go" + + "cookbook/devto-grpc/model" +) + +// Manager for JWT authentication +type Manager struct { + secretKey string + expired time.Duration +} + +// NewManager creates a new JWT Manager +func NewManager(secretKey string, expired time.Duration) *Manager { + return &Manager{ + secretKey: secretKey, + expired: expired, + } +} + +// Generate create a user token +func (m *Manager) Generate(user *model.User) (string, error) { + claims := UserClaims{ + Username: user.Username, + Role: user.Role, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(m.expired).Unix(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// UserClaims stands for user claim +type UserClaims struct { + Username string `json:"username"` + Role string `json:"role"` + jwt.StandardClaims +} diff --git a/devto-grpc/service/factory.go b/devto-grpc/service/factory.go index e273751..8d075af 100644 --- a/devto-grpc/service/factory.go +++ b/devto-grpc/service/factory.go @@ -234,3 +234,8 @@ func NewLaptop() *repo.Laptop { UpdateAt: timestamppb.Now(), } } + +// RandomLaptopScore returns a random score +func RandomLaptopScore() float64 { + return float64(randInt(1, 10)) +} diff --git a/devto-grpc/service/laptop_client_test.go b/devto-grpc/service/laptop_client_test.go index 91d3e9f..31d2465 100644 --- a/devto-grpc/service/laptop_client_test.go +++ b/devto-grpc/service/laptop_client_test.go @@ -57,7 +57,8 @@ func startTestLaptopServer(t *testing.T, laptopStore store.LaptopStore, imgStore t.Helper() server := grpc.NewServer() - svc := NewLaptopServer(laptopStore, imgStore) + rateStore := store.NewMemoryRateStore() + svc := NewLaptopServer(laptopStore, imgStore, rateStore) repo.RegisterLaptopServiceServer(server, svc) lis, err := net.Listen("tcp", ":0") diff --git a/devto-grpc/service/laptop_server.go b/devto-grpc/service/laptop_server.go index ce5df3f..2fdc65c 100644 --- a/devto-grpc/service/laptop_server.go +++ b/devto-grpc/service/laptop_server.go @@ -177,7 +177,62 @@ func (server *LaptopServer) UploadImage(stream repo.LaptopService_UploadImageSer // RateLaptop rate laptop score via streaming func (server *LaptopServer) RateLaptop(stream repo.LaptopService_RateLaptopServer) error { for { + err := ctxErr(stream.Context()) + if err != nil { + return err + } + + req, err := stream.Recv() + if err == io.EOF { + log.Print("no more data") + break + } + if err != nil { + return logErr(status.Errorf(codes.Unknown, "receive stream error: %v", err)) + } + + score := req.GetScore() + laptopID := req.GetLaptopId() + log.Printf("received request id=%v, score=%v", laptopID, score) + + got, err := server.laptopStore.Find(laptopID) + if err != nil { + return logErr(status.Errorf(codes.Internal, "find laptop error: %v", err)) + } + if got == nil { + return logErr(status.Errorf(codes.NotFound, "laptop not found: %v", err)) + } + rate, err := server.rateStore.Add(laptopID, score) + if err != nil { + return logErr(status.Errorf(codes.Internal, "rating laptop error: %v", err)) + } + res := &repo.RateLaptopResponse{ + LaptopId: laptopID, + RatedCount: rate.Count, + ScoreAverage: rate.Sum / float64(rate.Count), + } + if err := stream.Send(res); err != nil { + return logErr(status.Errorf(codes.Unknown, "send stream error: %v", err)) + } } return nil } + +func ctxErr(ctx context.Context) error { + switch ctx.Err() { + case context.Canceled: + return logErr(status.Error(codes.Canceled, "request canceld")) + case context.DeadlineExceeded: + return logErr(status.Error(codes.DeadlineExceeded, "deadlin exceeded")) + default: + return nil + } +} + +func logErr(err error) error { + if err != nil { + log.Print(err) + } + return err +} diff --git a/devto-grpc/service/laptop_server_test.go b/devto-grpc/service/laptop_server_test.go index 91f4bbe..0ef92f8 100644 --- a/devto-grpc/service/laptop_server_test.go +++ b/devto-grpc/service/laptop_server_test.go @@ -65,7 +65,8 @@ func TestLaptopServer_CreateLaptop(t *testing.T) { req := &repo.CreateLaptopRequest{Laptop: tt.laptop} imgStore := store.NewDiskImageStore("../tests/imgs") - server := NewLaptopServer(tt.store, imgStore) + rateStore := store.NewMemoryRateStore() + server := NewLaptopServer(tt.store, imgStore, rateStore) res, err := server.CreateLaptop(context.Background(), req) t.Logf("CreateLaptop() code=[%v], res=[%v], err=[%v]", tt.code, res, err) if tt.code == codes.OK { diff --git a/devto-grpc/store/user.go b/devto-grpc/store/user.go new file mode 100644 index 0000000..6741422 --- /dev/null +++ b/devto-grpc/store/user.go @@ -0,0 +1,49 @@ +package store + +import ( + "errors" + "sync" + + "cookbook/devto-grpc/model" +) + +// UserStore Errors +var ( + ErrUserAlreadyExists = errors.New("user already exists") +) + +// UserStore store user information +type UserStore interface { + Save(user *model.User) error + Find(username string) (*model.User, error) +} + +// MemoryUserStore store user in memory +type MemoryUserStore struct { + mutex sync.RWMutex + users map[string]*model.User +} + +// Save stores user in memory +func (s *MemoryUserStore) Save(user *model.User) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.users[user.Username] == nil { + return ErrAlreadyExists + } + s.users[user.Username] = user.Clone() + return nil +} + +// Find query user by username +func (s *MemoryUserStore) Find(username string) (*model.User, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + user := s.users[username] + if user == nil { + return nil, nil + } + return user.Clone(), nil +} diff --git a/go.mod b/go.mod index 7ba0546..30a5f3e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module cookbook go 1.19 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/golang/protobuf v1.5.3 github.com/google/uuid v1.4.0 github.com/jedib0t/go-pretty/v6 v6.4.9 @@ -10,6 +11,7 @@ require ( github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.12.0 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 ) diff --git a/go.sum b/go.sum index af168cb..f90cb54 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= @@ -30,6 +32,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=