From cbcb1aec0a1b6cdc265cf3e9bb57c14728e1688a Mon Sep 17 00:00:00 2001 From: nickajacks1 <128185314+nickajacks1@users.noreply.github.com> Date: Mon, 6 Nov 2023 23:25:23 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20support=20for=20param?= =?UTF-8?q?eters=20in=20content=20negotiation=20(#2678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add support for parameters in content negotiation Attempts to approach the level of support offered by express, but behavior may differ in unusual corner cases. Some key behaviors from Express that are implemented: - If an offer does not have every parameter listed in the given Accept, it is rejected. - Parameters do not affect specificity. - In a given specificity, more parameters gives greater precedence - Parameters are unordered - Matching is case-insensitive - Surrounding quotes for parameter values are stripped - If an Accept type specifies a parameter more than once, the last value provided is taken. - Parameters after q are not processed. https://www.rfc-editor.org/rfc/rfc9110#name-parameters * doc: properly attribute reader.go for validHeaderFieldByte * fix: remove underscores from fuzz test name * test(forEachParameter): improve coverage * doc: add comment clarifying RFC 9110 non-compliance for corner case --- docs/api/ctx.md | 31 ++++ helpers.go | 352 +++++++++++++++++++++++++++++++++++-------- helpers_fuzz_test.go | 23 +++ helpers_test.go | 283 +++++++++++++++++++++++++++++++++- 4 files changed, 625 insertions(+), 64 deletions(-) create mode 100644 helpers_fuzz_test.go diff --git a/docs/api/ctx.md b/docs/api/ctx.md index 2d77090a43..860799f451 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},