Skip to content

Bug: $ref to #/components/schemas/<N> fails with "does not exist" when N ≤ 99 #585

Description

@balakumar28

Bug: $ref to #/components/schemas/<N> fails with "does not exist" when N ≤ 99

Repository: https://github.com/pb33f/libopenapi
Version: v0.31.1 (reproduced; likely affects earlier versions too)
Severity: Bug — valid OAS 3.x specifications are rejected


Summary

When an OpenAPI 3.x specification defines a component schema whose name is a small integer string
(e.g. "2", "10", "99"), BuildV3Model reports:

component `#/components/schemas/2` does not exist in the specification

even though the component is present in the document. The same error does not occur when
the schema name is a numeric string greater than 99 (e.g. "400", "422"), because those are
handled differently by an internal threshold check.


Minimal Reproducer

Save the following as bug.json and run BuildV3Model against it:

{
  "openapi": "3.0.3",
  "info": { "title": "Numeric Schema Key Bug", "version": "1.0.0" },
  "paths": {
    "/capture": {
      "post": {
        "operationId": "capturePayment",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/2" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Captured",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/2" }
              }
            }
          },
          "400": {
            "description": "Bad request",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/400" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "2": {
        "title": "Capture Request",
        "type": "object",
        "properties": {
          "amount": {
            "type": "object",
            "properties": {
              "currency_code": { "type": "string" },
              "value": { "type": "string" }
            }
          }
        }
      },
      "400": {
        "title": "Error 400",
        "type": "object",
        "properties": {
          "message": { "type": "string" }
        }
      }
    }
  }
}
data, _ := os.ReadFile("bug.json")
doc, _ := libopenapi.NewDocument(data)
_, err := doc.BuildV3Model()
fmt.Println(err)
// Output: component `#/components/schemas/2` does not exist in the specification

Expected: No error — both #/components/schemas/2 and #/components/schemas/400 resolve
successfully, because both keys exist under components.schemas.

Actual:

  • #/components/schemas/400 resolves correctly ✓
  • #/components/schemas/2 fails with "does not exist in the specification" ✗

Root Cause

The bug is in utils/utils.go, function ConvertComponentIdIntoFriendlyPathSearch.

When the function encounters a path segment that is a pure integer, it applies this logic
(abridged):

intVal, err := strconv.Atoi(segs[i])
if err == nil {
    if intVal <= 99 {
        // Treated as an ARRAY INDEX — appends [2] (no quotes)
        appendSegmentOptimized(segs, cleaned, i, false)  // wrapInQuotes = false
    } else {
        // Treated as a STRING KEY — appends ['400'] (with quotes)
        appendSegmentOptimized(segs, cleaned, i, true)   // wrapInQuotes = true
    }
    continue
}

What goes wrong for #/components/schemas/2

The input #/components/schemas/2 is split into segments ["#", "components", "schemas", "2"].
When segment "2" is processed:

Step Value
strconv.Atoi("2") intVal = 2, err = nil
intVal <= 99 true
wrapInQuotes false
Generated JSONPath suffix [2] (array index notation)
Final JSONPath $.components.schemas[2]

schemas[2] asks for the third element of an array named schemas. Since schemas is a
JSON object (map), not an array, the query returns nothing and the reference is reported as
missing.

Why #/components/schemas/400 works

"400" produces intVal = 400 > 99, so wrapInQuotes = true, resulting in
$.components.schemas['400'] — a correct property key lookup.

Affected range

Any component name that is a pure integer string with a value ≤ 99 triggers the bug.
Examples: "0", "1", "2", "10", "42", "99".

The threshold of 99 appears to have been intended to distinguish between array-style indices
(small integers used in allOf[0], oneOf[1], etc.) and large numeric schema names (HTTP
status codes). However, the two contexts cannot be distinguished purely by value magnitude —
components/schemas is always a map, never an array, regardless of whether the key is "2" or
"400".

This is a real-world issue encountered in the
PayPal Payments v2 API specification,
which defines schemas named "2", "400", "401", "403", "404", "409", and "422"
under #/components/schemas.


Suggested Fix

The fix should quote all numeric string keys that appear as the final segment of a
#/components/... path, without relying on a magnitude threshold.

One approach: after strconv.Atoi succeeds, check whether the parent path segment ends in
's' (as the existing plural-parent logic already does for non-numeric keys). If it does, the
numeric segment is a map key name, not an array index, and must be quoted:

intVal, err := strconv.Atoi(segs[i])
if err == nil {
    // If the parent segment ends in 's' (e.g. "schemas", "parameters"),
    // this integer is a map key name — always quote it.
    parentEndsSingular := i > 0 && len(segs[i-1]) > 0 && segs[i-1][len(segs[i-1])-1] == 's'
    if parentEndsSingular || intVal > 99 {
        if len(cleaned) > 0 {
            appendSegmentOptimized(segs, cleaned, i, true) // wrap in quotes
        }
    } else {
        if len(cleaned) > 0 {
            appendSegmentOptimized(segs, cleaned, i, false) // array index
        }
    }
    continue
}

Alternatively, always wrap numeric last-segments in quotes when the reference path starts with
#/components/ (since all components/* values are maps, never arrays).


Environment

Item Value
libopenapi version v0.31.1
Affected function utils.ConvertComponentIdIntoFriendlyPathSearch in utils/utils.go
Go version 1.21+
OAS version 3.0.x (also expected to affect 3.1.x)

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