Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
joshgarnett opened this issue Apr 26, 2024 · 6 comments
Open

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

joshgarnett opened this issue Apr 26, 2024 · 6 comments

Comments

@joshgarnett
Copy link
Contributor

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
Copy link
Collaborator

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
Copy link
Contributor Author

joshgarnett commented Apr 27, 2024

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
Copy link
Contributor Author

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

@joshgarnett
Copy link
Contributor Author

What the change looks like when put behind an option joshgarnett@d1499d3

@joshgarnett
Copy link
Contributor Author

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
Copy link
Collaborator

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants