Compare commits

...

5 Commits

Author SHA1 Message Date
4e76ee468b init 2025-11-17 01:06:03 +01:00
bf78faba28 init 2025-11-15 16:19:25 +01:00
7ec38a443b Init 2025-11-15 00:35:41 +01:00
8acfbf1215 init 2025-11-14 00:34:28 +01:00
340d1b69f9 init 2025-11-11 23:08:29 +01:00
24 changed files with 1263 additions and 110 deletions

View File

@@ -4,16 +4,23 @@ import (
"context" "context"
"fmt" "fmt"
"go_oxspeak_server/config" "go_oxspeak_server/config"
"go_oxspeak_server/database"
"go_oxspeak_server/models"
"go_oxspeak_server/network/http" "go_oxspeak_server/network/http"
"go_oxspeak_server/network/udp" "go_oxspeak_server/network/udp"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"gorm.io/gorm"
) )
type App struct { type App struct {
cfg *config.Config cfg *config.Config
// DB
db *gorm.DB
// Serveurs // Serveurs
udpServer *udp.Server udpServer *udp.Server
httpServer *http.Server httpServer *http.Server
@@ -35,6 +42,17 @@ func NewApp(cfg *config.Config) *App {
// Channel pour les erreurs // Channel pour les erreurs
errChan := make(chan error, 1) errChan := make(chan error, 1)
// database init
dbConfig := database.DBConfig{
Driver: cfg.Database.Type,
DSN: cfg.GetDSN(),
}
// 1) Initialiser la DB
if err := database.Initialize(dbConfig); err != nil {
//return fmt.Errorf("failed to initialize database: %w", err)
}
// Servers // Servers
udpServer := udp.NewServer(cfg.Server.BindAddr) udpServer := udp.NewServer(cfg.Server.BindAddr)
httpServer := http.NewServer(cfg.Server.BindAddr) httpServer := http.NewServer(cfg.Server.BindAddr)
@@ -54,7 +72,15 @@ func (app *App) Run() error {
// Context pour gérer l'arrêt gracieux // Context pour gérer l'arrêt gracieux
defer app.cancel() defer app.cancel()
// Lancer les app ici // (optionnel) garder une référence locale si tu veux utiliser app.db ailleurs
app.db = database.DB
// 2) Lancer les migrations en utilisant le registry des modèles
if err := database.AutoMigrate(models.All()...); err != nil {
return fmt.Errorf("failed to auto-migrate database: %w", err)
}
// 3) Lancer les workers / serveurs
go app.runWorkers() go app.runWorkers()
fmt.Println("App started, press CTRL+C to stop...") fmt.Println("App started, press CTRL+C to stop...")
@@ -69,6 +95,10 @@ func (app *App) Run() error {
} }
} }
func (app *App) runDBMigrations() {
database.AutoMigrate()
}
func (app *App) runWorkers() { func (app *App) runWorkers() {
// lancer le serveur udp // lancer le serveur udp
go func() { go func() {

View File

@@ -46,6 +46,13 @@ func Initialize(config DBConfig) error {
return fmt.Errorf("erreur lors de la connexion à la base de données: %w", err) return fmt.Errorf("erreur lors de la connexion à la base de données: %w", err)
} }
// Activation du mode foreign keys pour SQLite
if config.Driver == "sqlite" {
if err := DB.Exec("PRAGMA foreign_keys = ON;").Error; err != nil {
return fmt.Errorf("impossible d'activer les foreign keys SQLite: %w", err)
}
}
log.Printf("Connexion à la base de données (%s) établie avec succès", config.Driver) log.Printf("Connexion à la base de données (%s) établie avec succès", config.Driver)
return nil return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type Attachment struct { type Attachment struct {
@@ -17,3 +18,10 @@ type Attachment struct {
// Relation optionnelle vers le message // Relation optionnelle vers le message
Message *Message `gorm:"foreignKey:MessageID" json:"message,omitempty"` Message *Message `gorm:"foreignKey:MessageID" json:"message,omitempty"`
} }
func (s *Attachment) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type Category struct { type Category struct {
@@ -14,5 +15,12 @@ type Category struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relation optionnelle vers le serveur // Relation optionnelle vers le serveur
Server *Server `gorm:"foreignKey:ServerID" json:"server,omitempty"` Server *Server `gorm:"foreignKey:ServerID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"server,omitempty"`
}
func (s *Category) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
// #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] // #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
@@ -48,3 +49,10 @@ type Channel struct {
Server *Server `gorm:"foreignKey:ServerID" json:"server,omitempty"` Server *Server `gorm:"foreignKey:ServerID" json:"server,omitempty"`
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
} }
func (s *Channel) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
}

View File

@@ -7,8 +7,9 @@ import (
) )
type ChannelUser struct { type ChannelUser struct {
ChannelID uuid.UUID `gorm:"primaryKey" json:"channel_id"` ID uuid.UUID `gorm:"primaryKey" json:"id"`
UserID uuid.UUID `gorm:"primaryKey" json:"user_id"` ChannelID uuid.UUID `gorm:"index;not null" json:"channel_id"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
Role string `gorm:"default:'member'" json:"role"` Role string `gorm:"default:'member'" json:"role"`
JoinedAt time.Time `gorm:"autoCreateTime" json:"joined_at"` JoinedAt time.Time `gorm:"autoCreateTime" json:"joined_at"`

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
// #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] // #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
@@ -26,3 +27,10 @@ type Message struct {
EditedAt *time.Time `gorm:"default:null" json:"edited_at,omitempty"` EditedAt *time.Time `gorm:"default:null" json:"edited_at,omitempty"`
ReplyToID *uuid.UUID `gorm:"index" json:"reply_to_id,omitempty"` ReplyToID *uuid.UUID `gorm:"index" json:"reply_to_id,omitempty"`
} }
func (s *Message) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
}

14
models/registry.go Normal file
View File

@@ -0,0 +1,14 @@
package models
func All() []interface{} {
return []interface{}{
&User{},
&Server{},
&ServerUser{},
&Category{},
&Channel{},
&ChannelUser{},
&Message{},
&Attachment{},
}
}

View File

@@ -4,11 +4,20 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type Server struct { type Server struct {
ID uuid.UUID `gorm:"primaryKey" json:"id"` ID uuid.UUID `gorm:"primaryKey" json:"id"`
Password string `gorm:"not null" json:"-"` Name string `gorm:"not null" json:"name"`
Password *string `gorm:"" json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }
func (s *Server) BeforeCreate(tx *gorm.DB) (err error) {
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
return nil
}

View File

@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"go_oxspeak_server/models" "go_oxspeak_server/models"
"go_oxspeak_server/network/http/handler" "go_oxspeak_server/network/http/handler"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type CategoryHandler struct { type CategoryHandler struct {
@@ -16,31 +19,105 @@ func NewCategoryHandler(h *handler.Handler) *CategoryHandler {
} }
func (h *CategoryHandler) RegisterRoutes(rg *gin.RouterGroup) { func (h *CategoryHandler) RegisterRoutes(rg *gin.RouterGroup) {
category := rg.Group("/category") rg.GET("/", h.getCategories)
category.GET("/", h.getCategories) rg.GET("/:id/", h.getCategory)
category.GET("/:id/", h.getCategory) rg.POST("/", h.addCategory)
category.POST("/", h.addCategory) rg.PUT("/:id/", h.updateCategory)
category.PUT("/:id/", h.updateCategory) rg.DELETE("/:id/", h.deleteCategory)
category.DELETE("/:id/", h.deleteCategory)
} }
func (h *CategoryHandler) getCategories(c *gin.Context) { func (h *CategoryHandler) getCategories(c *gin.Context) {
var categories []models.Category var categories []models.Category
h.DB.Find(&categories) result := h.DB.Find(&categories)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, categories)
} }
func (h *CategoryHandler) getCategory(c *gin.Context) { func (h *CategoryHandler) getCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category var category models.Category
h.DB.Find(&category) result := h.DB.First(&category, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, category)
} }
func (h *CategoryHandler) addCategory(c *gin.Context) { func (h *CategoryHandler) addCategory(c *gin.Context) {
var req CreateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category := models.Category{
ServerID: req.ServerID,
Name: req.Name,
}
if err := h.DB.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, category)
} }
func (h *CategoryHandler) updateCategory(c *gin.Context) { func (h *CategoryHandler) updateCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category
result := h.DB.First(&category, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
var req UpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.DB.Model(&category).Updates(req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update category"})
return
}
c.JSON(http.StatusOK, category)
} }
func (h *CategoryHandler) deleteCategory(c *gin.Context) { func (h *CategoryHandler) deleteCategory(c *gin.Context) {
id := c.Param("id")
var category models.Category
result := h.DB.First(&category, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if err := h.DB.Delete(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete category"})
return
}
c.Status(http.StatusNoContent)
} }

View File

@@ -0,0 +1,14 @@
package api
import "github.com/google/uuid"
// DTOs pour Category
type CreateCategoryRequest struct {
ServerID uuid.UUID `json:"server_id" binding:"required"`
Name string `json:"name" binding:"required"`
}
type UpdateCategoryRequest struct {
Name string `json:"name" binding:"required"`
}

View File

@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"go_oxspeak_server/models" "go_oxspeak_server/models"
"go_oxspeak_server/network/http/handler" "go_oxspeak_server/network/http/handler"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type ChannelHandler struct { type ChannelHandler struct {
@@ -16,36 +19,111 @@ func NewChannelHandler(h *handler.Handler) *ChannelHandler {
} }
func (h *ChannelHandler) RegisterRoutes(rg *gin.RouterGroup) { func (h *ChannelHandler) RegisterRoutes(rg *gin.RouterGroup) {
channel := rg.Group("/channel") rg.GET("/", h.getChannels)
channel.GET("/", h.getChannels) rg.GET("/:id/", h.getChannel)
channel.GET("/:id/", h.getChannel) rg.POST("/", h.addChannel)
channel.POST("/", h.addChannel) rg.PUT("/:id/", h.updateChannel)
channel.PUT("/:id/", h.updateChannel) rg.DELETE("/:id/", h.deleteChannel)
channel.DELETE("/:id/", h.deleteChannel)
} }
func (h *ChannelHandler) getChannels(c *gin.Context) { func (h *ChannelHandler) getChannels(c *gin.Context) {
var users []models.User var channels []models.Channel
h.DB.Find(&users) result := h.DB.Find(&channels)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, channels)
} }
func (h *ChannelHandler) getChannel(c *gin.Context) { func (h *ChannelHandler) getChannel(c *gin.Context) {
var user models.User id := c.Param("id")
h.DB.Find(&user) var channel models.Channel
result := h.DB.First(&channel, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Channel not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, channel)
} }
func (h *ChannelHandler) addChannel(c *gin.Context) { func (h *ChannelHandler) addChannel(c *gin.Context) {
var req CreateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
channel := models.Channel{
ServerID: req.ServerID,
CategoryID: req.CategoryID,
Type: req.Type,
Name: req.Name,
}
if req.Position != nil {
channel.Position = *req.Position
}
if err := h.DB.Create(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, channel)
} }
func (h *ChannelHandler) updateChannel(c *gin.Context) { func (h *ChannelHandler) updateChannel(c *gin.Context) {
var user models.User id := c.Param("id")
h.DB.Find(&user)
var channel models.Channel
result := h.DB.First(&channel, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Channel not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
var req UpdateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.DB.Model(&channel).Updates(req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update channel"})
return
}
c.JSON(http.StatusOK, channel)
} }
func (h *ChannelHandler) deleteChannel(c *gin.Context) { func (h *ChannelHandler) deleteChannel(c *gin.Context) {
var user models.User id := c.Param("id")
h.DB.Find(&user)
var channel models.Channel
result := h.DB.First(&channel, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Channel not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if err := h.DB.Delete(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete channel"})
return
}
c.Status(http.StatusNoContent)
} }

View File

@@ -0,0 +1,24 @@
package api
import (
"github.com/google/uuid"
"go_oxspeak_server/models"
)
// DTOs pour Channel
type CreateChannelRequest struct {
ServerID *uuid.UUID `json:"server_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
Position *int32 `json:"position,omitempty"`
Type models.ChannelType `json:"type" binding:"required"`
Name *string `json:"name,omitempty"`
}
type UpdateChannelRequest struct {
ServerID *uuid.UUID `json:"server_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
Position *int32 `json:"position,omitempty"`
Type *models.ChannelType `json:"type,omitempty"`
Name *string `json:"name,omitempty"`
}

View File

@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"go_oxspeak_server/models" "go_oxspeak_server/models"
"go_oxspeak_server/network/http/handler" "go_oxspeak_server/network/http/handler"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type MessageHandler struct { type MessageHandler struct {
@@ -16,31 +19,105 @@ func NewMessageHandler(h *handler.Handler) *MessageHandler {
} }
func (h *MessageHandler) RegisterRoutes(rg *gin.RouterGroup) { func (h *MessageHandler) RegisterRoutes(rg *gin.RouterGroup) {
message := rg.Group("/message") rg.GET("/", h.getMessages)
message.GET("/", h.getMessages) rg.GET("/:id/", h.getMessage)
message.GET("/:id/", h.getMessage) rg.POST("/", h.addMessage)
message.POST("/", h.addMessage) rg.PUT("/:id/", h.updateMessage)
message.PUT("/:id/", h.updateMessage) rg.DELETE("/:id/", h.deleteMessage)
message.DELETE("/:id/", h.deleteMessage)
} }
func (h *MessageHandler) getMessages(c *gin.Context) { func (h *MessageHandler) getMessages(c *gin.Context) {
var messages []models.Message var messages []models.Message
h.DB.Find(&messages) result := h.DB.Find(&messages)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, messages)
} }
func (h *MessageHandler) getMessage(c *gin.Context) { func (h *MessageHandler) getMessage(c *gin.Context) {
id := c.Param("id")
var message models.Message var message models.Message
h.DB.Find(&message) result := h.DB.First(&message, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, message)
} }
func (h *MessageHandler) addMessage(c *gin.Context) { func (h *MessageHandler) addMessage(c *gin.Context) {
var req CreateMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
message := models.Message{
ChannelID: req.ChannelID,
UserID: req.UserID,
Content: req.Content,
ReplyToID: req.ReplyToID,
}
if err := h.DB.Create(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, message)
} }
func (h *MessageHandler) updateMessage(c *gin.Context) { func (h *MessageHandler) updateMessage(c *gin.Context) {
id := c.Param("id")
var message models.Message
result := h.DB.First(&message, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
var req UpdateMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.DB.Model(&message).Updates(req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update message"})
return
}
c.JSON(http.StatusOK, message)
} }
func (h *MessageHandler) deleteMessage(c *gin.Context) { func (h *MessageHandler) deleteMessage(c *gin.Context) {
id := c.Param("id")
var message models.Message
result := h.DB.First(&message, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if err := h.DB.Delete(&message).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"})
return
}
c.Status(http.StatusNoContent)
} }

View File

@@ -0,0 +1,17 @@
package api
import "github.com/google/uuid"
// DTOs pour Message
type CreateMessageRequest struct {
ChannelID uuid.UUID `json:"channel_id" binding:"required"`
UserID uuid.UUID `json:"user_id" binding:"required"`
Content string `json:"content" binding:"required"`
ReplyToID *uuid.UUID `json:"reply_to_id,omitempty"`
}
type UpdateMessageRequest struct {
Content string `json:"content" binding:"required"`
ReplyToID *uuid.UUID `json:"reply_to_id,omitempty"`
}

View File

@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"go_oxspeak_server/models" "go_oxspeak_server/models"
"go_oxspeak_server/network/http/handler" "go_oxspeak_server/network/http/handler"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type ServerHandler struct { type ServerHandler struct {
@@ -16,31 +19,138 @@ func NewServerHandler(h *handler.Handler) *ServerHandler {
} }
func (h *ServerHandler) RegisterRoutes(rg *gin.RouterGroup) { func (h *ServerHandler) RegisterRoutes(rg *gin.RouterGroup) {
server := rg.Group("/server") rg.GET("/", h.serverList)
server.GET("/", h.getServers) rg.GET("/:id/", h.serverDetail)
server.GET("/:id/", h.getServer) rg.POST("/", h.serverAdd)
server.POST("/", h.addServer) rg.PUT("/:id/", h.serverUpdate)
server.PUT("/:id/", h.updateServer) rg.DELETE("/:id/", h.serverDelete)
server.DELETE("/:id/", h.deleteServer) rg.POST("/:id/join/", h.serverJoin)
} }
func (h *ServerHandler) getServers(c *gin.Context) { func (h *ServerHandler) serverList(c *gin.Context) {
var servers []models.Server var servers []models.Server
h.DB.Find(&servers) result := h.DB.Find(&servers)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, servers)
} }
func (h *ServerHandler) getServer(c *gin.Context) { func (h *ServerHandler) serverDetail(c *gin.Context) {
id := c.Param("id")
var server models.Server var server models.Server
h.DB.Find(&server) result := h.DB.First(&server, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, server)
} }
func (h *ServerHandler) addServer(c *gin.Context) { func (h *ServerHandler) serverAdd(c *gin.Context) {
var req CreateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// construire le modèle
server := models.Server{
Name: req.Name,
Password: req.Password,
}
if err := h.DB.Create(&server).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, server)
} }
func (h *ServerHandler) updateServer(c *gin.Context) { func (h *ServerHandler) serverUpdate(c *gin.Context) {
id := c.Param("id")
var server models.Server
result := h.DB.First(&server, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
var req UpdateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.DB.Model(&server).Updates(req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update server"})
return
}
c.JSON(http.StatusOK, server)
} }
func (h *ServerHandler) deleteServer(c *gin.Context) { func (h *ServerHandler) serverDelete(c *gin.Context) {
id := c.Param("id")
var server models.Server
result := h.DB.First(&server, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if err := h.DB.Delete(&server).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete server"})
return
}
c.Status(http.StatusNoContent)
}
func (h *ServerHandler) serverJoin(c *gin.Context) {
id := c.Param("id")
var server models.Server
result := h.DB.First(&server, "id = ?", id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
var req JoinServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if server.Password != req.Password {
c.JSON(http.StatusForbidden, gin.H{"error": "Wrong password"})
return
}
c.JSON(http.StatusOK, server)
} }

View File

@@ -0,0 +1,15 @@
package api
type CreateServerRequest struct {
Name string `json:"name" binding:"required"`
Password *string `json:"password,omitempty"`
}
type UpdateServerRequest struct {
Name string `json:"name" binding:"required"`
Password *string `json:"password,omitempty"`
}
type JoinServerRequest struct {
Password *string `json:"password,omitempty"`
}

View File

@@ -54,7 +54,7 @@ func (h *AuthHandler) authenticate(c *gin.Context) {
// Generate token // Generate token
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"user_id": user.ID, "user_id": user.ID,
"expiration_date": time.Now().Add(time.Hour * 72).Unix(), "expiration_date": time.Now().Add(time.Minute * 15).Unix(),
"creation_date": time.Now().Unix(), "creation_date": time.Now().Unix(),
} }

View File

@@ -32,14 +32,19 @@ func CreateRouter() *gin.Engine {
apiGroup := router.Group("/api") apiGroup := router.Group("/api")
{ {
serverHandler.RegisterRoutes(apiGroup) serverHandler.RegisterRoutes(apiGroup.Group("/server"))
channelHandler.RegisterRoutes(apiGroup) channelHandler.RegisterRoutes(apiGroup.Group("/channel"))
categoryHandler.RegisterRoutes(apiGroup) categoryHandler.RegisterRoutes(apiGroup.Group("/category"))
messageHandler.RegisterRoutes(apiGroup) messageHandler.RegisterRoutes(apiGroup.Group("/message"))
} }
router.GET("/health", healthcheck) router.GET("/health", healthcheck)
// Expose OpenAPI specification (static file placed at repository root)
router.GET("/openapi.yaml", func(c *gin.Context) {
c.File("openapi.yaml")
})
return router return router
} }

View File

@@ -0,0 +1,68 @@
//go:build linux || darwin
// +build linux darwin
package udp
import (
"context"
"fmt"
"net"
"syscall"
"golang.org/x/sys/unix"
)
// listenUDP crée workerCount sockets UDP distinctes avec SO_REUSEPORT activé.
// Chaque socket écoute sur bindAddr et sera utilisée par un worker.
func listenUDP(bindAddr string, workerCount int) ([]*net.UDPConn, error) {
if workerCount <= 0 {
workerCount = 1
}
var conns []*net.UDPConn
closeAll := func() {
for _, c := range conns {
_ = c.Close()
}
}
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
sockErr = fmt.Errorf("set SO_REUSEADDR: %w", err)
return
}
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
sockErr = fmt.Errorf("set SO_REUSEPORT: %w", err)
return
}
})
if err != nil {
return err
}
return sockErr
},
}
for i := 0; i < workerCount; i++ {
pc, err := lc.ListenPacket(context.Background(), "udp", bindAddr)
if err != nil {
closeAll()
return nil, fmt.Errorf("worker %d: listen failed on %q: %w", i, bindAddr, err)
}
udpConn, ok := pc.(*net.UDPConn)
if !ok {
pc.Close()
closeAll()
return nil, fmt.Errorf("worker %d: PacketConn is not *net.UDPConn", i)
}
conns = append(conns, udpConn)
}
return conns, nil
}

