Ver Fonte

feat(response): support weak ETag comparison for If-None-Match header

Frédéric Guillot há 2 semanas atrás
pai
commit
b68d1410e4
2 ficheiros alterados com 120 adições e 34 exclusões
  1. 24 1
      internal/http/response/builder.go
  2. 96 33
      internal/http/response/builder_test.go

+ 24 - 1
internal/http/response/builder.go

@@ -71,11 +71,12 @@ func (b *Builder) WithoutCompression() *Builder {
 
 // WithCaching adds caching headers to the response.
 func (b *Builder) WithCaching(etag string, duration time.Duration, callback func(*Builder)) {
+	etag = normalizeETag(etag)
 	b.headers["ETag"] = etag
 	b.headers["Cache-Control"] = "public"
 	b.headers["Expires"] = time.Now().Add(duration).UTC().Format(http.TimeFormat)
 
-	if etag == b.r.Header.Get("If-None-Match") {
+	if ifNoneMatch(b.r.Header.Get("If-None-Match"), etag) {
 		b.statusCode = http.StatusNotModified
 		b.body = nil
 		b.Write()
@@ -154,6 +155,28 @@ func (b *Builder) compress(data []byte) {
 	b.w.Write(data)
 }
 
+func normalizeETag(etag string) string {
+	etag = strings.TrimSpace(etag)
+	if etag == "" {
+		return ""
+	}
+	if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/"`) {
+		return etag
+	}
+	return `"` + etag + `"`
+}
+
+func ifNoneMatch(headerValue, etag string) bool {
+	if headerValue == "" || etag == "" {
+		return false
+	}
+	if strings.TrimSpace(headerValue) == "*" {
+		return true
+	}
+	// Weak ETag comparison: the opaque-tag (quoted string without W/ prefix) must match.
+	return strings.Contains(headerValue, strings.TrimPrefix(etag, `W/`))
+}
+
 // New creates a new response builder.
 func New(w http.ResponseWriter, r *http.Request) *Builder {
 	return &Builder{w: w, r: r, statusCode: http.StatusOK, headers: make(map[string]string), enableCompression: true}

+ 96 - 33
internal/http/response/builder_test.go

@@ -161,49 +161,112 @@ func TestBuildResponseWithCachingEnabled(t *testing.T) {
 		t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
 	}
 
+	if actualETag := resp.Header.Get("ETag"); actualETag != `"etag"` {
+		t.Fatalf(`Unexpected etag header, got %q instead of %q`, actualETag, `"etag"`)
+	}
+
 	if resp.Header.Get("Expires") == "" {
 		t.Fatalf(`Expires header should not be empty`)
 	}
 }
 
-func TestBuildResponseWithCachingAndEtag(t *testing.T) {
-	r, err := http.NewRequest("GET", "/", nil)
-	r.Header.Set("If-None-Match", "etag")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	w := httptest.NewRecorder()
-
-	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
-			b.WithBodyAsString("cached body")
-			b.Write()
+func TestBuildResponseWithCachingAndIfNoneMatch(t *testing.T) {
+	tests := []struct {
+		name           string
+		ifNoneMatch    string
+		expectedStatus int
+		expectedBody   string
+	}{
+		{"matching strong etag", `"etag"`, http.StatusNotModified, ""},
+		{"matching weak etag", `W/"etag"`, http.StatusNotModified, ""},
+		{"multiple etags with match", `"other", W/"etag"`, http.StatusNotModified, ""},
+		{"wildcard", `*`, http.StatusNotModified, ""},
+		{"non-matching etag", `"different"`, http.StatusOK, "cached body"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r, err := http.NewRequest("GET", "/", nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			r.Header.Set("If-None-Match", tt.ifNoneMatch)
+
+			w := httptest.NewRecorder()
+
+			handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				New(w, r).WithCaching("etag", 1*time.Minute, func(b *Builder) {
+					b.WithBodyAsString("cached body")
+					b.Write()
+				})
+			})
+
+			handler.ServeHTTP(w, r)
+			resp := w.Result()
+
+			if resp.StatusCode != tt.expectedStatus {
+				t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, tt.expectedStatus)
+			}
+
+			if actual := w.Body.String(); actual != tt.expectedBody {
+				t.Fatalf(`Unexpected body, got %q instead of %q`, actual, tt.expectedBody)
+			}
+
+			if resp.Header.Get("Cache-Control") != "public" {
+				t.Fatalf(`Unexpected Cache-Control header: %q`, resp.Header.Get("Cache-Control"))
+			}
+
+			if resp.Header.Get("Expires") == "" {
+				t.Fatalf(`Expires header should not be empty`)
+			}
 		})
-	})
-
-	handler.ServeHTTP(w, r)
-	resp := w.Result()
-
-	expectedStatusCode := http.StatusNotModified
-	if resp.StatusCode != expectedStatusCode {
-		t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
-	}
-
-	expectedBody := ``
-	actualBody := w.Body.String()
-	if actualBody != expectedBody {
-		t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
 	}
+}
 
-	expectedHeader := "public"
-	actualHeader := resp.Header.Get("Cache-Control")
-	if actualHeader != expectedHeader {
-		t.Fatalf(`Unexpected cache control header, got %q instead of %q`, actualHeader, expectedHeader)
+func TestNormalizeETag(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"abc", `"abc"`},
+		{`"already-quoted"`, `"already-quoted"`},
+		{`W/"weak"`, `W/"weak"`},
+		{"", ""},
+		{"  spaced  ", `"spaced"`},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			if actual := normalizeETag(tt.input); actual != tt.expected {
+				t.Fatalf(`normalizeETag(%q) = %q, want %q`, tt.input, actual, tt.expected)
+			}
+		})
 	}
+}
 
-	if resp.Header.Get("Expires") == "" {
-		t.Fatalf(`Expires header should not be empty`)
+func TestIfNoneMatch(t *testing.T) {
+	tests := []struct {
+		name        string
+		headerValue string
+		etag        string
+		expected    bool
+	}{
+		{"empty header", "", `"etag"`, false},
+		{"empty etag", `"etag"`, "", false},
+		{"exact match", `"etag"`, `"etag"`, true},
+		{"weak vs strong match", `W/"etag"`, `"etag"`, true},
+		{"wildcard", `*`, `"etag"`, true},
+		{"no match", `"other"`, `"etag"`, false},
+		{"match in list", `"a", "etag", "b"`, `"etag"`, true},
+		{"no match in list", `"a", "b", "c"`, `"etag"`, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if actual := ifNoneMatch(tt.headerValue, tt.etag); actual != tt.expected {
+				t.Fatalf(`ifNoneMatch(%q, %q) = %v, want %v`, tt.headerValue, tt.etag, actual, tt.expected)
+			}
+		})
 	}
 }