mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-06-28 07:47:39 +00:00
fix(handler): update sendFormattedResponse to include table name and model
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ func TestSendFormattedResponse_NoDataFoundHeader(t *testing.T) {
|
|||||||
|
|
||||||
// Test with empty data
|
// Test with empty data
|
||||||
emptyData := []interface{}{}
|
emptyData := []interface{}{}
|
||||||
handler.sendFormattedResponse(mockWriter, emptyData, metadata, options)
|
handler.sendFormattedResponse(mockWriter, emptyData, metadata, "", nil, options)
|
||||||
|
|
||||||
// Check if X-No-Data-Found header was set
|
// Check if X-No-Data-Found header was set
|
||||||
if mockWriter.headers["X-No-Data-Found"] != "true" {
|
if mockWriter.headers["X-No-Data-Found"] != "true" {
|
||||||
|
|||||||
+122
-5
@@ -289,7 +289,8 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma
|
|||||||
Limit: 0,
|
Limit: 0,
|
||||||
Offset: 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
|
// handleMeta processes meta operation requests
|
||||||
@@ -848,7 +849,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
return
|
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
|
// applyPreloadWithRecursion applies a preload with support for ComputedQL and recursive preloading
|
||||||
@@ -2585,8 +2586,103 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendFormattedResponse sends response with formatting options
|
// buildDetailFields returns the field metadata list for the detail API format,
|
||||||
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
|
// 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
|
// Handle nil data - convert to empty array
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = []interface{}{}
|
data = []interface{}{}
|
||||||
@@ -2639,8 +2735,29 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
|||||||
if err := w.WriteJSON(response); err != nil {
|
if err := w.WriteJSON(response); err != nil {
|
||||||
logger.Error("Failed to write JSON response: %v", err)
|
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:
|
||||||
// Default/detail format: standard response with metadata
|
// Default format: standard response with metadata
|
||||||
response := common.Response{
|
response := common.Response{
|
||||||
Success: true,
|
Success: true,
|
||||||
Data: data,
|
Data: data,
|
||||||
|
|||||||
Reference in New Issue
Block a user