View File

@@ -0,0 +1,25 @@
//go:build windows
// +build windows
package udp
import (
"fmt"
"net"
)
// listenUDP sur Windows crée une seule socket UDP qui sera partagée par tous
// les workers (tous les workerID utiliseront conns[0]).
func listenUDP(bindAddr string, workerCount int) ([]*net.UDPConn, error) {
addr, err := net.ResolveUDPAddr("udp", bindAddr)
if err != nil {
return nil, fmt.Errorf("cannot resolve address %q: %w", bindAddr, err)
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
return nil, fmt.Errorf("cannot listen on address %q: %w", bindAddr, err)
}
return []*net.UDPConn{conn}, nil
}

View File

@@ -7,49 +7,61 @@ import (
mapset "github.com/deckarep/golang-set/v2" mapset "github.com/deckarep/golang-set/v2"
) )
// ... existing code ...
type RoutingTable struct { type RoutingTable struct {
mu sync.RWMutex mu sync.RWMutex
routes map[string]mapset.Set[*net.UDPAddr] routes map[string]mapset.Set[*net.UDPAddr]
} }
// NewRoutingTable crée une table vide.
func NewRoutingTable() *RoutingTable { func NewRoutingTable() *RoutingTable {
return &RoutingTable{ return &RoutingTable{
routes: make(map[string]mapset.Set[*net.UDPAddr]), routes: make(map[string]mapset.Set[*net.UDPAddr]),
} }
} }
func (rt *RoutingTable) AddClient(channelID string, addr *net.UDPAddr) { // Add enregistre un client dans un channel.
func (rt *RoutingTable) Add(channel string, addr *net.UDPAddr) {
rt.mu.Lock() rt.mu.Lock()
defer rt.mu.Unlock() defer rt.mu.Unlock()
if rt.routes[channelID] == nil { set, ok := rt.routes[channel]
rt.routes[channelID] = mapset.NewSet[*net.UDPAddr]() if !ok {
set = mapset.NewSet[*net.UDPAddr]()
rt.routes[channel] = set
} }
rt.routes[channelID].Add(addr) set.Add(addr)
} }
func (rt *RoutingTable) RemoveClient(channelID string, addr *net.UDPAddr) { // Remove supprime un client d'un channel.
func (rt *RoutingTable) Remove(channel string, addr *net.UDPAddr) {
rt.mu.Lock() rt.mu.Lock()
defer rt.mu.Unlock() defer rt.mu.Unlock()
if clients, exists := rt.routes[channelID]; exists { set, ok := rt.routes[channel]
clients.Remove(addr) if !ok {
if clients.Cardinality() == 0 { return
delete(rt.routes, channelID)
} }
set.Remove(addr)
if set.Cardinality() == 0 {
delete(rt.routes, channel)
} }
} }
// GetClients returns the clients connected to the given channelID // GetAddrs renvoie une copie de la liste des clients d'un channel.
// don't modify the returned set! func (rt *RoutingTable) GetAddrs(channel string) []*net.UDPAddr {
func (rt *RoutingTable) GetClients(channelID string) mapset.Set[*net.UDPAddr] {
rt.mu.RLock() rt.mu.RLock()
defer rt.mu.RUnlock() defer rt.mu.RUnlock()
clients, exists := rt.routes[channelID] set, ok := rt.routes[channel]
if !exists { if !ok {
return nil return nil
} }
return clients addrs := make([]*net.UDPAddr, 0, set.Cardinality())
for addr := range set.Iter() {
addrs = append(addrs, addr)
}
return addrs
} }

