Open joshgarnett opened 6 months ago
Thanks for your issue 😁. I'm sympathetic to this request, but this is major change in behavior (and role) of the gateway. If you want to avoid writing the body to the client, you can create a custom responseWriter that replaces the real writer with io.Discard
if the Etag header is set. I'd prefer this was something we documented rather than implemented directly.
I've whipped up an example that appears to work with a custom responseWriter. The main downside is the ForwardResponseOption needs to marshal the message to a byte array. Also, the option doesn't have access to the request, so it can't limit writing etags to GET requests.
I know it would be another big change, but it would be really nice if a ForwardResponseOption could have access to the marshaled message and the request.
Here is what the code looks like:
import (
"context"
"crypto/md5"
"encoding/hex"
"net/http"
"google.golang.org/protobuf/proto"
)
type etagWriter struct {
http.ResponseWriter
wroteHeader bool
ifNoneMatch string
}
func (w *etagWriter) Write(b []byte) (int, error) {
etag := w.Header().Get("Etag")
if !w.wroteHeader && w.ifNoneMatch == etag {
w.ResponseWriter.WriteHeader(http.StatusNotModified)
return 0, nil
} else {
return w.ResponseWriter.Write(b)
}
}
func (w *etagWriter) WriteHeader(code int) {
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
// Unwrap returns the original http.ResponseWriter. This is necessary
// to expose Flush() and Push() on the underlying response writer.
func (w *etagWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// IfNoneMatchHandler wraps an http.Handler and will return a NotModified
// response if the If-None-Match header matches the Etag header.
func IfNoneMatchHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ifNoneMatch := r.Header.Get("If-None-Match")
if ifNoneMatch != "" {
w = &etagWriter{
ResponseWriter: w,
ifNoneMatch: ifNoneMatch,
}
}
h.ServeHTTP(w, r)
})
}
func ForwardResponseWithEtag(_ context.Context, w http.ResponseWriter, m proto.Message) error {
// NOTE: Unfortunately we have to serialize the protobuf
data, err := proto.Marshal(m)
if err != nil {
return err
}
// NOTE: We don't have access to the request, so this can't be limited to just GET methods
if len(data) > 100 {
h := md5.New()
h.Write(data)
etag := hex.EncodeToString(h.Sum(nil))
w.Header().Set("Etag", "\""+etag+"\"")
}
return nil
}
Usage code looks like:
mux := runtime.NewServeMux(runtime.WithForwardResponseOption(ForwardResponseWithEtag))
// Register generated gateway handlers
s := &http.Server{
Handler: IfNoneMatchHandler(mux),
}
Given the short comings of using a custom handler, would you be open to this functionality being added behind a ServerMuxOption?
What the change looks like when put behind an option https://github.com/joshgarnett/grpc-gateway/commit/d1499d3fab638d82a4e1a6aec68931fd2531c1cb
Alright, I thought through this some more over coffee this morning. I've rewritten the example code so it doesn't suffer from the problems I highlighted. This could be added to the documentation.
import (
"crypto/md5"
"encoding/hex"
"fmt"
"net/http"
)
type etagWriter struct {
http.ResponseWriter
wroteHeader bool
ifNoneMatch string
writeEtag bool
minBytes int
}
func (w etagWriter) Write(b []byte) (int, error) {
if w.wroteHeader || !w.writeEtag || len(b) < w.minBytes {
return w.ResponseWriter.Write(b)
}
// Generate the Etag
h := md5.New()
h.Write(b)
etag := fmt.Sprintf("\"%s\"", hex.EncodeToString(h.Sum(nil)))
w.Header().Set("Etag", etag)
if w.ifNoneMatch != "" && w.ifNoneMatch == etag {
w.ResponseWriter.WriteHeader(http.StatusNotModified)
return 0, nil
} else {
return w.ResponseWriter.Write(b)
}
}
func (w etagWriter) WriteHeader(code int) {
// Track if the headers have already been written
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
// Unwrap returns the original http.ResponseWriter. This is necessary
// to expose Flush() and Push() on the underlying response writer.
func (w etagWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// EtagHandler wraps an http.Handler and will write an Etag header to the
// response if the request method is GET and the response size is greater
// than or equal to minBytes. It will also return a NotModified response
// if the If-None-Match header matches the Etag header.
func EtagHandler(h http.Handler, minBytes int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w = etagWriter{
ResponseWriter: w,
ifNoneMatch: r.Header.Get("If-None-Match"),
writeEtag: r.Method == http.MethodGet,
minBytes: minBytes,
}
h.ServeHTTP(w, r)
})
}
Usage code:
mux := runtime.NewServeMux()
// Register generated gateway handlers
s := &http.Server{
Handler: EtagHandler(mux, 100),
}
Thanks a lot! This would make an excellent addition to our docs pages, perhaps a new page in our operations
or mapping
folders: https://github.com/grpc-ecosystem/grpc-gateway/tree/main/docs/docs?
Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag: "The ETag (or entity tag) HTTP response header is an identifier for a specific version of a resource. It lets caches be more efficient and save bandwidth, as a web server does not need to resend a full response if the content was not changed."
CDNs & Clients can send the Etag in subsequent requests as the If-None-Match header. The server can then skip writing the response if the header matches the Etag of the response. For large responses and clients on poor networks, this can help out a lot.
This can't be implemented with a WithForwardResponseOption, since that doesn't allow you to stop the ForwardResponseMessage method from writing the message to the client.