diff --git a/pkg/restheadspec/detail_response_test.go b/pkg/restheadspec/detail_response_test.go new file mode 100644 index 0000000..a1785aa --- /dev/null +++ b/pkg/restheadspec/detail_response_test.go @@ -0,0 +1,209 @@ +package restheadspec + +import ( + "encoding/json" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// detailTestModel is a simple model with gorm column/type tags for detail format tests. +type detailTestModel struct { + ID int64 `bun:"rid,pk" gorm:"column:rid;primaryKey" json:"rid"` + Name string `bun:"name" gorm:"column:name;type:citext" json:"name"` + Description *string `bun:"description" gorm:"column:description;type:text;nullable" json:"description"` + Score float64 `bun:"score" gorm:"column:score;type:numeric" json:"score"` + Active bool `bun:"active" gorm:"column:active;type:boolean;not null" json:"active"` +} + +func TestSendFormattedResponse_DetailFormat(t *testing.T) { + handler := &Handler{} + + name := "hello" + items := []*detailTestModel{ + {ID: 1, Name: "first", Description: &name, Score: 1.5, Active: true}, + {ID: 2, Name: "second", Description: nil, Score: 2.0, Active: false}, + } + metadata := &common.Metadata{ + Total: 36, + Count: 2, + Filtered: 36, + Limit: 10, + Offset: 0, + } + options := ExtendedRequestOptions{ + ResponseFormat: "detail", + } + + mockWriter := &MockTestResponseWriter{headers: make(map[string]string)} + handler.sendFormattedResponse(mockWriter, items, metadata, "myschema.myentity", detailTestModel{}, options) + + if mockWriter.statusCode != 200 { + t.Fatalf("expected status 200, got %d", mockWriter.statusCode) + } + + body, err := json.Marshal(mockWriter.body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + + var resp map[string]json.RawMessage + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + t.Run("top-level keys", func(t *testing.T) { + for _, key := range []string{"count", "fields", "items", "tablename", "tableprefix", "total"} { + if _, ok := resp[key]; !ok { + t.Errorf("missing key %q in detail response", key) + } + } + }) + + t.Run("count and total are string", func(t *testing.T) { + var count, total string + if err := json.Unmarshal(resp["count"], &count); err != nil { + t.Errorf("count is not a string: %v", err) + } + if err := json.Unmarshal(resp["total"], &total); err != nil { + t.Errorf("total is not a string: %v", err) + } + if count != "2" { + t.Errorf("expected count %q, got %q", "2", count) + } + if total != "36" { + t.Errorf("expected total %q, got %q", "36", total) + } + }) + + t.Run("tablename and tableprefix", func(t *testing.T) { + var tablename, tableprefix string + json.Unmarshal(resp["tablename"], &tablename) + json.Unmarshal(resp["tableprefix"], &tableprefix) + if tablename != "myschema.myentity" { + t.Errorf("expected tablename %q, got %q", "myschema.myentity", tablename) + } + if tableprefix != "myentity" { + t.Errorf("expected tableprefix %q, got %q", "myentity", tableprefix) + } + }) + + t.Run("items contains data", func(t *testing.T) { + var itemSlice []map[string]interface{} + if err := json.Unmarshal(resp["items"], &itemSlice); err != nil { + t.Fatalf("items is not an array: %v", err) + } + if len(itemSlice) != 2 { + t.Errorf("expected 2 items, got %d", len(itemSlice)) + } + }) + + t.Run("fields contains column metadata", func(t *testing.T) { + var fields []map[string]interface{} + if err := json.Unmarshal(resp["fields"], &fields); err != nil { + t.Fatalf("fields is not an array: %v", err) + } + if len(fields) == 0 { + t.Fatal("expected fields to be non-empty") + } + + bySQL := make(map[string]map[string]interface{}, len(fields)) + for _, f := range fields { + if sqlname, ok := f["sqlname"].(string); ok { + bySQL[sqlname] = f + } + } + + // Check required field keys are present + for _, f := range fields { + for _, key := range []string{"name", "datatype", "sqlname", "sqldatatype", "sqlkey", "nullable"} { + if _, ok := f[key]; !ok { + t.Errorf("field %v missing key %q", f, key) + } + } + } + + // Validate specific columns + if col, ok := bySQL["rid"]; ok { + if col["sqlkey"] != "primary_key" { + t.Errorf("rid: expected sqlkey %q, got %v", "primary_key", col["sqlkey"]) + } + } else { + t.Error("expected column 'rid' in fields") + } + + if col, ok := bySQL["name"]; ok { + if col["sqldatatype"] != "citext" { + t.Errorf("name: expected sqldatatype %q, got %v", "citext", col["sqldatatype"]) + } + if col["nullable"] != false { + t.Errorf("name: expected nullable false, got %v", col["nullable"]) + } + } else { + t.Error("expected column 'name' in fields") + } + + if col, ok := bySQL["description"]; ok { + if col["sqldatatype"] != "text" { + t.Errorf("description: expected sqldatatype %q, got %v", "text", col["sqldatatype"]) + } + if col["nullable"] != true { + t.Errorf("description: expected nullable true, got %v", col["nullable"]) + } + } else { + t.Error("expected column 'description' in fields") + } + }) +} + +func TestSendFormattedResponse_DetailFormat_EmptyItems(t *testing.T) { + handler := &Handler{} + metadata := &common.Metadata{Total: 0, Count: 0, Filtered: 0} + options := ExtendedRequestOptions{ResponseFormat: "detail"} + + mockWriter := &MockTestResponseWriter{headers: make(map[string]string)} + handler.sendFormattedResponse(mockWriter, []*detailTestModel{}, metadata, "s.t", detailTestModel{}, options) + + body, _ := json.Marshal(mockWriter.body) + var resp map[string]json.RawMessage + json.Unmarshal(body, &resp) + + var count, total string + json.Unmarshal(resp["count"], &count) + json.Unmarshal(resp["total"], &total) + + if count != "0" || total != "0" { + t.Errorf("expected count/total both %q, got count=%q total=%q", "0", count, total) + } + + var fields []interface{} + json.Unmarshal(resp["fields"], &fields) + if len(fields) == 0 { + t.Error("fields should still list column metadata even when items is empty") + } +} + +func TestBuildDetailFields_SkipsRelations(t *testing.T) { + type child struct { + ID int64 `bun:"id,pk" gorm:"column:id;primaryKey" json:"id"` + } + type parent struct { + ID int64 `bun:"id,pk" gorm:"column:id;primaryKey" json:"id"` + Name string `bun:"name" gorm:"column:name" json:"name"` + Children []child `bun:"rel:has-many" json:"children"` + Child *child `bun:"rel:has-one" json:"child"` + } + + handler := &Handler{} + fields := handler.buildDetailFields(parent{}) + + for _, f := range fields { + if f.SQLName == "children" || f.SQLName == "child" { + t.Errorf("relation field %q should not appear in detail fields", f.SQLName) + } + } + + if len(fields) != 2 { + t.Errorf("expected 2 scalar fields (id, name), got %d", len(fields)) + } +} diff --git a/pkg/restheadspec/empty_result_test.go b/pkg/restheadspec/empty_result_test.go index 802505e..5e52e97 100644 --- a/pkg/restheadspec/empty_result_test.go +++ b/pkg/restheadspec/empty_result_test.go @@ -95,7 +95,7 @@ func TestSendFormattedResponse_NoDataFoundHeader(t *testing.T) { // Test with empty data emptyData := []interface{}{} - handler.sendFormattedResponse(mockWriter, emptyData, metadata, options) + handler.sendFormattedResponse(mockWriter, emptyData, metadata, "", nil, options) // Check if X-No-Data-Found header was set if mockWriter.headers["X-No-Data-Found"] != "true" { diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index d967f26..9d20bb0 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -289,7 +289,8 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma Limit: 0, Offset: 0, } - h.sendFormattedResponse(w, tableMetadata, responseMetadata, options) + tableName := h.getTableName(schema, entity, model) + h.sendFormattedResponse(w, tableMetadata, responseMetadata, tableName, model, options) } // handleMeta processes meta operation requests @@ -848,7 +849,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st return } - h.sendFormattedResponse(w, modelPtr, metadata, options) + h.sendFormattedResponse(w, modelPtr, metadata, tableName, model, options) } // applyPreloadWithRecursion applies a preload with support for ComputedQL and recursive preloading @@ -2585,8 +2586,103 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} { return data } -// sendFormattedResponse sends response with formatting options -func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) { +// buildDetailFields returns the field metadata list for the detail API format, +// containing only non-relation scalar columns derived from the model's struct tags. +func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDetail { + if model == nil { + return []reflection.ModelFieldDetail{} + } + + modelType := reflect.TypeOf(model) + for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) { + modelType = modelType.Elem() + } + if modelType == nil || modelType.Kind() != reflect.Struct { + return []reflection.ModelFieldDetail{} + } + + fields := make([]reflection.ModelFieldDetail, 0, modelType.NumField()) + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + if !field.IsExported() { + continue + } + + jsonTag := field.Tag.Get("json") + if jsonTag == "-" { + continue + } + + // Skip relation fields (slices, structs that aren't time.Time, ptrs to struct) + ft := field.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Slice || + (ft.Kind() == reflect.Struct && ft.Name() != "Time") { + continue + } + + jsonName := strings.Split(jsonTag, ",")[0] + if jsonName == "" { + jsonName = field.Name + } + + gormTag := field.Tag.Get("gorm") + sqlName := fnFindTagVal(gormTag, "column:") + if sqlName == "" { + sqlName = jsonName + } + + sqlDataType := fnFindTagVal(gormTag, "type:") + + var sqlKey string + gormLower := strings.ToLower(gormTag) + switch { + case strings.Contains(gormLower, "identity") || strings.Contains(gormLower, "primary_key") || strings.Contains(gormLower, "primarykey"): + sqlKey = "primary_key" + case strings.Contains(gormLower, "uniqueindex"): + sqlKey = "uniqueindex" + case strings.Contains(gormLower, "unique"): + sqlKey = "unique" + } + + nullable := field.Type.Kind() == reflect.Ptr + if strings.Contains(gormLower, "not null") { + nullable = false + } else if strings.Contains(gormLower, "nullable") || strings.Contains(gormLower, ",null") { + nullable = true + } + + fields = append(fields, reflection.ModelFieldDetail{ + Name: jsonName, + DataType: h.getColumnType(field.Type), + SQLName: sqlName, + SQLDataType: sqlDataType, + SQLKey: sqlKey, + Nullable: nullable, + }) + } + return fields +} + +// fnFindTagVal extracts a value from a semicolon-separated struct tag string. +func fnFindTagVal(tag, key string) string { + lower := strings.ToLower(tag) + idx := strings.Index(lower, strings.ToLower(key)) + if idx < 0 { + return "" + } + val := tag[idx+len(key):] + if end := strings.Index(val, ";"); end >= 0 { + val = val[:end] + } + return val +} + +// sendFormattedResponse sends response with formatting options. +// model is used when ResponseFormat is "detail" to generate the fields metadata list. +func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, tableName string, model interface{}, options ExtendedRequestOptions) { // Handle nil data - convert to empty array if data == nil { data = []interface{}{} @@ -2639,8 +2735,29 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{ if err := w.WriteJSON(response); err != nil { logger.Error("Failed to write JSON response: %v", err) } + case "detail": + // Detail format: { count, fields, items, tablename, tableprefix, total } + var count, total int64 + if metadata != nil { + count = metadata.Count + total = metadata.Total + } + tablePrefix := reflection.ExtractTableNameOnly(tableName) + fieldList := h.buildDetailFields(model) + response := map[string]interface{}{ + "count": strconv.FormatInt(count, 10), + "fields": fieldList, + "items": data, + "tablename": tableName, + "tableprefix": tablePrefix, + "total": strconv.FormatInt(total, 10), + } + w.WriteHeader(http.StatusOK) + if err := w.WriteJSON(response); err != nil { + logger.Error("Failed to write JSON response: %v", err) + } default: - // Default/detail format: standard response with metadata + // Default format: standard response with metadata response := common.Response{ Success: true, Data: data,