You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In v0.14.0, a document whose servers: entry contains a {variable} in the host — e.g. https://{host}/api/v1 — no longer has its base path stripped from incoming request paths when using the high-level validator with default options. Every request then fails with Path '<base><path>' not found.
This worked in v0.13.13, and appears to be an unintended regression rather than a designed change. WithPathOnlyMatching's own doc comment (router/router.go:122) reads "preserves validator-compatible document base-path matching", and the README section added in the very same commit (c7963cf8ca0fac32540e42c39de1f727f2abef9f, README.md:29) states "Existing behavior remains the default. Strict server matching, non-JSON codecs, request mutation, and new rejection policies are opt-in:" — so path-only mode losing base-path stripping looks unintended.
The fallback that made this work was added deliberately in #68 (2024) for exactly this case, and was not carried into the new implementation.
Reproduction
Self-contained — no external spec files, only libopenapi and libopenapi-validator.
package main
import (
"fmt""net/http""os""github.com/pb33f/libopenapi"
validator "github.com/pb33f/libopenapi-validator"
)
// Four specs that differ ONLY in the servers: block.const (
templatedWithDefault=`openapi: 3.1.0info: {title: repro, version: 1.0.0}servers: - url: https://{host}/api/v1 variables: host: default: api.example.compaths: /widgets: get: responses: '200': {description: ok}`templatedNoDefault=`openapi: 3.1.0info: {title: repro, version: 1.0.0}servers: - url: https://{host}/api/v1 variables: host: description: no default declaredpaths: /widgets: get: responses: '200': {description: ok}`literal=`openapi: 3.1.0info: {title: repro, version: 1.0.0}servers: - url: https://api.example.com/api/v1paths: /widgets: get: responses: '200': {description: ok}`// Control: the variable is in the PATH, not the host, so url.Parse succeeds.templatedPathVar=`openapi: 3.1.0info: {title: repro, version: 1.0.0}servers: - url: https://api.example.com/{ver}/api variables: ver: default: v1paths: /widgets: get: responses: '200': {description: ok}`
)
typetestCasestruct {
namestringspecstringrequestURLstring
}
funcmain() {
cases:= []testCase{
{"literal server URL", literal, "https://api.example.com/api/v1/widgets"},
{"templated {host}, WITH default", templatedWithDefault, "https://api.example.com/api/v1/widgets"},
{"templated {host}, NO default", templatedNoDefault, "https://api.example.com/api/v1/widgets"},
{"templated {ver} in path", templatedPathVar, "https://api.example.com/v1/api/widgets"},
}
failures:=0for_, tc:=rangecases {
ok, detail:=check(tc)
status:="PASS"if!ok {
status="FAIL"failures++
}
fmt.Printf("%-4s %-32s %s\n", status, tc.name, detail)
}
fmt.Printf("\n%d/%d failed\n", failures, len(cases))
iffailures>0 {
os.Exit(1)
}
}
// check builds a validator with DEFAULT options (no functional options at all)// and validates one GET request whose path is server-base-path + spec path.funccheck(tctestCase) (bool, string) {
doc, err:=libopenapi.NewDocument([]byte(tc.spec))
iferr!=nil {
returnfalse, "parse spec: "+err.Error()
}
v, errs:=validator.NewValidator(doc)
iflen(errs) >0 {
returnfalse, fmt.Sprintf("build validator: %v", errs)
}
req, err:=http.NewRequest(http.MethodGet, tc.requestURL, nil)
iferr!=nil {
returnfalse, "build request: "+err.Error()
}
valid, validationErrs:=v.ValidateHttpRequest(req)
ifvalid {
returntrue, "request validated"
}
iflen(validationErrs) >0 {
returnfalse, validationErrs[0].Message
}
returnfalse, "invalid, no error returned"
}
go mod init repro
go get github.com/pb33f/libopenapi-validator@v0.13.13 && go run .
go get github.com/pb33f/libopenapi-validator@v0.14.0 && go run .
Results
spec servers[0].url
v0.13.13
v0.14.0
https://api.example.com/api/v1 (literal)
PASS
PASS
https://{host}/api/v1, hostwithdefault
PASS
FAILGET Path '/api/v1/widgets' not found
https://{host}/api/v1, hostwithoutdefault
PASS
FAIL (identical error)
(The fourth case in the program, {ver} in the server path, is a control — see the closing section.)
Removing the default makes no difference: path-only mode never substitutes variable defaults. The trigger is simply { appearing in the server URL's host.
config.WithStrictServerMatching() on v0.14.0 makes the templated case pass, which is what pointed me at the compatibility path:
FAIL default (path-only) GET Path '/api/v1/widgets' not found
PASS WithStrictServerMatching request validated
Root cause
NewValidatorFromV3Model now always installs a router, defaulting to path-only mode (validator.go:101-112), and paths.FindPath short-circuits into it (paths/paths.go:37-43). Path-only mode strips the base path in router/server.go:195-216:
url.Parse("https://{host}/api/v1") returns invalid character "{" in host name, so err != nil, the branch is skipped, and the base path is never removed.
The pre-existing paths.getBasePaths handles this and is still present verbatim in v0.14.0 (paths/paths.go:175-198) — it is simply no longer reached for high-level validator users:
u, err:=url.Parse(s.URL)
// if the host contains special characters, we should attempt to split and parse only the relative pathiferr!=nil {
// split at first occurrence_, serverPath, _:=strings.Cut(strings.Replace(s.URL, "//", "", 1), "/")
if!strings.HasPrefix(serverPath, "/") {
serverPath="/"+serverPath
}
u, _=url.Parse(serverPath)
}
That fallback came from #68, whose description names this exact scenario: "url.Parse does not handle special characters well. Some openapi server configurations will have variable templating (ie. {host})."
Only the high-level entry points are affected. Validators built directly from config.NewValidationOptions (requests.NewRequestBodyValidator etc.) leave Router nil and still take the legacy StripRequestPath route.
Suggested fix
Have compatibilityPath reuse paths.getBasePaths (or port its err != nil fallback), so path-only mode matches v0.13.x behaviour for templated servers.
A regression test combining WithPathOnlyMatching with a templated server would cover the gap: the existing templated-server coverage in router/router_test.go:35 runs only in strict mode, and the WithPathOnlyMatching tests use only literal or absent servers, so the default configuration is currently untested against server variables.
Separate, pre-existing observation (not part of this regression)
A variable in the server path rather than the host — https://api.example.com/{ver}/api — fails on both v0.13.13 and v0.14.0 (GET Path '/v1/api/widgets' not found). There url.Parse succeeds, but parsed.Path is the literal /{ver}/api, which cannot prefix-match a real request path. Noting it only so it isn't conflated with the regression above — happy to file separately if useful.
Environment
Go 1.26.5, darwin/arm64
github.com/pb33f/libopenapi as resolved transitively: v0.38.3 under the v0.13.13 step, v0.38.6 under the v0.14.0 step (v0.13.13 also passes on v0.38.6, so this is not a factor)
Compared github.com/pb33f/libopenapi-validator v0.13.13 vs v0.14.0
Summary
In v0.14.0, a document whose
servers:entry contains a{variable}in the host — e.g.https://{host}/api/v1— no longer has its base path stripped from incoming request paths when using the high-level validator with default options. Every request then fails withPath '<base><path>' not found.This worked in v0.13.13, and appears to be an unintended regression rather than a designed change.
WithPathOnlyMatching's own doc comment (router/router.go:122) reads "preserves validator-compatible document base-path matching", and the README section added in the very same commit (c7963cf8ca0fac32540e42c39de1f727f2abef9f,README.md:29) states "Existing behavior remains the default. Strict server matching, non-JSON codecs, request mutation, and new rejection policies are opt-in:" — so path-only mode losing base-path stripping looks unintended.The fallback that made this work was added deliberately in #68 (2024) for exactly this case, and was not carried into the new implementation.
Reproduction
Self-contained — no external spec files, only
libopenapiandlibopenapi-validator.Results
servers[0].urlhttps://api.example.com/api/v1(literal)https://{host}/api/v1,hostwithdefaultGET Path '/api/v1/widgets' not foundhttps://{host}/api/v1,hostwithoutdefault(The fourth case in the program,
{ver}in the server path, is a control — see the closing section.)Removing the
defaultmakes no difference: path-only mode never substitutes variable defaults. The trigger is simply{appearing in the server URL's host.config.WithStrictServerMatching()on v0.14.0 makes the templated case pass, which is what pointed me at the compatibility path:Root cause
NewValidatorFromV3Modelnow always installs a router, defaulting to path-only mode (validator.go:101-112), andpaths.FindPathshort-circuits into it (paths/paths.go:37-43). Path-only mode strips the base path inrouter/server.go:195-216:url.Parse("https://{host}/api/v1")returnsinvalid character "{" in host name, soerr != nil, the branch is skipped, and the base path is never removed.The pre-existing
paths.getBasePathshandles this and is still present verbatim in v0.14.0 (paths/paths.go:175-198) — it is simply no longer reached for high-level validator users:That fallback came from #68, whose description names this exact scenario: "
url.Parsedoes not handle special characters well. Some openapi server configurations will have variable templating (ie.{host})."Only the high-level entry points are affected. Validators built directly from
config.NewValidationOptions(requests.NewRequestBodyValidatoretc.) leaveRouternil and still take the legacyStripRequestPathroute.Suggested fix
Have
compatibilityPathreusepaths.getBasePaths(or port itserr != nilfallback), so path-only mode matches v0.13.x behaviour for templated servers.A regression test combining
WithPathOnlyMatchingwith a templated server would cover the gap: the existing templated-server coverage inrouter/router_test.go:35runs only in strict mode, and theWithPathOnlyMatchingtests use only literal or absent servers, so the default configuration is currently untested against server variables.Separate, pre-existing observation (not part of this regression)
A variable in the server path rather than the host —
https://api.example.com/{ver}/api— fails on both v0.13.13 and v0.14.0 (GET Path '/v1/api/widgets' not found). Thereurl.Parsesucceeds, butparsed.Pathis the literal/{ver}/api, which cannot prefix-match a real request path. Noting it only so it isn't conflated with the regression above — happy to file separately if useful.Environment
github.com/pb33f/libopenapias resolved transitively: v0.38.3 under the v0.13.13 step, v0.38.6 under the v0.14.0 step (v0.13.13 also passes on v0.38.6, so this is not a factor)github.com/pb33f/libopenapi-validatorv0.13.13 vs v0.14.0c7963cf8ca0fac32540e42c39de1f727f2abef9f