فهرست منبع

Merge pull request #9 from database-pizza/fix/http-sqlstate-codes

fix(http): report UNIQUE violations as HTTP 409 with code 23505
Danilo Fragoso 17 ساعت پیش
والد
کامیت
35b639cd89
2فایلهای تغییر یافته به همراه111 افزوده شده و 6 حذف شده
  1. 42 6
      pkg/httpserver/handler.go
  2. 69 0
      pkg/httpserver/sqlstate_test.go

+ 42 - 6
pkg/httpserver/handler.go

@@ -140,11 +140,7 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
 		// Execute using the database-specific executor
 		result, err := exec.Execute(stmt)
 		if err != nil {
-			errorChan <- &HTTPError{
-				Code:    "EXECUTION_ERROR",
-				Message: err.Error(),
-				Status:  http.StatusInternalServerError,
-			}
+			errorChan <- sqlStateHTTPError(err)
 			return
 		}
 
@@ -301,7 +297,8 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	} else if executeErr != nil {
-		writeError(w, http.StatusBadRequest, "EXECUTION_ERROR", executeErr.Error(), nil)
+		httpErr := sqlStateHTTPError(executeErr)
+		writeError(w, httpErr.Status, httpErr.Code, httpErr.Message, httpErr.Details)
 		return
 	}
 
@@ -398,6 +395,45 @@ func (s *Server) handleSchemaTable(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, resp, pretty)
 }
 
+// sqlStateHTTPError maps engine errors onto the same SQLSTATE codes the
+// PostgreSQL endpoint reports. The executor returns plain errors on released
+// builds, so the unique-violation and unsupported-feature markers are matched
+// on their stable message prefixes.
+func sqlStateHTTPError(err error) *HTTPError {
+	if err == nil {
+		return nil
+	}
+	message := err.Error()
+	if strings.HasPrefix(message, "UNIQUE constraint failed: ") {
+		constraint := strings.TrimPrefix(message, "UNIQUE constraint failed: ")
+		return &HTTPError{
+			Status:  http.StatusConflict,
+			Code:    "23505",
+			Message: message,
+			Details: map[string]interface{}{"constraint": constraint},
+		}
+	}
+	if errors.Is(err, storage.ErrSerialization) {
+		return &HTTPError{
+			Status:  http.StatusConflict,
+			Code:    "40001",
+			Message: message,
+		}
+	}
+	if strings.Contains(message, "feature not supported:") {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    "0A000",
+			Message: message,
+		}
+	}
+	return &HTTPError{
+		Status:  http.StatusInternalServerError,
+		Code:    "EXECUTION_ERROR",
+		Message: message,
+	}
+}
+
 // handleHealth handles GET /health
 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
 	resp := map[string]interface{}{

+ 69 - 0
pkg/httpserver/sqlstate_test.go

@@ -0,0 +1,69 @@
+package httpserver
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+type httpErrorBody struct {
+	Error struct {
+		Code    string                 `json:"code"`
+		Message string                 `json:"message"`
+		Details map[string]interface{} `json:"details"`
+	} `json:"error"`
+}
+
+func TestHTTPUniqueViolationReturns23505(t *testing.T) {
+	server := setupKVTestServer(t)
+	queryHTTP(t, server, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)")
+	queryHTTP(t, server, "INSERT INTO users (id, email) VALUES (1, 'a@x')")
+
+	body, _ := json.Marshal(QueryRequest{SQL: "INSERT INTO users (id, email) VALUES (2, 'a@x')"})
+	r := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(string(body)))
+	w := httptest.NewRecorder()
+	server.handleQuery(w, r)
+
+	if w.Code != http.StatusConflict {
+		t.Fatalf("status = %d, want 409; body %s", w.Code, w.Body.String())
+	}
+	var resp httpErrorBody
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	if resp.Error.Code != "23505" {
+		t.Fatalf("code = %q, want 23505", resp.Error.Code)
+	}
+	constraint, _ := resp.Error.Details["constraint"].(string)
+	if constraint == "" || !strings.Contains(resp.Error.Message, constraint) {
+		t.Fatalf("details.constraint = %q, message = %q", constraint, resp.Error.Message)
+	}
+}
+
+func TestSQLStateHTTPErrorMapping(t *testing.T) {
+	tests := []struct {
+		name   string
+		err    error
+		status int
+		code   string
+	}{
+		{"unique", fmt.Errorf("UNIQUE constraint failed: uniq_users_email"), http.StatusConflict, "23505"},
+		{"serialization", fmt.Errorf("commit: %w", storage.ErrSerialization), http.StatusConflict, "40001"},
+		{"unsupported", fmt.Errorf("feature not supported: DEFAULT is not constant"), http.StatusBadRequest, "0A000"},
+		{"other", errors.New("boom"), http.StatusInternalServerError, "EXECUTION_ERROR"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := sqlStateHTTPError(tt.err)
+			if got.Status != tt.status || got.Code != tt.code {
+				t.Fatalf("got status=%d code=%s, want status=%d code=%s", got.Status, got.Code, tt.status, tt.code)
+			}
+		})
+	}
+}