Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions parser/parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@ type Parameter struct {
Deprecated bool `yaml:"deprecated,omitempty" json:"deprecated,omitempty"` // OAS 3.0+

// OAS 3.0+ fields
Style string `yaml:"style,omitempty" json:"style,omitempty"`
Explode *bool `yaml:"explode,omitempty" json:"explode,omitempty"`
Style string `yaml:"style,omitempty" json:"style,omitempty"`
Explode *bool `yaml:"explode,omitempty" json:"explode,omitempty"`

// AllowReserved lets RFC 3986 reserved characters pass through a parameter
// value unencoded. Where it may legally appear depends on `in` and `style`;
// the validator's allowReservedPermitted holds the per-version table.
//
// Note: the specification constrains the field's presence rather than its
// value, which a bool cannot express. An illegal `allowReserved: false`
// decodes the same as an absent field, so oastools catches the violation
// only when the value is true. Changing the type would break the v1 API.
AllowReserved bool `yaml:"allowReserved,omitempty" json:"allowReserved,omitempty"`
Schema *Schema `yaml:"schema,omitempty" json:"schema,omitempty"`
Example any `yaml:"example,omitempty" json:"example,omitempty"`
Expand Down
48 changes: 22 additions & 26 deletions validator/oas3.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,7 @@ import (

// validateOAS3 performs OAS 3.x specific validation
func (v *Validator) validateOAS3(doc *parser.OAS3Document, result *ValidationResult) {
version := doc.OpenAPI
var baseURL string

// Determine the correct spec URL based on version
switch doc.OASVersion {
case parser.OASVersion300:
baseURL = "https://spec.openapis.org/oas/v3.0.0.html"
case parser.OASVersion301:
baseURL = "https://spec.openapis.org/oas/v3.0.1.html"
case parser.OASVersion302:
baseURL = "https://spec.openapis.org/oas/v3.0.2.html"
case parser.OASVersion303:
baseURL = "https://spec.openapis.org/oas/v3.0.3.html"
case parser.OASVersion304:
baseURL = "https://spec.openapis.org/oas/v3.0.4.html"
case parser.OASVersion310:
baseURL = "https://spec.openapis.org/oas/v3.1.0.html"
case parser.OASVersion311:
baseURL = "https://spec.openapis.org/oas/v3.1.1.html"
case parser.OASVersion312:
baseURL = "https://spec.openapis.org/oas/v3.1.2.html"
case parser.OASVersion320:
baseURL = "https://spec.openapis.org/oas/v3.2.0.html"
default:
baseURL = fmt.Sprintf("https://spec.openapis.org/oas/v%s.html", version)
}
baseURL := specBaseURL(doc.OASVersion, doc.OpenAPI)

// Validate required fields in info object
v.validateOAS3Info(doc, result, baseURL)
Expand Down Expand Up @@ -173,6 +148,27 @@ func (v *Validator) validateOAS3Servers(doc *parser.OAS3Document, result *Valida
)
}

// An enum that is present but empty permits no value at all, which
// cannot be satisfied (the default itself could never be a member).
// OAS 3.1 added `minItems: 1` to the Server Variable Object's enum;
// 3.0's schema has no such constraint, so this is gated.
//
// Presence, not length, is the test: an absent enum means "any
// value" and is fine. The parser keeps the two apart: absent
// decodes to a nil slice, `enum: []` to an empty non-nil one.
//
// An unrecognized version counts as in scope, matching
// oas32TraversalApplies and the other version gates: a constraint
// introduced at a threshold is assumed to hold in later versions
// this build does not yet know about.
if varObj.Enum != nil && len(varObj.Enum) == 0 && emptyServerEnumApplies(v.oasVersion) {
v.addError(result, varPath,
"Server variable enum must not be empty; omit it to allow any value",
withSpecRef(fmt.Sprintf("%s#server-variable-object", baseURL)),
withField("enum"),
)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// If enum is specified, default must be in enum
if len(varObj.Enum) > 0 && !slices.Contains(varObj.Enum, varObj.Default) {
v.addError(result, varPath,
Expand Down
16 changes: 14 additions & 2 deletions validator/schema_traversal.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ func (v *Validator) validateParameterSchemas(param *parser.Parameter, path strin
if param == nil {
return
}
// Hooked here rather than at each call site so these rules inherit the
// structural reachability this traversal exists to provide: a parameter is
// a parameter wherever it occurs.
v.validateParameterAllowReserved(param, path, result)
if param.In == parser.ParamInHeader {
v.validateHeaderName(param.Name, path, "name", result)
}
if param.Schema != nil {
v.validateSchema(param.Schema, path+".schema", result)
}
Expand All @@ -210,7 +217,9 @@ func (v *Validator) validateHeaderMapSchemas(headers map[string]*parser.Header,
return
}
for name, header := range headers {
v.validateHeaderSchemas(header, path+".headers."+name, result)
headerPath := path + ".headers." + name
v.validateHeaderName(name, headerPath, "headers", result)
v.validateHeaderSchemas(header, headerPath, result)
}
}

Expand All @@ -219,6 +228,7 @@ func (v *Validator) validateHeaderSchemas(header *parser.Header, path string, re
if header == nil {
return
}
v.validateHeaderAllowReserved(header, path, result)
if header.Schema != nil {
v.validateSchema(header.Schema, path+".schema", result)
}
Expand All @@ -243,7 +253,9 @@ func (v *Validator) validateOAS3ComponentSchemas(c *parser.Components, result *V
v.validateParameterSchemas(param, "components.parameters."+name, result)
}
for name, header := range c.Headers {
v.validateHeaderSchemas(header, "components.headers."+name, result)
headerPath := "components.headers." + name
v.validateHeaderName(name, headerPath, "headers", result)
v.validateHeaderSchemas(header, headerPath, result)
}
for name, mt := range c.MediaTypes {
v.validateMediaTypeSchemas(mt, "components.mediaTypes."+name, result)
Expand Down
143 changes: 143 additions & 0 deletions validator/serialization_constraints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package validator

import (
"fmt"
"regexp"

"github.com/erraggy/oastools/parser"
)

// rfc9110Token matches the `token` production of RFC 9110 §5.6.2, which is what
// a field name must be. The official OAS 3.2 schema states it as a `$defs/token`
// referenced from `propertyNames` on every header map and from a header
// parameter's `name`.
//
// https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2
var rfc9110Token = regexp.MustCompile(`^[0-9A-Za-z!#$%&'*+.^_` + "`" + `|~-]+$`)

// headerNameRulesApply reports whether the RFC 9110 token constraint on header
// names is in force.
//
// OAS 3.2 introduced it: the `token` definition does not exist in the 3.1 schema
// at all, so enforcing it there would reject documents 3.1 considers valid. An
// unrecognized version counts as in scope, matching oas32TraversalApplies.
func headerNameRulesApply(version parser.OASVersion) bool {
return !version.IsValid() || version >= parser.OASVersion320
}

// emptyServerEnumApplies reports whether the non-empty Server Variable enum
// constraint is in force. OAS 3.1 added `minItems: 1`; 3.0's schema has no such
// constraint.
//
// Declared beside the other version gates so all four can be compared in one
// place. An unrecognized version counts as in scope, as it does for the rest.
func emptyServerEnumApplies(version parser.OASVersion) bool {
return !version.IsValid() || version >= parser.OASVersion310
}

// validateHeaderName checks one header name against the RFC 9110 token rule.
// The name is a map key for a Header Object and the `name` field for a header
// parameter; both are field names on the wire, so both carry the constraint.
func (v *Validator) validateHeaderName(name, path, field string, result *ValidationResult) {
if !headerNameRulesApply(v.oasVersion) {
return
}
if name == "" || rfc9110Token.MatchString(name) {
return
}
v.addError(result, path,
fmt.Sprintf("Header name %q is not a valid HTTP field name; RFC 9110 allows only token characters (alphanumerics and !#$%%&'*+.^_`|~-)", name),
withSpecRef(oas32SpecRef+"#header-object"),
withField(field),
withValue(name),
)
}

// allowReservedPermitted reports whether `allowReserved` may appear on a
// parameter with the given `in` and `style`, for the document's version.
//
// The permitted set widened in 3.2, so this cannot be a single rule:
//
// - 3.0: the schema lists allowReserved as a plain Parameter property with no
// conditional, and the prose says it "only applies to" query parameters,
// which is a statement about effect rather than validity. Not enforced.
// - 3.1: the schema evaluates allowReserved only inside the `in: query`
// branch, and `unevaluatedProperties: false` makes it invalid anywhere else.
// - 3.2+: widened to the `in` and `style` combinations that percent-encode:
// `in: path`, `in: query`, and `in: cookie` with `style: form`.
//
// Applying one version's rule to all of them fails in both directions: 3.1's
// would reject valid 3.2 documents, and 3.2's would accept invalid 3.1 ones.
func allowReservedPermitted(version parser.OASVersion, in, style string) bool {
// 3.0 and earlier: structurally permitted, so nothing to enforce.
if version.IsValid() && version < parser.OASVersion310 {
return true
}

if version.IsValid() && version < parser.OASVersion320 {
return in == parser.ParamInQuery
}

switch in {
case parser.ParamInPath, parser.ParamInQuery:
return true
case parser.ParamInCookie:
// `form` is the default style for a cookie parameter, so an unset
// style is the permitted case rather than the forbidden one.
return style == "" || style == "form"
default:
return false
}
}

// validateParameterAllowReserved rejects `allowReserved` where the parameter's
// `in` and `style` do not permit it. See allowReservedPermitted for the
// per-version table.
func (v *Validator) validateParameterAllowReserved(param *parser.Parameter, path string, result *ValidationResult) {
if !param.AllowReserved || allowReservedPermitted(v.oasVersion, param.In, param.Style) {
return
}
v.addError(result, path,
fmt.Sprintf("allowReserved is not permitted on a parameter with in: %q%s", param.In, styleSuffix(param.Style)),
withSpecRef(v.specRef("#parameter-object")),
withField("allowReserved"),
withValue(true),
)
}

// validateHeaderAllowReserved rejects `allowReserved` on a Header Object, where
// no OAS 3.x version permits it: the field appears nowhere in the Header
// Object's schema, and `unevaluatedProperties: false` closes the object.
//
// Only enforced for 3.1+, which is where the schema makes it structural; 3.0's
// draft-04 schema does not close the object the same way.
func (v *Validator) validateHeaderAllowReserved(header *parser.Header, path string, result *ValidationResult) {
// parser.Header has no AllowReserved field, because no OAS version defines
// one for a Header Object, so the key lands in Extra: the inline decoder
// fills it with every unmatched field, not only x- extensions.
//
// This detects the one field rather than every unmatched one. A general
// check for `unevaluatedProperties: false` needs the field/version matrix
// (#439).
if _, present := header.Extra["allowReserved"]; !present {
return
}
if v.oasVersion.IsValid() && v.oasVersion < parser.OASVersion310 {
return
}
v.addError(result, path,
"allowReserved is not permitted on a Header Object; it applies only to parameters whose in and style percent-encode",
withSpecRef(v.specRef("#header-object")),
withField("allowReserved"),
withValue(true),
)
}

// styleSuffix renders the style clause of an allowReserved message, which is
// only informative when a style is actually set.
func styleSuffix(style string) string {
if style == "" {
return ""
}
return fmt.Sprintf(" and style: %q", style)
}
Loading