grpc-ecosystem / grpc-gateway

gRPC to JSON proxy generator following the gRPC HTTP spec
https://grpc-ecosystem.github.io/grpc-gateway/
BSD 3-Clause "New" or "Revised" License
18.24k stars 2.24k forks source link

Add support for Etag & If-None-Match headers #4263

Open joshgarnett opened 6 months ago

joshgarnett commented 6 months ago

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.

johanbrandhorst commented 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.

joshgarnett commented 6 months ago

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),
}
joshgarnett commented 6 months ago

Given the short comings of using a custom handler, would you be open to this functionality being added behind a ServerMuxOption?

joshgarnett commented 6 months ago

What the change looks like when put behind an option https://github.com/joshgarnett/grpc-gateway/commit/d1499d3fab638d82a4e1a6aec68931fd2531c1cb

joshgarnett commented 6 months ago

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),
}
johanbrandhorst commented 6 months ago

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?