diff --git a/docs/api/ctx.md b/docs/api/ctx.md index 1729438d7f..a8c04cd26d 100644 --- a/docs/api/ctx.md +++ b/docs/api/ctx.md @@ -49,6 +49,37 @@ app.Get("/", func(c *fiber.Ctx) error { }) ``` +Media-Type parameters are supported. + +```go title="Example 3" +// Accept: text/plain, application/json; version=1; foo=bar + +app.Get("/", func(c *fiber.Ctx) error { + // Extra parameters in the accept are ignored + c.Accepts("text/plain;format=flowed") // "text/plain;format=flowed" + + // An offer must contain all parameters present in the Accept type + c.Accepts("application/json") // "" + + // Parameter order and capitalization does not matter. Quotes on values are stripped. + c.Accepts(`application/json;foo="bar";VERSION=1`) // "application/json;foo="bar";VERSION=1" +}) +``` + +```go title="Example 4" +// Accept: text/plain;format=flowed;q=0.9, text/plain +// i.e., "I prefer text/plain;format=flowed less than other forms of text/plain" +app.Get("/", func(c *fiber.Ctx) error { + // Beware: the order in which offers are listed matters. + // Although the client specified they prefer not to receive format=flowed, + // the text/plain Accept matches with "text/plain;format=flowed" first, so it is returned. + c.Accepts("text/plain;format=flowed", "text/plain") // "text/plain;format=flowed" + + // Here, things behave as expected: + c.Accepts("text/plain", "text/plain;format=flowed") // "text/plain" +}) +``` + Fiber provides similar functions for the other accept headers. ```go diff --git a/helpers.go b/helpers.go index 0041458994..dd8de15f91 100644 --- a/helpers.go +++ b/helpers.go @@ -26,13 +26,14 @@ import ( ) // acceptType is a struct that holds the parsed value of an Accept header -// along with quality, specificity, and order. -// used for sorting accept headers. +// along with quality, specificity, parameters, and order. +// Used for sorting accept headers. type acceptedType struct { spec string quality float64 specificity int order int + params string } // getTLSConfig returns a net listener's tls config @@ -228,7 +229,7 @@ func getGroupPath(prefix, path string) string { // acceptsOffer This function determines if an offer matches a given specification. // It checks if the specification ends with a '*' or if the offer has the prefix of the specification. // Returns true if the offer matches the specification, false otherwise. -func acceptsOffer(spec, offer string) bool { +func acceptsOffer(spec, offer, _ string) bool { if len(spec) >= 1 && spec[len(spec)-1] == '*' { return true } else if strings.HasPrefix(spec, offer) { @@ -241,34 +242,94 @@ func acceptsOffer(spec, offer string) bool { // It checks if the specification is equal to */* (i.e., all types are accepted). // It gets the MIME type of the offer (either from the offer itself or by its file extension). // It checks if the offer MIME type matches the specification MIME type or if the specification is of the form /* and the offer MIME type has the same MIME type. +// It checks if the offer contains every parameter present in the specification. // Returns true if the offer type matches the specification, false otherwise. -func acceptsOfferType(spec, offerType string) bool { +func acceptsOfferType(spec, offerType, specParams string) bool { + var offerMime, offerParams string + + if i := strings.IndexByte(offerType, ';'); i == -1 { + offerMime = offerType + } else { + offerMime = offerType[:i] + offerParams = offerType[i:] + } + // Accept: */* if spec == "*/*" { - return true + return paramsMatch(specParams, offerParams) } var mimetype string - if strings.IndexByte(offerType, '/') != -1 { - mimetype = offerType // MIME type + if strings.IndexByte(offerMime, '/') != -1 { + mimetype = offerMime // MIME type } else { - mimetype = utils.GetMIME(offerType) // extension + mimetype = utils.GetMIME(offerMime) // extension } if spec == mimetype { // Accept: / - return true + return paramsMatch(specParams, offerParams) } s := strings.IndexByte(mimetype, '/') // Accept: /* if strings.HasPrefix(spec, mimetype[:s]) && (spec[s:] == "/*" || mimetype[s:] == "/*") { - return true + return paramsMatch(specParams, offerParams) } return false } +// paramsMatch returns whether offerParams contains all parameters present in specParams. +// Matching is case insensitive, and surrounding quotes are stripped. +// To align with the behavior of res.format from Express, the order of parameters is +// ignored, and if a parameter is specified twice in the incoming Accept, the last +// provided value is given precedence. +// In the case of quoted values, RFC 9110 says that we must treat any character escaped +// by a backslash as equivalent to the character itself (e.g., "a\aa" is equivalent to "aaa"). +// For the sake of simplicity, we forgo this and compare the value as-is. Besides, it would +// be highly unusual for a client to escape something other than a double quote or backslash. +// See https://www.rfc-editor.org/rfc/rfc9110#name-parameters +func paramsMatch(specParamStr, offerParams string) bool { + if specParamStr == "" { + return true + } + + // Preprocess the spec params to more easily test + // for out-of-order parameters + specParams := make([][2]string, 0, 2) + forEachParameter(specParamStr, func(s1, s2 string) bool { + if s1 == "q" || s1 == "Q" { + return false + } + for i := range specParams { + if utils.EqualFold(s1, specParams[i][0]) { + specParams[i][1] = s2 + return false + } + } + specParams = append(specParams, [2]string{s1, s2}) + return true + }) + + allSpecParamsMatch := true + for i := range specParams { + foundParam := false + forEachParameter(offerParams, func(offerParam, offerVal string) bool { + if utils.EqualFold(specParams[i][0], offerParam) { + foundParam = true + allSpecParamsMatch = utils.EqualFold(specParams[i][1], offerVal) + return false + } + return true + }) + if !foundParam || !allSpecParamsMatch { + return false + } + } + return allSpecParamsMatch +} + // getSplicedStrList function takes a string and a string slice as an argument, divides the string into different // elements divided by ',' and stores these elements in the string slice. // It returns the populated string slice as an output. @@ -304,8 +365,177 @@ func getSplicedStrList(headerValue string, dst []string) []string { return dst } +// forEachMediaRange parses an Accept or Content-Type header, calling functor +// on each media range. +// See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields +func forEachMediaRange(header string, functor func(string)) { + hasDQuote := strings.IndexByte(header, '"') != -1 + + for len(header) > 0 { + n := 0 + header = utils.TrimLeft(header, ' ') + quotes := 0 + escaping := false + + if hasDQuote { + // Complex case. We need to keep track of quotes and quoted-pairs (i.e., characters escaped with \ ) + loop: + for n < len(header) { + switch header[n] { + case ',': + if quotes%2 == 0 { + break loop + } + case '"': + if !escaping { + quotes++ + } + case '\\': + if quotes%2 == 1 { + escaping = !escaping + } + } + n++ + } + } else { + // Simple case. Just look for the next comma. + if n = strings.IndexByte(header, ','); n == -1 { + n = len(header) + } + } + + functor(header[:n]) + + if n >= len(header) { + return + } + header = header[n+1:] + } +} + +// forEachParamter parses a given parameter list, calling functor +// on each valid parameter. If functor returns false, we stop processing. +// It expects a leading ';'. +// See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.6 +// According to RFC-9110 2.4, it is up to our discretion whether +// to attempt to recover from errors in HTTP semantics. Therefor, +// we take the simple approach and exit early when a semantic error +// is detected in the header. +// +// parameter = parameter-name "=" parameter-value +// parameter-name = token +// parameter-value = ( token / quoted-string ) +// parameters = *( OWS ";" OWS [ parameter ] ) +func forEachParameter(params string, functor func(string, string) bool) { + for len(params) > 0 { + // eat OWS ";" OWS + params = utils.TrimLeft(params, ' ') + if len(params) == 0 || params[0] != ';' { + return + } + params = utils.TrimLeft(params[1:], ' ') + + n := 0 + + // make sure the parameter is at least one character long + if len(params) == 0 || !validHeaderFieldByte(params[n]) { + return + } + n++ + for n < len(params) && validHeaderFieldByte(params[n]) { + n++ + } + + // We should hit a '=' (that has more characters after it) + // If not, the parameter is invalid. + // param=foo + // ~~~~~^ + if n >= len(params)-1 || params[n] != '=' { + return + } + param := params[:n] + n++ + + if params[n] == '"' { + // Handle quoted strings and quoted-pairs (i.e., characters escaped with \ ) + // See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4 + foundEndQuote := false + escaping := false + n++ + m := n + for ; n < len(params); n++ { + if params[n] == '"' && !escaping { + foundEndQuote = true + break + } + // Recipients that process the value of a quoted-string MUST handle + // a quoted-pair as if it were replaced by the octet following the backslash + escaping = params[n] == '\\' && !escaping + } + if !foundEndQuote { + // Not a valid parameter + return + } + if !functor(param, params[m:n]) { + return + } + n++ + } else if validHeaderFieldByte(params[n]) { + // Parse a normal value, which should just be a token. + m := n + n++ + for n < len(params) && validHeaderFieldByte(params[n]) { + n++ + } + if !functor(param, params[m:n]) { + return + } + } else { + // Value was invalid + return + } + params = params[n:] + } +} + +// validHeaderFieldByte returns true if a valid tchar +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +// +// See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2 +// Function copied from net/textproto: +// https://github.com/golang/go/blob/master/src/net/textproto/reader.go#L663 +func validHeaderFieldByte(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c >= 128, then 1<>64)) != 0 +} + // getOffer return valid offer for header negotiation -func getOffer(header string, isAccepted func(spec, offer string) bool, offers ...string) string { +func getOffer(header string, isAccepted func(spec, offer, specParams string) bool, offers ...string) string { if len(offers) == 0 { return "" } @@ -313,49 +543,52 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. return offers[0] } + acceptedTypes := make([]acceptedType, 0, 8) + order := 0 + // Parse header and get accepted types with their quality and specificity // See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields - spec, commaPos, order := "", 0, 0 - acceptedTypes := make([]acceptedType, 0, 20) - for len(header) > 0 { + forEachMediaRange(header, func(accept string) { order++ + spec, quality, params := accept, 1.0, "" - // Skip spaces - header = utils.TrimLeft(header, ' ') - - // Get spec - commaPos = strings.IndexByte(header, ',') - if commaPos != -1 { - spec = utils.Trim(header[:commaPos], ' ') - } else { - spec = utils.TrimLeft(header, ' ') - } + if i := strings.IndexByte(accept, ';'); i != -1 { + spec = accept[:i] - // Get quality - quality := 1.0 - if factorSign := strings.IndexByte(spec, ';'); factorSign != -1 { - factor := utils.Trim(spec[factorSign+1:], ' ') - if strings.HasPrefix(factor, "q=") { - if q, err := fasthttp.ParseUfloat(utils.UnsafeBytes(factor[2:])); err == nil { + // The vast majority of requests will have only the q parameter with + // no whitespace. Check this first to see if we can skip + // the more involved parsing. + if strings.HasPrefix(accept[i:], ";q=") && strings.IndexByte(accept[i+3:], ';') == -1 { + if q, err := fasthttp.ParseUfloat([]byte(utils.TrimRight(accept[i+3:], ' '))); err == nil { quality = q } - } - spec = spec[:factorSign] - } - - // Skip if quality is 0.0 - // See: https://www.rfc-editor.org/rfc/rfc9110#quality.values - if quality == 0.0 { - if commaPos != -1 { - header = header[commaPos+1:] } else { - break + hasParams := false + forEachParameter(accept[i:], func(param, val string) bool { + if param == "q" || param == "Q" { + if q, err := fasthttp.ParseUfloat([]byte(val)); err == nil { + quality = q + } + return false + } + hasParams = true + return true + }) + if hasParams { + params = accept[i:] + } + } + // Skip this accept type if quality is 0.0 + // See: https://www.rfc-editor.org/rfc/rfc9110#quality.values + if quality == 0.0 { + return } - continue } + spec = utils.TrimRight(spec, ' ') + // Get specificity - specificity := 0 + var specificity int // check for wildcard this could be a mime */* or a wildcard character * if spec == "*/*" || spec == "*" { specificity = 1 @@ -368,15 +601,8 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. } // Add to accepted types - acceptedTypes = append(acceptedTypes, acceptedType{spec, quality, specificity, order}) - - // Next - if commaPos != -1 { - header = header[commaPos+1:] - } else { - break - } - } + acceptedTypes = append(acceptedTypes, acceptedType{spec, quality, specificity, order, params}) + }) if len(acceptedTypes) > 1 { // Sort accepted types by quality and specificity, preserving order of equal elements @@ -389,7 +615,7 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. if len(offer) == 0 { continue } - if isAccepted(acceptedType.spec, offer) { + if isAccepted(acceptedType.spec, offer, acceptedType.params) { return offer } } @@ -399,30 +625,30 @@ func getOffer(header string, isAccepted func(spec, offer string) bool, offers .. } // sortAcceptedTypes sorts accepted types by quality and specificity, preserving order of equal elements -// -// Parameters are not supported, they are ignored when sorting by specificity. -// +// A type with parameters has higher priority than an equivalent one without parameters. +// e.g., text/html;a=1;b=2 comes before text/html;a=1 // See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields -func sortAcceptedTypes(at *[]acceptedType) { - if at == nil || len(*at) < 2 { +func sortAcceptedTypes(acceptedTypes *[]acceptedType) { + if acceptedTypes == nil || len(*acceptedTypes) < 2 { return } - acceptedTypes := *at + at := *acceptedTypes - for i := 1; i < len(acceptedTypes); i++ { + for i := 1; i < len(at); i++ { lo, hi := 0, i-1 for lo <= hi { mid := (lo + hi) / 2 - if acceptedTypes[i].quality < acceptedTypes[mid].quality || - (acceptedTypes[i].quality == acceptedTypes[mid].quality && acceptedTypes[i].specificity < acceptedTypes[mid].specificity) || - (acceptedTypes[i].quality == acceptedTypes[mid].quality && acceptedTypes[i].specificity == acceptedTypes[mid].specificity && acceptedTypes[i].order > acceptedTypes[mid].order) { + if at[i].quality < at[mid].quality || + (at[i].quality == at[mid].quality && at[i].specificity < at[mid].specificity) || + (at[i].quality == at[mid].quality && at[i].specificity < at[mid].specificity && len(at[i].params) < len(at[mid].params)) || + (at[i].quality == at[mid].quality && at[i].specificity == at[mid].specificity && len(at[i].params) == len(at[mid].params) && at[i].order > at[mid].order) { lo = mid + 1 } else { hi = mid - 1 } } for j := i; j > lo; j-- { - acceptedTypes[j-1], acceptedTypes[j] = acceptedTypes[j], acceptedTypes[j-1] + at[j-1], at[j] = at[j], at[j-1] } } } diff --git a/helpers_fuzz_test.go b/helpers_fuzz_test.go new file mode 100644 index 0000000000..2fce6475dd --- /dev/null +++ b/helpers_fuzz_test.go @@ -0,0 +1,23 @@ +//go:build go1.18 + +package fiber + +import ( + "testing" +) + +// go test -v -run=^$ -fuzz=FuzzUtilsGetOffer +func FuzzUtilsGetOffer(f *testing.F) { + inputs := []string{ + `application/json; v=1; foo=bar; q=0.938; extra=param, text/plain;param="big fox"; q=0.43`, + `text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8`, + `*/*`, + `text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c`, + } + for _, input := range inputs { + f.Add(input) + } + f.Fuzz(func(_ *testing.T, spec string) { + getOffer(spec, acceptsOfferType, `application/json;version=1;v=1;foo=bar`, `text/plain;param="big fox"`) + }) +} diff --git a/helpers_test.go b/helpers_test.go index 788b7a9a47..39292d77cd 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -77,6 +77,28 @@ func Test_Utils_GetOffer(t *testing.T) { utils.AssertEqual(t, "text/html", getOffer("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", acceptsOfferType, "text/html")) utils.AssertEqual(t, "application/pdf", getOffer("text/plain;q=0,application/pdf;q=0.9,*/*;q=0.000", acceptsOfferType, "application/pdf", "application/json")) utils.AssertEqual(t, "application/pdf", getOffer("text/plain;q=0,application/pdf;q=0.9,*/*;q=0.000", acceptsOfferType, "application/pdf", "application/json")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain;a=1", acceptsOfferType, "text/plain;a=1")) + utils.AssertEqual(t, "", getOffer("text/plain;a=1;b=2", acceptsOfferType, "text/plain;b=2")) + + // Spaces, quotes, out of order params, and case insensitivity + utils.AssertEqual(t, "text/plain", getOffer("text/plain ", acceptsOfferType, "text/plain")) + utils.AssertEqual(t, "text/plain", getOffer("text/plain;q=0.4 ", acceptsOfferType, "text/plain")) + utils.AssertEqual(t, "text/plain", getOffer("text/plain;q=0.4 ;", acceptsOfferType, "text/plain")) + utils.AssertEqual(t, "text/plain", getOffer("text/plain;q=0.4 ; p=foo", acceptsOfferType, "text/plain")) + utils.AssertEqual(t, "text/plain;b=2;a=1", getOffer("text/plain ;a=1;b=2", acceptsOfferType, "text/plain;b=2;a=1")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain; a=1 ", acceptsOfferType, "text/plain;a=1")) + utils.AssertEqual(t, `text/plain;a="1;b=2\",text/plain"`, getOffer(`text/plain;a="1;b=2\",text/plain";q=0.9`, acceptsOfferType, `text/plain;a=1;b=2`, `text/plain;a="1;b=2\",text/plain"`)) + utils.AssertEqual(t, "text/plain;A=CAPS", getOffer(`text/plain;a="caPs"`, acceptsOfferType, "text/plain;A=CAPS")) + + // Priority + utils.AssertEqual(t, "text/plain", getOffer("text/plain", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain", acceptsOfferType, "text/plain;a=1", "text/plain")) + utils.AssertEqual(t, "text/plain;a=1", getOffer("text/plain,text/plain;a=1", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain", getOffer("text/plain;q=0.899,text/plain;a=1;q=0.898", acceptsOfferType, "text/plain", "text/plain;a=1")) + utils.AssertEqual(t, "text/plain;a=1;b=2", getOffer("text/plain,text/plain;a=1,text/plain;a=1;b=2", acceptsOfferType, "text/plain", "text/plain;a=1", "text/plain;a=1;b=2")) + + // Takes the last value specified + utils.AssertEqual(t, "text/plain;a=1;b=2", getOffer("text/plain;a=1;b=1;B=2", acceptsOfferType, "text/plain;a=1;b=1", "text/plain;a=1;b=2")) utils.AssertEqual(t, "", getOffer("utf-8, iso-8859-1;q=0.5", acceptsOffer)) utils.AssertEqual(t, "", getOffer("utf-8, iso-8859-1;q=0.5", acceptsOffer, "ascii")) @@ -87,6 +109,7 @@ func Test_Utils_GetOffer(t *testing.T) { utils.AssertEqual(t, "", getOffer("gzip, deflate;q=0", acceptsOffer, "deflate")) } +// go test -v -run=^$ -bench=Benchmark_Utils_GetOffer -benchmem -count=4 func Benchmark_Utils_GetOffer(b *testing.B) { headers := []string{ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -107,6 +130,262 @@ func Benchmark_Utils_GetOffer(b *testing.B) { } } +// go test -v -run=^$ -bench=Benchmark_Utils_GetOffer_WithParams -benchmem -count=4 +func Benchmark_Utils_GetOffer_WithParams(b *testing.B) { + headers := []string{ + "text/html;p=1,application/xhtml+xml;p=1;b=2,application/xml;a=2;q=0.9,*/*;q=0.8", + "application/json; version=1", + "utf-8, iso-8859-1;q=0.5", + } + offers := [][]string{ + {"text/html;p=1", "application/xml;a=2", "application/xml+xhtml; p=1; b=2"}, + {"application/json; version=2"}, + {`utf-8;charset="utf-16"`}, + } + for n := 0; n < b.N; n++ { + for i, header := range headers { + getOffer(header, acceptsOfferType, offers[i]...) + } + } +} + +func Test_Utils_ForEachParameter(t *testing.T) { + testCases := []struct { + description string + paramStr string + expectedParams [][]string + }{ + { + description: "empty input", + paramStr: ``, + }, + { + description: "no parameters", + paramStr: `; `, + }, + { + description: "naked equals", + paramStr: `; = `, + }, + { + description: "no value", + paramStr: `;s=`, + }, + { + description: "no name", + paramStr: `;=bar`, + }, + { + description: "illegal characters in name", + paramStr: `; foo@bar=baz`, + }, + { + description: "value starts with illegal characters", + paramStr: `; foo=@baz; param=val`, + }, + { + description: "unterminated quoted value", + paramStr: `; foo="bar`, + }, + { + description: "illegal character after value terminates parsing", + paramStr: `; foo=bar@baz; param=val`, + expectedParams: [][]string{ + {"foo", "bar"}, + }, + }, + { + description: "parses parameters", + paramStr: `; foo=bar; PARAM=BAZ`, + expectedParams: [][]string{ + {"foo", "bar"}, + {"PARAM", "BAZ"}, + }, + }, + { + description: "stops parsing when functor returns false", + paramStr: `; foo=bar; end=baz; extra=unparsed`, + expectedParams: [][]string{ + {"foo", "bar"}, + {"end", "baz"}, + }, + }, + { + description: "stops parsing when encountering a non-parameter string", + paramStr: `; foo=bar; gzip; param=baz`, + expectedParams: [][]string{ + {"foo", "bar"}, + }, + }, + { + description: "quoted string with escapes and special characters", + // Note: the sequence \\\" is effectively an escaped backslash \\ and + // an escaped double quote \" + paramStr: `;foo="20t\w,b\\\"b;s=k o"`, + expectedParams: [][]string{ + {"foo", `20t\w,b\\\"b;s=k o`}, + }, + }, + { + description: "complex", + paramStr: ` ; foo=1 ; bar="\"value\""; end="20tw,b\\\"b;s=k o" ; action=skip `, + expectedParams: [][]string{ + {"foo", "1"}, + {"bar", `\"value\"`}, + {"end", `20tw,b\\\"b;s=k o`}, + }, + }, + } + for _, tc := range testCases { + n := 0 + forEachParameter(tc.paramStr, func(p, v string) bool { + utils.AssertEqual(t, true, n < len(tc.expectedParams), "Received more parameters than expected: "+p+"="+v) + utils.AssertEqual(t, tc.expectedParams[n][0], p, tc.description) + utils.AssertEqual(t, tc.expectedParams[n][1], v, tc.description) + n++ + + // Stop parsing at the first parameter called "end" + return p != "end" + }) + utils.AssertEqual(t, len(tc.expectedParams), n, tc.description+": number of parameters differs") + } + // Check that we exited on the second parameter (bar) +} + +// go test -v -run=^$ -bench=Benchmark_Utils_ForEachParameter -benchmem -count=4 +func Benchmark_Utils_ForEachParameter(b *testing.B) { + for n := 0; n < b.N; n++ { + forEachParameter(` ; josua=1 ; vermant="20tw\",bob;sack o" ; version=1; foo=bar; `, func(s1, s2 string) bool { + return true + }) + } +} + +func Test_Utils_ParamsMatch(t *testing.T) { + testCases := []struct { + description string + accept string + offer string + match bool + }{ + { + description: "empty accept and offer", + accept: "", + offer: "", + match: true, + }, + { + description: "accept is empty, offer has params", + accept: "", + offer: ";foo=bar", + match: true, + }, + { + description: "offer is empty, accept has params", + accept: ";foo=bar", + offer: "", + match: false, + }, + { + description: "accept has extra parameters", + accept: ";foo=bar;a=1", + offer: ";foo=bar", + match: false, + }, + { + description: "matches regardless of order", + accept: "; a=1; b=2", + offer: ";b=2;a=1", + match: true, + }, + { + description: "case insensitive", + accept: ";ParaM=FoO", + offer: ";pAram=foO", + match: true, + }, + { + description: "ignores q", + accept: ";q=0.42", + offer: "", + match: true, + }, + } + + for _, tc := range testCases { + utils.AssertEqual(t, tc.match, paramsMatch(tc.accept, tc.offer), tc.description) + } +} + +func Benchmark_Utils_ParamsMatch(b *testing.B) { + var match bool + for n := 0; n < b.N; n++ { + match = paramsMatch(`; appLe=orange; param="foo"`, `;param=foo; apple=orange`) + } + utils.AssertEqual(b, true, match) +} + +func Test_Utils_AcceptsOfferType(t *testing.T) { + testCases := []struct { + description string + spec string + specParams string + offerType string + accepts bool + }{ + { + description: "no params, matching", + spec: "application/json", + offerType: "application/json", + accepts: true, + }, + { + description: "no params, mismatch", + spec: "application/json", + offerType: "application/xml", + accepts: false, + }, + { + description: "params match", + spec: "application/json", + specParams: `; format=foo; version=1`, + offerType: "application/json;version=1;format=foo;q=0.1", + accepts: true, + }, + { + description: "spec has extra params", + spec: "text/html", + specParams: "; charset=utf-8", + offerType: "text/html", + accepts: false, + }, + { + description: "offer has extra params", + spec: "text/html", + offerType: "text/html;charset=utf-8", + accepts: true, + }, + { + description: "ignores optional whitespace", + spec: "application/json", + specParams: `;format=foo; version=1`, + offerType: "application/json; version=1 ; format=foo ", + accepts: true, + }, + { + description: "ignores optional whitespace", + spec: "application/json", + specParams: `;format="foo bar"; version=1`, + offerType: `application/json;version="1";format="foo bar"`, + accepts: true, + }, + } + for _, tc := range testCases { + accepts := acceptsOfferType(tc.spec, tc.offerType, tc.specParams) + utils.AssertEqual(t, tc.accepts, accepts, tc.description) + } +} + func Test_Utils_GetSplicedStrList(t *testing.T) { testCases := []struct { description string @@ -147,7 +426,7 @@ func Test_Utils_GetSplicedStrList(t *testing.T) { func Benchmark_Utils_GetSplicedStrList(b *testing.B) { destination := make([]string, 5) result := destination - const input = "deflate, gzip,br,brotli" + const input = `deflate, gzip,br,brotli` for n := 0; n < b.N; n++ { result = getSplicedStrList(input, destination) } @@ -168,6 +447,7 @@ func Test_Utils_SortAcceptedTypes(t *testing.T) { {spec: "image/*", quality: 1, specificity: 2, order: 8}, {spec: "image/gif", quality: 1, specificity: 3, order: 9}, {spec: "text/plain", quality: 1, specificity: 3, order: 10}, + {spec: "application/json", quality: 0.999, specificity: 3, params: ";a=1", order: 11}, } sortAcceptedTypes(&acceptedTypes) utils.AssertEqual(t, acceptedTypes, []acceptedType{ @@ -179,6 +459,7 @@ func Test_Utils_SortAcceptedTypes(t *testing.T) { {spec: "image/gif", quality: 1, specificity: 3, order: 9}, {spec: "text/plain", quality: 1, specificity: 3, order: 10}, {spec: "image/*", quality: 1, specificity: 2, order: 8}, + {spec: "application/json", quality: 0.999, specificity: 3, params: ";a=1", order: 11}, {spec: "application/json", quality: 0.999, specificity: 3, order: 3}, {spec: "text/*", quality: 0.5, specificity: 2, order: 1}, {spec: "*/*", quality: 0.1, specificity: 1, order: 2},