This commit is contained in:
2026-02-15 15:55:49 +08:00
commit 23decb8687
32 changed files with 3822 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"monica-go-completion-backend/internal/completion"
)
type CompletionService interface {
Complete(ctx context.Context, req completion.Request) (completion.Response, error)
}
func RegisterRoutes(router *gin.Engine, service CompletionService) {
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
registerWSRoutes(router, service)
router.POST("/api/v1/completions/go", func(c *gin.Context) {
var req completion.Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
return
}
resp, err := service.Complete(c.Request.Context(), req)
if err != nil {
if errors.Is(err, completion.ErrInvalidRequest) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
return
}
c.JSON(http.StatusOK, resp)
})
}

View File

@@ -0,0 +1,171 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"monica-go-completion-backend/internal/completion"
)
type fakeCompletionService struct {
resp completion.Response
err error
}
func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request) (completion.Response, error) {
if f.err != nil {
return completion.Response{}, f.err
}
return f.resp, nil
}
func TestRegisterRoutesCompletionSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: true},
})
body, _ := json.Marshal(map[string]any{
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var got struct {
Items []completion.Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(got.Items) != 1 || got.Items[0].Label != "Println" || !got.IsIncomplete {
t.Fatalf("unexpected response body: %s", w.Body.String())
}
}
func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionValidationError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
rv := &fakeCompletionService{err: completion.ErrInvalidRequest}
RegisterRoutes(r, rv)
body, _ := json.Marshal(map[string]any{
"uri": "",
"text": "",
"line": -1,
"character": 0,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionServerError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{err: errors.New("boom")})
body, _ := json.Marshal(map[string]any{
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterRoutes(r, &fakeCompletionService{
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: false},
})
srv := httptest.NewServer(r)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/completions/go"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("websocket dial failed: %v", err)
}
defer conn.Close()
req := map[string]any{
"id": "1",
"uri": "file:///main.go",
"text": "package main",
"line": 0,
"character": 0,
}
if err := conn.WriteJSON(req); err != nil {
t.Fatalf("WriteJSON() failed: %v", err)
}
var resp struct {
ID string `json:"id"`
Items []completion.Item `json:"items"`
IsIncomplete bool `json:"isIncomplete"`
Error string `json:"error"`
}
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("ReadJSON() failed: %v", err)
}
if resp.Error != "" {
t.Fatalf("unexpected websocket error: %s", resp.Error)
}
if resp.ID != "1" {
t.Fatalf("unexpected id: %s", resp.ID)
}
if len(resp.Items) != 1 || resp.Items[0].Label != "Println" {
t.Fatalf("unexpected items: %+v", resp.Items)
}
}

View File

@@ -0,0 +1,101 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"monica-go-completion-backend/internal/completion"
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(_ *http.Request) bool {
return true
},
}
type wsCompletionRequest struct {
ID string `json:"id"`
URI string `json:"uri"`
Text string `json:"text"`
Line int `json:"line"`
Character int `json:"character"`
}
type wsCompletionResponse struct {
ID string `json:"id"`
Items []completion.Item `json:"items,omitempty"`
IsIncomplete bool `json:"isIncomplete,omitempty"`
Error string `json:"error,omitempty"`
}
func registerWSRoutes(router *gin.Engine, service CompletionService) {
router.GET("/ws/completions/go", func(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
var writeMu sync.Mutex
for {
_, payload, err := conn.ReadMessage()
if err != nil {
break
}
var req wsCompletionRequest
if err := json.Unmarshal(payload, &req); err != nil {
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: "",
Error: "invalid JSON payload",
})
continue
}
go func(r wsCompletionRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := service.Complete(ctx, completion.Request{
URI: r.URI,
Text: r.Text,
Line: r.Line,
Character: r.Character,
})
if err != nil {
msg := "completion failed"
if errors.Is(err, completion.ErrInvalidRequest) {
msg = err.Error()
}
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: r.ID,
Error: msg,
})
return
}
sendWSResponse(conn, &writeMu, wsCompletionResponse{
ID: r.ID,
Items: resp.Items,
IsIncomplete: resp.IsIncomplete,
})
}(req)
}
})
}
func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) {
writeMu.Lock()
defer writeMu.Unlock()
_ = conn.WriteJSON(resp)
}