grpc-ecosystem / grpc-gateway

gRPC to JSON proxy generator following the gRPC HTTP spec

Home Page:https://grpc-ecosystem.github.io/grpc-gateway/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Add support for Etag & If-None-Match headers

joshgarnett opened this issue · comments

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.

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 joshgarnett@d1499d3

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?