View File

@@ -12,7 +12,7 @@ type Server struct {
bindAddr string bindAddr string
routingTable *RoutingTable routingTable *RoutingTable
conn *net.UDPConn conns []*net.UDPConn
wg sync.WaitGroup wg sync.WaitGroup
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -23,93 +23,139 @@ func NewServer(bindAddr string) *Server {
return &Server{ return &Server{
bindAddr: bindAddr, bindAddr: bindAddr,
routingTable: NewRoutingTable(),
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
} }
} }
func (s *Server) Router() *RoutingTable {
return s.routingTable
}
func (s *Server) Run() error { func (s *Server) Run() error {
add, err := net.ResolveUDPAddr("udp", s.bindAddr) if s.bindAddr == "" {
if err != nil { return fmt.Errorf("bind address is empty")
return fmt.Errorf("cannot resolve address: %w", err)
} }
s.conn, err = net.ListenUDP("udp", add) workerCount := runtime.NumCPU()
conns, err := listenUDP(s.bindAddr, workerCount)
if err != nil { if err != nil {
return fmt.Errorf("cannot listen on address: %w", err) return fmt.Errorf("cannot listen on address: %w", err)
} }
if len(conns) == 0 {
return fmt.Errorf("no UDP connections created")
}
s.conns = conns
s.conn.SetReadBuffer(8 * 1024 * 1024) for _, conn := range s.conns {
s.conn.SetWriteBuffer(8 * 1024 * 1024) _ = conn.SetReadBuffer(8 * 1024 * 1024)
fmt.Println("Listening on", s.bindAddr) _ = conn.SetWriteBuffer(8 * 1024 * 1024)
}
for i := 0; i < runtime.NumCPU(); i++ { fmt.Println("[udp] listening on", s.bindAddr, "with", len(s.conns), "worker socket(s)")
// Cas Windows : listenUDP a créé une seule socket,
// on lance workerCount goroutines sur la même conn.
if len(s.conns) == 1 {
conn := s.conns[0]
for workerID := 0; workerID < workerCount; workerID++ {
s.wg.Add(1) s.wg.Add(1)
// todo : add so_reuseport option when on unix like system go s.workerLoop(workerID, conn)
go s.workerLoop(i) }
return nil
}
// Cas Unix : une conn par worker (SO_REUSEPORT).
for workerID, conn := range s.conns {
s.wg.Add(1)
go s.workerLoop(workerID, conn)
} }
return nil return nil
} }
func (s *Server) sendTo(data []byte, addr *net.UDPAddr) error { func (s *Server) Stop() {
if s.conn == nil { s.cancel()
return fmt.Errorf("server not started") for _, c := range s.conns {
_ = c.Close()
} }
s.wg.Wait()
_, err := s.conn.WriteToUDP(data, addr)
return err
} }
func (s *Server) workerLoop(id int) { func (s *Server) workerLoop(workerID int, conn *net.UDPConn) {
defer s.wg.Done() defer s.wg.Done()
buffer := make([]byte, 1500) buf := make([]byte, 1500)
fmt.Println("Worker", id, "started") fmt.Println("[udp] worker", workerID, "started")
for { for {
select { select {
case <-s.ctx.Done(): case <-s.ctx.Done():
fmt.Println("Worker", id, "stopped") fmt.Println("[udp] worker", workerID, "stopped")
return return
default: default:
size, addr, err := s.conn.ReadFromUDP(buffer) n, addr, err := conn.ReadFromUDP(buf)
if err != nil { if err != nil {
if s.ctx.Err() != nil { if s.ctx.Err() != nil {
return return
} }
if opErr, ok := err.(*net.OpError); ok && opErr.Temporary() { if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue continue
} }
fmt.Printf("Error reading from UDP: %v\n", err) fmt.Printf("[udp] worker %d: read error: %v\n", workerID, err)
continue continue
} }
s.handlePacket(buffer[:size], addr) // Le worker qui lit est aussi celui qui traite et qui écrit.
s.handlePacket(conn, buf[:n], addr)
} }
} }
} }
func (s *Server) handlePacket(data []byte, addr *net.UDPAddr) { // handlePacket reçoit la conn du worker.
func (s *Server) handlePacket(conn *net.UDPConn, data []byte, addr *net.UDPAddr) {
if len(data) == 0 {
return
}
pt := PacketType(data[0]) pt := PacketType(data[0])
switch pt { switch pt {
case PacketTypePing: case PacketTypePing:
err := s.sendTo([]byte{byte(PacketTypePing)}, addr) _, _ = conn.WriteToUDP([]byte{byte(PacketTypePing)}, addr)
if err != nil {
return
}
return
case PacketTypeConnect: case PacketTypeConnect:
if len(data) < 2 {
return return
case PacketTypeDisconnect: }
return channelID := string(data[1:])
case PacketTypeVoiceData: s.routingTable.Add(channelID, addr)
// todo : déterminer le format du packet
//channelID := string(data[1:5])
return
default:
return
case PacketTypeDisconnect:
if len(data) < 2 {
return
}
channelID := string(data[1:])
s.routingTable.Remove(channelID, addr)
case PacketTypeVoiceData:
if len(data) < 2 {
return
}
channelID := string(data[1:]) // à adapter selon ton vrai format
recipients := s.routingTable.GetAddrs(channelID)
for _, dst := range recipients {
// optionnel: ne pas renvoyer à la source
if dst.IP.Equal(addr.IP) && dst.Port == addr.Port {
continue
}
_, _ = conn.WriteToUDP(data, dst)
} }
default:
// type inconnu -> ignore
}
} }

492
openapi.yaml Normal file
View File

@@ -0,0 +1,492 @@
openapi: 3.1.0
info:
title: OXSpeak Server API
version: 1.0.0
description: API HTTP exposée par le serveur OXSpeak.
servers:
- url: http://localhost:7000
paths:
/health:
get:
summary: Vérifie l'état du service
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
/auth/channel/login/:
get:
summary: Authentification par clé publique (renvoie un JWT)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthRequest'
responses:
'200':
description: Jeton JWT généré
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
description: Requête invalide
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Erreur interne
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/server/:
get:
summary: Liste des serveurs
responses:
'200':
description: Liste de serveurs
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Server'
'500':
description: Erreur serveur
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
summary: Crée un serveur
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateServerRequest'
responses:
'201':
description: Serveur créé
content:
application/json:
schema:
$ref: '#/components/schemas/Server'
'400': { description: Requête invalide }
'500': { description: Erreur interne }
/api/server/{id}/:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Détail d'un serveur
responses:
'200':
description: Serveur
content:
application/json:
schema:
$ref: '#/components/schemas/Server'
'404': { description: Introuvable }
'500': { description: Erreur interne }
put:
summary: Met à jour un serveur
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateServerRequest'
responses:
'200':
description: Serveur mis à jour
content:
application/json:
schema:
$ref: '#/components/schemas/Server'
'400': { description: Requête invalide }
'404': { description: Introuvable }
'500': { description: Erreur interne }
delete:
summary: Supprime un serveur
responses:
'204': { description: Supprimé }
'404': { description: Introuvable }
'500': { description: Erreur interne }
/api/category/:
get:
summary: Liste des catégories
responses:
'200':
description: Liste des catégories
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
'500': { description: Erreur interne }
post:
summary: Crée une catégorie
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCategoryRequest'
responses:
'201':
description: Catégorie créée
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'400': { description: Requête invalide }
'500': { description: Erreur interne }
/api/category/{id}/:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Détail d'une catégorie
responses:
'200':
description: Catégorie
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'404': { description: Introuvable }
'500': { description: Erreur interne }
put:
summary: Met à jour une catégorie
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateCategoryRequest'
responses:
'200':
description: Catégorie mise à jour
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'400': { description: Requête invalide }
'404': { description: Introuvable }
'500': { description: Erreur interne }
delete:
summary: Supprime une catégorie
responses:
'204': { description: Supprimé }
'404': { description: Introuvable }
'500': { description: Erreur interne }
/api/channel/:
get:
summary: Liste des canaux
responses:
'200':
description: Liste des canaux
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Channel'
'500': { description: Erreur interne }
post:
summary: Crée un canal
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateChannelRequest'
responses:
'201':
description: Canal créé
content:
application/json:
schema:
$ref: '#/components/schemas/Channel'
'400': { description: Requête invalide }
'500': { description: Erreur interne }
/api/channel/{id}/:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Détail d'un canal
responses:
'200':
description: Canal
content:
application/json:
schema:
$ref: '#/components/schemas/Channel'
'404': { description: Introuvable }
'500': { description: Erreur interne }
put:
summary: Met à jour un canal
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateChannelRequest'
responses:
'200':
description: Canal mis à jour
content:
application/json:
schema:
$ref: '#/components/schemas/Channel'
'400': { description: Requête invalide }
'404': { description: Introuvable }
'500': { description: Erreur interne }
delete:
summary: Supprime un canal
responses:
'204': { description: Supprimé }
'404': { description: Introuvable }
'500': { description: Erreur interne }
/api/message/:
get:
summary: Liste des messages
responses:
'200':
description: Liste des messages
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Message'
'500': { description: Erreur interne }
post:
summary: Crée un message
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateMessageRequest'
responses:
'201':
description: Message créé
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
'400': { description: Requête invalide }
'500': { description: Erreur interne }
/api/message/{id}/:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: Détail d'un message
responses:
'200':
description: Message
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
'404': { description: Introuvable }
'500': { description: Erreur interne }
put:
summary: Met à jour un message
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateMessageRequest'
responses:
'200':
description: Message mis à jour
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
'400': { description: Requête invalide }
'404': { description: Introuvable }
'500': { description: Erreur interne }
delete:
summary: Supprime un message
responses:
'204': { description: Supprimé }
'404': { description: Introuvable }
'500': { description: Erreur interne }
components:
schemas:
UUID:
type: string
format: uuid
Error:
type: object
properties:
error:
type: string
AuthRequest:
type: object
required: [pub_key]
properties:
pub_key:
type: string
description: Clé publique du client
AuthResponse:
type: object
properties:
JWT:
type: string
Server:
type: object
properties:
id: { $ref: '#/components/schemas/UUID' }
name: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
required: [id, name, created_at, updated_at]
CreateServerRequest:
type: object
required: [name]
properties:
name: { type: string }
password: { type: string, nullable: true }
UpdateServerRequest:
type: object
required: [name]
properties:
name: { type: string }
password: { type: string, nullable: true }
Category:
type: object
properties:
id: { $ref: '#/components/schemas/UUID' }
server_id: { $ref: '#/components/schemas/UUID' }
name: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
required: [id, server_id, name, created_at, updated_at]
CreateCategoryRequest:
type: object
required: [server_id, name]
properties:
server_id: { $ref: '#/components/schemas/UUID' }
name: { type: string }
UpdateCategoryRequest:
type: object
required: [name]
properties:
name: { type: string }
Channel:
type: object
properties:
id: { $ref: '#/components/schemas/UUID' }
server_id: { $ref: '#/components/schemas/UUID' }
category_id: { $ref: '#/components/schemas/UUID' }
position: { type: integer, format: int32 }
type: { $ref: '#/components/schemas/ChannelType' }
name: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
required: [id, position, type, created_at, updated_at]
ChannelType:
type: string
enum: [text, voice, dm]
CreateChannelRequest:
type: object
required: [type]
properties:
server_id: { $ref: '#/components/schemas/UUID' }
category_id: { $ref: '#/components/schemas/UUID' }
position: { type: integer, format: int32 }
type: { $ref: '#/components/schemas/ChannelType' }
name: { type: string, nullable: true }
UpdateChannelRequest:
type: object
properties:
server_id: { $ref: '#/components/schemas/UUID' }
category_id: { $ref: '#/components/schemas/UUID' }
position: { type: integer, format: int32 }
type: { $ref: '#/components/schemas/ChannelType' }
name: { type: string, nullable: true }
Message:
type: object
properties:
id: { $ref: '#/components/schemas/UUID' }
channel_id: { $ref: '#/components/schemas/UUID' }
user_id: { $ref: '#/components/schemas/UUID' }
content: { type: string }
created_at: { type: string, format: date-time }
edited_at: { type: string, format: date-time, nullable: true }
reply_to_id: { $ref: '#/components/schemas/UUID' }
required: [id, channel_id, user_id, content, created_at]
CreateMessageRequest:
type: object
required: [channel_id, user_id, content]
properties:
channel_id: { $ref: '#/components/schemas/UUID' }
user_id: { $ref: '#/components/schemas/UUID' }
content: { type: string }
reply_to_id: { $ref: '#/components/schemas/UUID' }
UpdateMessageRequest:
type: object
required: [content]
properties:
content: { type: string }
reply_to_id: { $ref: '#/components/schemas/UUID' }