Initial checkin. Only feature so far is accept header parsing.
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/.gitignore
@@ -0,0 +1 @@ +vendor/
diff --git a/accept.go b/accept.go new file mode 100644 index 0000000..ed7d16e --- /dev/null +++ b/accept.go
@@ -0,0 +1,175 @@ +package goscaffold + +import ( + "net/http" + "regexp" + "sort" + "strconv" + "strings" +) + +var qParamRe = regexp.MustCompile("^q=([0-9\\.]+)$") + +type acceptCriterion struct { + major string + minor string + precedence float32 + origOrder int + match string +} +type acceptCriteria []acceptCriterion + +/* +SelectMediaType matches a set of candidate media types against the +Accept header in an HTTP request. It does this using the rules from +RFC2616 section 1.4. +If no Accept header is present, then the first choice in the +"choices" array is returned. +If multiple choices match, then we will take the first one specified +in the "Accept" header. +If there are no compatible media types, then an empty string +is returned. +Only the first "Accept" header on the request is considered. +*/ +func SelectMediaType(req *http.Request, choices []string) string { + hdr := req.Header.Get("Accept") + if hdr == "" { + if len(choices) >= 1 { + return choices[0] + } + return "" + } + + // Parse accept header and parse the criteria + candidates := parseAcceptHeader(hdr) + + // For each choice, assign the best match (least wildcardy) + matches := make(map[string]*acceptCriterion) + for _, choice := range choices { + for c := range candidates { + crit := candidates[c] + if crit.matches(choice) { + if matches[choice] == nil || + matches[choice].level() < crit.level() { + matches[choice] = &acceptCriterion{ + minor: crit.minor, + major: crit.major, + precedence: crit.precedence, + origOrder: crit.origOrder, + match: choice, + } + } + } + } + } + + if len(matches) == 0 { + return "" + } + + // Sort the matches now by precedence level and original order + var sortedMatches acceptCriteria + for _, v := range matches { + sortedMatches = append(sortedMatches, *v) + } + sortedMatches.sort() + + return sortedMatches[0].match +} + +func parseAcceptHeader(hdr string) acceptCriteria { + var ret acceptCriteria + parts := strings.Split(hdr, ",") + for i, part := range parts { + candidate := parseAcceptPart(part, i) + ret = append(ret, candidate) + } + return ret +} + +/* +parseAcceptPart parses a single section of the header and extracts the +"q" parameter. +*/ +func parseAcceptPart(part string, order int) acceptCriterion { + params := strings.Split(part, ";") + finalType := strings.TrimSpace(params[0]) + var precedence float32 = 1.0 + + for _, param := range params[1:] { + match := qParamRe.FindStringSubmatch(strings.TrimSpace(param)) + if match == nil { + finalType += ";" + param + } else { + qVal, err := strconv.ParseFloat(match[1], 32) + if err == nil { + precedence = float32(qVal) + } + } + } + + splitType := strings.SplitN(finalType, "/", 2) + + return acceptCriterion{ + major: splitType[0], + minor: splitType[1], + precedence: precedence, + origOrder: order, + } +} + +/* +matches matches a candidate media type with a criterion. +*/ +func (a acceptCriterion) matches(t string) bool { + st := strings.SplitN(t, "/", 2) + + if a.major != "*" && a.major != st[0] { + return false + } + if a.minor != "*" && a.minor != st[1] { + return false + } + return true +} +func (a acceptCriterion) level() int { + if a.minor == "*" { + if a.major == "*" { + return 0 + } + return 1 + } + return 2 +} + +/* +sortCandidates sorts accept header candidates in order of: +1) Precedence (from the "q" parameter) +2) Original order in accept header +3) Stable sort otherwise +*/ +func (c acceptCriteria) sort() { + sort.Stable(c) +} + +func (c acceptCriteria) Less(i, j int) bool { + // Higher precedence goes first + if c[i].precedence > c[j].precedence { + return true + } + if c[i].precedence == c[j].precedence && + c[i].origOrder < c[j].origOrder { + return true + } + return false +} + +func (c acceptCriteria) Len() int { + return len(c) +} + +func (c acceptCriteria) Swap(i, j int) { + tmp := c[i] + c[i] = c[j] + c[j] = tmp +}
diff --git a/accept_test.go b/accept_test.go new file mode 100644 index 0000000..2a80b21 --- /dev/null +++ b/accept_test.go
@@ -0,0 +1,139 @@ +package goscaffold + +import ( + "net/http" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Accept header tests", func() { + It("No nothing", func() { + Expect(SelectMediaType( + makeRequest(""), []string{})).Should(BeEmpty()) + }) + + It("No header, one choice", func() { + Expect(SelectMediaType( + makeRequest(""), + []string{"text/plain"})).Should(Equal("text/plain")) + }) + + It("No header, two choices", func() { + Expect(SelectMediaType( + makeRequest(""), + []string{"text/plain", "text/xml"})).Should(Equal("text/plain")) + }) + + It("One Header, two choices", func() { + Expect(SelectMediaType( + makeRequest("application/json"), + []string{"text/plain", "application/json"})).Should(Equal("application/json")) + }) + + It("One Header, bad choices", func() { + Expect(SelectMediaType( + makeRequest("application/json"), + []string{"text/plain", "application/xml"})).Should(Equal("")) + }) + + It("audio/basic", func() { + req := makeRequest("audio/*; q=0.2, audio/basic") + Expect(SelectMediaType(req, + []string{"audio/mp3", "audio/basic"})).Should(Equal("audio/basic")) + Expect(SelectMediaType(req, + []string{"audio/mp3", "text/plain"})).Should(Equal("audio/mp3")) + Expect(SelectMediaType(req, + []string{"text/plain", "application/xml"})).Should(Equal("")) + }) + + It("text/plain", func() { + req := makeRequest("text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c") + Expect(SelectMediaType(req, + []string{"text/html", "text/x-c"})).Should(Equal("text/html")) + Expect(SelectMediaType(req, + []string{"text/x-c", "text/html"})).Should(Equal("text/html")) + Expect(SelectMediaType(req, + []string{"text/x-dvi", "text/plain"})).Should(Equal("text/x-dvi")) + Expect(SelectMediaType(req, + []string{"text/plain"})).Should(Equal("text/plain")) + Expect(SelectMediaType(req, + []string{"application/json"})).Should(Equal("")) + }) + + It("text/plain 2", func() { + req := makeRequest( + `text/*;q=0.3, text/html;q=0.7, text/html;level=1, + text/html;level=2;q=0.4, */*;q=0.5`) + Expect(SelectMediaType(req, + []string{"text/plain", "text/html;level=1", "text/html", "image/jpeg", "text/html;level=2", "foo/bar"})). + Should(Equal("text/html;level=1")) + Expect(SelectMediaType(req, + []string{"image/jpeg", "text/html", "text/plain", "text/html;level=2", "foo/bar"})). + Should(Equal("text/html")) + Expect(SelectMediaType(req, + []string{"image/jpeg", "text/plain", "text/html;level=2"})). + Should(Equal("image/jpeg")) + Expect(SelectMediaType(req, + []string{"text/plain", "text/html;level=2"})). + Should(Equal("text/html;level=2")) + }) + + It("Weird Browser example", func() { + req := makeRequest("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json") + Expect(SelectMediaType(req, + []string{"application/xml", "text/xml", "text/html", "application/json"})). + Should(Equal("text/html")) + Expect(SelectMediaType(req, + []string{"application/xml", "application/json"})). + Should(Equal("application/json")) + }) + + It("Firefox Example", func() { + req := makeRequest("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + Expect(SelectMediaType(req, + []string{"application/xml", "text/xml", "text/html", "text/plain", "application/json"})). + Should(Equal("text/html")) + Expect(SelectMediaType(req, + []string{"application/xml", "application/json"})). + Should(Equal("application/xml")) + Expect(SelectMediaType(req, + []string{"application/json"})). + Should(Equal("application/json")) + }) + + It("Parse accept part", func() { + ap := parseAcceptPart("text/plain", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain")) + Expect(ap.precedence).Should(BeEquivalentTo(1.0)) + ap = parseAcceptPart("text/plain;q=0.2", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain")) + Expect(ap.precedence).Should(BeNumerically("~", 0.2)) + ap = parseAcceptPart("text/plain; q=0.2", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain")) + Expect(ap.precedence).Should(BeNumerically("~", 0.2)) + ap = parseAcceptPart("text/plain;level=1", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain;level=1")) + Expect(ap.precedence).Should(BeEquivalentTo(1.0)) + ap = parseAcceptPart("text/plain;level=1;q=0.5", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain;level=1")) + Expect(ap.precedence).Should(BeNumerically("~", 0.5)) + ap = parseAcceptPart("text/plain;level=1;q=0.5;foo=bar", 0) + Expect(ap.major).Should(Equal("text")) + Expect(ap.minor).Should(Equal("plain;level=1;foo=bar")) + Expect(ap.precedence).Should(BeNumerically("~", 0.5)) + }) +}) + +func makeRequest(accept string) *http.Request { + return &http.Request{ + Header: http.Header{ + "Accept": []string{accept}, + }, + } +}
diff --git a/glide.lock b/glide.lock new file mode 100644 index 0000000..910b296 --- /dev/null +++ b/glide.lock
@@ -0,0 +1,8 @@ +hash: 9b96c783f272b0a712add71d2d753d1eb07ba91dfe9b42e3532b8d3e3cec639c +updated: 2016-10-04T12:46:18.025613379-07:00 +imports: [] +testImports: +- name: github.com/onsi/ginkgo + version: 45a5f6ffb2a14e4f29698c16ae4c0a34018a8951 +- name: github.com/onsi/gomega + version: d59fa0ac68bb5dd932ee8d24eed631cdd519efc3
diff --git a/glide.yaml b/glide.yaml new file mode 100644 index 0000000..c151fcc --- /dev/null +++ b/glide.yaml
@@ -0,0 +1,4 @@ +package: github.com/30x/go-scaffold +testImport: +- package: github.com/onsi/ginkgo +- package: github.com/onsi/gomega
diff --git a/scaffold_main_test.go b/scaffold_main_test.go new file mode 100644 index 0000000..a12dc50 --- /dev/null +++ b/scaffold_main_test.go
@@ -0,0 +1,13 @@ +package goscaffold + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestScaffold(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Scaffold Suite") +}