diff --git a/internal/handler/file_handler.go b/internal/handler/file_handler.go new file mode 100644 index 0000000..ec49f3f --- /dev/null +++ b/internal/handler/file_handler.go @@ -0,0 +1,138 @@ +package handler + +import ( + "context" + "io" + "strconv" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/server" + "gcy_hpc_server/internal/service" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type fileServiceProvider interface { + ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) + GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) + DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) + DeleteFile(ctx context.Context, fileID int64) error +} + +type FileHandler struct { + svc fileServiceProvider + logger *zap.Logger +} + +func NewFileHandler(svc *service.FileService, logger *zap.Logger) *FileHandler { + return &FileHandler{svc: svc, logger: logger} +} + +func (h *FileHandler) ListFiles(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + + var folderID *int64 + if v := c.Query("folder_id"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + h.logger.Warn("invalid folder_id", zap.String("folder_id", v)) + server.BadRequest(c, "invalid folder_id") + return + } + folderID = &id + } + + search := c.Query("search") + + files, total, err := h.svc.ListFiles(c.Request.Context(), folderID, page, pageSize, search) + if err != nil { + h.logger.Error("failed to list files", zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + server.OK(c, model.ListFilesResponse{ + Files: files, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +func (h *FileHandler) GetFile(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid file id", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + + file, blob, err := h.svc.GetFileMetadata(c.Request.Context(), id) + if err != nil { + h.logger.Error("failed to get file", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + resp := model.FileResponse{ + ID: file.ID, + Name: file.Name, + FolderID: file.FolderID, + Size: blob.FileSize, + MimeType: blob.MimeType, + SHA256: file.BlobSHA256, + CreatedAt: file.CreatedAt, + UpdatedAt: file.UpdatedAt, + } + server.OK(c, resp) +} + +func (h *FileHandler) DownloadFile(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid file id for download", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + + rangeHeader := c.GetHeader("Range") + + reader, file, blob, start, end, err := h.svc.DownloadFile(c.Request.Context(), id, rangeHeader) + if err != nil { + h.logger.Error("failed to download file", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + if rangeHeader != "" { + server.StreamRange(c, reader, start, end, blob.FileSize, blob.MimeType) + } else { + server.StreamFile(c, reader, file.Name, blob.FileSize, blob.MimeType) + } +} + +func (h *FileHandler) DeleteFile(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid file id for delete", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + + if err := h.svc.DeleteFile(c.Request.Context(), id); err != nil { + h.logger.Error("failed to delete file", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + h.logger.Info("file deleted", zap.Int64("id", id)) + server.OK(c, gin.H{"message": "file deleted"}) +} diff --git a/internal/handler/file_handler_test.go b/internal/handler/file_handler_test.go new file mode 100644 index 0000000..d8d0f70 --- /dev/null +++ b/internal/handler/file_handler_test.go @@ -0,0 +1,369 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gcy_hpc_server/internal/model" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type mockFileService struct { + listFilesFn func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) + getFileMetadataFn func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) + downloadFileFn func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) + deleteFileFn func(ctx context.Context, fileID int64) error +} + +func (m *mockFileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { + return m.listFilesFn(ctx, folderID, page, pageSize, search) +} + +func (m *mockFileService) GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) { + return m.getFileMetadataFn(ctx, fileID) +} + +func (m *mockFileService) DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) { + return m.downloadFileFn(ctx, fileID, rangeHeader) +} + +func (m *mockFileService) DeleteFile(ctx context.Context, fileID int64) error { + return m.deleteFileFn(ctx, fileID) +} + +type fileHandlerSetup struct { + handler *FileHandler + mock *mockFileService + router *gin.Engine +} + +func newFileHandlerSetup() *fileHandlerSetup { + gin.SetMode(gin.TestMode) + mock := &mockFileService{} + h := &FileHandler{ + svc: mock, + logger: zap.NewNop(), + } + r := gin.New() + v1 := r.Group("/api/v1") + files := v1.Group("/files") + files.GET("", h.ListFiles) + files.GET("/:id", h.GetFile) + files.GET("/:id/download", h.DownloadFile) + files.DELETE("/:id", h.DeleteFile) + return &fileHandlerSetup{handler: h, mock: mock, router: r} +} + +// ---- ListFiles Tests ---- + +func TestListFiles_Empty(t *testing.T) { + s := newFileHandlerSetup() + s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { + return []model.FileResponse{}, 0, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + files := data["files"].([]interface{}) + if len(files) != 0 { + t.Fatalf("expected empty files list, got %d", len(files)) + } + if data["total"].(float64) != 0 { + t.Fatalf("expected total=0, got %v", data["total"]) + } +} + +func TestListFiles_WithFiles(t *testing.T) { + s := newFileHandlerSetup() + now := time.Now() + s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { + return []model.FileResponse{ + {ID: 1, Name: "a.txt", Size: 100, MimeType: "text/plain", SHA256: "abc123", CreatedAt: now, UpdatedAt: now}, + {ID: 2, Name: "b.pdf", Size: 200, MimeType: "application/pdf", SHA256: "def456", CreatedAt: now, UpdatedAt: now}, + }, 2, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files?page=1&page_size=10", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + files := data["files"].([]interface{}) + if len(files) != 2 { + t.Fatalf("expected 2 files, got %d", len(files)) + } + if data["total"].(float64) != 2 { + t.Fatalf("expected total=2, got %v", data["total"]) + } + if data["page"].(float64) != 1 { + t.Fatalf("expected page=1, got %v", data["page"]) + } + if data["page_size"].(float64) != 10 { + t.Fatalf("expected page_size=10, got %v", data["page_size"]) + } +} + +func TestListFiles_WithFolderID(t *testing.T) { + s := newFileHandlerSetup() + var capturedFolderID *int64 + s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { + capturedFolderID = folderID + return []model.FileResponse{}, 0, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files?folder_id=5", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if capturedFolderID == nil || *capturedFolderID != 5 { + t.Fatalf("expected folder_id=5, got %v", capturedFolderID) + } +} + +func TestListFiles_ServiceError(t *testing.T) { + s := newFileHandlerSetup() + s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) { + return nil, 0, fmt.Errorf("db error") + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +// ---- GetFile Tests ---- + +func TestGetFile_Found(t *testing.T) { + s := newFileHandlerSetup() + now := time.Now() + s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) { + return &model.File{ + ID: 1, Name: "test.txt", BlobSHA256: "abc123", CreatedAt: now, UpdatedAt: now, + }, &model.FileBlob{ + ID: 1, SHA256: "abc123", FileSize: 1024, MimeType: "text/plain", CreatedAt: now, + }, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } +} + +func TestGetFile_NotFound(t *testing.T) { + s := newFileHandlerSetup() + s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) { + return nil, nil, fmt.Errorf("file not found: 999") + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/999", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetFile_InvalidID(t *testing.T) { + s := newFileHandlerSetup() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/abc", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +// ---- DownloadFile Tests ---- + +func TestDownloadFile_Full(t *testing.T) { + s := newFileHandlerSetup() + content := "hello world file content" + now := time.Now() + s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) { + reader := io.NopCloser(strings.NewReader(content)) + return reader, + &model.File{ID: 1, Name: "test.txt", CreatedAt: now}, + &model.FileBlob{FileSize: int64(len(content)), MimeType: "text/plain"}, + 0, int64(len(content)) - 1, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1/download", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Check streaming headers + if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "test.txt") { + t.Fatalf("expected Content-Disposition to contain 'test.txt', got %s", got) + } + if got := w.Header().Get("Content-Type"); got != "text/plain" { + t.Fatalf("expected Content-Type=text/plain, got %s", got) + } + if got := w.Header().Get("Accept-Ranges"); got != "bytes" { + t.Fatalf("expected Accept-Ranges=bytes, got %s", got) + } + if w.Body.String() != content { + t.Fatalf("expected body %q, got %q", content, w.Body.String()) + } +} + +func TestDownloadFile_WithRange(t *testing.T) { + s := newFileHandlerSetup() + content := "hello world" + now := time.Now() + s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) { + reader := io.NopCloser(strings.NewReader(content[0:5])) + return reader, + &model.File{ID: 1, Name: "test.txt", CreatedAt: now}, + &model.FileBlob{FileSize: int64(len(content)), MimeType: "text/plain"}, + 0, 4, nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1/download", nil) + req.Header.Set("Range", "bytes=0-4") + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusPartialContent { + t.Fatalf("expected 206, got %d: %s", w.Code, w.Body.String()) + } + + // Check Content-Range header + if got := w.Header().Get("Content-Range"); got != "bytes 0-4/11" { + t.Fatalf("expected Content-Range 'bytes 0-4/11', got %s", got) + } + if got := w.Header().Get("Content-Type"); got != "text/plain" { + t.Fatalf("expected Content-Type=text/plain, got %s", got) + } + if got := w.Header().Get("Content-Length"); got != "5" { + t.Fatalf("expected Content-Length=5, got %s", got) + } +} + +func TestDownloadFile_InvalidID(t *testing.T) { + s := newFileHandlerSetup() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/abc/download", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestDownloadFile_ServiceError(t *testing.T) { + s := newFileHandlerSetup() + s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) { + return nil, nil, nil, 0, 0, fmt.Errorf("file not found: 999") + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/999/download", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +// ---- DeleteFile Tests ---- + +func TestDeleteFile_Success(t *testing.T) { + s := newFileHandlerSetup() + s.mock.deleteFileFn = func(ctx context.Context, fileID int64) error { + return nil + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/1", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["message"] != "file deleted" { + t.Fatalf("expected message 'file deleted', got %v", data["message"]) + } +} + +func TestDeleteFile_InvalidID(t *testing.T) { + s := newFileHandlerSetup() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/abc", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestDeleteFile_ServiceError(t *testing.T) { + s := newFileHandlerSetup() + s.mock.deleteFileFn = func(ctx context.Context, fileID int64) error { + return fmt.Errorf("file not found: 999") + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/999", nil) + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handler/folder_handler.go b/internal/handler/folder_handler.go new file mode 100644 index 0000000..ee00532 --- /dev/null +++ b/internal/handler/folder_handler.go @@ -0,0 +1,133 @@ +package handler + +import ( + "context" + "strconv" + "strings" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/server" + "gcy_hpc_server/internal/service" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type folderServiceProvider interface { + CreateFolder(ctx context.Context, name string, parentID *int64) (*model.FolderResponse, error) + GetFolder(ctx context.Context, id int64) (*model.FolderResponse, error) + ListFolders(ctx context.Context, parentID *int64) ([]model.FolderResponse, error) + DeleteFolder(ctx context.Context, id int64) error +} + +type FolderHandler struct { + svc folderServiceProvider + logger *zap.Logger +} + +func NewFolderHandler(svc *service.FolderService, logger *zap.Logger) *FolderHandler { + return &FolderHandler{svc: svc, logger: logger} +} + +func (h *FolderHandler) CreateFolder(c *gin.Context) { + var req model.CreateFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("invalid request body for create folder", zap.Error(err)) + server.BadRequest(c, "invalid request body") + return + } + if req.Name == "" { + h.logger.Warn("missing folder name") + server.BadRequest(c, "name is required") + return + } + + resp, err := h.svc.CreateFolder(c.Request.Context(), req.Name, req.ParentID) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "invalid folder name") || strings.Contains(errStr, "cannot be") { + h.logger.Warn("invalid folder name", zap.String("name", req.Name), zap.Error(err)) + server.BadRequest(c, errStr) + return + } + h.logger.Error("failed to create folder", zap.Error(err)) + server.InternalError(c, errStr) + return + } + h.logger.Info("folder created", zap.Int64("id", resp.ID)) + server.Created(c, resp) +} + +func (h *FolderHandler) GetFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid folder id", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + + resp, err := h.svc.GetFolder(c.Request.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + h.logger.Warn("folder not found", zap.Int64("id", id)) + server.NotFound(c, "folder not found") + return + } + h.logger.Error("failed to get folder", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + server.OK(c, resp) +} + +func (h *FolderHandler) ListFolders(c *gin.Context) { + var parentID *int64 + if q := c.Query("parent_id"); q != "" { + pid, err := strconv.ParseInt(q, 10, 64) + if err != nil { + h.logger.Warn("invalid parent_id query param", zap.String("parent_id", q)) + server.BadRequest(c, "invalid parent_id") + return + } + parentID = &pid + } + + folders, err := h.svc.ListFolders(c.Request.Context(), parentID) + if err != nil { + h.logger.Error("failed to list folders", zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + if folders == nil { + folders = []model.FolderResponse{} + } + server.OK(c, folders) +} + +func (h *FolderHandler) DeleteFolder(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid folder id for delete", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + + if err := h.svc.DeleteFolder(c.Request.Context(), id); err != nil { + errStr := err.Error() + if strings.Contains(errStr, "not empty") { + h.logger.Warn("cannot delete non-empty folder", zap.Int64("id", id)) + server.BadRequest(c, "folder is not empty") + return + } + if strings.Contains(errStr, "not found") { + h.logger.Warn("folder not found for delete", zap.Int64("id", id)) + server.NotFound(c, "folder not found") + return + } + h.logger.Error("failed to delete folder", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, errStr) + return + } + h.logger.Info("folder deleted", zap.Int64("id", id)) + server.OK(c, gin.H{"message": "folder deleted"}) +} diff --git a/internal/handler/folder_handler_test.go b/internal/handler/folder_handler_test.go new file mode 100644 index 0000000..3d76dcd --- /dev/null +++ b/internal/handler/folder_handler_test.go @@ -0,0 +1,206 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/service" + "gcy_hpc_server/internal/store" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func setupFolderHandler() (*FolderHandler, *gorm.DB) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + db.AutoMigrate(&model.Folder{}, &model.File{}) + folderStore := store.NewFolderStore(db) + fileStore := store.NewFileStore(db) + svc := service.NewFolderService(folderStore, fileStore, zap.NewNop()) + h := NewFolderHandler(svc, zap.NewNop()) + return h, db +} + +func setupFolderRouter(h *FolderHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + v1 := r.Group("/api/v1") + folders := v1.Group("/files/folders") + folders.POST("", h.CreateFolder) + folders.GET("/:id", h.GetFolder) + folders.GET("", h.ListFolders) + folders.DELETE("/:id", h.DeleteFolder) + return r +} + +func createTestFolder(h *FolderHandler, r *gin.Engine) int64 { + body, _ := json.Marshal(model.CreateFolderRequest{Name: "test-folder"}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + return int64(data["id"].(float64)) +} + +func TestCreateFolder_Success(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + body, _ := json.Marshal(model.CreateFolderRequest{Name: "my-folder"}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["name"] != "my-folder" { + t.Fatalf("expected name=my-folder, got %v", data["name"]) + } + if data["path"] != "/my-folder/" { + t.Fatalf("expected path=/my-folder/, got %v", data["path"]) + } +} + +func TestCreateFolder_PathTraversal(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + body, _ := json.Marshal(model.CreateFolderRequest{Name: ".."}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateFolder_MissingName(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + body, _ := json.Marshal(map[string]interface{}{}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetFolder_Success(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + id := createTestFolder(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders/"+itoa(id), nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + if data["name"] != "test-folder" { + t.Fatalf("expected name=test-folder, got %v", data["name"]) + } +} + +func TestGetFolder_NotFound(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders/999", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListFolders_Success(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + createTestFolder(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].([]interface{}) + if len(data) < 1 { + t.Fatal("expected at least 1 folder") + } +} + +func TestDeleteFolder_Success(t *testing.T) { + h, _ := setupFolderHandler() + r := setupFolderRouter(h) + + id := createTestFolder(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/folders/"+itoa(id), nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestDeleteFolder_NonEmpty(t *testing.T) { + h, db := setupFolderHandler() + r := setupFolderRouter(h) + + parentID := createTestFolder(h, r) + + child := &model.Folder{ + Name: "child-folder", + ParentID: &parentID, + Path: "/test-folder/child-folder/", + } + db.Create(child) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/folders/"+itoa(parentID), nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handler/upload_handler.go b/internal/handler/upload_handler.go new file mode 100644 index 0000000..ca12e63 --- /dev/null +++ b/internal/handler/upload_handler.go @@ -0,0 +1,154 @@ +package handler + +import ( + "context" + "io" + "strconv" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/server" + "gcy_hpc_server/internal/service" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// uploadServiceProvider defines the interface for upload operations. +type uploadServiceProvider interface { + InitUpload(ctx context.Context, req model.InitUploadRequest) (interface{}, error) + UploadChunk(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error + CompleteUpload(ctx context.Context, sessionID int64) (*model.FileResponse, error) + GetUploadStatus(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) + CancelUpload(ctx context.Context, sessionID int64) error +} + +// UploadHandler handles HTTP requests for chunked file uploads. +type UploadHandler struct { + svc uploadServiceProvider + logger *zap.Logger +} + +// NewUploadHandler creates a new UploadHandler. +func NewUploadHandler(svc *service.UploadService, logger *zap.Logger) *UploadHandler { + return &UploadHandler{svc: svc, logger: logger} +} + +// InitUpload initiates a new chunked upload session. +// POST /api/v1/files/uploads +func (h *UploadHandler) InitUpload(c *gin.Context) { + var req model.InitUploadRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("invalid request body for init upload", zap.Error(err)) + server.BadRequest(c, err.Error()) + return + } + + result, err := h.svc.InitUpload(c.Request.Context(), req) + if err != nil { + h.logger.Error("failed to init upload", zap.Error(err)) + server.BadRequest(c, err.Error()) + return + } + + switch resp := result.(type) { + case model.FileResponse: + server.OK(c, resp) + case model.UploadSessionResponse: + server.Created(c, resp) + default: + server.Created(c, resp) + } +} + +// UploadChunk uploads a single chunk of an upload session. +// PUT /api/v1/files/uploads/:id/chunks/:index +func (h *UploadHandler) UploadChunk(c *gin.Context) { + sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid session id", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid session id") + return + } + + chunkIndex, err := strconv.Atoi(c.Param("index")) + if err != nil { + h.logger.Warn("invalid chunk index", zap.String("index", c.Param("index"))) + server.BadRequest(c, "invalid chunk index") + return + } + + file, header, err := c.Request.FormFile("chunk") + if err != nil { + h.logger.Warn("missing chunk file in request", zap.Error(err)) + server.BadRequest(c, "missing chunk file") + return + } + defer file.Close() + + if err := h.svc.UploadChunk(c.Request.Context(), sessionID, chunkIndex, file, header.Size); err != nil { + h.logger.Error("failed to upload chunk", zap.Int64("session_id", sessionID), zap.Int("chunk_index", chunkIndex), zap.Error(err)) + server.BadRequest(c, err.Error()) + return + } + + server.OK(c, gin.H{"message": "chunk uploaded"}) +} + +// CompleteUpload finalizes an upload session and assembles the file. +// POST /api/v1/files/uploads/:id/complete +func (h *UploadHandler) CompleteUpload(c *gin.Context) { + sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid session id for complete", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid session id") + return + } + + resp, err := h.svc.CompleteUpload(c.Request.Context(), sessionID) + if err != nil { + h.logger.Error("failed to complete upload", zap.Int64("session_id", sessionID), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + server.Created(c, resp) +} + +// GetUploadStatus returns the current status of an upload session. +// GET /api/v1/files/uploads/:id +func (h *UploadHandler) GetUploadStatus(c *gin.Context) { + sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid session id for status", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid session id") + return + } + + resp, err := h.svc.GetUploadStatus(c.Request.Context(), sessionID) + if err != nil { + h.logger.Error("failed to get upload status", zap.Int64("session_id", sessionID), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + server.OK(c, resp) +} + +// CancelUpload cancels and cleans up an upload session. +// DELETE /api/v1/files/uploads/:id +func (h *UploadHandler) CancelUpload(c *gin.Context) { + sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid session id for cancel", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid session id") + return + } + + if err := h.svc.CancelUpload(c.Request.Context(), sessionID); err != nil { + h.logger.Error("failed to cancel upload", zap.Int64("session_id", sessionID), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + server.OK(c, gin.H{"message": "upload cancelled"}) +} diff --git a/internal/handler/upload_handler_test.go b/internal/handler/upload_handler_test.go new file mode 100644 index 0000000..33ac5ea --- /dev/null +++ b/internal/handler/upload_handler_test.go @@ -0,0 +1,307 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gcy_hpc_server/internal/model" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type mockUploadService struct { + initUploadFn func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) + uploadChunkFn func(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error + completeUploadFn func(ctx context.Context, sessionID int64) (*model.FileResponse, error) + getUploadStatusFn func(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) + cancelUploadFn func(ctx context.Context, sessionID int64) error +} + +func (m *mockUploadService) InitUpload(ctx context.Context, req model.InitUploadRequest) (interface{}, error) { + return m.initUploadFn(ctx, req) +} + +func (m *mockUploadService) UploadChunk(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error { + return m.uploadChunkFn(ctx, sessionID, chunkIndex, reader, size) +} + +func (m *mockUploadService) CompleteUpload(ctx context.Context, sessionID int64) (*model.FileResponse, error) { + return m.completeUploadFn(ctx, sessionID) +} + +func (m *mockUploadService) GetUploadStatus(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) { + return m.getUploadStatusFn(ctx, sessionID) +} + +func (m *mockUploadService) CancelUpload(ctx context.Context, sessionID int64) error { + return m.cancelUploadFn(ctx, sessionID) +} + +func setupUploadHandler(mock uploadServiceProvider) (*UploadHandler, *gin.Engine) { + gin.SetMode(gin.TestMode) + h := &UploadHandler{svc: mock, logger: zap.NewNop()} + r := gin.New() + v1 := r.Group("/api/v1") + uploads := v1.Group("/files/uploads") + uploads.POST("", h.InitUpload) + uploads.PUT("/:id/chunks/:index", h.UploadChunk) + uploads.POST("/:id/complete", h.CompleteUpload) + uploads.GET("/:id", h.GetUploadStatus) + uploads.DELETE("/:id", h.CancelUpload) + return h, r +} + +func TestInitUpload_NewSession(t *testing.T) { + mock := &mockUploadService{ + initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) { + return model.UploadSessionResponse{ + ID: 1, + FileName: req.FileName, + FileSize: req.FileSize, + ChunkSize: 5 * 1024 * 1024, + TotalChunks: 2, + SHA256: req.SHA256, + Status: "pending", + UploadedChunks: []int{}, + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + }, nil + }, + } + _, r := setupUploadHandler(mock) + + body, _ := json.Marshal(model.InitUploadRequest{ + FileName: "test.bin", + FileSize: 10 * 1024 * 1024, + SHA256: "abc123", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["status"] != "pending" { + t.Fatalf("expected status=pending, got %v", data["status"]) + } +} + +func TestInitUpload_DedupHit(t *testing.T) { + mock := &mockUploadService{ + initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) { + return model.FileResponse{ + ID: 42, + Name: req.FileName, + Size: req.FileSize, + SHA256: req.SHA256, + MimeType: "application/octet-stream", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + _, r := setupUploadHandler(mock) + + body, _ := json.Marshal(model.InitUploadRequest{ + FileName: "existing.bin", + FileSize: 1024, + SHA256: "existinghash", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["sha256"] != "existinghash" { + t.Fatalf("expected sha256=existinghash, got %v", data["sha256"]) + } +} + +func TestInitUpload_MissingFields(t *testing.T) { + mock := &mockUploadService{ + initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) { + return nil, nil + }, + } + _, r := setupUploadHandler(mock) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUploadChunk_Success(t *testing.T) { + var receivedSessionID int64 + var receivedChunkIndex int + + mock := &mockUploadService{ + uploadChunkFn: func(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error { + receivedSessionID = sessionID + receivedChunkIndex = chunkIndex + readBytes, _ := io.ReadAll(reader) + if len(readBytes) == 0 { + t.Error("expected non-empty chunk data") + } + return nil + }, + } + _, r := setupUploadHandler(mock) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, _ := writer.CreateFormFile("chunk", "test.bin") + fmt.Fprintf(part, "chunk data here") + writer.Close() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/api/v1/files/uploads/1/chunks/0", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if receivedSessionID != 1 { + t.Fatalf("expected session_id=1, got %d", receivedSessionID) + } + if receivedChunkIndex != 0 { + t.Fatalf("expected chunk_index=0, got %d", receivedChunkIndex) + } +} + +func TestCompleteUpload_Success(t *testing.T) { + mock := &mockUploadService{ + completeUploadFn: func(ctx context.Context, sessionID int64) (*model.FileResponse, error) { + return &model.FileResponse{ + ID: 10, + Name: "completed.bin", + Size: 2048, + SHA256: "completehash", + MimeType: "application/octet-stream", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + _, r := setupUploadHandler(mock) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads/5/complete", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["name"] != "completed.bin" { + t.Fatalf("expected name=completed.bin, got %v", data["name"]) + } +} + +func TestGetUploadStatus_Success(t *testing.T) { + mock := &mockUploadService{ + getUploadStatusFn: func(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) { + return &model.UploadSessionResponse{ + ID: sessionID, + FileName: "status.bin", + FileSize: 4096, + ChunkSize: 2048, + TotalChunks: 2, + SHA256: "statushash", + Status: "uploading", + UploadedChunks: []int{0}, + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + }, nil + }, + } + _, r := setupUploadHandler(mock) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/uploads/3", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if !resp["success"].(bool) { + t.Fatal("expected success=true") + } + data := resp["data"].(map[string]interface{}) + if data["status"] != "uploading" { + t.Fatalf("expected status=uploading, got %v", data["status"]) + } +} + +func TestCancelUpload_Success(t *testing.T) { + var receivedSessionID int64 + + mock := &mockUploadService{ + cancelUploadFn: func(ctx context.Context, sessionID int64) error { + receivedSessionID = sessionID + return nil + }, + } + _, r := setupUploadHandler(mock) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/uploads/7", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if receivedSessionID != 7 { + t.Fatalf("expected session_id=7, got %d", receivedSessionID) + } + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + if data["message"] != "upload cancelled" { + t.Fatalf("expected message='upload cancelled', got %v", data["message"]) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a3331c..ab1cb46 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,8 +34,30 @@ type ApplicationHandler interface { SubmitApplication(c *gin.Context) } +type UploadHandler interface { + InitUpload(c *gin.Context) + GetUploadStatus(c *gin.Context) + UploadChunk(c *gin.Context) + CompleteUpload(c *gin.Context) + CancelUpload(c *gin.Context) +} + +type FileHandler interface { + ListFiles(c *gin.Context) + GetFile(c *gin.Context) + DownloadFile(c *gin.Context) + DeleteFile(c *gin.Context) +} + +type FolderHandler interface { + CreateFolder(c *gin.Context) + GetFolder(c *gin.Context) + ListFolders(c *gin.Context) + DeleteFolder(c *gin.Context) +} + // NewRouter creates a Gin engine with all API v1 routes registered with real handlers. -func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler, logger *zap.Logger) *gin.Engine { +func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler, uploadH UploadHandler, fileH FileHandler, folderH FolderHandler, logger *zap.Logger) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) @@ -68,6 +90,32 @@ func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler apps.DELETE("/:id", appH.DeleteApplication) apps.POST("/:id/submit", appH.SubmitApplication) + files := v1.Group("/files") + + if uploadH != nil { + uploads := files.Group("/uploads") + uploads.POST("", uploadH.InitUpload) + uploads.GET("/:id", uploadH.GetUploadStatus) + uploads.PUT("/:id/chunks/:index", uploadH.UploadChunk) + uploads.POST("/:id/complete", uploadH.CompleteUpload) + uploads.DELETE("/:id", uploadH.CancelUpload) + } + + if fileH != nil { + files.GET("", fileH.ListFiles) + files.GET("/:id", fileH.GetFile) + files.GET("/:id/download", fileH.DownloadFile) + files.DELETE("/:id", fileH.DeleteFile) + } + + if folderH != nil { + folders := files.Group("/folders") + folders.POST("", folderH.CreateFolder) + folders.GET("", folderH.ListFolders) + folders.GET("/:id", folderH.GetFolder) + folders.DELETE("/:id", folderH.DeleteFolder) + } + return r } @@ -104,6 +152,26 @@ func registerPlaceholderRoutes(v1 *gin.RouterGroup) { apps.PUT("/:id", notImplemented) apps.DELETE("/:id", notImplemented) apps.POST("/:id/submit", notImplemented) + + files := v1.Group("/files") + + uploads := files.Group("/uploads") + uploads.POST("", notImplemented) + uploads.GET("/:id", notImplemented) + uploads.PUT("/:id/chunks/:index", notImplemented) + uploads.POST("/:id/complete", notImplemented) + uploads.DELETE("/:id", notImplemented) + + files.GET("", notImplemented) + files.GET("/:id", notImplemented) + files.GET("/:id/download", notImplemented) + files.DELETE("/:id", notImplemented) + + folders := files.Group("/folders") + folders.POST("", notImplemented) + folders.GET("", notImplemented) + folders.GET("/:id", notImplemented) + folders.DELETE("/:id", notImplemented) } func notImplemented(c *gin.Context) {