init
This commit is contained in:
76
internal/adapter/auth/jwt/jwt.go
Normal file
76
internal/adapter/auth/jwt/jwt.go
Normal file
@ -0,0 +1,76 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JWTService struct {
|
||||
key string
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
type tokenPayload struct {
|
||||
UserID uuid.UUID `json:"user_id,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTService(duration time.Duration, key string) *JWTService {
|
||||
return &JWTService{
|
||||
key: key,
|
||||
duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
func (jwtService *JWTService) CreateToken(
|
||||
user *domain.User,
|
||||
) (string, error) {
|
||||
token := jwt.NewWithClaims(
|
||||
jwt.SigningMethodHS256,
|
||||
&tokenPayload{
|
||||
UserID: user.ID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(
|
||||
time.Now().Add(jwtService.duration),
|
||||
),
|
||||
Audience: jwt.ClaimStrings{"user"},
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
tokenStr, err := token.SignedString([]byte(jwtService.key))
|
||||
if err != nil {
|
||||
return "", domain.ErrTokenCreation
|
||||
}
|
||||
|
||||
return tokenStr, nil
|
||||
}
|
||||
|
||||
func (jwtService *JWTService) VerifyToken(
|
||||
token string,
|
||||
) (*domain.AuthPayload, error) {
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, domain.ErrInvalidToken
|
||||
}
|
||||
return []byte(jwtService.key), nil
|
||||
}
|
||||
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &tokenPayload{}, keyFunc)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, domain.ErrExpiredToken
|
||||
}
|
||||
if errors.Is(err, jwt.ErrSignatureInvalid) {
|
||||
return nil, domain.ErrInvalidToken
|
||||
}
|
||||
return nil, domain.ErrInternal
|
||||
}
|
||||
payload := parsedToken.Claims.(*tokenPayload)
|
||||
return &domain.AuthPayload{UserID: payload.UserID}, nil
|
||||
}
|
25
internal/adapter/config/app.go
Normal file
25
internal/adapter/config/app.go
Normal file
@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
)
|
||||
|
||||
type AppConfig struct {
|
||||
IsDev bool
|
||||
CORSAllowedOrigins string
|
||||
ListenerPort int
|
||||
SecretKey string
|
||||
JWTDuration *time.Duration
|
||||
}
|
||||
|
||||
func NewAppConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
IsDev: utils.GetEnvOrDefault("APP_IS_PROD", "true") == "true",
|
||||
CORSAllowedOrigins: utils.GetEnvOrDefault("APP_CORS_ALLOWED_ORIGINS", "*"),
|
||||
ListenerPort: Str2IntOrDie(GetEnvOrDie("APP_LISTENER_PORT")),
|
||||
SecretKey: GetEnvOrDie("APP_SECRET_KEY"),
|
||||
JWTDuration: Str2DurationOrDie(GetEnvOrDie("APP_JWT_DURATION")),
|
||||
}
|
||||
}
|
17
internal/adapter/config/config.go
Normal file
17
internal/adapter/config/config.go
Normal file
@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
type ContainerConfig struct {
|
||||
*AppConfig
|
||||
*PostgresConfig
|
||||
*KafkaConfig
|
||||
*ScyllaConfig
|
||||
}
|
||||
|
||||
func NewContainerConfig() *ContainerConfig {
|
||||
return &ContainerConfig{
|
||||
NewAppConfig(),
|
||||
NewPostgresConfig(),
|
||||
NewKafkaConfig(),
|
||||
NewScyllaConfig(),
|
||||
}
|
||||
}
|
44
internal/adapter/config/helper.go
Normal file
44
internal/adapter/config/helper.go
Normal file
@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
)
|
||||
|
||||
var log = logger.NewStdLogger()
|
||||
|
||||
func GetEnvOrDie(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
log.Error(
|
||||
"Error get environment variable",
|
||||
"error",
|
||||
fmt.Errorf("Environment variable "+key+" is not set"),
|
||||
)
|
||||
utils.ExitErr()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func Str2IntOrDie(value string) int {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
log.Error("Error convert string to int", "error", err)
|
||||
utils.ExitErr()
|
||||
}
|
||||
return intValue
|
||||
}
|
||||
|
||||
func Str2DurationOrDie(value string) *time.Duration {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
log.Error("Error convert string to duration", "error", err)
|
||||
utils.ExitErr()
|
||||
}
|
||||
return &duration
|
||||
}
|
45
internal/adapter/config/kafka.go
Normal file
45
internal/adapter/config/kafka.go
Normal file
@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
)
|
||||
|
||||
type BootstrapServers []string
|
||||
|
||||
func (b *BootstrapServers) String() string {
|
||||
return strings.Join(*b, ",")
|
||||
}
|
||||
|
||||
type KafkaConfig struct {
|
||||
*KafkaProducerConfig
|
||||
*KafkaConsumerConfig
|
||||
}
|
||||
|
||||
type KafkaProducerConfig struct {
|
||||
BootstrapServers BootstrapServers
|
||||
}
|
||||
|
||||
type KafkaConsumerConfig struct {
|
||||
BootstrapServers BootstrapServers
|
||||
}
|
||||
|
||||
func NewKafkaConfig() *KafkaConfig {
|
||||
return &KafkaConfig{
|
||||
NewKafkaProducerConfig(),
|
||||
NewKafkaConsumerConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewKafkaProducerConfig() *KafkaProducerConfig {
|
||||
return &KafkaProducerConfig{
|
||||
BootstrapServers: utils.Str2StrSlice(GetEnvOrDie("KAFKA_PRODUCER_BOOTSTRAP_SERVERS")),
|
||||
}
|
||||
}
|
||||
|
||||
func NewKafkaConsumerConfig() *KafkaConsumerConfig {
|
||||
return &KafkaConsumerConfig{
|
||||
BootstrapServers: utils.Str2StrSlice(GetEnvOrDie("KAFKA_CONSUMER_BOOTSTRAP_SERVERS")),
|
||||
}
|
||||
}
|
19
internal/adapter/config/postgres.go
Normal file
19
internal/adapter/config/postgres.go
Normal file
@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
type PostgresConfig struct {
|
||||
User string
|
||||
Password string
|
||||
Host string
|
||||
Port string
|
||||
DBName string
|
||||
}
|
||||
|
||||
func NewPostgresConfig() *PostgresConfig {
|
||||
return &PostgresConfig{
|
||||
User: GetEnvOrDie("POSTGRES_USER"),
|
||||
Password: GetEnvOrDie("POSTGRES_PASSWORD"),
|
||||
Host: GetEnvOrDie("POSTGRES_HOST"),
|
||||
Port: GetEnvOrDie("POSTGRES_PORT"),
|
||||
DBName: GetEnvOrDie("POSTGRES_DB"),
|
||||
}
|
||||
}
|
21
internal/adapter/config/scylla.go
Normal file
21
internal/adapter/config/scylla.go
Normal file
@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import "github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
|
||||
type ScyllaConfig struct {
|
||||
Hosts []string
|
||||
DataCenter string
|
||||
Keyspace string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewScyllaConfig() *ScyllaConfig {
|
||||
return &ScyllaConfig{
|
||||
Hosts: utils.Str2StrSlice(GetEnvOrDie("SCYLLA_HOSTS")),
|
||||
DataCenter: GetEnvOrDie("SCYLLA_DATACENTER"),
|
||||
Keyspace: GetEnvOrDie("SCYLLA_KEYSPACE"),
|
||||
User: GetEnvOrDie("SCYLLA_USER"),
|
||||
Password: GetEnvOrDie("SCYLLA_PASSWORD"),
|
||||
}
|
||||
}
|
76
internal/adapter/handlers/http/app.go
Normal file
76
internal/adapter/handlers/http/app.go
Normal file
@ -0,0 +1,76 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/handlers/http/middlewares"
|
||||
"github.com/gofiber/contrib/websocket"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
userHandler *UserHandler
|
||||
authHandler *AuthHandler
|
||||
chatHandler *ChatHandler
|
||||
}
|
||||
|
||||
type Middlewares struct {
|
||||
authMiddleware *middlewares.AuthMiddleware
|
||||
wsMiddleware *middlewares.WSMiddleware
|
||||
}
|
||||
|
||||
func NewApp(
|
||||
isDev bool,
|
||||
corsAllowedOrigins string,
|
||||
authMiddleware *middlewares.AuthMiddleware,
|
||||
wsMiddleware *middlewares.WSMiddleware,
|
||||
userHandler *UserHandler,
|
||||
authHandler *AuthHandler,
|
||||
chatHandler *ChatHandler,
|
||||
) *fiber.App {
|
||||
handlers := &Handlers{
|
||||
userHandler: userHandler,
|
||||
authHandler: authHandler,
|
||||
chatHandler: chatHandler,
|
||||
}
|
||||
middlewares := &Middlewares{
|
||||
authMiddleware: authMiddleware,
|
||||
wsMiddleware: wsMiddleware,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
if !isDev {
|
||||
app.Use(recover.New())
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: corsAllowedOrigins,
|
||||
}))
|
||||
}
|
||||
router := app.Group("/api")
|
||||
setV1Routers(router, handlers, middlewares)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func setV1Routers(
|
||||
router fiber.Router,
|
||||
handlers *Handlers,
|
||||
middlewares *Middlewares,
|
||||
) {
|
||||
router = router.Group("/v1")
|
||||
|
||||
{ // User routes
|
||||
user := router.Group("/user")
|
||||
user.Post("/register", handlers.userHandler.Register)
|
||||
}
|
||||
|
||||
{ // Auth routes
|
||||
auth := router.Group("/auth")
|
||||
auth.Post("/login", handlers.authHandler.Login)
|
||||
}
|
||||
|
||||
{ // Chat routes
|
||||
chat := router.Group("/chat")
|
||||
chat.Use("/ws", middlewares.authMiddleware.IsUser, middlewares.wsMiddleware.Upgrade)
|
||||
chat.Get("/ws", websocket.New(handlers.chatHandler.Connect))
|
||||
}
|
||||
}
|
50
internal/adapter/handlers/http/auth.go
Normal file
50
internal/adapter/handlers/http/auth.go
Normal file
@ -0,0 +1,50 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService port.AuthService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService port.AuthService, validator *validator.Validate) *AuthHandler {
|
||||
return &AuthHandler{authService, validator}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" validate:"email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
func (authHandler *AuthHandler) Login(ctx *fiber.Ctx) error {
|
||||
loginBody := new(loginRequest)
|
||||
if err := ctx.BodyParser(loginBody); err != nil {
|
||||
return invalidRequestBodyResponse(ctx)
|
||||
}
|
||||
|
||||
if err := authHandler.validator.Struct(loginBody); err != nil {
|
||||
return validationErrorResponse(ctx, err)
|
||||
}
|
||||
|
||||
loginField := loginBody.Email
|
||||
loginFunc := authHandler.authService.LoginByEmail
|
||||
if loginField == "" {
|
||||
if loginBody.Username == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "email or username is required")
|
||||
}
|
||||
loginField = loginBody.Username
|
||||
loginFunc = authHandler.authService.LoginByUsername
|
||||
}
|
||||
|
||||
serviceCtx := ctx.Context()
|
||||
token, err := loginFunc(serviceCtx, loginField, loginBody.Password)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
|
||||
}
|
||||
return ctx.JSON(fiber.Map{"token": token})
|
||||
}
|
127
internal/adapter/handlers/http/chat.go
Normal file
127
internal/adapter/handlers/http/chat.go
Normal file
@ -0,0 +1,127 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/contrib/websocket"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var log = logger.NewStdLogger()
|
||||
|
||||
type ChatHandler struct {
|
||||
messageService port.MessageService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewChatHandler(
|
||||
messageService port.MessageService,
|
||||
validator *validator.Validate,
|
||||
) *ChatHandler {
|
||||
return &ChatHandler{messageService, validator}
|
||||
}
|
||||
|
||||
type messageReuqest struct {
|
||||
Content string `json:"content" validate:"required"`
|
||||
To string `json:"to" validate:"required"`
|
||||
}
|
||||
|
||||
func (chatHandler *ChatHandler) Connect(conn *websocket.Conn) {
|
||||
authPayload := getAuthPayloadInWS(conn)
|
||||
|
||||
streamReceiveMessageCtx, streamReceiveMessageCtxCancel := context.WithCancel(context.Background())
|
||||
consumeMessageChan := make(chan *domain.StreamMessage)
|
||||
wsMessageChan := make(chan *domain.Message)
|
||||
|
||||
go readMessageFromWS(
|
||||
conn,
|
||||
chatHandler.validator,
|
||||
authPayload.UserID,
|
||||
wsMessageChan,
|
||||
)
|
||||
|
||||
go func() {
|
||||
err := chatHandler.messageService.ReceiveMessage(
|
||||
streamReceiveMessageCtx,
|
||||
authPayload.UserID,
|
||||
consumeMessageChan,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
streamReceiveMessageCtxCancel()
|
||||
}()
|
||||
|
||||
var err error
|
||||
|
||||
for {
|
||||
select {
|
||||
case streamReceivedMessage := <-consumeMessageChan:
|
||||
messageBytes, _ := json.Marshal(streamReceivedMessage.Message)
|
||||
if err = conn.WriteMessage(websocket.TextMessage, messageBytes); err != nil {
|
||||
return
|
||||
}
|
||||
err := streamReceivedMessage.Commit()
|
||||
if err != nil {
|
||||
log.Error("Stream message commit error", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
case wsMessage := <-wsMessageChan:
|
||||
if wsMessage == nil {
|
||||
return
|
||||
}
|
||||
streamSendMessageCtx := context.Background()
|
||||
chatHandler.messageService.SendMessage(streamSendMessageCtx, wsMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readMessageFromWS(
|
||||
conn *websocket.Conn,
|
||||
validator *validator.Validate,
|
||||
userID uuid.UUID,
|
||||
messageChan chan<- *domain.Message,
|
||||
) {
|
||||
var (
|
||||
wsReceivedMessageType int
|
||||
wsReceivedMessage []byte
|
||||
err error
|
||||
)
|
||||
for {
|
||||
if wsReceivedMessageType, wsReceivedMessage, err = conn.ReadMessage(); err != nil {
|
||||
messageChan <- nil
|
||||
break
|
||||
}
|
||||
|
||||
if wsReceivedMessageType == websocket.TextMessage {
|
||||
messageBody := new(messageReuqest)
|
||||
if err := json.Unmarshal(wsReceivedMessage, &messageBody); err != nil {
|
||||
messageChan <- nil
|
||||
break
|
||||
}
|
||||
|
||||
if err := validator.Struct(messageBody); err != nil {
|
||||
messageChan <- nil
|
||||
break
|
||||
}
|
||||
|
||||
timestamp := time.Now()
|
||||
messageChan <- &domain.Message{
|
||||
UserID: userID,
|
||||
ChatID: messageBody.To,
|
||||
Content: messageBody.Content,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
23
internal/adapter/handlers/http/helper.go
Normal file
23
internal/adapter/handlers/http/helper.go
Normal file
@ -0,0 +1,23 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/gofiber/contrib/websocket"
|
||||
// "github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// func getAuthPayload(ctx *fiber.Ctx) *domain.AuthPayload {
|
||||
// payload := ctx.Locals("authPayload")
|
||||
// if payload == nil {
|
||||
// return nil
|
||||
// }
|
||||
// return payload.(*domain.AuthPayload)
|
||||
// }
|
||||
|
||||
func getAuthPayloadInWS(conn *websocket.Conn) *domain.AuthPayload {
|
||||
payload := conn.Locals("authPayload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
return payload.(*domain.AuthPayload)
|
||||
}
|
33
internal/adapter/handlers/http/middlewares/auth.go
Normal file
33
internal/adapter/handlers/http/middlewares/auth.go
Normal file
@ -0,0 +1,33 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
tokenService port.TokenService
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(tokenService port.TokenService) *AuthMiddleware {
|
||||
return &AuthMiddleware{tokenService}
|
||||
}
|
||||
|
||||
func (authMiddleware *AuthMiddleware) IsUser(ctx *fiber.Ctx) error {
|
||||
token := ctx.Get("Authorization")
|
||||
if token == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Authorization header is required")
|
||||
}
|
||||
payload, err := authMiddleware.tokenService.VerifyToken(token[7:])
|
||||
if err != nil {
|
||||
if err == domain.ErrInternal {
|
||||
fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error")
|
||||
}
|
||||
return fiber.NewError(fiber.StatusUnauthorized, fmt.Sprintf("Unauthorized: %v", err))
|
||||
}
|
||||
ctx.Locals("authPayload", payload)
|
||||
return ctx.Next()
|
||||
}
|
20
internal/adapter/handlers/http/middlewares/ws.go
Normal file
20
internal/adapter/handlers/http/middlewares/ws.go
Normal file
@ -0,0 +1,20 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/gofiber/contrib/websocket"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type WSMiddleware struct{}
|
||||
|
||||
func NewWSMiddleware() *WSMiddleware {
|
||||
return &WSMiddleware{}
|
||||
}
|
||||
|
||||
func (wsMiddleware *WSMiddleware) Upgrade(ctx *fiber.Ctx) error {
|
||||
if websocket.IsWebSocketUpgrade(ctx) {
|
||||
ctx.Locals("allowed", true)
|
||||
return ctx.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
}
|
29
internal/adapter/handlers/http/responses.go
Normal file
29
internal/adapter/handlers/http/responses.go
Normal file
@ -0,0 +1,29 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func notFoundResponse(ctx *fiber.Ctx, err ...error) error {
|
||||
errMsg := "Not found"
|
||||
if len(err) > 0 {
|
||||
errMsg = err[0].Error()
|
||||
}
|
||||
return ctx.Status(fiber.StatusNotFound).JSON(
|
||||
fiber.Map{"error": errMsg},
|
||||
)
|
||||
}
|
||||
|
||||
func invalidRequestBodyResponse(ctx *fiber.Ctx) error {
|
||||
return ctx.Status(fiber.StatusBadRequest).JSON(
|
||||
fiber.Map{"error": "Invalid request body"},
|
||||
)
|
||||
}
|
||||
|
||||
func validationErrorResponse(ctx *fiber.Ctx, err error) error {
|
||||
errs := err.(validator.ValidationErrors)
|
||||
return ctx.Status(fiber.StatusBadRequest).JSON(
|
||||
fiber.Map{"errors": validationErrorFormater(errs)},
|
||||
)
|
||||
}
|
57
internal/adapter/handlers/http/user.go
Normal file
57
internal/adapter/handlers/http/user.go
Normal file
@ -0,0 +1,57 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService port.UserService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewUserHandler(svc port.UserService, validator *validator.Validate) *UserHandler {
|
||||
return &UserHandler{svc, validator}
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username" validate:"required,max=50"`
|
||||
Email string `json:"email" validate:"email"`
|
||||
Password string `json:"password" validate:"min=8,max=72"`
|
||||
}
|
||||
|
||||
func (userHandler *UserHandler) Register(ctx *fiber.Ctx) error {
|
||||
registerBody := new(registerRequest)
|
||||
if err := ctx.BodyParser(registerBody); err != nil {
|
||||
return invalidRequestBodyResponse(ctx)
|
||||
}
|
||||
|
||||
if err := userHandler.validator.Struct(registerBody); err != nil {
|
||||
return validationErrorResponse(ctx, err)
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
Username: registerBody.Username,
|
||||
Email: registerBody.Email,
|
||||
Password: registerBody.Password,
|
||||
}
|
||||
|
||||
serviceCtx := ctx.Context()
|
||||
_, err := userHandler.userService.Register(serviceCtx, &user)
|
||||
if err != nil {
|
||||
if err == domain.ErrUsernameExists || err == domain.ErrEmailExists {
|
||||
return notFoundResponse(ctx, err)
|
||||
}
|
||||
return fiber.ErrInternalServerError
|
||||
}
|
||||
|
||||
return ctx.Status(fiber.StatusCreated).JSON(
|
||||
fiber.Map{
|
||||
"ID": user.ID,
|
||||
"Username": user.Username,
|
||||
"Email": user.Email,
|
||||
},
|
||||
)
|
||||
}
|
60
internal/adapter/handlers/http/validator.go
Normal file
60
internal/adapter/handlers/http/validator.go
Normal file
@ -0,0 +1,60 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type fieldError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewValidator() *validator.Validate {
|
||||
validate := validator.New()
|
||||
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
if fld.Tag.Get("validation_name") != "" {
|
||||
return fld.Tag.Get("validation_name")
|
||||
} else {
|
||||
return fld.Tag.Get("json")
|
||||
}
|
||||
})
|
||||
|
||||
return validate
|
||||
}
|
||||
|
||||
func validationErrorFormater(errs validator.ValidationErrors) []fieldError {
|
||||
fieldErrs := make([]fieldError, 0)
|
||||
|
||||
if errs != nil {
|
||||
for _, err := range errs {
|
||||
fieldErrs = append(
|
||||
fieldErrs,
|
||||
fieldError{
|
||||
Field: err.Field(),
|
||||
Message: msgForTag(err.Tag(), err.Field(), err.Param()),
|
||||
},
|
||||
)
|
||||
}
|
||||
return fieldErrs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func msgForTag(tag, field, param string) string {
|
||||
switch tag {
|
||||
case "required":
|
||||
return fmt.Sprintf("%s is required", field)
|
||||
case "email":
|
||||
return "email is invalid"
|
||||
case "min":
|
||||
return fmt.Sprintf("The length of %s must be at least %s", field, param)
|
||||
case "max":
|
||||
return fmt.Sprintf("The length of %s must be at most %s", field, param)
|
||||
default:
|
||||
return fmt.Sprintf("%s is invalid", field)
|
||||
}
|
||||
}
|
15
internal/adapter/logger/slog.go
Normal file
15
internal/adapter/logger/slog.go
Normal file
@ -0,0 +1,15 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func NewStdLogger() *slog.Logger {
|
||||
return slog.New(
|
||||
slog.NewJSONHandler(
|
||||
os.Stdout,
|
||||
&slog.HandlerOptions{AddSource: true},
|
||||
),
|
||||
)
|
||||
}
|
38
internal/adapter/storages/postgres/db.go
Normal file
38
internal/adapter/storages/postgres/db.go
Normal file
@ -0,0 +1,38 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/config"
|
||||
postgresDriver "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewDB(config *config.PostgresConfig) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s",
|
||||
config.Host,
|
||||
config.User,
|
||||
config.Password,
|
||||
config.DBName,
|
||||
config.Port,
|
||||
"disable",
|
||||
"UTC",
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
for range 3 {
|
||||
db, err = gorm.Open(postgresDriver.Open(dsn), &gorm.Config{})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "public"."users";
|
@ -0,0 +1,11 @@
|
||||
CREATE TABLE "public"."users" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"username" character varying(50) NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password" character varying(72) NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uni_users_email" UNIQUE ("email"),
|
||||
CONSTRAINT "uni_users_username" UNIQUE ("username")
|
||||
);
|
24
internal/adapter/storages/postgres/models/user.go
Normal file
24
internal/adapter/storages/postgres/models/user.go
Normal file
@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
UserTableName = "users"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"primarykey;unique;type:uuid;default:gen_random_uuid()"`
|
||||
Username string `gorm:"unique;not null;size:50"`
|
||||
Email string `gorm:"unique;not null"`
|
||||
Password string `gorm:"not null;size:72"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (u User) TableName() string {
|
||||
return UserTableName
|
||||
}
|
109
internal/adapter/storages/postgres/repository/user.go
Normal file
109
internal/adapter/storages/postgres/repository/user.go
Normal file
@ -0,0 +1,109 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/storages/postgres/models"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db}
|
||||
}
|
||||
|
||||
func (userRepository *UserRepository) CreateUser(
|
||||
ctx context.Context,
|
||||
user *domain.User,
|
||||
) (*domain.User, error) {
|
||||
userModel := &models.User{
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Password: user.Password,
|
||||
}
|
||||
tx := userRepository.db.Create(userModel)
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
user.ID = userModel.ID
|
||||
user.Username = userModel.Username
|
||||
user.Email = userModel.Email
|
||||
user.Password = userModel.Password
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (userRepository *UserRepository) IsUsernameExists(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
) (bool, error) {
|
||||
var count int64
|
||||
tx := userRepository.db.
|
||||
Table(models.UserTableName).
|
||||
Where("username = ?", username).
|
||||
Count(&count)
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (userRepository *UserRepository) IsEmailExists(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) (bool, error) {
|
||||
var count int64
|
||||
tx := userRepository.db.
|
||||
Table(models.UserTableName).
|
||||
Where("email = ?", email).
|
||||
Count(&count)
|
||||
if tx.Error != nil {
|
||||
return false, tx.Error
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (userRepository *UserRepository) GetUserByEmail(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) (*domain.User, error) {
|
||||
user := &domain.User{}
|
||||
tx := userRepository.db.
|
||||
Table(models.UserTableName).
|
||||
Where("email = ?", email).
|
||||
First(user)
|
||||
if tx.Error != nil {
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrDataNotFound
|
||||
}
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (userRepository *UserRepository) GetUserByUsername(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
) (*domain.User, error) {
|
||||
user := &domain.User{}
|
||||
tx := userRepository.db.
|
||||
Table(models.UserTableName).
|
||||
Where("username = ?", username).
|
||||
First(user)
|
||||
if tx.Error != nil {
|
||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, domain.ErrDataNotFound
|
||||
}
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
33
internal/adapter/storages/scylla/db.go
Normal file
33
internal/adapter/storages/scylla/db.go
Normal file
@ -0,0 +1,33 @@
|
||||
package scylla
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/config"
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
func NewDB(config *config.ScyllaConfig) (*gocql.Session, error) {
|
||||
cluster := gocql.NewCluster(config.Hosts...)
|
||||
cluster.Keyspace = config.Keyspace
|
||||
cluster.Consistency = gocql.LocalQuorum
|
||||
cluster.Authenticator = gocql.PasswordAuthenticator{
|
||||
Username: config.User,
|
||||
Password: config.Password,
|
||||
}
|
||||
cluster.PoolConfig.HostSelectionPolicy = gocql.TokenAwareHostPolicy(
|
||||
gocql.DCAwareRoundRobinPolicy(config.DataCenter),
|
||||
)
|
||||
|
||||
var session *gocql.Session
|
||||
var err error
|
||||
for range 20 {
|
||||
session, err = cluster.CreateSession()
|
||||
if err == nil {
|
||||
return session, nil
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS messages;
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
chat_id UUID, -- Partition key
|
||||
user_id UUID,
|
||||
content text, -- Clustering column
|
||||
type text, -- Clustering column
|
||||
created_at timestamp, -- Clustering column
|
||||
PRIMARY KEY (chat_id, created_at, content, type)
|
||||
) WITH CLUSTERING ORDER BY (created_at DESC);
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_chats;
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS user_chats (
|
||||
user_id UUID, -- Partition key
|
||||
chat_id UUID, -- Clustering column
|
||||
blocked boolean,
|
||||
created_at timestamp,
|
||||
PRIMARY KEY (user_id, created_at, chat_id, blocked)
|
||||
);
|
23
internal/adapter/storages/scylla/repository/message.go
Normal file
23
internal/adapter/storages/scylla/repository/message.go
Normal file
@ -0,0 +1,23 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/gocql/gocql"
|
||||
)
|
||||
|
||||
type MessageRepository struct {
|
||||
db *gocql.Session
|
||||
}
|
||||
|
||||
func NewMessageRepository(db *gocql.Session) *MessageRepository {
|
||||
return &MessageRepository{db}
|
||||
}
|
||||
|
||||
func (messageRepository *MessageRepository) CreateMessage(
|
||||
ctx context.Context,
|
||||
message *domain.Message,
|
||||
) (*domain.Message, error) {
|
||||
return nil, nil
|
||||
}
|
94
internal/adapter/streamers/kafka/consumer/message.go
Normal file
94
internal/adapter/streamers/kafka/consumer/message.go
Normal file
@ -0,0 +1,94 @@
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/config"
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/logger"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
)
|
||||
|
||||
var log = logger.NewStdLogger()
|
||||
|
||||
type MessageConsumer struct {
|
||||
kafkaConsumerConfig *config.KafkaConsumerConfig
|
||||
}
|
||||
|
||||
type ConsumerMessage struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func NewMessageConsumer(consumerConfig *config.KafkaConsumerConfig) *MessageConsumer {
|
||||
return &MessageConsumer{consumerConfig}
|
||||
}
|
||||
|
||||
func (messageConsumer *MessageConsumer) ConsumeMessage(
|
||||
ctx context.Context,
|
||||
uid string,
|
||||
getChats func() []string,
|
||||
message chan<- *domain.StreamMessage,
|
||||
) error {
|
||||
consumer := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: messageConsumer.kafkaConsumerConfig.BootstrapServers,
|
||||
GroupID: uid,
|
||||
GroupTopics: getChats(),
|
||||
MaxBytes: 10e6, // 10MB
|
||||
ReadLagInterval: -1,
|
||||
MaxWait: 300 * time.Millisecond,
|
||||
GroupBalancers: []kafka.GroupBalancer{kafka.RoundRobinGroupBalancer{}},
|
||||
StartOffset: kafka.FirstOffset,
|
||||
})
|
||||
|
||||
defer func() {
|
||||
if err := consumer.Close(); err != nil {
|
||||
log.Error("Error closing consumer", "error", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
msg, err := consumer.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case io.EOF:
|
||||
return nil
|
||||
case context.Canceled:
|
||||
return nil
|
||||
}
|
||||
log.Error("Error fetching message from kafka", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
consumerMeesage := &ConsumerMessage{}
|
||||
err = json.Unmarshal(msg.Value, consumerMeesage)
|
||||
if err != nil {
|
||||
log.Error("Error unmarshalling message", "error", err.Error())
|
||||
return domain.ErrInternal
|
||||
}
|
||||
|
||||
message <- &domain.StreamMessage{
|
||||
Message: &domain.Message{
|
||||
UserID: consumerMeesage.UserID,
|
||||
ChatID: consumerMeesage.ChatID,
|
||||
Content: consumerMeesage.Content,
|
||||
Timestamp: consumerMeesage.Timestamp,
|
||||
},
|
||||
Commit: func() error {
|
||||
err := consumer.CommitMessages(ctx, msg)
|
||||
if err != nil {
|
||||
log.Error("Error committing kafka message", "error", err.Error())
|
||||
return domain.ErrInternal
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
73
internal/adapter/streamers/kafka/producer/message.go
Normal file
73
internal/adapter/streamers/kafka/producer/message.go
Normal file
@ -0,0 +1,73 @@
|
||||
package kafka
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/sarama"
|
||||
"github.com/aykhans/oh-my-chat/internal/adapter/config"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type MessageProducer struct {
|
||||
saramaProducer *sarama.SyncProducer
|
||||
}
|
||||
|
||||
func (producer *MessageProducer) ProduceMessage(
|
||||
ctx context.Context,
|
||||
message *domain.Message,
|
||||
) error {
|
||||
messageJSON, err := (&ProducerMessage{
|
||||
UserID: message.UserID,
|
||||
ChatID: message.ChatID,
|
||||
Content: message.Content,
|
||||
Timestamp: message.Timestamp,
|
||||
}).JSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
producerMessage := &sarama.ProducerMessage{
|
||||
Topic: message.ChatID,
|
||||
Value: sarama.StringEncoder(messageJSON),
|
||||
Timestamp: message.Timestamp,
|
||||
}
|
||||
_, _, err = (*producer.saramaProducer).SendMessage(producerMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProducerMessage struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func (message *ProducerMessage) JSON() ([]byte, error) {
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
func NewMessageProducer(producerConfig *config.KafkaProducerConfig) (*MessageProducer, error) {
|
||||
config := sarama.NewConfig()
|
||||
config.Producer.Return.Successes = true // enable message delivery reports
|
||||
config.Producer.RequiredAcks = sarama.WaitForAll // require all in-sync replicas to acknowledge the message
|
||||
config.Producer.Retry.Max = 5 // number of retries before giving up on sending a message to a partition
|
||||
config.Producer.Retry.Backoff = time.Second * 60 // time to wait between retries
|
||||
config.Producer.Partitioner = sarama.NewRoundRobinPartitioner // walks through the available partitions one at a time
|
||||
config.Producer.Compression = sarama.CompressionSnappy // compress messages using Snappy
|
||||
config.Producer.Idempotent = true // producer will ensure that messages are successfully sent and acknowledged
|
||||
config.Producer.Flush.Frequency = time.Millisecond * 20 // time to wait before sending a batch of messages
|
||||
config.Producer.Flush.Bytes = 32 * 1024 // number of bytes to trigger a batch of messages
|
||||
config.Net.MaxOpenRequests = 1
|
||||
config.Metadata.AllowAutoTopicCreation = true
|
||||
|
||||
producer, err := sarama.NewSyncProducer(producerConfig.BootstrapServers, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MessageProducer{&producer}, err
|
||||
}
|
16
internal/core/domain/errors.go
Normal file
16
internal/core/domain/errors.go
Normal file
@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInternal = errors.New("internal error")
|
||||
ErrConflictingData = errors.New("data conflicts with existing data in unique column")
|
||||
ErrDataNotFound = errors.New("data not found")
|
||||
ErrInvalidEmailCredentials = errors.New("invalid email or password")
|
||||
ErrInvalidUsernameCredentials = errors.New("invalid username or password")
|
||||
ErrTokenCreation = errors.New("error creating token")
|
||||
ErrExpiredToken = errors.New("access token has expired")
|
||||
ErrUsernameExists = errors.New("username already exists")
|
||||
ErrEmailExists = errors.New("email already exists")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
)
|
20
internal/core/domain/message.go
Normal file
20
internal/core/domain/message.go
Normal file
@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
UserID uuid.UUID
|
||||
ChatID string
|
||||
Content string
|
||||
Type string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type StreamMessage struct {
|
||||
*Message
|
||||
Commit func() error
|
||||
}
|
7
internal/core/domain/token.go
Normal file
7
internal/core/domain/token.go
Normal file
@ -0,0 +1,7 @@
|
||||
package domain
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type AuthPayload struct {
|
||||
UserID uuid.UUID
|
||||
}
|
14
internal/core/domain/user.go
Normal file
14
internal/core/domain/user.go
Normal file
@ -0,0 +1,14 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Username string
|
||||
Email string
|
||||
Password string
|
||||
// CreatedAt time.Time
|
||||
// UpdatedAt time.Time
|
||||
}
|
17
internal/core/port/auth.go
Normal file
17
internal/core/port/auth.go
Normal file
@ -0,0 +1,17 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
CreateToken(user *domain.User) (string, error)
|
||||
VerifyToken(token string) (*domain.AuthPayload, error)
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
LoginByEmail(ctx context.Context, email, password string) (string, error)
|
||||
LoginByUsername(ctx context.Context, username, password string) (string, error)
|
||||
}
|
26
internal/core/port/message.go
Normal file
26
internal/core/port/message.go
Normal file
@ -0,0 +1,26 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type MessageProducer interface {
|
||||
ProduceMessage(ctx context.Context, message *domain.Message) error
|
||||
}
|
||||
|
||||
type MessageConsumer interface {
|
||||
ConsumeMessage(ctx context.Context, uid string, getChats func() []string, message chan<- *domain.StreamMessage) error
|
||||
}
|
||||
|
||||
type MessageRepository interface {
|
||||
CreateMessage(ctx context.Context, message *domain.Message) (*domain.Message, error)
|
||||
}
|
||||
|
||||
type MessageService interface {
|
||||
SendMessage(ctx context.Context, message *domain.Message) error
|
||||
ReceiveMessage(ctx context.Context, userID uuid.UUID, message chan<- *domain.StreamMessage) error
|
||||
CreateMessage(ctx context.Context, message *domain.Message) (*domain.Message, error)
|
||||
}
|
22
internal/core/port/user.go
Normal file
22
internal/core/port/user.go
Normal file
@ -0,0 +1,22 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
CreateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (*domain.User, error)
|
||||
IsUsernameExists(ctx context.Context, username string) (bool, error)
|
||||
IsEmailExists(ctx context.Context, email string) (bool, error)
|
||||
// GetUserByID(ctx context.Context, id uint64) (*domain.User, error)
|
||||
// DeleteUser(ctx context.Context, id uint64) error
|
||||
}
|
||||
|
||||
type UserService interface {
|
||||
Register(ctx context.Context, user *domain.User) (*domain.User, error)
|
||||
// GetUser(ctx context.Context, id uint64) (*domain.User, error)
|
||||
// DeleteUser(ctx context.Context, id uint64) error
|
||||
}
|
72
internal/core/service/auth.go
Normal file
72
internal/core/service/auth.go
Normal file
@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
userRepository port.UserRepository
|
||||
tokenService port.TokenService
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service instance
|
||||
func NewAuthService(userRepository port.UserRepository, tokenService port.TokenService) *AuthService {
|
||||
return &AuthService{
|
||||
userRepository,
|
||||
tokenService,
|
||||
}
|
||||
}
|
||||
|
||||
func (authService *AuthService) LoginByEmail(
|
||||
ctx context.Context,
|
||||
email, password string,
|
||||
) (string, error) {
|
||||
user, err := authService.userRepository.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if err == domain.ErrDataNotFound {
|
||||
return "", domain.ErrInvalidEmailCredentials
|
||||
}
|
||||
return "", domain.ErrInternal
|
||||
}
|
||||
|
||||
err = utils.ComparePassword(password, user.Password)
|
||||
if err != nil {
|
||||
return "", domain.ErrInvalidEmailCredentials
|
||||
}
|
||||
|
||||
accessToken, err := authService.tokenService.CreateToken(user)
|
||||
if err != nil {
|
||||
return "", domain.ErrTokenCreation
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func (authService *AuthService) LoginByUsername(
|
||||
ctx context.Context,
|
||||
username, password string,
|
||||
) (string, error) {
|
||||
user, err := authService.userRepository.GetUserByEmail(ctx, username)
|
||||
if err != nil {
|
||||
if err == domain.ErrDataNotFound {
|
||||
return "", domain.ErrInvalidUsernameCredentials
|
||||
}
|
||||
return "", domain.ErrInternal
|
||||
}
|
||||
|
||||
err = utils.ComparePassword(password, user.Password)
|
||||
if err != nil {
|
||||
return "", domain.ErrInvalidUsernameCredentials
|
||||
}
|
||||
|
||||
accessToken, err := authService.tokenService.CreateToken(user)
|
||||
if err != nil {
|
||||
return "", domain.ErrTokenCreation
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
55
internal/core/service/message.go
Normal file
55
internal/core/service/message.go
Normal file
@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type MessageService struct {
|
||||
producer port.MessageProducer
|
||||
consumer port.MessageConsumer
|
||||
repo port.MessageRepository
|
||||
}
|
||||
|
||||
func NewMessageService(
|
||||
producerService port.MessageProducer,
|
||||
consumerService port.MessageConsumer,
|
||||
repo port.MessageRepository,
|
||||
) *MessageService {
|
||||
return &MessageService{
|
||||
producerService,
|
||||
consumerService,
|
||||
repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (chatServie *MessageService) SendMessage(
|
||||
ctx context.Context,
|
||||
message *domain.Message,
|
||||
) error {
|
||||
message.ChatID = "chat_" + message.ChatID
|
||||
return chatServie.producer.ProduceMessage(ctx, message)
|
||||
}
|
||||
|
||||
func (chatServie *MessageService) ReceiveMessage(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
message chan<- *domain.StreamMessage,
|
||||
) error {
|
||||
return chatServie.consumer.ConsumeMessage(
|
||||
ctx,
|
||||
userID.String(),
|
||||
func() []string { return []string{"chat_1", "chat_5", "chat_9"} },
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
func (chatServie *MessageService) CreateMessage(
|
||||
ctx context.Context,
|
||||
message *domain.Message,
|
||||
) (*domain.Message, error) {
|
||||
return chatServie.repo.CreateMessage(ctx, message)
|
||||
}
|
49
internal/core/service/user.go
Normal file
49
internal/core/service/user.go
Normal file
@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aykhans/oh-my-chat/internal/core/domain"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/port"
|
||||
"github.com/aykhans/oh-my-chat/internal/core/utils"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
repo port.UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo port.UserRepository) *UserService {
|
||||
return &UserService{repo: repo}
|
||||
}
|
||||
|
||||
func (userService *UserService) Register(
|
||||
ctx context.Context,
|
||||
user *domain.User,
|
||||
) (*domain.User, error) {
|
||||
if exists, err := userService.repo.IsUsernameExists(ctx, user.Username); err != nil {
|
||||
return nil, domain.ErrInternal
|
||||
} else if exists {
|
||||
return nil, domain.ErrUsernameExists
|
||||
}
|
||||
if exists, err := userService.repo.IsEmailExists(ctx, user.Email); err != nil {
|
||||
return nil, domain.ErrInternal
|
||||
} else if exists {
|
||||
return nil, domain.ErrEmailExists
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(user.Password)
|
||||
if err != nil {
|
||||
return nil, domain.ErrInternal
|
||||
}
|
||||
user.Password = hashedPassword
|
||||
|
||||
user, err = userService.repo.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
if err == domain.ErrConflictingData {
|
||||
return nil, err
|
||||
}
|
||||
return nil, domain.ErrInternal
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
9
internal/core/utils/convert.go
Normal file
9
internal/core/utils/convert.go
Normal file
@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Str2StrSlice(value string) []string {
|
||||
return strings.Split(strings.ReplaceAll(value, " ", ""), ",")
|
||||
}
|
15
internal/core/utils/os.go
Normal file
15
internal/core/utils/os.go
Normal file
@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import "os"
|
||||
|
||||
func ExitErr() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func GetEnvOrDefault(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
26
internal/core/utils/password.go
Normal file
26
internal/core/utils/password.go
Normal file
@ -0,0 +1,26 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword hashes password and returns hashed password or error
|
||||
func HashPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
// ComparePassword compares password with hashed password and returns error if they don't match or nil if they do
|
||||
func ComparePassword(password, hashedPassword string) error {
|
||||
return bcrypt.CompareHashAndPassword(
|
||||
[]byte(hashedPassword),
|
||||
[]byte(password),
|
||||
)
|
||||
}
|
7
internal/core/utils/time.go
Normal file
7
internal/core/utils/time.go
Normal file
@ -0,0 +1,7 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func GetNow() time.Time {
|
||||
return time.Now()
|
||||
}
|
Reference in New Issue
Block a user