├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── .gitignore ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── example └── main.go ├── go.mod ├── go.sum ├── negotiate.go ├── negotiate_test.go ├── parser.go └── parser_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kevinpollet 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | version: [ 1.16, 1.17 ] 12 | 13 | steps: 14 | - name: Check-out 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.version }} 21 | 22 | - name: Lint 23 | uses: golangci/golangci-lint-action@v2 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/go/pkg/mod 29 | key: ${{ runner.os }}-go-${{ matrix.version }}-${{ hashFiles('**/go.sum') }} 30 | 31 | - name: Download and check dependencies 32 | run: | 33 | go mod tidy 34 | git diff --exit-code go.mod 35 | git diff --exit-code go.sum 36 | 37 | - name: Test 38 | run: go test -v -race ./... 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 10m 3 | skip-files: [ ] 4 | 5 | linters-settings: 6 | godox: 7 | keywords: 8 | - FIXME 9 | 10 | funlen: 11 | lines: -1 12 | statements: 50 13 | 14 | linters: 15 | enable-all: true 16 | disable: 17 | - maligned # deprecated 18 | - interfacer # deprecated 19 | - scopelint # deprecated 20 | - golint # deprecated 21 | - wrapcheck 22 | - exhaustivestruct 23 | - testpackage 24 | - paralleltest 25 | - tparallel 26 | - gomnd 27 | - goerr113 28 | - wsl 29 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pollet.kevin@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © `2021` `kevinpollet ` 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the “Software”), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nego 2 | 3 | [![Build Status](https://github.com/kevinpollet/nego/workflows/build/badge.svg)](https://github.com/kevinpollet/nego/actions) 4 | [![GoDoc](https://godoc.org/github.com/kevinpollet/nego?status.svg)](https://pkg.go.dev/github.com/kevinpollet/nego) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/kevinpollet/nego?burst=)](https://goreportcard.com/report/github.com/kevinpollet/nego) 6 | 7 | Go package providing an implementation of [HTTP Content Negotiation](https://en.wikipedia.org/wiki/Content_negotiation) compliant with [RFC 7231](https://tools.ietf.org/html/rfc7231#section-5.3). 8 | 9 | As defined in [RFC 7231](https://tools.ietf.org/html/rfc7231#section-5.3) the following request headers are sent by a user agent to engage in a proactive negotiation of the response content: `Accept`, `Accept-Charset`, `Accept-Language` and `Accept-Encoding`. 10 | This package provides convenient functions to negotiate the best and acceptable response content `type`, `charset`, `language` and `encoding`. 11 | 12 | ## Installation 13 | 14 | Install using `go get github.com/kevinpollet/nego`. 15 | 16 | ## Usage 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "log" 23 | "net/http" 24 | "github.com/kevinpollet/nego" 25 | ) 26 | 27 | func main() { 28 | handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 29 | contentCharset := nego.NegotiateContentCharset(req, "utf-8") 30 | contentEncoding := nego.NegotiateContentEncoding(req, "gzip", "deflate") 31 | contentLanguage := nego.NegotiateContentLanguage(req, "fr", "en") 32 | contentType := nego.NegotiateContentType(req, "text/html", "text/plain") 33 | ... 34 | }) 35 | } 36 | ``` 37 | 38 | ## Contributing 39 | 40 | Contributions are welcome! 41 | 42 | Want to file a bug, request a feature or contribute some code? 43 | 44 | 1. Check out the [Code of Conduct](./CODE_OF_CONDUCT.md). 45 | 2. Check for an existing [issue](https://github.com/kevinpollet/nego/issues) corresponding to your bug or feature request. 46 | 3. Open an issue to describe your bug or feature request. 47 | 48 | ## License 49 | 50 | [MIT](./LICENSE.md) 51 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/kevinpollet/nego" 8 | ) 9 | 10 | func main() { 11 | handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 12 | rw.Header().Add("Content-Charset", nego.NegotiateContentCharset(req, "utf-8")) 13 | rw.Header().Add("Content-Language", nego.NegotiateContentLanguage(req, "fr", "en")) 14 | rw.Header().Add("Content-Type", nego.NegotiateContentType(req, "text/plain")) 15 | 16 | rw.WriteHeader(http.StatusOK) 17 | }) 18 | 19 | log.Fatal(http.ListenAndServe(":8080", handler)) 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevinpollet/nego 2 | 3 | go 1.17 4 | 5 | require github.com/stretchr/testify v1.6.1 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.0 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 7 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /negotiate.go: -------------------------------------------------------------------------------- 1 | // Package nego implements HTTP Content Negotiation functions compliant with RFC 7231. 2 | // 3 | // See https://tools.ietf.org/html/rfc7231#section-5.3 for more details. 4 | // 5 | // Example 6 | // 7 | // This example shows how to use the negotiation functions. 8 | // 9 | // import ( 10 | // "net/http" 11 | // "github.com/kevinpollet/nego" 12 | // ) 13 | // 14 | // handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 15 | // nego.NegotiateContentCharset(req, "utf-8") 16 | // nego.NegotiateContentEncoding(req, "gzip", "deflate") 17 | // nego.NegotiateContentLanguage(req, "fr", "en") 18 | // nego.NegotiateContentType(req, "text/plain") 19 | // }) 20 | package nego 21 | 22 | import "net/http" 23 | 24 | // The identity encoding constant used as a synonym for "no encoding" in order to communicate when 25 | // no encoding is preferred. 26 | // 27 | // See https://tools.ietf.org/html/rfc7231#section-5.3.4 for more details. 28 | const EncodingIdentity = "identity" 29 | 30 | // NegotiateContentCharset returns the best acceptable charset offer to use in the response according 31 | // to the Accept-Charset request's header. If the given offer list is empty or no offer is acceptable 32 | // then, an empty string is returned. 33 | // 34 | // See https://tools.ietf.org/html/rfc7231#section-5.3.3 for more details. 35 | func NegotiateContentCharset(req *http.Request, offerCharsets ...string) string { 36 | bestQvalue := 0.0 37 | bestCharset := "" 38 | 39 | acceptCharsets, exists := parseAccept(req.Header, "Accept-Charset") 40 | if !exists && len(offerCharsets) > 0 { 41 | return offerCharsets[0] 42 | } 43 | 44 | for _, offer := range offerCharsets { 45 | if qvalue, exists := acceptCharsets.qvalue(offer); exists && qvalue > bestQvalue { 46 | bestCharset = offer 47 | bestQvalue = qvalue 48 | } 49 | } 50 | 51 | return bestCharset 52 | } 53 | 54 | // NegotiateContentEncoding returns the best acceptable encoding offer to use in the response according 55 | // to the Accept-Encoding request's header. If the given offer list is empty or no offer is acceptable 56 | // then, an empty string is returned. 57 | // 58 | // See https://tools.ietf.org/html/rfc7231#section-5.3.4 for more details. 59 | func NegotiateContentEncoding(req *http.Request, offerEncodings ...string) string { 60 | bestQvalue := 0.0 61 | bestEncoding := "" 62 | 63 | acceptEncodings, exists := parseAccept(req.Header, "Accept-Encoding") 64 | if !exists && len(offerEncodings) > 0 { 65 | return offerEncodings[0] 66 | } 67 | 68 | for _, offer := range offerEncodings { 69 | if qvalue, exists := acceptEncodings.qvalue(offer); exists && qvalue > bestQvalue { 70 | bestEncoding = offer 71 | bestQvalue = qvalue 72 | } 73 | } 74 | 75 | if qvalue, exists := acceptEncodings.qvalue(EncodingIdentity); bestEncoding == "" && (!exists || qvalue > 0.0) { 76 | return EncodingIdentity 77 | } 78 | 79 | return bestEncoding 80 | } 81 | 82 | // NegotiateContentLanguage returns the best acceptable language offer to use in the response according 83 | // to the Accept-Language request's header. If the given offer list is empty or no offer is acceptable 84 | // then, an empty string is returned. 85 | // 86 | // See https://tools.ietf.org/html/rfc7231#section-5.3.5 for more details. 87 | func NegotiateContentLanguage(req *http.Request, offerLanguages ...string) string { 88 | bestQvalue := 0.0 89 | bestLanguage := "" 90 | 91 | acceptLanguages, exists := parseAccept(req.Header, "Accept-Language") 92 | if !exists && len(offerLanguages) > 0 { 93 | return offerLanguages[0] 94 | } 95 | 96 | for _, offer := range offerLanguages { 97 | if qvalue, exists := acceptLanguages.qvalue(offer); exists && qvalue > bestQvalue { 98 | bestLanguage = offer 99 | bestQvalue = qvalue 100 | } 101 | } 102 | 103 | return bestLanguage 104 | } 105 | 106 | // NegotiateContentType returns the best acceptable media type offer to use in the response according 107 | // to the Accept-Language request's header. If the given offer list is empty or no offer is acceptable 108 | // then, an empty string is returned. 109 | // 110 | // See https://tools.ietf.org/html/rfc7231#section-5.3.2 for more details. 111 | func NegotiateContentType(req *http.Request, offerMediaTypes ...string) string { 112 | bestMediaType := "" 113 | bestQvalue := 0.0 114 | 115 | acceptTypes, exists := parseAccept(req.Header, "Accept") 116 | if !exists && len(offerMediaTypes) > 0 { 117 | return offerMediaTypes[0] 118 | } 119 | 120 | for _, offer := range offerMediaTypes { 121 | if qvalue, exists := acceptTypes.qvalue(offer); exists && qvalue > bestQvalue { 122 | bestMediaType = offer 123 | bestQvalue = qvalue 124 | } 125 | } 126 | 127 | return bestMediaType 128 | } 129 | -------------------------------------------------------------------------------- /negotiate_test.go: -------------------------------------------------------------------------------- 1 | package nego 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNegotiateContentCharset(t *testing.T) { 12 | testCases := []struct { 13 | desc string 14 | offers []string 15 | accept string 16 | expCharset string 17 | }{ 18 | { 19 | desc: "should return the first offer if the request has no Accept-Charset header", 20 | offers: []string{"utf-8"}, 21 | accept: "", 22 | expCharset: "utf-8", 23 | }, 24 | { 25 | desc: "should return an empty string if no offer is acceptable", 26 | offers: []string{"utf-8"}, 27 | accept: "utf-16", 28 | expCharset: "", 29 | }, 30 | { 31 | desc: "should return an empty string if the offer is explicitly discared", 32 | offers: []string{"utf-8"}, 33 | accept: "utf-8;q=0", 34 | expCharset: "", 35 | }, 36 | { 37 | desc: "should return an empty string if no offer is defined", 38 | offers: []string{}, 39 | accept: "utf-8", 40 | expCharset: "", 41 | }, 42 | { 43 | desc: "should return the acceptable offer with the best qvalue if multiple offers are acceptable", 44 | offers: []string{"utf-8", "utf-16"}, 45 | accept: "utf-8;q=0.8, utf-16", 46 | expCharset: "utf-16", 47 | }, 48 | } 49 | 50 | for _, testCase := range testCases { 51 | testCase := testCase 52 | 53 | t.Run(testCase.desc, func(t *testing.T) { 54 | t.Parallel() 55 | 56 | req := httptest.NewRequest(http.MethodGet, "http://dummy.com", nil) 57 | if testCase.accept != "" { 58 | req.Header.Add("Accept-Charset", testCase.accept) 59 | } 60 | 61 | contentCharset := NegotiateContentCharset(req, testCase.offers...) 62 | 63 | assert.Equal(t, testCase.expCharset, contentCharset) 64 | }) 65 | } 66 | } 67 | 68 | func TestNegotiateContentEncoding(t *testing.T) { 69 | testCases := []struct { 70 | desc string 71 | offers []string 72 | accept string 73 | expEncoding string 74 | }{ 75 | { 76 | desc: "should return the first offer if the request has no Accept-Encoding header", 77 | offers: []string{"gzip", "deflate"}, 78 | accept: "", 79 | expEncoding: "gzip", 80 | }, 81 | { 82 | desc: "should return identity if no offer is defined", 83 | offers: []string{}, 84 | accept: "", 85 | expEncoding: "identity", 86 | }, 87 | { 88 | desc: "should return identity if no offer is acceptable", 89 | offers: []string{"gzip", "br"}, 90 | accept: "deflate", 91 | expEncoding: "identity", 92 | }, 93 | { 94 | desc: "should return identity if the request has an empty Accept-Encoding header", 95 | offers: []string{"gzip", "br"}, 96 | accept: " ", 97 | expEncoding: "identity", 98 | }, 99 | { 100 | desc: "should return an empty string if no offer is defined and identity is explicitly discared", 101 | offers: []string{}, 102 | accept: "identity;q=0", 103 | expEncoding: "", 104 | }, 105 | { 106 | desc: "should return an empty string if no offer is defined and identity is discared", 107 | offers: []string{}, 108 | accept: "*;q=0", 109 | expEncoding: "", 110 | }, 111 | { 112 | desc: "should return the acceptable offer", 113 | offers: []string{"gzip", "br"}, 114 | accept: "br", 115 | expEncoding: "br", 116 | }, 117 | { 118 | desc: "should return the acceptable offer with the best qvalue if multiple offers are acceptable", 119 | offers: []string{"gzip", "br"}, 120 | accept: "br;q=0.5, gzip", 121 | expEncoding: "gzip", 122 | }, 123 | } 124 | 125 | for _, testCase := range testCases { 126 | testCase := testCase 127 | 128 | t.Run(testCase.desc, func(t *testing.T) { 129 | t.Parallel() 130 | 131 | req := httptest.NewRequest(http.MethodGet, "http://dummy.com", nil) 132 | if testCase.accept != "" { 133 | req.Header.Add("Accept-Encoding", testCase.accept) 134 | } 135 | 136 | contentEncoding := NegotiateContentEncoding(req, testCase.offers...) 137 | 138 | assert.Equal(t, testCase.expEncoding, contentEncoding) 139 | }) 140 | } 141 | } 142 | 143 | func TestNegotiateContentLanguage(t *testing.T) { 144 | testCases := []struct { 145 | desc string 146 | offers []string 147 | accept string 148 | expLanguage string 149 | }{ 150 | { 151 | desc: "should return the first offer if request has no Accept-Language header", 152 | offers: []string{"en", "en-us"}, 153 | accept: "", 154 | expLanguage: "en", 155 | }, 156 | { 157 | desc: "should return an empty string if no offers is acceptable", 158 | offers: []string{"en", "en-us"}, 159 | accept: "fr", 160 | expLanguage: "", 161 | }, 162 | { 163 | desc: "should return an empty string if an offer is explicitly discared", 164 | offers: []string{"en", "en-us"}, 165 | accept: "en;q=0", 166 | expLanguage: "", 167 | }, 168 | { 169 | desc: "should return an empty string if no offer is defined", 170 | offers: []string{}, 171 | accept: "en", 172 | expLanguage: "", 173 | }, 174 | { 175 | desc: "should return the acceptable offer with the best qvalue if multiple offers are acceptable", 176 | offers: []string{"en", "en-us"}, 177 | accept: "en;q=0.8, en-us", 178 | expLanguage: "en-us", 179 | }, 180 | } 181 | 182 | for _, testCase := range testCases { 183 | testCase := testCase 184 | 185 | t.Run(testCase.desc, func(t *testing.T) { 186 | t.Parallel() 187 | 188 | req := httptest.NewRequest(http.MethodGet, "http://dummy.com", nil) 189 | if testCase.accept != "" { 190 | req.Header.Add("Accept-Language", testCase.accept) 191 | } 192 | 193 | contentLanguage := NegotiateContentLanguage(req, testCase.offers...) 194 | 195 | assert.Equal(t, testCase.expLanguage, contentLanguage) 196 | }) 197 | } 198 | } 199 | 200 | func TestNegotiateContentType(t *testing.T) { 201 | testCases := []struct { 202 | desc string 203 | offers []string 204 | accept string 205 | expMediaType string 206 | }{ 207 | { 208 | desc: "should return the first offer if the request has no Accept header", 209 | offers: []string{"text/html"}, 210 | accept: "", 211 | expMediaType: "text/html", 212 | }, 213 | { 214 | desc: "should return an empty string if no offer is acceptable", 215 | offers: []string{"text/html", "text/plain"}, 216 | accept: "application/json", 217 | expMediaType: "", 218 | }, 219 | { 220 | desc: "should return an empty string if no offer is acceptable", 221 | offers: []string{"text/html", "text/plain"}, 222 | accept: "application/json", 223 | expMediaType: "", 224 | }, 225 | { 226 | desc: "should return an empty string if offer is explicitly discared", 227 | offers: []string{"text/html"}, 228 | accept: "text/html;q=0", 229 | expMediaType: "", 230 | }, 231 | { 232 | desc: "should return an empty string if offer is discared", 233 | offers: []string{"text/html"}, 234 | accept: "text/*;q=0", 235 | expMediaType: "", 236 | }, 237 | { 238 | desc: "should return an empty string if no offer is defined", 239 | offers: []string{}, 240 | accept: "application/json", 241 | expMediaType: "", 242 | }, 243 | { 244 | desc: "should return the acceptable offer with the best qvalue if multiple offers are acceptable", 245 | offers: []string{"text/html", "text/plain"}, 246 | accept: "text/html;q=0.8, text/plain", 247 | expMediaType: "text/plain", 248 | }, 249 | // { 250 | // desc: "should return the most specific acceptable offer if multiple offers are acceptable", 251 | // offers: []string{"text/html", "text/plain"}, 252 | // accept: "text/plain, text/*", 253 | // expMediaType: "text/plain", 254 | // }, 255 | } 256 | 257 | for _, testCase := range testCases { 258 | testCase := testCase 259 | 260 | t.Run(testCase.desc, func(t *testing.T) { 261 | t.Parallel() 262 | 263 | req := httptest.NewRequest(http.MethodGet, "http://dummy.com", nil) 264 | if testCase.accept != "" { 265 | req.Header.Add("Accept", testCase.accept) 266 | } 267 | 268 | contentType := NegotiateContentType(req, testCase.offers...) 269 | 270 | assert.Equal(t, testCase.expMediaType, contentType) 271 | }) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package nego 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type accept map[string]float64 10 | 11 | func (a accept) qvalue(offer string) (float64, bool) { 12 | if qvalue, exists := a[offer]; exists { 13 | return qvalue, exists 14 | } 15 | 16 | if !strings.Contains(offer, "/") { 17 | qvalue, exists := a["*"] 18 | 19 | return qvalue, exists 20 | } 21 | 22 | slashIndex := strings.Index(offer, "/") 23 | 24 | if qvalue, exists := a[offer[:slashIndex]+"/*"]; exists { 25 | return qvalue, exists 26 | } 27 | 28 | if qvalue, exists := a["*/*"]; exists { 29 | return qvalue, exists 30 | } 31 | 32 | return 0.0, false 33 | } 34 | 35 | // parseAccept parses the values of a content negotiation header. The following request headers are sent 36 | // by a user agent to engage in proactive negotiation: Accept, Accept-Charset, Accept-Encoding, Accept-Language. 37 | func parseAccept(header http.Header, key string) (accept, bool) { 38 | values, exists := header[key] 39 | accepts := make(map[string]float64) 40 | 41 | for _, value := range values { 42 | if value == "" { 43 | continue 44 | } 45 | 46 | for _, spec := range strings.Split(value, ",") { 47 | name, qvalue := parseSpec(spec) 48 | accepts[name] = qvalue 49 | } 50 | } 51 | 52 | return accepts, exists 53 | } 54 | 55 | func parseSpec(spec string) (string, float64) { 56 | qvalue := 1.0 57 | sToken := strings.ReplaceAll(spec, " ", "") 58 | parts := strings.Split(sToken, ";") 59 | 60 | for _, param := range parts[1:] { 61 | lowerParam := strings.ToLower(param) 62 | qvalueStr := strings.TrimPrefix(lowerParam, "q=") 63 | 64 | if qvalueStr != lowerParam { 65 | qvalue = parseQuality(qvalueStr) 66 | } 67 | } 68 | 69 | return parts[0], qvalue 70 | } 71 | 72 | func parseQuality(value string) float64 { 73 | float, err := strconv.ParseFloat(value, 64) 74 | if err != nil { 75 | return -1 76 | } 77 | 78 | return float 79 | } 80 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package nego 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseAccept(t *testing.T) { 11 | testCases := []struct { 12 | desc string 13 | accept string 14 | expLen int 15 | }{ 16 | { 17 | desc: "should return an empty map if the given values are empty", 18 | accept: "", 19 | expLen: 0, 20 | }, 21 | { 22 | desc: "should return a map with one element", 23 | accept: "gzip", 24 | expLen: 1, 25 | }, 26 | { 27 | desc: "should return a map with the given number of elements", 28 | accept: "gzip,deflate", 29 | expLen: 2, 30 | }, 31 | { 32 | desc: "should return a map with the given number of elements ignoring spaces", 33 | accept: "gzip , deflate", 34 | expLen: 2, 35 | }, 36 | { 37 | desc: "should return a map with the given number of elements in the given values", 38 | accept: "gzip, deflate, br", 39 | expLen: 3, 40 | }, 41 | } 42 | 43 | for _, testCase := range testCases { 44 | testCase := testCase 45 | 46 | t.Run(testCase.desc, func(t *testing.T) { 47 | t.Parallel() 48 | 49 | header := make(http.Header) 50 | header.Add("Accept", testCase.accept) 51 | 52 | specs, _ := parseAccept(header, "Accept") 53 | 54 | assert.Equal(t, testCase.expLen, len(specs)) 55 | }) 56 | } 57 | } 58 | 59 | func TestParseSpec(t *testing.T) { 60 | testCases := []struct { 61 | desc string 62 | value string 63 | expN string 64 | expQ float64 65 | }{ 66 | { 67 | desc: "should return the parsed name with the default quality", 68 | value: "test", 69 | expN: "test", 70 | expQ: 1.0, 71 | }, 72 | { 73 | desc: "should return the parsed name with the given quality", 74 | value: "test;q=0.1", 75 | expN: "test", 76 | expQ: 0.1, 77 | }, 78 | { 79 | desc: "should return the parsed name with the given quality ignoring whitespaces", 80 | value: "test ; q=0.1", 81 | expN: "test", 82 | expQ: 0.1, 83 | }, 84 | { 85 | desc: "should return the parsed name with the given quality ignoring extra params", 86 | value: "test ; format=foo; q=0.1; format=bar", 87 | expN: "test", 88 | expQ: 0.1, 89 | }, 90 | } 91 | 92 | for _, testCase := range testCases { 93 | testCase := testCase 94 | 95 | t.Run(testCase.desc, func(t *testing.T) { 96 | t.Parallel() 97 | 98 | name, quality := parseSpec(testCase.value) 99 | 100 | assert.Equal(t, testCase.expN, name) 101 | assert.Equal(t, testCase.expQ, quality) 102 | }) 103 | } 104 | } 105 | 106 | func TestParseQuality(t *testing.T) { 107 | testCases := []struct { 108 | desc string 109 | value string 110 | expQ float64 111 | }{ 112 | { 113 | desc: "should return the parsed value", 114 | value: "1.0", 115 | expQ: 1.0, 116 | }, 117 | { 118 | desc: "should return -1 if the value cannot be parsed", 119 | value: "aa", 120 | expQ: -1.0, 121 | }, 122 | } 123 | 124 | for _, testCase := range testCases { 125 | testCase := testCase 126 | 127 | t.Run(testCase.desc, func(t *testing.T) { 128 | t.Parallel() 129 | 130 | quality := parseQuality(testCase.value) 131 | 132 | assert.Equal(t, testCase.expQ, quality) 133 | }) 134 | } 135 | } 136 | --------------------------------------------------------------------------------