From b15cce516b852689535d68013c0ce0aad6144520 Mon Sep 17 00:00:00 2001 From: k2tzumi Date: Sat, 14 Feb 2026 17:38:46 +0900 Subject: [PATCH 1/2] Fix: enum with nullable property to automatically include null value in validation --- helpers/schema_compiler.go | 20 +++++++ helpers/schema_compiler_test.go | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go index 9fc6cec6..91e4ddaa 100644 --- a/helpers/schema_compiler.go +++ b/helpers/schema_compiler.go @@ -207,6 +207,26 @@ func transformNullableSchema(schema map[string]interface{}) map[string]interface schema["oneOf"] = oneOfSlice } + // Handle enum values - add null if nullable but not already in enum + enum, hasEnum := schema["enum"] + if hasEnum { + if enumSlice, ok := enum.([]interface{}); ok { + // Check if null is already in enum + hasNull := false + for _, v := range enumSlice { + if v == nil { + hasNull = true + break + } + } + // Add null if not present + if !hasNull { + enumSlice = append(enumSlice, nil) + schema["enum"] = enumSlice + } + } + } + return schema } diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go index c5965b37..da228a94 100644 --- a/helpers/schema_compiler_test.go +++ b/helpers/schema_compiler_test.go @@ -704,3 +704,98 @@ func TestTransformTypeForCoercion_EdgeCases(t *testing.T) { result = transformTypeForCoercion([]interface{}{"string"}) assert.Equal(t, []interface{}{"string"}, result) } + +func TestTransformNullableSchema_EnumWithoutNull(t *testing.T) { + // Test case: nullable: true with enum that doesn't contain null + // Expected: null should be automatically added to the enum + schema := map[string]interface{}{ + "type": "string", + "enum": []interface{}{ + "active", + "inactive", + "pending", + "archived", + }, + "nullable": true, + } + + result := transformNullableSchema(schema) + + // nullable keyword should be removed + _, hasNullable := result["nullable"] + assert.False(t, hasNullable) + + // type should be converted to array including null + schemaType, ok := result["type"] + require.True(t, ok) + + typeArray, ok := schemaType.([]interface{}) + require.True(t, ok) + assert.Contains(t, typeArray, "string") + assert.Contains(t, typeArray, "null") + + // enum should contain null + enum, ok := result["enum"] + require.True(t, ok) + + enumSlice, ok := enum.([]interface{}) + require.True(t, ok) + assert.Len(t, enumSlice, 5) // original 4 values + null + assert.Contains(t, enumSlice, "active") + assert.Contains(t, enumSlice, "inactive") + assert.Contains(t, enumSlice, "pending") + assert.Contains(t, enumSlice, "archived") + assert.Contains(t, enumSlice, nil) +} + +func TestTransformNullableSchema_EnumWithNull(t *testing.T) { + // Test case: nullable: true with enum that already contains null + // Expected: null should NOT be added twice + schema := map[string]interface{}{ + "type": "string", + "enum": []interface{}{ + "active", + "inactive", + "pending", + "archived", + nil, + }, + "nullable": true, + } + + result := transformNullableSchema(schema) + + // nullable keyword should be removed + _, hasNullable := result["nullable"] + assert.False(t, hasNullable) + + // type should be converted to array including null + schemaType, ok := result["type"] + require.True(t, ok) + + typeArray, ok := schemaType.([]interface{}) + require.True(t, ok) + assert.Contains(t, typeArray, "string") + assert.Contains(t, typeArray, "null") + + // enum should still contain only one null (not duplicated) + enum, ok := result["enum"] + require.True(t, ok) + + enumSlice, ok := enum.([]interface{}) + require.True(t, ok) + assert.Len(t, enumSlice, 5) // original 5 values (no duplication) + assert.Contains(t, enumSlice, "active") + assert.Contains(t, enumSlice, "inactive") + assert.Contains(t, enumSlice, "pending") + assert.Contains(t, enumSlice, "archived") + + // Count how many nulls are in the enum + nullCount := 0 + for _, v := range enumSlice { + if v == nil { + nullCount++ + } + } + assert.Equal(t, 1, nullCount, "enum should contain exactly one null value") +} From e693d65f5e029f7dca75cd2b3875a524a24b0de1 Mon Sep 17 00:00:00 2001 From: k2tzumi Date: Sat, 14 Feb 2026 18:14:15 +0900 Subject: [PATCH 2/2] Add comprehensive test cases for this fix. --- test_specs/nullable_enum.yaml | 131 +++++++++++++ validator_nullable_enum_test.go | 333 ++++++++++++++++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 test_specs/nullable_enum.yaml create mode 100644 validator_nullable_enum_test.go diff --git a/test_specs/nullable_enum.yaml b/test_specs/nullable_enum.yaml new file mode 100644 index 00000000..32f71274 --- /dev/null +++ b/test_specs/nullable_enum.yaml @@ -0,0 +1,131 @@ +openapi: 3.0.2 +info: + title: Nullable Enum Test API + version: 1.0.0 + description: Test specification for nullable enum validation +paths: + /status: + get: + summary: Get status with nullable enum + operationId: getStatus + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + post: + summary: Create status + operationId: createStatus + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatusRequest' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + /items: + get: + summary: Get items with nullable enum in array + operationId: getItems + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Item' +components: + schemas: + StatusResponse: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + status: + type: string + description: Status field with nullable enum (no null in enum) + enum: + - active + - inactive + - pending + - archived + nullable: true + priority: + type: string + description: Priority field with nullable enum (null already in enum) + enum: + - high + - medium + - low + - null + nullable: true + category: + type: string + description: Non-nullable enum + enum: + - public + - private + - internal + StatusRequest: + type: object + required: + - status + properties: + status: + type: string + enum: + - active + - inactive + - pending + - archived + nullable: true + priority: + type: string + enum: + - high + - medium + - low + - null + nullable: true + Item: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + status: + type: string + description: Nested nullable enum + enum: + - available + - sold + - reserved + nullable: true + metadata: + type: object + properties: + visibility: + type: string + description: Deeply nested nullable enum + enum: + - visible + - hidden + nullable: true diff --git a/validator_nullable_enum_test.go b/validator_nullable_enum_test.go new file mode 100644 index 00000000..26931659 --- /dev/null +++ b/validator_nullable_enum_test.go @@ -0,0 +1,333 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package validator + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNullableEnum_ResponseValidation_NullValue tests that nullable enum fields +// accept null values even when null is not explicitly in the enum definition +func TestNullableEnum_ResponseValidation_NullValue(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with null status (enum doesn't explicitly contain null) + responseBody := map[string]interface{}{ + "id": 1, + "status": nil, // null value for nullable enum + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with null enum value") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_ResponseValidation_EnumValue tests that nullable enum fields +// accept valid enum values +func TestNullableEnum_ResponseValidation_EnumValue(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with valid enum value + responseBody := map[string]interface{}{ + "id": 1, + "status": "active", // valid enum value + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with enum value") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_ResponseValidation_InvalidEnumValue tests that nullable enum fields +// reject invalid enum values +func TestNullableEnum_ResponseValidation_InvalidEnumValue(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with invalid enum value + responseBody := map[string]interface{}{ + "id": 1, + "status": "invalid_status", // invalid enum value + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.False(t, valid, "Response should be invalid with non-enum value") + assert.NotEmpty(t, validationErrs, "Should have validation errors") +} + +// TestNullableEnum_ResponseValidation_PriorityWithNullInEnum tests enum that +// already has null in the enum definition +func TestNullableEnum_ResponseValidation_PriorityWithNullInEnum(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with null priority (enum explicitly contains null) + responseBody := map[string]interface{}{ + "id": 1, + "priority": nil, // null value for nullable enum (null already in enum) + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with null enum value") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_ResponseValidation_NonNullableEnum tests that non-nullable +// enum fields reject null values +func TestNullableEnum_ResponseValidation_NonNullableEnum(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with null category (non-nullable enum) + responseBody := map[string]interface{}{ + "id": 1, + "category": nil, // null value for NON-nullable enum + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.False(t, valid, "Response should be invalid with null for non-nullable enum") + assert.NotEmpty(t, validationErrs, "Should have validation errors") +} + +// TestNullableEnum_RequestValidation_NullValue tests that nullable enum fields +// accept null values in request body +func TestNullableEnum_RequestValidation_NullValue(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test request with null status + requestBody := map[string]interface{}{ + "status": nil, // null value for nullable enum + } + + body, _ := json.Marshal(requestBody) + + request, _ := http.NewRequest(http.MethodPost, "https://example.com/status", bytes.NewBuffer(body)) + request.Header.Set("Content-Type", "application/json") + + valid, validationErrs := v.ValidateHttpRequest(request) + + assert.True(t, valid, "Request should be valid with null enum value") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_ArrayResponse tests nullable enum in array items +func TestNullableEnum_ArrayResponse(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test array response with nullable enum + responseBody := []map[string]interface{}{ + { + "id": 1, + "name": "Item 1", + "status": "available", + }, + { + "id": 2, + "name": "Item 2", + "status": nil, // null value for nullable enum in array + }, + { + "id": 3, + "name": "Item 3", + "status": "sold", + }, + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/items", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with null enum value in array") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_NestedObject tests nullable enum in nested object +func TestNullableEnum_NestedObject(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with deeply nested nullable enum + responseBody := []map[string]interface{}{ + { + "id": 1, + "name": "Item 1", + "metadata": map[string]interface{}{ + "visibility": nil, // null value for deeply nested nullable enum + }, + }, + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/items", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with null enum value in nested object") + assert.Empty(t, validationErrs, "Should have no validation errors") +} + +// TestNullableEnum_MultipleNullableFields tests response with multiple nullable enum fields +func TestNullableEnum_MultipleNullableFields(t *testing.T) { + spec, err := os.ReadFile("test_specs/nullable_enum.yaml") + require.NoError(t, err) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Empty(t, errs) + + // Test response with multiple nullable fields set to null + responseBody := map[string]interface{}{ + "id": 1, + "status": nil, // null for status (enum doesn't have null) + "priority": nil, // null for priority (enum has null) + } + + body, _ := json.Marshal(responseBody) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/status", nil) + response := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBuffer(body)), + Request: request, + } + + valid, validationErrs := v.ValidateHttpResponse(request, response) + + assert.True(t, valid, "Response should be valid with multiple null enum values") + assert.Empty(t, validationErrs, "Should have no validation errors") +}