Added Single Record as Object feature

This commit is contained in:
Hein 2025-11-19 13:58:52 +02:00
parent 7f5b851669
commit cdfb7a67fd
4 changed files with 161 additions and 27 deletions

View File

@ -31,6 +31,8 @@ Both share the same core architecture and provide dynamic data querying, relatio
- [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) - [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1)
- [Lifecycle Hooks](#lifecycle-hooks) - [Lifecycle Hooks](#lifecycle-hooks)
- [Cursor Pagination](#cursor-pagination) - [Cursor Pagination](#cursor-pagination)
- [Response Formats](#response-formats)
- [Single Record as Object](#single-record-as-object-default-behavior)
- [Example Usage](#example-usage) - [Example Usage](#example-usage)
- [Recursive CRUD Operations](#recursive-crud-operations-) - [Recursive CRUD Operations](#recursive-crud-operations-)
- [Testing](#testing) - [Testing](#testing)
@ -59,6 +61,7 @@ Both share the same core architecture and provide dynamic data querying, relatio
- **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations - **🆕 Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations
- **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support - **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support
- **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats - **🆕 Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats
- **🆕 Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default)
- **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL - **🆕 Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL
- **🆕 Base64 Encoding**: Support for base64-encoded header values - **🆕 Base64 Encoding**: Support for base64-encoded header values
@ -163,6 +166,7 @@ restheadspec.SetupMuxRoutes(router, handler)
| `X-Limit` | Limit results | `50` | | `X-Limit` | Limit results | `50` |
| `X-Offset` | Offset for pagination | `100` | | `X-Offset` | Offset for pagination | `100` |
| `X-Clean-JSON` | Remove null/empty fields | `true` | | `X-Clean-JSON` | Remove null/empty fields | `true` |
| `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` |
**Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty` **Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty`
@ -303,6 +307,55 @@ RestHeadSpec supports multiple response formats:
} }
``` ```
### Single Record as Object (Default Behavior)
By default, RestHeadSpec automatically converts single-element arrays into objects for cleaner API responses. This provides a better developer experience when fetching individual records.
**Default behavior (enabled)**:
```http
GET /public/users/123
```
```json
{
"success": true,
"data": { "id": 123, "name": "John", "email": "john@example.com" }
}
```
Instead of:
```json
{
"success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
}
```
**To disable** (force arrays for consistency):
```http
GET /public/users/123
X-Single-Record-As-Object: false
```
```json
{
"success": true,
"data": [{ "id": 123, "name": "John", "email": "john@example.com" }]
}
```
**How it works**:
- When a query returns exactly **one record**, it's returned as an object
- When a query returns **multiple records**, they're returned as an array
- Set `X-Single-Record-As-Object: false` to always receive arrays
- Works with all response formats (simple, detail, syncfusion)
- Applies to both read operations and create/update returning clauses
**Benefits**:
- Cleaner API responses for single-record queries
- No need to unwrap single-element arrays on the client side
- Better TypeScript/type inference support
- Consistent with common REST API patterns
- Backward compatible via header opt-out
## Example Usage ## Example Usage
### Reading Data with Related Entities ### Reading Data with Related Entities
@ -924,6 +977,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting - **Cursor Pagination**: Efficient cursor-based pagination with complex sorting
- **Advanced Filtering**: Field filters, search operators, AND/OR logic - **Advanced Filtering**: Field filters, search operators, AND/OR logic
- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses - **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses
- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header)
- **Base64 Support**: Base64-encoded header values for complex queries - **Base64 Support**: Base64-encoded header values for complex queries
- **Type-Aware Filtering**: Automatic type detection and conversion for filters - **Type-Aware Filtering**: Automatic type detection and conversion for filters

View File

@ -588,7 +588,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return return
} }
logger.Info("Successfully created record with nested data, ID: %v", result.ID) logger.Info("Successfully created record with nested data, ID: %v", result.ID)
h.sendResponse(w, result.Data, nil) h.sendResponseWithOptions(w, result.Data, nil, &options)
return return
} }
} }
@ -672,7 +672,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
} }
logger.Info("Successfully created %d records with nested data", len(results)) logger.Info("Successfully created %d records with nested data", len(results))
h.sendResponse(w, results, nil) h.sendResponseWithOptions(w, results, nil, &options)
return return
} }
@ -789,7 +789,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return return
} }
h.sendResponse(w, modelValue, nil) h.sendResponseWithOptions(w, modelValue, nil, &options)
} }
func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id string, idPtr *int64, data interface{}, options ExtendedRequestOptions) { func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id string, idPtr *int64, data interface{}, options ExtendedRequestOptions) {
@ -843,7 +843,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return return
} }
logger.Info("Successfully updated record with nested data, rows: %d", result.AffectedRows) logger.Info("Successfully updated record with nested data, rows: %d", result.AffectedRows)
h.sendResponse(w, result.Data, nil) h.sendResponseWithOptions(w, result.Data, nil, &options)
return return
} }
@ -932,7 +932,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return return
} }
h.sendResponse(w, responseData, nil) h.sendResponseWithOptions(w, responseData, nil, &options)
} }
func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) { func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) {
@ -1434,6 +1434,16 @@ func (h *Handler) isNullable(field reflect.StructField) bool {
} }
func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) { func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) {
h.sendResponseWithOptions(w, data, metadata, nil)
}
// sendResponseWithOptions sends a response with optional formatting
func (h *Handler) sendResponseWithOptions(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options *ExtendedRequestOptions) {
// Normalize single-record arrays to objects if requested
if options != nil && options.SingleRecordAsObject {
data = h.normalizeResultArray(data)
}
response := common.Response{ response := common.Response{
Success: true, Success: true,
Data: data, Data: data,
@ -1445,8 +1455,35 @@ func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metada
} }
} }
// normalizeResultArray converts a single-element array to an object if requested
// 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 data
}
// Use reflection to check if data is a slice or array
dataValue := reflect.ValueOf(data)
if dataValue.Kind() == reflect.Ptr {
dataValue = dataValue.Elem()
}
// Check if it's a slice or array with exactly one element
if (dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array) && dataValue.Len() == 1 {
// Return the single element
return dataValue.Index(0).Interface()
}
return data
}
// sendFormattedResponse sends response with formatting options // sendFormattedResponse sends response with formatting options
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) { func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
// Normalize single-record arrays to objects if requested
if options.SingleRecordAsObject {
data = h.normalizeResultArray(data)
}
// Clean JSON if requested (remove null/empty fields) // Clean JSON if requested (remove null/empty fields)
if options.CleanJSON { if options.CleanJSON {
data = h.cleanJSON(data) data = h.cleanJSON(data)

View File

@ -37,6 +37,9 @@ type ExtendedRequestOptions struct {
// Response format // Response format
ResponseFormat string // "simple", "detail", "syncfusion" ResponseFormat string // "simple", "detail", "syncfusion"
// Single record normalization - convert single-element arrays to objects
SingleRecordAsObject bool
// Transaction // Transaction
AtomicTransaction bool AtomicTransaction bool
} }
@ -99,10 +102,11 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
Sort: make([]common.SortOption, 0), Sort: make([]common.SortOption, 0),
Preload: make([]common.PreloadOption, 0), Preload: make([]common.PreloadOption, 0),
}, },
AdvancedSQL: make(map[string]string), AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string), ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0), Expand: make([]ExpandOption, 0),
ResponseFormat: "simple", // Default response format ResponseFormat: "simple", // Default response format
SingleRecordAsObject: true, // Default: normalize single-element arrays to objects
} }
// Get all headers // Get all headers
@ -199,6 +203,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
options.ResponseFormat = "detail" options.ResponseFormat = "detail"
case strings.HasPrefix(normalizedKey, "x-syncfusion"): case strings.HasPrefix(normalizedKey, "x-syncfusion"):
options.ResponseFormat = "syncfusion" options.ResponseFormat = "syncfusion"
case strings.HasPrefix(normalizedKey, "x-single-record-as-object"):
// Parse as boolean - "false" disables, "true" enables (default is true)
if strings.EqualFold(decodedValue, "false") {
options.SingleRecordAsObject = false
} else if strings.EqualFold(decodedValue, "true") {
options.SingleRecordAsObject = true
}
// Transaction Control // Transaction Control
case strings.HasPrefix(normalizedKey, "x-transaction-atomic"): case strings.HasPrefix(normalizedKey, "x-transaction-atomic"):

