From 7b8469c50cb9eb934e8319c86c2a07daeba50243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Mouton?= Date: Wed, 3 Dec 2025 15:04:15 +0100 Subject: [PATCH 1/2] Enhance JSON type mismatch error handling tests with additional cases for new fields --- json_type_error_test.go | 304 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 302 insertions(+), 2 deletions(-) diff --git a/json_type_error_test.go b/json_type_error_test.go index 38965b4..e3c65b8 100644 --- a/json_type_error_test.go +++ b/json_type_error_test.go @@ -33,8 +33,12 @@ func TestJSONTypeMismatchErrors(t *testing.T) { oapi := New(app) type CreateRequest struct { - Description string `json:"description,omitempty" validate:"omitempty,max=255"` - Ips []string `json:"ips,omitempty" validate:"dive,cidrv4|ip4_addr"` + Description string `json:"description,omitempty" validate:"omitempty,max=255"` + Ips []string `json:"ips,omitempty" validate:"dive,cidrv4|ip4_addr"` + Count int `json:"count,omitempty"` + Price float64 `json:"price,omitempty"` + Active bool `json:"active,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } type CreateResponse struct { @@ -71,12 +75,48 @@ func TestJSONTypeMismatchErrors(t *testing.T) { expectedStatus: 400, errorContains: "invalid type for field 'description'", }, + { + name: "Invalid request - description is a bool", + body: `{"description": true}`, + expectedStatus: 400, + errorContains: "invalid type for field 'description'", + }, { name: "Invalid request - ips contains number", body: `{"ips": [123]}`, expectedStatus: 400, errorContains: "invalid type", }, + { + name: "Invalid request - ips is an int", + body: `{"ips": 123}`, + expectedStatus: 400, + errorContains: "invalid type for field 'ips'", + }, + { + name: "Invalid request - ips is a float", + body: `{"ips": 123.45}`, + expectedStatus: 400, + errorContains: "invalid type for field 'ips'", + }, + { + name: "Invalid request - ips is a string instead of array", + body: `{"ips": "0.0.0.0"}`, + expectedStatus: 400, + errorContains: "invalid type for field 'ips'", + }, + { + name: "Invalid request - ips is a bool instead of array", + body: `{"ips": true}`, + expectedStatus: 400, + errorContains: "invalid type for field 'ips'", + }, + { + name: "Invalid request - ips is an object instead of array", + body: `{"ips": {"key": "value"}}`, + expectedStatus: 400, + errorContains: "invalid type for field 'ips'", + }, { name: "Valid request with empty body", body: `{}`, @@ -87,6 +127,126 @@ func TestJSONTypeMismatchErrors(t *testing.T) { body: `{"description": "Test", "ips": ["192.168.1.0/24", "10.0.0.1"]}`, expectedStatus: 200, }, + // Tests for int field (count) + { + name: "Invalid request - count is a string", + body: `{"count": "123"}`, + expectedStatus: 400, + errorContains: "invalid type for field 'count'", + }, + { + name: "Invalid request - count is a bool", + body: `{"count": true}`, + expectedStatus: 400, + errorContains: "invalid type for field 'count'", + }, + { + name: "Invalid request - count is an object", + body: `{"count": {"value": 123}}`, + expectedStatus: 400, + errorContains: "invalid type for field 'count'", + }, + { + name: "Invalid request - count is an array", + body: `{"count": [123]}`, + expectedStatus: 400, + errorContains: "invalid type for field 'count'", + }, + { + name: "Valid request - count is a number", + body: `{"count": 123}`, + expectedStatus: 200, + }, + // Tests for float field (price) + { + name: "Invalid request - price is a string", + body: `{"price": "99.99"}`, + expectedStatus: 400, + errorContains: "invalid type for field 'price'", + }, + { + name: "Invalid request - price is a bool", + body: `{"price": false}`, + expectedStatus: 400, + errorContains: "invalid type for field 'price'", + }, + { + name: "Invalid request - price is an object", + body: `{"price": {"amount": 99.99}}`, + expectedStatus: 400, + errorContains: "invalid type for field 'price'", + }, + { + name: "Invalid request - price is an array", + body: `{"price": [99.99]}`, + expectedStatus: 400, + errorContains: "invalid type for field 'price'", + }, + { + name: "Valid request - price is a number", + body: `{"price": 99.99}`, + expectedStatus: 200, + }, + // Tests for bool field (active) + { + name: "Invalid request - active is a string", + body: `{"active": "true"}`, + expectedStatus: 400, + errorContains: "invalid type for field 'active'", + }, + { + name: "Invalid request - active is a number", + body: `{"active": 1}`, + expectedStatus: 400, + errorContains: "invalid type for field 'active'", + }, + { + name: "Invalid request - active is an object", + body: `{"active": {"enabled": true}}`, + expectedStatus: 400, + errorContains: "invalid type for field 'active'", + }, + { + name: "Invalid request - active is an array", + body: `{"active": [true]}`, + expectedStatus: 400, + errorContains: "invalid type for field 'active'", + }, + { + name: "Valid request - active is a bool", + body: `{"active": true}`, + expectedStatus: 200, + }, + // Tests for map field (metadata) + { + name: "Invalid request - metadata is a string", + body: `{"metadata": "some data"}`, + expectedStatus: 400, + errorContains: "invalid type for field 'metadata'", + }, + { + name: "Invalid request - metadata is a number", + body: `{"metadata": 123}`, + expectedStatus: 400, + errorContains: "invalid type for field 'metadata'", + }, + { + name: "Invalid request - metadata is a bool", + body: `{"metadata": true}`, + expectedStatus: 400, + errorContains: "invalid type for field 'metadata'", + }, + { + name: "Invalid request - metadata is an array", + body: `{"metadata": ["key1", "value1"]}`, + expectedStatus: 400, + errorContains: "invalid type for field 'metadata'", + }, + { + name: "Valid request - metadata is an object", + body: `{"metadata": {"key1": "value1", "key2": "value2"}}`, + expectedStatus: 200, + }, } for _, tt := range tests { @@ -172,3 +332,143 @@ func TestJSONTypeMismatchWithCustomHandler(t *testing.T) { t.Errorf("Expected 'invalid type' in error message, got %s", bodyStr) } } + +// Test all type combinations: bool, map, int, etc. +func TestAllTypeMismatches(t *testing.T) { + app := fiber.New() + oapi := New(app) + + type ComplexRequest struct { + Name string `json:"name"` + Age int `json:"age"` + Active bool `json:"active"` + Tags []string `json:"tags"` + Metadata map[string]string `json:"metadata"` + Score float64 `json:"score"` + } + + type TestResponse struct { + Message string `json:"message"` + } + + Post(oapi, "/test", func(c *fiber.Ctx, input ComplexRequest) (TestResponse, TestError) { + return TestResponse{Message: "OK"}, TestError{} + }, OpenAPIOptions{}) + + tests := []struct { + name string + body string + expectedField string + expectedType string + actualType string + }{ + { + name: "String field receives number", + body: `{"name": 123}`, + expectedField: "name", + expectedType: "string", + actualType: "number", + }, + { + name: "String field receives bool", + body: `{"name": true}`, + expectedField: "name", + expectedType: "string", + actualType: "bool", + }, + { + name: "Int field receives string", + body: `{"age": "25"}`, + expectedField: "age", + expectedType: "int", + actualType: "string", + }, + { + name: "Bool field receives string", + body: `{"active": "true"}`, + expectedField: "active", + expectedType: "bool", + actualType: "string", + }, + { + name: "Bool field receives number", + body: `{"active": 1}`, + expectedField: "active", + expectedType: "bool", + actualType: "number", + }, + { + name: "Array field receives string", + body: `{"tags": "tag1"}`, + expectedField: "tags", + expectedType: "[]string", + actualType: "string", + }, + { + name: "Array field receives object", + body: `{"tags": {"key": "value"}}`, + expectedField: "tags", + expectedType: "[]string", + actualType: "object", + }, + { + name: "Map field receives string", + body: `{"metadata": "data"}`, + expectedField: "metadata", + expectedType: "map[string]string", + actualType: "string", + }, + { + name: "Map field receives array", + body: `{"metadata": ["a", "b"]}`, + expectedField: "metadata", + expectedType: "map[string]string", + actualType: "array", + }, + { + name: "Float field receives string", + body: `{"score": "99.5"}`, + expectedField: "score", + expectedType: "float64", + actualType: "string", + }, + { + name: "Float field receives bool", + body: `{"score": true}`, + expectedField: "score", + expectedType: "float64", + actualType: "bool", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/test", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("Test error: %v", err) + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + if resp.StatusCode != 400 { + t.Errorf("Expected status 400, got %d. Body: %s", resp.StatusCode, bodyStr) + } + + // Check for clean error message + if !strings.Contains(bodyStr, "invalid type for field") { + t.Errorf("Expected 'invalid type for field' in error, got: %s", bodyStr) + } + + // Check field name is present + if !strings.Contains(bodyStr, tt.expectedField) { + t.Errorf("Expected field name '%s' in error, got: %s", tt.expectedField, bodyStr) + } + + // Log the error for debugging + t.Logf("Error message: %s", bodyStr) + }) + } +} From 4c9daf91543343bb9dc6d0c8bd459dc3c97be5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Mouton?= Date: Wed, 3 Dec 2025 15:04:36 +0100 Subject: [PATCH 2/2] Improve JSON type mismatch error handling with detailed field identification --- common.go | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/common.go b/common.go index 956e268..0cc4dc2 100644 --- a/common.go +++ b/common.go @@ -1,6 +1,7 @@ package fiberoapi import ( + "encoding/json" "fmt" "reflect" "strconv" @@ -50,8 +51,9 @@ func parseInput[TInput any](app *OApiApp, c *fiber.Ctx, path string, options *Op // It's OK, the POST has no body - ignore the error } else { // Transform JSON unmarshal type errors into readable validation errors - // Check if error message contains unmarshal type error pattern errMsg := err.Error() + + // Check if error message contains unmarshal type error pattern if strings.Contains(errMsg, "json: cannot unmarshal") && strings.Contains(errMsg, "into Go struct field") { // Parse the error message to extract field name and type info // Format: "json: cannot unmarshal into Go struct field . of type " @@ -75,8 +77,20 @@ func parseInput[TInput any](app *OApiApp, c *fiber.Ctx, path string, options *Op fieldName, expectedType, typePart) } } + } else if strings.Contains(errMsg, "json: slice") || strings.Contains(errMsg, "json: map") { + // Handle "json: slice unexpected end of JSON input" and similar errors + // This happens when sending wrong type for slice/map fields + // Try to identify which field caused the error by parsing the request body + fieldName, expectedType, actualType := detectTypeMismatchFromBody(c.Body(), input) + if fieldName != "" { + return input, fmt.Errorf("invalid type for field '%s': expected %s but got %s", + fieldName, expectedType, actualType) + } + // Fallback to generic message if we can't identify the field + return input, fmt.Errorf("invalid JSON: expected array or object but got incompatible type") } + // Return original error if no pattern matched return input, err } } @@ -441,6 +455,116 @@ func getSchemaForType(t reflect.Type) map[string]interface{} { return schema } +// detectTypeMismatchFromBody attempts to identify which field caused a JSON type mismatch +// by parsing the request body and comparing against the expected struct type +func detectTypeMismatchFromBody(body []byte, input interface{}) (fieldName, expectedType, actualType string) { + // Parse the JSON body into a map to see what was actually sent + var bodyMap map[string]interface{} + if err := json.Unmarshal(body, &bodyMap); err != nil { + return "", "", "" + } + + // Get the struct type using reflection + inputValue := reflect.ValueOf(input) + if inputValue.Kind() == reflect.Ptr { + inputValue = inputValue.Elem() + } + inputType := inputValue.Type() + + if inputType.Kind() != reflect.Struct { + return "", "", "" + } + + // Iterate through struct fields to find the mismatch + for i := 0; i < inputType.NumField(); i++ { + field := inputType.Field(i) + + // Get the JSON tag name (default to field name if no tag) + jsonTag := field.Tag.Get("json") + if jsonTag == "" { + jsonTag = field.Name + } else { + // Remove omitempty and other options from the tag + jsonTag = strings.Split(jsonTag, ",")[0] + } + + // Check if this field is in the body map + if actualValue, exists := bodyMap[jsonTag]; exists { + expectedFieldType := dereferenceType(field.Type) + actualValueType := getJSONValueType(actualValue) + + // Check for type mismatch + mismatch := false + expectedTypeName := "" + + switch expectedFieldType.Kind() { + case reflect.String: + expectedTypeName = "string" + if actualValueType != "string" { + mismatch = true + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + expectedTypeName = "integer" + if actualValueType != "number" { + mismatch = true + } + case reflect.Float32, reflect.Float64: + expectedTypeName = "number" + if actualValueType != "number" { + mismatch = true + } + case reflect.Bool: + expectedTypeName = "boolean" + if actualValueType != "boolean" { + mismatch = true + } + case reflect.Slice, reflect.Array: + expectedTypeName = fmt.Sprintf("[]%s", dereferenceType(expectedFieldType.Elem()).Kind()) + if actualValueType != "array" { + mismatch = true + } + case reflect.Map: + expectedTypeName = "map" + if actualValueType != "object" { + mismatch = true + } + case reflect.Struct: + expectedTypeName = "object" + if actualValueType != "object" { + mismatch = true + } + } + + if mismatch { + return field.Name, expectedTypeName, actualValueType + } + } + } + + return "", "", "" +} + +// getJSONValueType returns the JSON type name for a value parsed from JSON +func getJSONValueType(value interface{}) string { + switch value.(type) { + case string: + return "string" + case float64, int, int64: + return "number" + case bool: + return "boolean" + case []interface{}: + return "array" + case map[string]interface{}: + return "object" + case nil: + return "null" + default: + return "unknown" + } +} + // mergeParameters merges auto-generated parameters with manually defined ones // Manual parameters take precedence over auto-generated ones with the same name func mergeParameters(autoParams []map[string]interface{}, manualParams []map[string]interface{}) []map[string]interface{} {