From cdfb7a67fd3bbbe0c920b214fd8dca47a0fb892e Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 19 Nov 2025 13:58:52 +0200 Subject: [PATCH] Added Single Record as Object feature --- README.md | 54 +++++++++++++++++++++++++++++ pkg/restheadspec/handler.go | 47 ++++++++++++++++++++++--- pkg/restheadspec/headers.go | 19 ++++++++--- tests/crud_test.go | 68 +++++++++++++++++++++++++++---------- 4 files changed, 161 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d844902..9850247 100644 --- a/README.md +++ b/README.md @@ -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) - [Lifecycle Hooks](#lifecycle-hooks) - [Cursor Pagination](#cursor-pagination) + - [Response Formats](#response-formats) + - [Single Record as Object](#single-record-as-object-default-behavior) - [Example Usage](#example-usage) - [Recursive CRUD Operations](#recursive-crud-operations-) - [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 - **🆕 Cursor Pagination**: Efficient cursor-based pagination with complex sort support - **🆕 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 - **🆕 Base64 Encoding**: Support for base64-encoded header values @@ -163,6 +166,7 @@ restheadspec.SetupMuxRoutes(router, handler) | `X-Limit` | Limit results | `50` | | `X-Offset` | Offset for pagination | `100` | | `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` @@ -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 ### 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 - **Advanced Filtering**: Field filters, search operators, AND/OR logic - **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 - **Type-Aware Filtering**: Automatic type detection and conversion for filters diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 33978cc..3b6235b 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -588,7 +588,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat return } 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 } } @@ -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)) - h.sendResponse(w, results, nil) + h.sendResponseWithOptions(w, results, nil, &options) return } @@ -789,7 +789,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat 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) { @@ -843,7 +843,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id return } 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 } @@ -932,7 +932,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id 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{}) { @@ -1434,6 +1434,16 @@ func (h *Handler) isNullable(field reflect.StructField) bool { } 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{ Success: true, 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 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) if options.CleanJSON { data = h.cleanJSON(data) diff --git a/pkg/restheadspec/headers.go b/pkg/restheadspec/headers.go index c687b81..053f834 100644 --- a/pkg/restheadspec/headers.go +++ b/pkg/restheadspec/headers.go @@ -37,6 +37,9 @@ type ExtendedRequestOptions struct { // Response format ResponseFormat string // "simple", "detail", "syncfusion" + // Single record normalization - convert single-element arrays to objects + SingleRecordAsObject bool + // Transaction AtomicTransaction bool } @@ -99,10 +102,11 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio Sort: make([]common.SortOption, 0), Preload: make([]common.PreloadOption, 0), }, - AdvancedSQL: make(map[string]string), - ComputedQL: make(map[string]string), - Expand: make([]ExpandOption, 0), - ResponseFormat: "simple", // Default response format + AdvancedSQL: make(map[string]string), + ComputedQL: make(map[string]string), + Expand: make([]ExpandOption, 0), + ResponseFormat: "simple", // Default response format + SingleRecordAsObject: true, // Default: normalize single-element arrays to objects } // Get all headers @@ -199,6 +203,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio options.ResponseFormat = "detail" case strings.HasPrefix(normalizedKey, "x-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 case strings.HasPrefix(normalizedKey, "x-transaction-atomic"): diff --git a/tests/crud_test.go b/tests/crud_test.go index 9f92cc1..e33bdb4 100644 --- a/tests/crud_test.go +++ b/tests/crud_test.go @@ -402,25 +402,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) { resp := makeRestHeadSpecRequest(t, serverURL, fmt.Sprintf("/restheadspec/departments/%s", deptID), "GET", nil, nil) 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) 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{} if err := json.Unmarshal(body, &dataArray); err == nil { 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 } - // Try to decode as standard response object (detail format) - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err == nil { - if success, ok := result["success"]; ok && success != nil && success.(bool) { - if data, ok := result["data"].([]interface{}); ok { + // Try to decode as a single object first (simple format with SingleRecordAsObject enabled) + var singleObj map[string]interface{} + if err := json.Unmarshal(body, &singleObj); err == nil { + // Check if it's a data object (not a response wrapper) + 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") - 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 } } @@ -446,25 +462,41 @@ func testRestHeadSpecCRUD(t *testing.T, serverURL string) { resp := makeRestHeadSpecRequest(t, serverURL, "/restheadspec/employees", "GET", nil, headers) 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) 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{} if err := json.Unmarshal(body, &dataArray); err == nil { 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 } - // Try standard response format - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err == nil { - if success, ok := result["success"]; ok && success != nil && success.(bool) { - if data, ok := result["data"].([]interface{}); ok { + // Try to decode as a single object (simple format with SingleRecordAsObject enabled) + var singleObj map[string]interface{} + if err := json.Unmarshal(body, &singleObj); err == nil { + // Check if it's a data object (not a response wrapper) + 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") - 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 } }