From 2a2d351ad4db91bf73abe8353f00f6fdc1ac2b36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:57:15 +0000 Subject: [PATCH] Fix API returning null with 200 code when no records found - Modified handleRead to always return empty array [] instead of null when no ID provided - Added X-No-Data-Found header when result count is 0 - Updated normalizeResultArray to keep empty arrays as arrays instead of converting to empty objects - Updated sendFormattedResponse and sendResponseWithOptions to handle empty data properly - All responses now return 200 OK instead of 206 Partial Content when no data found - Added comprehensive tests to verify the fix Co-authored-by: warkanum <208308+warkanum@users.noreply.github.com> --- pkg/restheadspec/empty_result_test.go | 196 ++++++++++++++++++++++++++ pkg/restheadspec/handler.go | 40 ++++-- 2 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 pkg/restheadspec/empty_result_test.go diff --git a/pkg/restheadspec/empty_result_test.go b/pkg/restheadspec/empty_result_test.go new file mode 100644 index 0000000..31a3ca4 --- /dev/null +++ b/pkg/restheadspec/empty_result_test.go @@ -0,0 +1,196 @@ +package restheadspec + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// Test that normalizeResultArray returns empty array when no records found without ID +func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) { + handler := &Handler{} + + tests := []struct { + name string + input interface{} + expected interface{} + }{ + { + name: "nil should return empty array", + input: nil, + expected: []interface{}{}, + }, + { + name: "empty slice should return empty array", + input: []*EmptyTestModel{}, + expected: []interface{}{}, + }, + { + name: "single element should return the element", + input: []*EmptyTestModel{{ID: 1, Name: "test"}}, + expected: &EmptyTestModel{ID: 1, Name: "test"}, + }, + { + name: "multiple elements should return the slice", + input: []*EmptyTestModel{ + {ID: 1, Name: "test1"}, + {ID: 2, Name: "test2"}, + }, + expected: []*EmptyTestModel{ + {ID: 1, Name: "test1"}, + {ID: 2, Name: "test2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := handler.normalizeResultArray(tt.input) + + // For nil and empty cases, check it returns an empty array + if tt.input == nil || (tt.name == "empty slice should return empty array") { + emptyArr, ok := result.([]interface{}) + if !ok { + t.Errorf("Expected empty array []interface{}{}, got %T: %v", result, result) + return + } + if len(emptyArr) != 0 { + t.Errorf("Expected empty array with length 0, got length %d", len(emptyArr)) + } + + // Verify it serializes to [] and not null + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Errorf("Failed to marshal result: %v", err) + return + } + if string(jsonBytes) != "[]" { + t.Errorf("Expected JSON '[]', got '%s'", string(jsonBytes)) + } + } + }) + } +} + +// Test that sendFormattedResponse adds X-No-Data-Found header +func TestSendFormattedResponse_NoDataFoundHeader(t *testing.T) { + handler := &Handler{} + + // Mock ResponseWriter + mockWriter := &MockTestResponseWriter{ + headers: make(map[string]string), + } + + metadata := &common.Metadata{ + Total: 0, + Count: 0, + Filtered: 0, + Limit: 10, + Offset: 0, + } + + options := ExtendedRequestOptions{ + RequestOptions: common.RequestOptions{}, + } + + // Test with empty data + emptyData := []interface{}{} + handler.sendFormattedResponse(mockWriter, emptyData, metadata, options) + + // Check if X-No-Data-Found header was set + if mockWriter.headers["X-No-Data-Found"] != "true" { + t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"]) + } + + // Verify the body is an empty array + if mockWriter.body == nil { + t.Error("Expected body to be set, got nil") + } else { + bodyBytes, err := json.Marshal(mockWriter.body) + if err != nil { + t.Errorf("Failed to marshal body: %v", err) + } + // The body should be wrapped in a Response object with "data" field + bodyStr := string(bodyBytes) + if !strings.Contains(bodyStr, `"data":[]`) && !strings.Contains(bodyStr, `"result":[]`) { + t.Errorf("Expected body to contain empty array, got: %s", bodyStr) + } + } +} + +// Test that sendResponseWithOptions adds X-No-Data-Found header +func TestSendResponseWithOptions_NoDataFoundHeader(t *testing.T) { + handler := &Handler{} + + // Mock ResponseWriter + mockWriter := &MockTestResponseWriter{ + headers: make(map[string]string), + } + + metadata := &common.Metadata{} + options := &ExtendedRequestOptions{} + + // Test with nil data + handler.sendResponseWithOptions(mockWriter, nil, metadata, options) + + // Check if X-No-Data-Found header was set + if mockWriter.headers["X-No-Data-Found"] != "true" { + t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"]) + } + + // Check status code is 200 + if mockWriter.statusCode != 200 { + t.Errorf("Expected status code 200, got %d", mockWriter.statusCode) + } + + // Verify the body is an empty array + if mockWriter.body == nil { + t.Error("Expected body to be set, got nil") + } else { + bodyBytes, err := json.Marshal(mockWriter.body) + if err != nil { + t.Errorf("Failed to marshal body: %v", err) + } + bodyStr := string(bodyBytes) + if bodyStr != "[]" { + t.Errorf("Expected body to be '[]', got: %s", bodyStr) + } + } +} + +// MockTestResponseWriter for testing +type MockTestResponseWriter struct { + headers map[string]string + statusCode int + body interface{} +} + +func (m *MockTestResponseWriter) SetHeader(key, value string) { + m.headers[key] = value +} + +func (m *MockTestResponseWriter) WriteHeader(statusCode int) { + m.statusCode = statusCode +} + +func (m *MockTestResponseWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +func (m *MockTestResponseWriter) WriteJSON(data interface{}) error { + m.body = data + return nil +} + +func (m *MockTestResponseWriter) UnderlyingResponseWriter() http.ResponseWriter { + return nil +} + +// EmptyTestModel for testing +type EmptyTestModel struct { + ID int64 `json:"id"` + Name string `json:"name"` +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index eda5f4c..092c70a 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -2143,12 +2143,23 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada // sendResponseWithOptions sends a response with optional formatting func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options *ExtendedRequestOptions) { w.SetHeader("Content-Type", "application/json") + + // Calculate data length + dataLen := 0 if data == nil { - data = map[string]interface{}{} - w.WriteHeader(http.StatusPartialContent) + // When data is nil, return empty array instead of null + data = []interface{}{} } else { - w.WriteHeader(http.StatusOK) + dataLen = reflection.Len(data) } + + // Add X-No-Data-Found header when no records were found + if dataLen == 0 { + w.SetHeader("X-No-Data-Found", "true") + } + + w.WriteHeader(http.StatusOK) + // Normalize single-record arrays to objects if requested if options != nil && options.SingleRecordAsObject { data = h.normalizeResultArray(data) @@ -2165,7 +2176,7 @@ func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interfac // Returns the single element if data is a slice/array with exactly one element, otherwise returns data unchanged func (h *Handler) normalizeResultArray(data interface{}) interface{} { if data == nil { - return map[string]interface{}{} + return []interface{}{} } // Use reflection to check if data is a slice or array @@ -2180,15 +2191,15 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} { // Return the single element return dataValue.Index(0).Interface() } else if dataValue.Len() == 0 { - // Return empty object instead of empty array - return map[string]interface{}{} + // Keep empty array as empty array, don't convert to empty object + return []interface{}{} } } if dataValue.Kind() == reflect.String { str := dataValue.String() if str == "" || str == "null" { - return map[string]interface{}{} + return []interface{}{} } } @@ -2199,14 +2210,17 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} { func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) { // Normalize single-record arrays to objects if requested httpStatus := http.StatusOK + dataLen := 0 if data == nil { - data = map[string]interface{}{} - httpStatus = http.StatusPartialContent + // When data is nil, return empty array instead of null + data = []interface{}{} } else { - dataLen := reflection.Len(data) - if dataLen == 0 { - httpStatus = http.StatusPartialContent - } + dataLen = reflection.Len(data) + } + + // Add X-No-Data-Found header when no records were found + if dataLen == 0 { + w.SetHeader("X-No-Data-Found", "true") } if options.SingleRecordAsObject {