From aad75eba080ede0b0a7ad754607bcbcb07835a8b Mon Sep 17 00:00:00 2001 From: Nicolas Carlier Date: Sun, 19 May 2024 09:02:37 +0000 Subject: [PATCH] feat(api): support content negociation close #97 --- pkg/api/index.go | 20 ++--- pkg/helper/header/header.go | 151 ++++++++++++++++++++++++++++++++++++ pkg/helper/negociate.go | 64 +++++++++++++++ 3 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 pkg/helper/header/header.go create mode 100644 pkg/helper/negociate.go diff --git a/pkg/api/index.go b/pkg/api/index.go index aa8e54b..fb35e34 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/ncarlier/webhookd/pkg/config" + "github.com/ncarlier/webhookd/pkg/helper" "github.com/ncarlier/webhookd/pkg/hook" "github.com/ncarlier/webhookd/pkg/worker" ) @@ -23,6 +24,8 @@ var ( outputDir string ) +var supportedContentTypes = []string{"text/plain", "text/event-stream", "application/json", "text/*"} + func atoiFallback(str string, fallback int) int { if value, err := strconv.Atoi(str); err == nil && value > 0 { return value @@ -118,17 +121,14 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) { // Put work in queue worker.WorkQueue <- job - // Use content negotiation to enable Server-Sent Events - useSSE := r.Method == "GET" && r.Header.Get("Accept") == "text/event-stream" - if useSSE { - // Send SSE response - w.Header().Set("Content-Type", "text/event-stream") - } else { - // Send chunked response - w.Header().Set("X-Content-Type-Options", "nosniff") - } + // Use content negotiation + ct = helper.NegotiateContentType(r, supportedContentTypes, "text/plain") + + // set respons headers + w.Header().Set("Content-Type", ct+"; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Hook-ID", strconv.FormatUint(job.ID(), 10)) for { @@ -136,7 +136,7 @@ func triggerWebhook(w http.ResponseWriter, r *http.Request) { if !open { break } - if useSSE { + if ct == "text/event-stream" { fmt.Fprintf(w, "data: %s\n\n", msg) // Send SSE response } else { fmt.Fprintf(w, "%s\n", msg) // Send chunked response diff --git a/pkg/helper/header/header.go b/pkg/helper/header/header.go new file mode 100644 index 0000000..eb9447e --- /dev/null +++ b/pkg/helper/header/header.go @@ -0,0 +1,151 @@ +// Copyright 2013 The Go Authors. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd. +// Package header provides functions for parsing HTTP headers. +package header + +import ( + "net/http" + "strings" +) + +var octetTypes [256]octetType + +type octetType byte + +const ( + isToken octetType = 1 << iota + isSpace +) + +func init() { + // OCTET = + // CHAR = + // CTL = + // CR = + // LF = + // SP = + // HT = + // <"> = + // CRLF = CR LF + // LWS = [CRLF] 1*( SP | HT ) + // TEXT = + // separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> + // | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT + // token = 1* + // qdtext = > + for c := 0; c < 256; c++ { + var t octetType + isCtl := c <= 31 || c == 127 + isChar := 0 <= c && c <= 127 + isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) + if strings.ContainsRune(" \t\r\n", rune(c)) { + t |= isSpace + } + if isChar && !isCtl && !isSeparator { + t |= isToken + } + octetTypes[c] = t + } +} + +// Copy returns a shallow copy of the header. +func Copy(header http.Header) http.Header { + h := make(http.Header) + for k, vs := range header { + h[k] = vs + } + return h +} + +// AcceptSpec describes an Accept* header. +type AcceptSpec struct { + Value string + Q float64 +} + +// ParseAccept parses Accept* headers. +func ParseAccept(header http.Header, key string) (specs []AcceptSpec) { +loop: + for _, s := range header[key] { + for { + var spec AcceptSpec + spec.Value, s = expectTokenSlash(s) + if spec.Value == "" { + continue loop + } + spec.Q = 1.0 + s = skipSpace(s) + if strings.HasPrefix(s, ";") { + s = skipSpace(s[1:]) + if !strings.HasPrefix(s, "q=") { + continue loop + } + spec.Q, s = expectQuality(s[2:]) + if spec.Q < 0.0 { + continue loop + } + } + specs = append(specs, spec) + s = skipSpace(s) + if !strings.HasPrefix(s, ",") { + continue loop + } + s = skipSpace(s[1:]) + } + } + return +} + +func skipSpace(s string) (rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isSpace == 0 { + break + } + } + return s[i:] +} + +func expectTokenSlash(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + b := s[i] + if (octetTypes[b]&isToken == 0) && b != '/' { + break + } + } + return s[:i], s[i:] +} + +func expectQuality(s string) (q float64, rest string) { + switch { + case len(s) == 0: + return -1, "" + case s[0] == '0': + q = 0 + case s[0] == '1': + q = 1 + default: + return -1, "" + } + s = s[1:] + if !strings.HasPrefix(s, ".") { + return q, s + } + s = s[1:] + i := 0 + n := 0 + d := 1 + for ; i < len(s); i++ { + b := s[i] + if b < '0' || b > '9' { + break + } + n = n*10 + int(b) - '0' + d *= 10 + } + return q + float64(n)/float64(d), s[i:] +} diff --git a/pkg/helper/negociate.go b/pkg/helper/negociate.go new file mode 100644 index 0000000..ad4e00a --- /dev/null +++ b/pkg/helper/negociate.go @@ -0,0 +1,64 @@ +package helper + +// Copyright 2013 The Go Authors. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd. + +import ( + "net/http" + "strings" + + "github.com/ncarlier/webhookd/pkg/helper/header" +) + +// NegotiateContentType returns the best offered content type for the request's +// Accept header. If two offers match with equal weight, then the more specific +// offer is preferred. For example, text/* trumps */*. If two offers match +// with equal weight and specificity, then the offer earlier in the list is +// preferred. If no offers match, then defaultOffer is returned. +func NegotiateContentType(r *http.Request, offers []string, defaultOffer string) string { + bestOffer := defaultOffer + bestQ := -1.0 + bestWild := 3 + specs := header.ParseAccept(r.Header, "Accept") + for _, offer := range offers { + for _, spec := range specs { + switch { + case spec.Q == 0.0: + // ignore + case spec.Q < bestQ: + // better match found + case spec.Value == "*/*": + if spec.Q > bestQ || bestWild > 2 { + bestQ = spec.Q + bestWild = 2 + bestOffer = offer + } + case strings.HasSuffix(spec.Value, "/*"): + if strings.HasPrefix(offer, spec.Value[:len(spec.Value)-1]) && + (spec.Q > bestQ || bestWild > 1) { + bestQ = spec.Q + bestWild = 1 + bestOffer = offer + } + case strings.HasSuffix(offer, "/*"): + if strings.HasPrefix(spec.Value, offer[:len(offer)-1]) && + (spec.Q > bestQ || bestWild > 1) { + bestQ = spec.Q + bestWild = 1 + bestOffer = spec.Value + } + default: + if spec.Value == offer && + (spec.Q > bestQ || bestWild > 0) { + bestQ = spec.Q + bestWild = 0 + bestOffer = offer + } + } + } + } + return bestOffer +}