View File

@ -402,25 +402,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) {
resp := makeRestHeadSpecRequest(t, serverURL, fmt.Sprintf("/restheadspec/departments/%s", deptID), "GET", nil, nil) resp := makeRestHeadSpecRequest(t, serverURL, fmt.Sprintf("/restheadspec/departments/%s", deptID), "GET", nil, nil)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
// RestHeadSpec may return data directly as array or wrapped in response object // RestHeadSpec may return data directly as array/object or wrapped in response object
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Failed to read response body") assert.NoError(t, err, "Failed to read response body")
// Try to decode as array first (simple format) // Try to decode as array first (simple format - multiple records or disabled SingleRecordAsObject)
var dataArray []interface{} var dataArray []interface{}
if err := json.Unmarshal(body, &dataArray); err == nil { if err := json.Unmarshal(body, &dataArray); err == nil {
assert.GreaterOrEqual(t, len(dataArray), 1, "Should find department") assert.GreaterOrEqual(t, len(dataArray), 1, "Should find department")
logger.Info("Department read successfully (simple format): %s", deptID) logger.Info("Department read successfully (simple format - array): %s", deptID)
return return
} }
// Try to decode as standard response object (detail format) // Try to decode as a single object first (simple format with SingleRecordAsObject enabled)
var result map[string]interface{} var singleObj map[string]interface{}
if err := json.Unmarshal(body, &result); err == nil { if err := json.Unmarshal(body, &singleObj); err == nil {
if success, ok := result["success"]; ok && success != nil && success.(bool) { // Check if it's a data object (not a response wrapper)
if data, ok := result["data"].([]interface{}); ok { if _, hasSuccess := singleObj["success"]; !hasSuccess {
// This is a direct data object (simple format, single record)
assert.NotEmpty(t, singleObj, "Should find department")
logger.Info("Department read successfully (simple format - single object): %s", deptID)
return
}
// Otherwise it's a standard response object (detail format)
if success, ok := singleObj["success"]; ok && success != nil && success.(bool) {
// Check if data is an array
if data, ok := singleObj["data"].([]interface{}); ok {
assert.GreaterOrEqual(t, len(data), 1, "Should find department") assert.GreaterOrEqual(t, len(data), 1, "Should find department")
logger.Info("Department read successfully (detail format): %s", deptID) logger.Info("Department read successfully (detail format - array): %s", deptID)
return
}
// Check if data is a single object (SingleRecordAsObject feature in detail format)
if data, ok := singleObj["data"].(map[string]interface{}); ok {
assert.NotEmpty(t, data, "Should find department")
logger.Info("Department read successfully (detail format - single object): %s", deptID)
return return
} }
} }
@ -446,25 +462,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) {
resp := makeRestHeadSpecRequest(t, serverURL, "/restheadspec/employees", "GET", nil, headers) resp := makeRestHeadSpecRequest(t, serverURL, "/restheadspec/employees", "GET", nil, headers)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
// RestHeadSpec may return data directly as array or wrapped in response object // RestHeadSpec may return data directly as array/object or wrapped in response object
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Failed to read response body") assert.NoError(t, err, "Failed to read response body")
// Try array format first // Try array format first (multiple records or disabled SingleRecordAsObject)
var dataArray []interface{} var dataArray []interface{}
if err := json.Unmarshal(body, &dataArray); err == nil { if err := json.Unmarshal(body, &dataArray); err == nil {
assert.GreaterOrEqual(t, len(dataArray), 1, "Should find at least one employee") assert.GreaterOrEqual(t, len(dataArray), 1, "Should find at least one employee")
logger.Info("Employees read with filter successfully (simple format), found: %d", len(dataArray)) logger.Info("Employees read with filter successfully (simple format - array), found: %d", len(dataArray))
return return
} }
// Try standard response format // Try to decode as a single object (simple format with SingleRecordAsObject enabled)
var result map[string]interface{} var singleObj map[string]interface{}
if err := json.Unmarshal(body, &result); err == nil { if err := json.Unmarshal(body, &singleObj); err == nil {
if success, ok := result["success"]; ok && success != nil && success.(bool) { // Check if it's a data object (not a response wrapper)
if data, ok := result["data"].([]interface{}); ok { if _, hasSuccess := singleObj["success"]; !hasSuccess {
// This is a direct data object (simple format, single record)
assert.NotEmpty(t, singleObj, "Should find at least one employee")
logger.Info("Employees read with filter successfully (simple format - single object), found: 1")
return
}
// Otherwise it's a standard response object (detail format)
if success, ok := singleObj["success"]; ok && success != nil && success.(bool) {
// Check if data is an array
if data, ok := singleObj["data"].([]interface{}); ok {
assert.GreaterOrEqual(t, len(data), 1, "Should find at least one employee") assert.GreaterOrEqual(t, len(data), 1, "Should find at least one employee")
logger.Info("Employees read with filter successfully (detail format), found: %d", len(data)) logger.Info("Employees read with filter successfully (detail format - array), found: %d", len(data))
return
}
// Check if data is a single object (SingleRecordAsObject feature in detail format)
if data, ok := singleObj["data"].(map[string]interface{}); ok {
assert.NotEmpty(t, data, "Should find at least one employee")
logger.Info("Employees read with filter successfully (detail format - single object), found: 1")
return return
} }
} }