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) |
Bug:
$refto#/components/schemas/<N>fails with "does not exist" when N ≤ 99Repository: 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"),BuildV3Modelreports: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 arehandled differently by an internal threshold check.
Minimal Reproducer
Save the following as
bug.jsonand runBuildV3Modelagainst 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" } } } } } }Expected: No error — both
#/components/schemas/2and#/components/schemas/400resolvesuccessfully, because both keys exist under
components.schemas.Actual:
#/components/schemas/400resolves correctly ✓#/components/schemas/2fails with "does not exist in the specification" ✗Root Cause
The bug is in
utils/utils.go, functionConvertComponentIdIntoFriendlyPathSearch.When the function encounters a path segment that is a pure integer, it applies this logic
(abridged):
What goes wrong for
#/components/schemas/2The input
#/components/schemas/2is split into segments["#", "components", "schemas", "2"].When segment
"2"is processed:strconv.Atoi("2")intVal = 2, err = nilintVal <= 99truewrapInQuotesfalse[2](array index notation)$.components.schemas[2]schemas[2]asks for the third element of an array namedschemas. Sinceschemasis aJSON object (map), not an array, the query returns nothing and the reference is reported as
missing.
Why
#/components/schemas/400works"400"producesintVal = 400 > 99, sowrapInQuotes = 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 (HTTPstatus codes). However, the two contexts cannot be distinguished purely by value magnitude —
components/schemasis 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.Atoisucceeds, check whether the parent path segment ends in's'(as the existing plural-parent logic already does for non-numeric keys). If it does, thenumeric segment is a map key name, not an array index, and must be quoted:
Alternatively, always wrap numeric last-segments in quotes when the reference path starts with
#/components/(since allcomponents/*values are maps, never arrays).Environment
libopenapiversionutils.ConvertComponentIdIntoFriendlyPathSearchinutils/utils.go