feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/util"
|
||||
"codespace/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// writeError maps a service-layer error to an HTTP JSON error response.
|
||||
func writeError(c *gin.Context, err error) {
|
||||
code := util.CodeOf(err)
|
||||
var status int
|
||||
switch code {
|
||||
case util.CodeBadRequest:
|
||||
status = http.StatusBadRequest
|
||||
case util.CodeNotFound:
|
||||
status = http.StatusNotFound
|
||||
case util.CodeConflict:
|
||||
status = http.StatusConflict
|
||||
default:
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
msg := http.StatusText(status)
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
c.JSON(status, response.ErrorBody{Error: response.ErrorDetail{
|
||||
Code: string(code),
|
||||
Message: msg,
|
||||
}})
|
||||
}
|
||||
|
||||
// writeBadRequest is a shorthand for 400 responses without a service error.
|
||||
func writeBadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, response.ErrorBody{Error: response.ErrorDetail{
|
||||
Code: string(util.CodeBadRequest),
|
||||
Message: message,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type fileHandler struct {
|
||||
svc *service.FileService
|
||||
}
|
||||
|
||||
func (h *fileHandler) list(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
infos, err := h.svc.List(id, path)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
|
||||
func (h *fileHandler) read(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Read(id, path)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.ReadFileResponse{
|
||||
Path: path,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *fileHandler) write(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.WriteFileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Write(id, req.Path, []byte(req.Content)); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) mkdir(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.MkdirRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Mkdir(id, req.Path); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) remove(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Remove(id, path); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) rename(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.RenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.OldPath == "" || req.NewPath == "" {
|
||||
writeBadRequest(c, "oldPath and newPath are required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Rename(id, req.OldPath, req.NewPath); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type processHandler struct {
|
||||
svc *service.ProcessService
|
||||
}
|
||||
|
||||
func (h *processHandler) start(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Start(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) stop(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Stop(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) restart(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Restart(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) status(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
status, err := h.svc.Status(id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.ProcessStatusResponse{
|
||||
WorkspaceID: status.WorkspaceID,
|
||||
Running: status.Running,
|
||||
PID: status.PID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter builds a Gin engine with all API routes registered.
|
||||
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
wsHandler := &workspaceHandler{svc: workspaces}
|
||||
fileHandler := &fileHandler{svc: files}
|
||||
procHandler := &processHandler{svc: processes}
|
||||
|
||||
r.GET("/healthz", healthHandler)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.POST("/workspaces", wsHandler.create)
|
||||
api.GET("/workspaces/:id", wsHandler.get)
|
||||
api.DELETE("/workspaces/:id", wsHandler.delete)
|
||||
|
||||
api.GET("/workspaces/:id/files", fileHandler.list)
|
||||
api.GET("/workspaces/:id/files/read", fileHandler.read)
|
||||
api.PUT("/workspaces/:id/files/write", fileHandler.write)
|
||||
api.POST("/workspaces/:id/files/mkdir", fileHandler.mkdir)
|
||||
api.DELETE("/workspaces/:id/files", fileHandler.remove)
|
||||
api.POST("/workspaces/:id/files/rename", fileHandler.rename)
|
||||
|
||||
api.POST("/workspaces/:id/process/start", procHandler.start)
|
||||
api.POST("/workspaces/:id/process/stop", procHandler.stop)
|
||||
api.POST("/workspaces/:id/process/restart", procHandler.restart)
|
||||
api.GET("/workspaces/:id/process/status", procHandler.status)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func healthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/service"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
func setupTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
wsMgr := workspace.NewLocalManager(root)
|
||||
procMgr := process.NewManager("")
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr)
|
||||
fileSvc := service.NewFileService(wsMgr)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
req := httptest.NewRequest("GET", "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status = %q, want ok", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspace(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["id"] != "user1" {
|
||||
t.Errorf("id = %q, want user1", resp["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadFile(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// Create workspace first.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Write file.
|
||||
writePayload, _ := json.Marshal(map[string]string{"path": "main.go", "content": "package main\n"})
|
||||
req = httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(writePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("write file: status=%d want=%d body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
|
||||
// Read file.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=main.go", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read file: status=%d want=%d body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["content"] != "package main\n" {
|
||||
t.Errorf("content = %q, want 'package main\\n'", resp["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStatus(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// Create workspace.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d", rec.Code)
|
||||
}
|
||||
|
||||
// Get process status.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/process/status", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != "user1" {
|
||||
t.Errorf("workspaceId = %q, want user1", resp.WorkspaceID)
|
||||
}
|
||||
if resp.Running {
|
||||
t.Error("expected Running=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type workspaceHandler struct {
|
||||
svc *service.WorkspaceService
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) create(c *gin.Context) {
|
||||
var req model.CreateWorkspaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.ID == "" {
|
||||
writeBadRequest(c, "id is required")
|
||||
return
|
||||
}
|
||||
ws, err := h.svc.Create(req.ID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ws, err := h.svc.Get(id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Delete(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user