Skip to content

Response validation rejects valid payloads when schema uses oneOf with discriminator #247

@SJrX

Description

@SJrX

Apologies, this is a real bug I'm facing, but the investigation was largely Claude Code's :D.

The tl;dr is I have a spec that uses oneOf with a discriminator, and I'm getting validation errors at runtime (when validating the response), with errors complaining about the json pointers being missing.

Claude suggests that the main issue is that in a few cases, we should use : base.NewInlineRenderContextForValidation() instead of base.NewInlineRenderContext()

It suggested these places should be changed:

 The fix is trivial. It's literally a one-line change in each location:                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                                                   
  // Before                                                                                                                                                                                                                                                                                                        
  renderCtx := base.NewInlineRenderContext()                                                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                                                                                                   
  // After                                                                                                                                                                                                                                                                                                         
  renderCtx := base.NewInlineRenderContextForValidation()                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                   
  Fix Summary                                                                                                                                                                                                                                                                                                      
  ┌──────────────────────────────────┬─────────────────┐                                                                                                                                                                                                                                                           
  │               File               │ Lines to change │                                                                                                                                                                                                                                                           
  ├──────────────────────────────────┼─────────────────┤                                                                                                                                                                                                                                                           
  │ responses/validate_response.go   │ Line 84         │                                                                                                                                                                                                                                                           
  ├──────────────────────────────────┼─────────────────┤                                                                                                                                                                                                                                                           
  │ requests/validate_request.go     │ Line 81         │                                                                                                                                                                                                                                                           
  ├──────────────────────────────────┼─────────────────┤                                                                                                                                                                                                                                                           
  │ parameters/validate_parameter.go │ Lines 97, 237   │                                                                                                                                                                                                                                                           
  ├──────────────────────────────────┼─────────────────┤                                                                                                                                                                                                                                                           
  │ validator.go                     │ Lines 488, 541  │                                                                                                                                                                                                                                                           
  ├──────────────────────────────────┼─────────────────┤                                                                                                                                                                                                                                                           
  │ strict/types.go                  │ Line 333        │                                                                                                                                                                                                                                                           
  └──────────────────────────────────┴─────────────────┘                                                                                                                                                                                                                                                           
  Total: 7 one-line changes (all identical substitutions)                                        

Here is a sample test that fails, and in theory shows that switching the method gets rid of the weird refs.

package validator

import (
	"io"
	"net/http"
	"strings"
	"testing"

	"github.com/pb33f/libopenapi"
	"github.com/pb33f/libopenapi/datamodel/high/base"
)

// Minimal OpenAPI spec with oneOf + discriminator pattern that triggers the bug
const discriminatorSpec = `openapi: "3.1.0"
info:
  title: "Test"
  version: "1.0.0"
components:
  schemas:
    BaseField:
      type: object
      properties:
        id:
          type: string
        field_type:
          type: string
    BooleanField:
      allOf:
        - $ref: '#/components/schemas/BaseField'
        - type: object
          properties:
            default_value:
              type: boolean
    StringField:
      allOf:
        - $ref: '#/components/schemas/BaseField'
        - type: object
          properties:
            max_length:
              type: integer
    Field:
      oneOf:
        - $ref: '#/components/schemas/BooleanField'
        - $ref: '#/components/schemas/StringField'
      discriminator:
        propertyName: field_type
        mapping:
          boolean: '#/components/schemas/BooleanField'
          string: '#/components/schemas/StringField'
paths:
  /fields:
    get:
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Field'
`

