From eed21e8cf5b4dd7734504c21b881bc13866a1554 Mon Sep 17 00:00:00 2001 From: Per Abich Date: Tue, 29 Dec 2020 09:33:50 +0100 Subject: [PATCH] added support for fasthttp (but broke compatibility with previous versions by moving stuff to different packages) Should solve #1 --- .gitignore | 2 + contenttype.go | 178 ++-------------------------- contenttype_test.go | 219 +---------------------------------- fasthttp/mediatype.go | 159 +++++++++++++++++++++++++ fasthttp/mediatype_test.go | 228 ++++++++++++++++++++++++++++++++++++ go.mod | 4 +- go.sum | 19 +++ http/mediatype.go | 160 ++++++++++++++++++++++++++ http/mediatype_test.go | 229 +++++++++++++++++++++++++++++++++++++ 9 files changed, 814 insertions(+), 384 deletions(-) create mode 100644 .gitignore create mode 100644 fasthttp/mediatype.go create mode 100644 fasthttp/mediatype_test.go create mode 100644 go.sum create mode 100644 http/mediatype.go create mode 100644 http/mediatype_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1062418 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +*.iml diff --git a/contenttype.go b/contenttype.go index a8f84d3..e78eb73 100644 --- a/contenttype.go +++ b/contenttype.go @@ -2,7 +2,6 @@ package contenttype import ( "errors" - "net/http" "strings" ) @@ -73,7 +72,7 @@ func isQuotedPairChar(c byte) bool { isObsoleteTextChar(c) } -func skipWhiteSpaces(s string) string { +func SkipWhiteSpaces(s string) string { // RFC 7230, 3.2.3. Whitespace for i := 0; i < len(s); i++ { if !isWhiteSpaceChar(s[i]) { @@ -116,9 +115,9 @@ func consumeQuotedString(s string) (token, remaining string, consumed bool) { return strings.ToLower(stringBuilder.String()), s[index:], true } -func consumeType(s string) (string, string, string, bool) { +func ConsumeType(s string) (string, string, string, bool) { // RFC 7231, 3.1.1.1. Media Type - s = skipWhiteSpaces(s) + s = SkipWhiteSpaces(s) var t, subt string var consumed bool @@ -142,14 +141,14 @@ func consumeType(s string) (string, string, string, bool) { return "", "", s, false } - s = skipWhiteSpaces(s) + s = SkipWhiteSpaces(s) return t, subt, s, true } -func consumeParameter(s string) (string, string, string, bool) { +func ConsumeParameter(s string) (string, string, string, bool) { // RFC 7231, 3.1.1.1. Media Type - s = skipWhiteSpaces(s) + s = SkipWhiteSpaces(s) var consumed bool var key string @@ -186,12 +185,12 @@ func consumeParameter(s string) (string, string, string, bool) { } } - s = skipWhiteSpaces(s) + s = SkipWhiteSpaces(s) return key, value, s, true } -func getWeight(s string) (int, bool) { +func GetWeight(s string) (int, bool) { // RFC 7231, 5.3.1. Quality Values result := 0 multiplier := 1000 @@ -230,7 +229,7 @@ func getWeight(s string) (int, bool) { return result, true } -func compareMediaTypes(checkMediaType, mediaType MediaType) bool { +func CompareMediaTypes(checkMediaType, mediaType MediaType) bool { if (checkMediaType.Type == "*" || checkMediaType.Type == mediaType.Type) && (checkMediaType.Subtype == "*" || checkMediaType.Subtype == mediaType.Subtype) { @@ -246,7 +245,7 @@ func compareMediaTypes(checkMediaType, mediaType MediaType) bool { return false } -func getPrecedence(checkMediaType, mediaType MediaType) bool { +func GetPrecedence(checkMediaType, mediaType MediaType) bool { if len(mediaType.Type) == 0 || len(mediaType.Subtype) == 0 { // not set return true } @@ -264,7 +263,7 @@ func getPrecedence(checkMediaType, mediaType MediaType) bool { func NewMediaType(s string) MediaType { mediaType := MediaType{} var consumed bool - mediaType.Type, mediaType.Subtype, s, consumed = consumeType(s) + mediaType.Type, mediaType.Subtype, s, consumed = ConsumeType(s) if !consumed { return MediaType{} } @@ -274,7 +273,7 @@ func NewMediaType(s string) MediaType { for len(s) > 0 && s[0] == ';' { s = s[1:] // skip the semicolon - key, value, remaining, consumed := consumeParameter(s) + key, value, remaining, consumed := ConsumeParameter(s) if !consumed { return MediaType{} } @@ -306,156 +305,3 @@ func (mediaType *MediaType) String() string { return stringBuilder.String() } - -// Gets the content of Content-Type header, parses it, and returns the parsed MediaType -// If the request does not contain the Content-Type header, an empty MediaType is returned -func GetMediaType(request *http.Request) (MediaType, error) { - // RFC 7231, 3.1.1.5. Content-Type - contentTypeHeaders := request.Header.Values("Content-Type") - if len(contentTypeHeaders) == 0 { - return MediaType{}, nil - } - - s := contentTypeHeaders[0] - mediaType := MediaType{} - var consumed bool - mediaType.Type, mediaType.Subtype, s, consumed = consumeType(s) - if !consumed { - return MediaType{}, ErrInvalidMediaType - } - - mediaType.Parameters = make(Parameters) - - for len(s) > 0 && s[0] == ';' { - s = s[1:] // skip the semicolon - - key, value, remaining, consumed := consumeParameter(s) - if !consumed { - return MediaType{}, ErrInvalidParameter - } - - s = remaining - - mediaType.Parameters[key] = value - } - - // there must not be anything left after parsing the header - if len(s) > 0 { - return MediaType{}, ErrInvalidMediaType - } - - return mediaType, nil -} - -// Choses a media type from available media types according to the Accept -// Returns the most suitable media type or an error if no type can be selected -func GetAcceptableMediaType(request *http.Request, availableMediaTypes []MediaType) (MediaType, Parameters, error) { - // RFC 7231, 5.3.2. Accept - if len(availableMediaTypes) == 0 { - return MediaType{}, Parameters{}, ErrNoAvailableTypeGiven - } - - acceptHeaders := request.Header.Values("Accept") - if len(acceptHeaders) == 0 { - return availableMediaTypes[0], Parameters{}, nil - } - - s := acceptHeaders[0] - - weights := make([]struct { - mediaType MediaType - extensionParameters Parameters - weight int - order int - }, len(availableMediaTypes)) - - for mediaTypeCount := 0; len(s) > 0; mediaTypeCount++ { - if mediaTypeCount > 0 { - // every media type after the first one must start with a comma - if s[0] != ',' { - break - } - s = s[1:] // skip the comma - } - - acceptableMediaType := MediaType{} - var consumed bool - acceptableMediaType.Type, acceptableMediaType.Subtype, s, consumed = consumeType(s) - if !consumed { - return MediaType{}, Parameters{}, ErrInvalidMediaType - } - - acceptableMediaType.Parameters = make(Parameters) - weight := 1000 // 1.000 - - // media type parameters - for len(s) > 0 && s[0] == ';' { - s = s[1:] // skip the semicolon - - var key, value string - key, value, s, consumed = consumeParameter(s) - if !consumed { - return MediaType{}, Parameters{}, ErrInvalidParameter - } - - if key == "q" { - weight, consumed = getWeight(value) - if !consumed { - return MediaType{}, Parameters{}, ErrInvalidWeight - } - break // "q" parameter separates media type parameters from Accept extension parameters - } - - acceptableMediaType.Parameters[key] = value - } - - extensionParameters := make(Parameters) - for len(s) > 0 && s[0] == ';' { - s = s[1:] // skip the semicolon - - key, value, remaining, consumed := consumeParameter(s) - if !consumed { - return MediaType{}, Parameters{}, ErrInvalidParameter - } - - s = remaining - - extensionParameters[key] = value - } - - for i := 0; i < len(availableMediaTypes); i++ { - if compareMediaTypes(acceptableMediaType, availableMediaTypes[i]) && - getPrecedence(acceptableMediaType, weights[i].mediaType) { - weights[i].mediaType = acceptableMediaType - weights[i].extensionParameters = extensionParameters - weights[i].weight = weight - weights[i].order = mediaTypeCount - } - } - - s = skipWhiteSpaces(s) - } - - // there must not be anything left after parsing the header - if len(s) > 0 { - return MediaType{}, Parameters{}, ErrInvalidMediaRange - } - - resultIndex := -1 - for i := 0; i < len(availableMediaTypes); i++ { - if resultIndex != -1 { - if weights[i].weight > weights[resultIndex].weight || - (weights[i].weight == weights[resultIndex].weight && weights[i].order < weights[resultIndex].order) { - resultIndex = i - } - } else if weights[i].weight > 0 { - resultIndex = i - } - } - - if resultIndex == -1 { - return MediaType{}, Parameters{}, ErrNoAcceptableTypeFound - } - - return availableMediaTypes[resultIndex], weights[resultIndex].extensionParameters, nil -} diff --git a/contenttype_test.go b/contenttype_test.go index 3bea2f5..bcfbefd 100644 --- a/contenttype_test.go +++ b/contenttype_test.go @@ -1,8 +1,6 @@ package contenttype import ( - "log" - "net/http" "reflect" "testing" ) @@ -22,6 +20,7 @@ func TestNewMediaType(t *testing.T) { } for _, testCase := range testCases { + testCase := testCase t.Run(testCase.name, func(t *testing.T) { result := NewMediaType(testCase.value) @@ -46,6 +45,7 @@ func TestString(t *testing.T) { } for _, testCase := range testCases { + testCase := testCase t.Run(testCase.name, func(t *testing.T) { result := testCase.value.String() @@ -55,218 +55,3 @@ func TestString(t *testing.T) { }) } } - -func TestGetMediaType(t *testing.T) { - testCases := []struct { - name string - header string - result MediaType - }{ - {"Empty header", "", MediaType{}}, - {"Type and subtype", "application/json", MediaType{"application", "json", Parameters{}}}, - {"Wildcard", "*/*", MediaType{"*", "*", Parameters{}}}, - {"Capital subtype", "Application/JSON", MediaType{"application", "json", Parameters{}}}, - {"Space in front of type", " application/json ", MediaType{"application", "json", Parameters{}}}, - {"Capital and parameter", "Application/XML;charset=utf-8", MediaType{"application", "xml", Parameters{"charset": "utf-8"}}}, - {"White space after parameter", "application/xml;foo=bar ", MediaType{"application", "xml", Parameters{"foo": "bar"}}}, - {"White space after subtype and before parameter", "application/xml ; foo=bar ", MediaType{"application", "xml", Parameters{"foo": "bar"}}}, - {"Quoted parameter", "application/xml;foo=\"bar\" ", MediaType{"application", "xml", Parameters{"foo": "bar"}}}, - {"Quoted empty parameter", "application/xml;foo=\"\" ", MediaType{"application", "xml", Parameters{"foo": ""}}}, - {"Quoted pair", "application/xml;foo=\"\\\"b\" ", MediaType{"application", "xml", Parameters{"foo": "\"b"}}}, - {"Whitespace after quoted parameter", "application/xml;foo=\"\\\"B\" ", MediaType{"application", "xml", Parameters{"foo": "\"b"}}}, - {"Plus in subtype", "a/b+c;a=b;c=d", MediaType{"a", "b+c", Parameters{"a": "b", "c": "d"}}}, - {"Capital parameter", "a/b;A=B", MediaType{"a", "b", Parameters{"a": "b"}}}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) - if requestError != nil { - log.Fatal(requestError) - } - - if len(testCase.header) > 0 { - request.Header.Set("Content-Type", testCase.header) - } - - result, mediaTypeError := GetMediaType(request) - if mediaTypeError != nil { - t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) - } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { - t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) - } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { - t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) - } - }) - } -} - -func TestGetMediaTypeErrors(t *testing.T) { - testCases := []struct { - name string - header string - err error - }{ - {"Type only", "Application", ErrInvalidMediaType}, - {"Subtype only", "/Application", ErrInvalidMediaType}, - {"Type with slash", "Application/", ErrInvalidMediaType}, - {"Invalid token character", "a/b\x19", ErrInvalidMediaType}, - {"Invalid character after subtype", "Application/JSON/test", ErrInvalidMediaType}, - {"No parameter name", "application/xml;=bar ", ErrInvalidParameter}, - {"Whitespace and no parameter name", "application/xml; =bar ", ErrInvalidParameter}, - {"No value and whitespace", "application/xml;foo= ", ErrInvalidParameter}, - {"Invalid character in value", "a/b;c=\x19", ErrInvalidParameter}, - {"Invalid character in quoted string", "a/b;c=\"\x19\"", ErrInvalidParameter}, - {"Invalid character in quoted pair", "a/b;c=\"\\\x19\"", ErrInvalidParameter}, - {"No assignment after parameter", "a/b;c", ErrInvalidParameter}, - {"No semicolon before paremeter", "a/b e", ErrInvalidMediaType}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) - if requestError != nil { - log.Fatal(requestError) - } - - if len(testCase.header) > 0 { - request.Header.Set("Content-Type", testCase.header) - } - - _, mediaTypeError := GetMediaType(request) - if mediaTypeError == nil { - t.Errorf("Expected an error for %s", testCase.header) - } else if testCase.err != mediaTypeError { - t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) - } - }) - } -} - -func TestGetAcceptableMediaType(t *testing.T) { - testCases := []struct { - name string - header string - availableMediaTypes []MediaType - result MediaType - extensionParameters Parameters - }{ - {"Empty header", "", []MediaType{{"application", "json", Parameters{}}}, MediaType{"application", "json", Parameters{}}, Parameters{}}, - {"Type and subtype", "application/json", []MediaType{{"application", "json", Parameters{}}}, MediaType{"application", "json", Parameters{}}, Parameters{}}, - {"Capitalized type and subtype", "Application/Json", []MediaType{{"application", "json", Parameters{}}}, MediaType{"application", "json", Parameters{}}, Parameters{}}, - {"Multiple accept types", "text/plain,application/xml", []MediaType{{"text", "plain", Parameters{}}}, MediaType{"text", "plain", Parameters{}}, Parameters{}}, - {"Multiple accept types, second available", "text/plain,application/xml", []MediaType{{"application", "xml", Parameters{}}}, MediaType{"application", "xml", Parameters{}}, Parameters{}}, - {"Accept weight", "text/plain;q=1.0", []MediaType{{"text", "plain", Parameters{}}}, MediaType{"text", "plain", Parameters{}}, Parameters{}}, - {"Wildcard", "*/*", []MediaType{{"application", "json", Parameters{}}}, MediaType{"application", "json", Parameters{}}, Parameters{}}, - {"Wildcard subtype", "application/*", []MediaType{{"application", "json", Parameters{}}}, MediaType{"application", "json", Parameters{}}, Parameters{}}, - {"Weight with dot", "a/b;q=1.", []MediaType{{"a", "b", Parameters{}}}, MediaType{"a", "b", Parameters{}}, Parameters{}}, - {"Multiple weights", "a/b;q=0.1,c/d;q=0.2", []MediaType{ - {"a", "b", Parameters{}}, - {"c", "d", Parameters{}}, - }, MediaType{"c", "d", Parameters{}}, Parameters{}}, - {"Multiple weights and default weight", "a/b;q=0.2,c/d;q=0.2", []MediaType{ - {"a", "b", Parameters{}}, - {"c", "d", Parameters{}}, - }, MediaType{"a", "b", Parameters{}}, Parameters{}}, - {"Wildcard subtype and weight", "a/*;q=0.2,a/c", []MediaType{ - {"a", "b", Parameters{}}, - {"a", "c", Parameters{}}, - }, MediaType{"a", "c", Parameters{}}, Parameters{}}, - {"Different accept order", "a/b,a/a", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "b", Parameters{}}, - }, MediaType{"a", "b", Parameters{}}, Parameters{}}, - {"Wildcard subtype with multiple available types", "a/*", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "b", Parameters{}}, - }, MediaType{"a", "a", Parameters{}}, Parameters{}}, - {"Wildcard subtype against weighted type", "a/a;q=0.2,a/*", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "b", Parameters{}}, - }, MediaType{"a", "b", Parameters{}}, Parameters{}}, - {"Media type parameter", "a/a;q=0.2,a/a;c=d", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "a", Parameters{"c": "d"}}, - }, MediaType{"a", "a", Parameters{"c": "d"}}, Parameters{}}, - {"Weight and media type parameter", "a/b;q=1;e=e", []MediaType{{"a", "b", Parameters{}}}, MediaType{"a", "b", Parameters{}}, Parameters{"e": "e"}}, - {"", "a/*,a/a;q=0", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "b", Parameters{}}, - }, MediaType{"a", "b", Parameters{}}, Parameters{}}, - {"Maximum length weight", "a/a;q=0.001,a/b;q=0.002", []MediaType{ - {"a", "a", Parameters{}}, - {"a", "b", Parameters{}}, - }, MediaType{"a", "b", Parameters{}}, Parameters{}}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) - if requestError != nil { - log.Fatal(requestError) - } - - if len(testCase.header) > 0 { - request.Header.Set("Accept", testCase.header) - } - - result, extensionParameters, mediaTypeError := GetAcceptableMediaType(request, testCase.availableMediaTypes) - - if mediaTypeError != nil { - t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) - } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { - t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) - } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { - t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) - } else if !reflect.DeepEqual(extensionParameters, testCase.extensionParameters) { - t.Errorf("Wrong extension parameters, got %v, expected %v for %s", extensionParameters, testCase.extensionParameters, testCase.header) - } - }) - } -} - -func TestGetAcceptableMediaTypeErrors(t *testing.T) { - testCases := []struct { - name string - header string - availableMediaTypes []MediaType - err error - }{ - {"No available type", "", []MediaType{}, ErrNoAvailableTypeGiven}, - {"No acceptable type", "application/xml", []MediaType{{"application", "json", Parameters{}}}, ErrNoAcceptableTypeFound}, - {"Invalid character after subtype", "application/xml/", []MediaType{{"application", "json", Parameters{}}}, ErrInvalidMediaRange}, - {"Comma after subtype with no parameter", "application/xml,", []MediaType{{"application", "json", Parameters{}}}, ErrInvalidMediaType}, - {"Subtype only", "/xml", []MediaType{{"application", "json", Parameters{}}}, ErrInvalidMediaType}, - {"Type with comma and without subtype", "application/,", []MediaType{{"application", "json", Parameters{}}}, ErrInvalidMediaType}, - {"Invalid character", "a/b c", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidMediaRange}, - {"No value for parameter", "a/b;c", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidParameter}, - {"Wildcard type only", "*/b", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidMediaType}, - {"Invalid character in weight", "a/b;q=a", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidWeight}, - {"Weight bigger than 1.0", "a/b;q=11", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidWeight}, - {"More than 3 digitas after dot", "a/b;q=1.0000", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidWeight}, - {"Invalid character after dot", "a/b;q=1.a", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidWeight}, - {"Invalid digit after dot", "a/b;q=1.100", []MediaType{{"a", "b", Parameters{}}}, ErrInvalidWeight}, - {"Type with weight zero only", "a/b;q=0", []MediaType{{"a", "b", Parameters{}}}, ErrNoAcceptableTypeFound}, - {"No value for extension parameter", "a/a;q=1;ext=", []MediaType{{"a", "a", Parameters{}}}, ErrInvalidParameter}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) - if requestError != nil { - log.Fatal(requestError) - } - - if len(testCase.header) > 0 { - request.Header.Set("Accept", testCase.header) - } - - _, _, mediaTypeError := GetAcceptableMediaType(request, testCase.availableMediaTypes) - if mediaTypeError == nil { - t.Errorf("Expected an error for %s", testCase.header) - } else if testCase.err != mediaTypeError { - t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) - } - }) - } -} diff --git a/fasthttp/mediatype.go b/fasthttp/mediatype.go new file mode 100644 index 0000000..af8f126 --- /dev/null +++ b/fasthttp/mediatype.go @@ -0,0 +1,159 @@ +package fasthttp + +import ( + "github.com/elnormous/contenttype" + "github.com/valyala/fasthttp" +) + +// Gets the content of Content-Type header, parses it, and returns the parsed MediaType +// If the request does not contain the Content-Type header, an empty MediaType is returned +func GetMediaType(request *fasthttp.Request) (contenttype.MediaType, error) { + // RFC 7231, 3.1.1.5. Content-Type + contentTypeHeaders := string(request.Header.Peek("Content-Type")) + if len(contentTypeHeaders) == 0 { + return contenttype.MediaType{}, nil + } + + s := contentTypeHeaders + mediaType := contenttype.MediaType{} + var consumed bool + mediaType.Type, mediaType.Subtype, s, consumed = contenttype.ConsumeType(s) + if !consumed { + return contenttype.MediaType{}, contenttype.ErrInvalidMediaType + } + + mediaType.Parameters = make(contenttype.Parameters) + + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + key, value, remaining, consumed := contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.ErrInvalidParameter + } + + s = remaining + + mediaType.Parameters[key] = value + } + + // there must not be anything left after parsing the header + if len(s) > 0 { + return contenttype.MediaType{}, contenttype.ErrInvalidMediaType + } + + return mediaType, nil +} + +// Choses a media type from available media types according to the Accept +// Returns the most suitable media type or an error if no type can be selected +func GetAcceptableMediaType(request *fasthttp.Request, availableMediaTypes []contenttype.MediaType) (contenttype.MediaType, contenttype.Parameters, error) { + // RFC 7231, 5.3.2. Accept + if len(availableMediaTypes) == 0 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrNoAvailableTypeGiven + } + + acceptHeaders := string(request.Header.Peek("Accept")) + if len(acceptHeaders) == 0 { + return availableMediaTypes[0], contenttype.Parameters{}, nil + } + + s := acceptHeaders + + weights := make([]struct { + mediaType contenttype.MediaType + extensionParameters contenttype.Parameters + weight int + order int + }, len(availableMediaTypes)) + + for mediaTypeCount := 0; len(s) > 0; mediaTypeCount++ { + if mediaTypeCount > 0 { + // every media type after the first one must start with a comma + if s[0] != ',' { + break + } + s = s[1:] // skip the comma + } + + acceptableMediaType := contenttype.MediaType{} + var consumed bool + acceptableMediaType.Type, acceptableMediaType.Subtype, s, consumed = contenttype.ConsumeType(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidMediaType + } + + acceptableMediaType.Parameters = make(contenttype.Parameters) + weight := 1000 // 1.000 + + // media type parameters + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + var key, value string + key, value, s, consumed = contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidParameter + } + + if key == "q" { + weight, consumed = contenttype.GetWeight(value) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidWeight + } + break // "q" parameter separates media type parameters from Accept extension parameters + } + + acceptableMediaType.Parameters[key] = value + } + + extensionParameters := make(contenttype.Parameters) + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + key, value, remaining, consumed := contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidParameter + } + + s = remaining + + extensionParameters[key] = value + } + + for i := 0; i < len(availableMediaTypes); i++ { + if contenttype.CompareMediaTypes(acceptableMediaType, availableMediaTypes[i]) && + contenttype.GetPrecedence(acceptableMediaType, weights[i].mediaType) { + weights[i].mediaType = acceptableMediaType + weights[i].extensionParameters = extensionParameters + weights[i].weight = weight + weights[i].order = mediaTypeCount + } + } + + s = contenttype.SkipWhiteSpaces(s) + } + + // there must not be anything left after parsing the header + if len(s) > 0 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidMediaRange + } + + resultIndex := -1 + for i := 0; i < len(availableMediaTypes); i++ { + if resultIndex != -1 { + if weights[i].weight > weights[resultIndex].weight || + (weights[i].weight == weights[resultIndex].weight && weights[i].order < weights[resultIndex].order) { + resultIndex = i + } + } else if weights[i].weight > 0 { + resultIndex = i + } + } + + if resultIndex == -1 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrNoAcceptableTypeFound + } + + return availableMediaTypes[resultIndex], weights[resultIndex].extensionParameters, nil +} diff --git a/fasthttp/mediatype_test.go b/fasthttp/mediatype_test.go new file mode 100644 index 0000000..765b9a5 --- /dev/null +++ b/fasthttp/mediatype_test.go @@ -0,0 +1,228 @@ +package fasthttp_test + +import ( + "bufio" + "bytes" + "reflect" + "testing" + + "github.com/elnormous/contenttype" + fasthttp2 "github.com/elnormous/contenttype/fasthttp" + "github.com/valyala/fasthttp" +) + +func TestGetMediaType(t *testing.T) { + testCases := []struct { + name string + header string + result contenttype.MediaType + }{ + {"Empty header", "", contenttype.MediaType{}}, + {"Type and subtype", "application/json", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Wildcard", "*/*", contenttype.MediaType{Type: "*", Subtype: "*", Parameters: contenttype.Parameters{}}}, + {"Capital subtype", "Application/JSON", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Space in front of type", " application/json ", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Capital and parameter", "Application/XML;charset=utf-8", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"charset": "utf-8"}}}, + {"White space after parameter", "application/xml;foo=bar ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"White space after subtype and before parameter", "application/xml ; foo=bar ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"Quoted parameter", "application/xml;foo=\"bar\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"Quoted empty parameter", "application/xml;foo=\"\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": ""}}}, + {"Quoted pair", "application/xml;foo=\"\\\"b\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "\"b"}}}, + {"Whitespace after quoted parameter", "application/xml;foo=\"\\\"B\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "\"b"}}}, + {"Plus in subtype", "a/b+c;a=b;c=d", contenttype.MediaType{Type: "a", Subtype: "b+c", Parameters: contenttype.Parameters{"a": "b", "c": "d"}}}, + {"Capital parameter", "a/b;A=B", contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{"a": "b"}}}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request := getBaseRequest(t) + + if len(testCase.header) > 0 { + request.Header.Set("Content-Type", testCase.header) + } + + result, mediaTypeError := fasthttp2.GetMediaType(request) + if mediaTypeError != nil { + t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) + } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { + t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) + } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { + t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) + } + }) + } +} + +func getBaseRequest(t *testing.T) *fasthttp.Request { + s := "GET / HTTP/1.0\n\r\n" + request := fasthttp.AcquireRequest() + br := bufio.NewReader(bytes.NewBufferString(s)) + if err := request.Read(br); err != nil { + t.Fatalf("unexpected error: %s", err) + } + return request +} + +func TestGetMediaTypeErrors(t *testing.T) { + testCases := []struct { + name string + header string + err error + }{ + {"Type only", "Application", contenttype.ErrInvalidMediaType}, + {"Subtype only", "/Application", contenttype.ErrInvalidMediaType}, + {"Type with slash", "Application/", contenttype.ErrInvalidMediaType}, + {"Invalid token character", "a/b\x19", contenttype.ErrInvalidMediaType}, + {"Invalid character after subtype", "Application/JSON/test", contenttype.ErrInvalidMediaType}, + {"No parameter name", "application/xml;=bar ", contenttype.ErrInvalidParameter}, + {"Whitespace and no parameter name", "application/xml; =bar ", contenttype.ErrInvalidParameter}, + {"No value and whitespace", "application/xml;foo= ", contenttype.ErrInvalidParameter}, + {"Invalid character in value", "a/b;c=\x19", contenttype.ErrInvalidParameter}, + {"Invalid character in quoted string", "a/b;c=\"\x19\"", contenttype.ErrInvalidParameter}, + {"Invalid character in quoted pair", "a/b;c=\"\\\x19\"", contenttype.ErrInvalidParameter}, + {"No assignment after parameter", "a/b;c", contenttype.ErrInvalidParameter}, + {"No semicolon before paremeter", "a/b e", contenttype.ErrInvalidMediaType}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request := getBaseRequest(t) + + if len(testCase.header) > 0 { + request.Header.Set("Content-Type", testCase.header) + } + + _, mediaTypeError := fasthttp2.GetMediaType(request) + if mediaTypeError == nil { + t.Errorf("Expected an error for %s", testCase.header) + } else if testCase.err != mediaTypeError { + t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) + } + }) + } +} + +func TestGetAcceptableMediaType(t *testing.T) { + testCases := []struct { + name string + header string + availableMediaTypes []contenttype.MediaType + result contenttype.MediaType + extensionParameters contenttype.Parameters + }{ + {"Empty header", "", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Type and subtype", "application/json", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Capitalized type and subtype", "Application/Json", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple accept types", "text/plain,application/xml", []contenttype.MediaType{{"text", "plain", contenttype.Parameters{}}}, contenttype.MediaType{Type: "text", Subtype: "plain", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple accept types, second available", "text/plain,application/xml", []contenttype.MediaType{{"application", "xml", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Accept weight", "text/plain;q=1.0", []contenttype.MediaType{{"text", "plain", contenttype.Parameters{}}}, contenttype.MediaType{Type: "text", Subtype: "plain", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard", "*/*", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype", "application/*", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Weight with dot", "a/b;q=1.", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple weights", "a/b;q=0.1,c/d;q=0.2", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"c", "d", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "c", Subtype: "d", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple weights and default weight", "a/b;q=0.2,c/d;q=0.2", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"c", "d", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype and weight", "a/*;q=0.2,a/c", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"a", "c", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "c", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Different accept order", "a/b,a/a", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype with multiple available types", "a/*", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "a", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype against weighted type", "a/a;q=0.2,a/*", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Media type parameter", "a/a;q=0.2,a/a;c=d", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "a", contenttype.Parameters{"c": "d"}}, + }, contenttype.MediaType{Type: "a", Subtype: "a", Parameters: contenttype.Parameters{"c": "d"}}, contenttype.Parameters{}}, + {"Weight and media type parameter", "a/b;q=1;e=e", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{"e": "e"}}, + {"", "a/*,a/a;q=0", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Maximum length weight", "a/a;q=0.001,a/b;q=0.002", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request := getBaseRequest(t) + if len(testCase.header) > 0 { + request.Header.Set("Accept", testCase.header) + } + + result, extensionParameters, mediaTypeError := fasthttp2.GetAcceptableMediaType(request, testCase.availableMediaTypes) + + if mediaTypeError != nil { + t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) + } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { + t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) + } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { + t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) + } else if !reflect.DeepEqual(extensionParameters, testCase.extensionParameters) { + t.Errorf("Wrong extension parameters, got %v, expected %v for %s", extensionParameters, testCase.extensionParameters, testCase.header) + } + }) + } +} + +func TestGetAcceptableMediaTypeErrors(t *testing.T) { + testCases := []struct { + name string + header string + availableMediaTypes []contenttype.MediaType + err error + }{ + {"No available type", "", []contenttype.MediaType{}, contenttype.ErrNoAvailableTypeGiven}, + {"No acceptable type", "application/xml", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrNoAcceptableTypeFound}, + {"Invalid character after subtype", "application/xml/", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaRange}, + {"Comma after subtype with no parameter", "application/xml,", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Subtype only", "/xml", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Type with comma and without subtype", "application/,", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Invalid character", "a/b c", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaRange}, + {"No value for parameter", "a/b;c", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidParameter}, + {"Wildcard type only", "*/b", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Invalid character in weight", "a/b;q=a", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Weight bigger than 1.0", "a/b;q=11", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"More than 3 digitas after dot", "a/b;q=1.0000", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Invalid character after dot", "a/b;q=1.a", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Invalid digit after dot", "a/b;q=1.100", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Type with weight zero only", "a/b;q=0", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrNoAcceptableTypeFound}, + {"No value for extension parameter", "a/a;q=1;ext=", []contenttype.MediaType{{"a", "a", contenttype.Parameters{}}}, contenttype.ErrInvalidParameter}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request := getBaseRequest(t) + + if len(testCase.header) > 0 { + request.Header.Set("Accept", testCase.header) + } + + _, _, mediaTypeError := fasthttp2.GetAcceptableMediaType(request, testCase.availableMediaTypes) + if mediaTypeError == nil { + t.Errorf("Expected an error for %s", testCase.header) + } else if testCase.err != mediaTypeError { + t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) + } + }) + } +} diff --git a/go.mod b/go.mod index 8c379c3..37e0297 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/elnormous/contenttype -go 1.12 \ No newline at end of file +go 1.12 + +require github.com/valyala/fasthttp v1.18.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a3ee659 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= +github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/klauspost/compress v1.10.7 h1:7rix8v8GpI3ZBb0nSozFRgbtXKv+hOe+qfEpZqybrAg= +github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.18.0 h1:IV0DdMlatq9QO1Cr6wGJPVW1sV1Q8HvZXAIcjorylyM= +github.com/valyala/fasthttp v1.18.0/go.mod h1:jjraHZVbKOXftJfsOYoAjaeygpj5hr8ermTRJNroD7A= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/http/mediatype.go b/http/mediatype.go new file mode 100644 index 0000000..3fc1562 --- /dev/null +++ b/http/mediatype.go @@ -0,0 +1,160 @@ +package http + +import ( + "net/http" + + "github.com/elnormous/contenttype" +) + +// Gets the content of Content-Type header, parses it, and returns the parsed MediaType +// If the request does not contain the Content-Type header, an empty MediaType is returned +func GetMediaType(request *http.Request) (contenttype.MediaType, error) { + // RFC 7231, 3.1.1.5. Content-Type + contentTypeHeaders := request.Header.Values("Content-Type") + if len(contentTypeHeaders) == 0 { + return contenttype.MediaType{}, nil + } + + s := contentTypeHeaders[0] + mediaType := contenttype.MediaType{} + var consumed bool + mediaType.Type, mediaType.Subtype, s, consumed = contenttype.ConsumeType(s) + if !consumed { + return contenttype.MediaType{}, contenttype.ErrInvalidMediaType + } + + mediaType.Parameters = make(contenttype.Parameters) + + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + key, value, remaining, consumed := contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.ErrInvalidParameter + } + + s = remaining + + mediaType.Parameters[key] = value + } + + // there must not be anything left after parsing the header + if len(s) > 0 { + return contenttype.MediaType{}, contenttype.ErrInvalidMediaType + } + + return mediaType, nil +} + +// Choses a media type from available media types according to the Accept +// Returns the most suitable media type or an error if no type can be selected +func GetAcceptableMediaType(request *http.Request, availableMediaTypes []contenttype.MediaType) (contenttype.MediaType, contenttype.Parameters, error) { + // RFC 7231, 5.3.2. Accept + if len(availableMediaTypes) == 0 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrNoAvailableTypeGiven + } + + acceptHeaders := request.Header.Values("Accept") + if len(acceptHeaders) == 0 { + return availableMediaTypes[0], contenttype.Parameters{}, nil + } + + s := acceptHeaders[0] + + weights := make([]struct { + mediaType contenttype.MediaType + extensionParameters contenttype.Parameters + weight int + order int + }, len(availableMediaTypes)) + + for mediaTypeCount := 0; len(s) > 0; mediaTypeCount++ { + if mediaTypeCount > 0 { + // every media type after the first one must start with a comma + if s[0] != ',' { + break + } + s = s[1:] // skip the comma + } + + acceptableMediaType := contenttype.MediaType{} + var consumed bool + acceptableMediaType.Type, acceptableMediaType.Subtype, s, consumed = contenttype.ConsumeType(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidMediaType + } + + acceptableMediaType.Parameters = make(contenttype.Parameters) + weight := 1000 // 1.000 + + // media type parameters + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + var key, value string + key, value, s, consumed = contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidParameter + } + + if key == "q" { + weight, consumed = contenttype.GetWeight(value) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidWeight + } + break // "q" parameter separates media type parameters from Accept extension parameters + } + + acceptableMediaType.Parameters[key] = value + } + + extensionParameters := make(contenttype.Parameters) + for len(s) > 0 && s[0] == ';' { + s = s[1:] // skip the semicolon + + key, value, remaining, consumed := contenttype.ConsumeParameter(s) + if !consumed { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidParameter + } + + s = remaining + + extensionParameters[key] = value + } + + for i := 0; i < len(availableMediaTypes); i++ { + if contenttype.CompareMediaTypes(acceptableMediaType, availableMediaTypes[i]) && + contenttype.GetPrecedence(acceptableMediaType, weights[i].mediaType) { + weights[i].mediaType = acceptableMediaType + weights[i].extensionParameters = extensionParameters + weights[i].weight = weight + weights[i].order = mediaTypeCount + } + } + + s = contenttype.SkipWhiteSpaces(s) + } + + // there must not be anything left after parsing the header + if len(s) > 0 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrInvalidMediaRange + } + + resultIndex := -1 + for i := 0; i < len(availableMediaTypes); i++ { + if resultIndex != -1 { + if weights[i].weight > weights[resultIndex].weight || + (weights[i].weight == weights[resultIndex].weight && weights[i].order < weights[resultIndex].order) { + resultIndex = i + } + } else if weights[i].weight > 0 { + resultIndex = i + } + } + + if resultIndex == -1 { + return contenttype.MediaType{}, contenttype.Parameters{}, contenttype.ErrNoAcceptableTypeFound + } + + return availableMediaTypes[resultIndex], weights[resultIndex].extensionParameters, nil +} diff --git a/http/mediatype_test.go b/http/mediatype_test.go new file mode 100644 index 0000000..b09db76 --- /dev/null +++ b/http/mediatype_test.go @@ -0,0 +1,229 @@ +package http + +import ( + "log" + "net/http" + "reflect" + "testing" + + "github.com/elnormous/contenttype" +) + +func TestGetMediaType(t *testing.T) { + testCases := []struct { + name string + header string + result contenttype.MediaType + }{ + {"Empty header", "", contenttype.MediaType{}}, + {"Type and subtype", "application/json", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Wildcard", "*/*", contenttype.MediaType{Type: "*", Subtype: "*", Parameters: contenttype.Parameters{}}}, + {"Capital subtype", "Application/JSON", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Space in front of type", " application/json ", contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}}, + {"Capital and parameter", "Application/XML;charset=utf-8", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"charset": "utf-8"}}}, + {"White space after parameter", "application/xml;foo=bar ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"White space after subtype and before parameter", "application/xml ; foo=bar ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"Quoted parameter", "application/xml;foo=\"bar\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "bar"}}}, + {"Quoted empty parameter", "application/xml;foo=\"\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": ""}}}, + {"Quoted pair", "application/xml;foo=\"\\\"b\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "\"b"}}}, + {"Whitespace after quoted parameter", "application/xml;foo=\"\\\"B\" ", contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{"foo": "\"b"}}}, + {"Plus in subtype", "a/b+c;a=b;c=d", contenttype.MediaType{Type: "a", Subtype: "b+c", Parameters: contenttype.Parameters{"a": "b", "c": "d"}}}, + {"Capital parameter", "a/b;A=B", contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{"a": "b"}}}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) + if requestError != nil { + log.Fatal(requestError) + } + + if len(testCase.header) > 0 { + request.Header.Set("Content-Type", testCase.header) + } + + result, mediaTypeError := GetMediaType(request) + if mediaTypeError != nil { + t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) + } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { + t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) + } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { + t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) + } + }) + } +} + +func TestGetMediaTypeErrors(t *testing.T) { + testCases := []struct { + name string + header string + err error + }{ + {"Type only", "Application", contenttype.ErrInvalidMediaType}, + {"Subtype only", "/Application", contenttype.ErrInvalidMediaType}, + {"Type with slash", "Application/", contenttype.ErrInvalidMediaType}, + {"Invalid token character", "a/b\x19", contenttype.ErrInvalidMediaType}, + {"Invalid character after subtype", "Application/JSON/test", contenttype.ErrInvalidMediaType}, + {"No parameter name", "application/xml;=bar ", contenttype.ErrInvalidParameter}, + {"Whitespace and no parameter name", "application/xml; =bar ", contenttype.ErrInvalidParameter}, + {"No value and whitespace", "application/xml;foo= ", contenttype.ErrInvalidParameter}, + {"Invalid character in value", "a/b;c=\x19", contenttype.ErrInvalidParameter}, + {"Invalid character in quoted string", "a/b;c=\"\x19\"", contenttype.ErrInvalidParameter}, + {"Invalid character in quoted pair", "a/b;c=\"\\\x19\"", contenttype.ErrInvalidParameter}, + {"No assignment after parameter", "a/b;c", contenttype.ErrInvalidParameter}, + {"No semicolon before paremeter", "a/b e", contenttype.ErrInvalidMediaType}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) + if requestError != nil { + log.Fatal(requestError) + } + + if len(testCase.header) > 0 { + request.Header.Set("Content-Type", testCase.header) + } + + _, mediaTypeError := GetMediaType(request) + if mediaTypeError == nil { + t.Errorf("Expected an error for %s", testCase.header) + } else if testCase.err != mediaTypeError { + t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) + } + }) + } +} + +func TestGetAcceptableMediaType(t *testing.T) { + testCases := []struct { + name string + header string + availableMediaTypes []contenttype.MediaType + result contenttype.MediaType + extensionParameters contenttype.Parameters + }{ + {"Empty header", "", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Type and subtype", "application/json", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Capitalized type and subtype", "Application/Json", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple accept types", "text/plain,application/xml", []contenttype.MediaType{{"text", "plain", contenttype.Parameters{}}}, contenttype.MediaType{Type: "text", Subtype: "plain", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple accept types, second available", "text/plain,application/xml", []contenttype.MediaType{{"application", "xml", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "xml", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Accept weight", "text/plain;q=1.0", []contenttype.MediaType{{"text", "plain", contenttype.Parameters{}}}, contenttype.MediaType{Type: "text", Subtype: "plain", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard", "*/*", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype", "application/*", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.MediaType{Type: "application", Subtype: "json", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Weight with dot", "a/b;q=1.", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple weights", "a/b;q=0.1,c/d;q=0.2", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"c", "d", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "c", Subtype: "d", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Multiple weights and default weight", "a/b;q=0.2,c/d;q=0.2", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"c", "d", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype and weight", "a/*;q=0.2,a/c", []contenttype.MediaType{ + {"a", "b", contenttype.Parameters{}}, + {"a", "c", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "c", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Different accept order", "a/b,a/a", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype with multiple available types", "a/*", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "a", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Wildcard subtype against weighted type", "a/a;q=0.2,a/*", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Media type parameter", "a/a;q=0.2,a/a;c=d", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "a", contenttype.Parameters{"c": "d"}}, + }, contenttype.MediaType{Type: "a", Subtype: "a", Parameters: contenttype.Parameters{"c": "d"}}, contenttype.Parameters{}}, + {"Weight and media type parameter", "a/b;q=1;e=e", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{"e": "e"}}, + {"", "a/*,a/a;q=0", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + {"Maximum length weight", "a/a;q=0.001,a/b;q=0.002", []contenttype.MediaType{ + {"a", "a", contenttype.Parameters{}}, + {"a", "b", contenttype.Parameters{}}, + }, contenttype.MediaType{Type: "a", Subtype: "b", Parameters: contenttype.Parameters{}}, contenttype.Parameters{}}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) + if requestError != nil { + log.Fatal(requestError) + } + + if len(testCase.header) > 0 { + request.Header.Set("Accept", testCase.header) + } + + result, extensionParameters, mediaTypeError := GetAcceptableMediaType(request, testCase.availableMediaTypes) + + if mediaTypeError != nil { + t.Errorf("Unexpected error \"%s\" for %s", mediaTypeError.Error(), testCase.header) + } else if result.Type != testCase.result.Type || result.Subtype != testCase.result.Subtype { + t.Errorf("Invalid content type, got %s/%s, exptected %s/%s for %s", result.Type, result.Subtype, testCase.result.Type, testCase.result.Subtype, testCase.header) + } else if !reflect.DeepEqual(result.Parameters, testCase.result.Parameters) { + t.Errorf("Wrong parameters, got %v, expected %v for %s", result.Parameters, testCase.result.Parameters, testCase.header) + } else if !reflect.DeepEqual(extensionParameters, testCase.extensionParameters) { + t.Errorf("Wrong extension parameters, got %v, expected %v for %s", extensionParameters, testCase.extensionParameters, testCase.header) + } + }) + } +} + +func TestGetAcceptableMediaTypeErrors(t *testing.T) { + testCases := []struct { + name string + header string + availableMediaTypes []contenttype.MediaType + err error + }{ + {"No available type", "", []contenttype.MediaType{}, contenttype.ErrNoAvailableTypeGiven}, + {"No acceptable type", "application/xml", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrNoAcceptableTypeFound}, + {"Invalid character after subtype", "application/xml/", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaRange}, + {"Comma after subtype with no parameter", "application/xml,", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Subtype only", "/xml", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Type with comma and without subtype", "application/,", []contenttype.MediaType{{"application", "json", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Invalid character", "a/b c", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaRange}, + {"No value for parameter", "a/b;c", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidParameter}, + {"Wildcard type only", "*/b", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidMediaType}, + {"Invalid character in weight", "a/b;q=a", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Weight bigger than 1.0", "a/b;q=11", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"More than 3 digitas after dot", "a/b;q=1.0000", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Invalid character after dot", "a/b;q=1.a", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Invalid digit after dot", "a/b;q=1.100", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrInvalidWeight}, + {"Type with weight zero only", "a/b;q=0", []contenttype.MediaType{{"a", "b", contenttype.Parameters{}}}, contenttype.ErrNoAcceptableTypeFound}, + {"No value for extension parameter", "a/a;q=1;ext=", []contenttype.MediaType{{"a", "a", contenttype.Parameters{}}}, contenttype.ErrInvalidParameter}, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + request, requestError := http.NewRequest(http.MethodGet, "http://test.test", nil) + if requestError != nil { + log.Fatal(requestError) + } + + if len(testCase.header) > 0 { + request.Header.Set("Accept", testCase.header) + } + + _, _, mediaTypeError := GetAcceptableMediaType(request, testCase.availableMediaTypes) + if mediaTypeError == nil { + t.Errorf("Expected an error for %s", testCase.header) + } else if testCase.err != mediaTypeError { + t.Errorf("Unexpected error \"%s\", expected \"%s\" for %s", mediaTypeError.Error(), testCase.err.Error(), testCase.header) + } + }) + } +}