Skip to content

v0.14.0: templated server URLs no longer strip the base path in default (path-only) mode — the #68 fallback was lost #313

Description

@tmaier

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 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.0
info: {title: repro, version: 1.0.0}
servers:
  - url: https://{host}/api/v1
    variables:
      host:
        default: api.example.com
paths:
  /widgets:
    get:
      responses:
        '200': {description: ok}
`

	templatedNoDefault = `openapi: 3.1.0
info: {title: repro, version: 1.0.0}
servers:
  - url: https://{host}/api/v1
    variables:
      host:
        description: no default declared
paths:
  /widgets:
    get:
      responses:
        '200': {description: ok}
`

	literal = `openapi: 3.1.0
info: {title: repro, version: 1.0.0}
servers:
  - url: https://api.example.com/api/v1
paths:
  /widgets:
    get:
      responses:
        '200': {description: ok}
`

	// Control: the variable is in the PATH, not the host, so url.Parse succeeds.
	templatedPathVar = `openapi: 3.1.0
info: {title: repro, version: 1.0.0}
servers:
  - url: https://api.example.com/{ver}/api
    variables:
      ver:
        default: v1
paths:
  /widgets:
    get:
      responses:
        '200': {description: ok}
`
)

type testCase struct {
	name       string
	spec       string
	requestURL string
}

func main() {
	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 := 0
	for _, tc := range cases {
		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))
	if failures > 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.
func check(tc testCase) (bool, string) {
	doc, err := libopenapi.NewDocument([]byte(tc.spec))
	if err != nil {
		return false, "parse spec: " + err.Error()
	}
	v, errs := validator.NewValidator(doc)
	if len(errs) > 0 {
		return false, fmt.Sprintf("build validator: %v", errs)
	}

	req, err := http.NewRequest(http.MethodGet, tc.requestURL, nil)
	if err != nil {
		return false, "build request: " + err.Error()
	}

	valid, validationErrs := v.ValidateHttpRequest(req)
	if valid {
		return true, "request validated"
	}
	if len(validationErrs) > 0 {
		return false, validationErrs[0].Message
	}
	return false, "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, host with default PASS FAIL GET Path '/api/v1/widgets' not found
https://{host}/api/v1, host without default 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:

parsed, err := url.Parse(server.URL)
if err == nil && parsed.Path != "" && strings.HasPrefix(path, parsed.Path) {
    path = strings.TrimPrefix(path, parsed.Path)
    break
}

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 path
if err != 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
  • Regression introduced by Add kin-openapi parity features: router, content validation #303, commit c7963cf8ca0fac32540e42c39de1f727f2abef9f

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