// TestValidateHttpResponse_FailsWithDiscriminatorOneOf demonstrates the actual
// user-facing bug: ValidateHttpResponse fails with "json-pointer ... not found"
// when validating responses against schemas using oneOf with discriminator.
//
// The response body is completely valid according to the schema, but validation
// fails because the schema renderer uses Bundle mode which preserves $ref in
// discriminator oneOf schemas. The JSON Schema compiler then can't resolve
// these refs because it's treating the rendered schema as a standalone document.
func TestValidateHttpResponse_FailsWithDiscriminatorOneOf(t *testing.T) {
	// Valid response body that matches BooleanField schema
	responseBody := `{
		"id": "field-1",
		"field_type": "boolean",
		"default_value": true
	}`

	doc, err := libopenapi.NewDocument([]byte(discriminatorSpec))
	if err != nil {
		t.Fatalf("Failed to parse spec: %v", err)
	}

	v, errs := NewValidator(doc)
	if len(errs) > 0 {
		t.Fatalf("Failed to create validator: %v", errs)
	}

	request, _ := http.NewRequest(http.MethodGet, "/fields", nil)
	response := &http.Response{
		StatusCode: 200,
		Header:     http.Header{"Content-Type": []string{"application/json"}},
		Body:       io.NopCloser(strings.NewReader(responseBody)),
	}

	valid, validationErrs := v.ValidateHttpResponse(request, response)

	// Check if we hit the json-pointer bug
	for _, ve := range validationErrs {
		if strings.Contains(ve.Message, "json-pointer") ||
			strings.Contains(ve.Reason, "json-pointer") {
			t.Logf("BUG: Response validation failed with json-pointer error")
			t.Logf("Message: %s", ve.Message)
			t.Logf("Reason: %s", ve.Reason)
			t.Log("")
			t.Log("ROOT CAUSE: validate_response.go uses NewInlineRenderContext() (Bundle mode)")
			t.Log("which preserves $ref in discriminator oneOf schemas.")
			t.Log("")
			t.Log("FIX: Change to NewInlineRenderContextForValidation() which inlines all $refs")
			t.FailNow()
		}
	}

	if !valid {
		t.Logf("Validation failed with other errors: %v", validationErrs)
	} else {
		t.Log("Validation passed - bug may be fixed!")
	}
}

// TestRenderInlineContext_DiscriminatorOneOf demonstrates the root cause:
// NewInlineRenderContext() (Bundle mode) preserves $ref in discriminator oneOf,
// while NewInlineRenderContextForValidation() correctly inlines everything.
//
// See libopenapi schema_proxy.go:74-107 for the two rendering modes:
// - RenderingModeBundle (NewInlineRenderContext): preserves discriminator $refs
// - RenderingModeValidation (NewInlineRenderContextForValidation): inlines everything
func TestRenderInlineContext_DiscriminatorOneOf(t *testing.T) {
	doc, err := libopenapi.NewDocument([]byte(discriminatorSpec))
	if err != nil {
		t.Fatalf("Failed to parse spec: %v", err)
	}

	model, errs := doc.BuildV3Model()
	if errs != nil {
		t.Fatalf("Failed to build model: %v", errs)
	}

	// Get the response schema
	pathItem := model.Model.Paths.PathItems.GetOrZero("/fields")
	response := pathItem.Get.Responses.Codes.GetOrZero("200")
	mediaType := response.Content.GetOrZero("application/json")
	schema := mediaType.Schema.Schema()

	t.Run("BundleMode_PreservesRefs_CausesValidationFailure", func(t *testing.T) {
		// This is what libopenapi-validator currently uses in validate_response.go
		renderCtx := base.NewInlineRenderContext()
		rendered, err := schema.RenderInlineWithContext(renderCtx)
		if err != nil {
			t.Fatalf("RenderInlineWithContext failed: %v", err)
		}

		renderedStr := string(rendered)
		t.Logf("Bundle mode rendered schema:\n%s", renderedStr)

		// Bundle mode intentionally preserves $ref in discriminator oneOf
		// This causes JSON Schema compilation to fail with:
		// "json-pointer in file:///...#/components/schemas/BooleanField not found"
		if !strings.Contains(renderedStr, "$ref") {
			t.Error("Expected Bundle mode to preserve $ref, but none found")
		} else {
			refCount := strings.Count(renderedStr, "$ref")
			t.Logf("Bundle mode preserved %d $ref (as designed)", refCount)
			t.Log("This is the ROOT CAUSE: passing this to JSON Schema compiler fails")
		}
	})

	t.Run("ValidationMode_InlinesAllRefs_FixesValidation", func(t *testing.T) {
		// This is what libopenapi-validator SHOULD use
		renderCtx := base.NewInlineRenderContextForValidation()
		rendered, err := schema.RenderInlineWithContext(renderCtx)
		if err != nil {
			t.Fatalf("RenderInlineWithContext failed: %v", err)
		}

		renderedStr := string(rendered)
		t.Logf("Validation mode rendered schema:\n%s", renderedStr)

		// Validation mode should fully inline all $ref
		if strings.Contains(renderedStr, "$ref") {
			refCount := strings.Count(renderedStr, "$ref")
			t.Errorf("Validation mode should inline all $ref, but found %d", refCount)
		} else {
			t.Log("Validation mode correctly inlined all $ref")
			t.Log("This schema can be passed to JSON Schema compiler successfully")
		}
	})
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions