mirror of
https://github.com/caddyserver/caddy.git
synced 2024-11-29 12:16:16 +08:00
HTTP/2 push support (golang 1.8) (#1215)
* WIP * HTTP2/Push for golang 1.8 * Push plugin completed for review * Correct build tag * Move push plugin position * Add build tags to tests * Gofmt that code * Add header/method validations * Load push plugin * Fixes for wrapping writers * Push after delivering file * Fixes, review changes * Remove build tags, support new syntax * Fix spelling * gofmt -s -w . * Gogland time * Add interface guards * gofmt * After review fixes
This commit is contained in:
parent
579007822f
commit
cdf7cf5c3f
|
@ -21,6 +21,7 @@ import (
|
|||
_ "github.com/mholt/caddy/caddyhttp/mime"
|
||||
_ "github.com/mholt/caddy/caddyhttp/pprof"
|
||||
_ "github.com/mholt/caddy/caddyhttp/proxy"
|
||||
_ "github.com/mholt/caddy/caddyhttp/push"
|
||||
_ "github.com/mholt/caddy/caddyhttp/redirect"
|
||||
_ "github.com/mholt/caddy/caddyhttp/rewrite"
|
||||
_ "github.com/mholt/caddy/caddyhttp/root"
|
||||
|
|
|
@ -11,7 +11,7 @@ import (
|
|||
// ensure that the standard plugins are in fact plugged in
|
||||
// and registered properly; this is a quick/naive way to do it.
|
||||
func TestStandardPlugins(t *testing.T) {
|
||||
numStandardPlugins := 29 // importing caddyhttp plugs in this many plugins
|
||||
numStandardPlugins := 30 // importing caddyhttp plugs in this many plugins
|
||||
s := caddy.DescribePlugins()
|
||||
if got, want := strings.Count(s, "\n"), numStandardPlugins+5; got != want {
|
||||
t.Errorf("Expected all standard plugins to be plugged in, got:\n%s", s)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"errors"
|
||||
"github.com/mholt/caddy"
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
@ -161,3 +162,17 @@ func (w *gzipResponseWriter) CloseNotify() <-chan bool {
|
|||
}
|
||||
panic(httpserver.NonCloseNotifierError{Underlying: w.ResponseWriter})
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||
if pusher, hasPusher := w.ResponseWriter.(http.Pusher); hasPusher {
|
||||
return pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
return errors.New("push is unavailable (probably chained http.ResponseWriter does not implement http.Pusher)")
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var _ http.Pusher = (*gzipResponseWriter)(nil)
|
||||
var _ http.Flusher = (*gzipResponseWriter)(nil)
|
||||
var _ http.CloseNotifier = (*gzipResponseWriter)(nil)
|
||||
var _ http.Hijacker = (*gzipResponseWriter)(nil)
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"errors"
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
|
@ -23,7 +24,7 @@ type Headers struct {
|
|||
// setting headers on the response according to the configured rules.
|
||||
func (h Headers) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
replacer := httpserver.NewReplacer(r, nil, "")
|
||||
rww := &responseWriterWrapper{w: w}
|
||||
rww := &responseWriterWrapper{ResponseWriter: w}
|
||||
for _, rule := range h.Rules {
|
||||
if httpserver.Path(r.URL.Path).Matches(rule.Path) {
|
||||
for name := range rule.Headers {
|
||||
|
@ -62,20 +63,20 @@ type headerOperation func(http.Header)
|
|||
// responseWriterWrapper wraps the real ResponseWriter.
|
||||
// It defers header operations until writeHeader
|
||||
type responseWriterWrapper struct {
|
||||
w http.ResponseWriter
|
||||
http.ResponseWriter
|
||||
ops []headerOperation
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rww *responseWriterWrapper) Header() http.Header {
|
||||
return rww.w.Header()
|
||||
return rww.ResponseWriter.Header()
|
||||
}
|
||||
|
||||
func (rww *responseWriterWrapper) Write(d []byte) (int, error) {
|
||||
if !rww.wroteHeader {
|
||||
rww.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return rww.w.Write(d)
|
||||
return rww.ResponseWriter.Write(d)
|
||||
}
|
||||
|
||||
func (rww *responseWriterWrapper) WriteHeader(status int) {
|
||||
|
@ -91,7 +92,7 @@ func (rww *responseWriterWrapper) WriteHeader(status int) {
|
|||
op(h)
|
||||
}
|
||||
|
||||
rww.w.WriteHeader(status)
|
||||
rww.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// delHeader deletes the existing header according to the key
|
||||
|
@ -109,19 +110,19 @@ func (rww *responseWriterWrapper) delHeader(key string) {
|
|||
// Hijack implements http.Hijacker. It simply wraps the underlying
|
||||
// ResponseWriter's Hijack method if there is one, or returns an error.
|
||||
func (rww *responseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if hj, ok := rww.w.(http.Hijacker); ok {
|
||||
if hj, ok := rww.ResponseWriter.(http.Hijacker); ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
return nil, nil, httpserver.NonHijackerError{Underlying: rww.w}
|
||||
return nil, nil, httpserver.NonHijackerError{Underlying: rww.ResponseWriter}
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher. It simply wraps the underlying
|
||||
// ResponseWriter's Flush method if there is one, or panics.
|
||||
func (rww *responseWriterWrapper) Flush() {
|
||||
if f, ok := rww.w.(http.Flusher); ok {
|
||||
if f, ok := rww.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
} else {
|
||||
panic(httpserver.NonFlusherError{Underlying: rww.w}) // should be recovered at the beginning of middleware stack
|
||||
panic(httpserver.NonFlusherError{Underlying: rww.ResponseWriter}) // should be recovered at the beginning of middleware stack
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,8 +130,22 @@ func (rww *responseWriterWrapper) Flush() {
|
|||
// It just inherits the underlying ResponseWriter's CloseNotify method.
|
||||
// It panics if the underlying ResponseWriter is not a CloseNotifier.
|
||||
func (rww *responseWriterWrapper) CloseNotify() <-chan bool {
|
||||
if cn, ok := rww.w.(http.CloseNotifier); ok {
|
||||
if cn, ok := rww.ResponseWriter.(http.CloseNotifier); ok {
|
||||
return cn.CloseNotify()
|
||||
}
|
||||
panic(httpserver.NonCloseNotifierError{Underlying: rww.w})
|
||||
panic(httpserver.NonCloseNotifierError{Underlying: rww.ResponseWriter})
|
||||
}
|
||||
|
||||
func (rww *responseWriterWrapper) Push(target string, opts *http.PushOptions) error {
|
||||
if pusher, hasPusher := rww.ResponseWriter.(http.Pusher); hasPusher {
|
||||
return pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
return errors.New("push is unavailable (probably chained http.ResponseWriter does not implement http.Pusher)")
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var _ http.Pusher = (*responseWriterWrapper)(nil)
|
||||
var _ http.Flusher = (*responseWriterWrapper)(nil)
|
||||
var _ http.CloseNotifier = (*responseWriterWrapper)(nil)
|
||||
var _ http.Hijacker = (*responseWriterWrapper)(nil)
|
||||
|
|
|
@ -459,6 +459,7 @@ var directives = []string{
|
|||
"proxy",
|
||||
"fastcgi",
|
||||
"cgi", // github.com/jung-kurt/caddy-cgi
|
||||
"push",
|
||||
"websocket",
|
||||
"filemanager", // github.com/hacdias/caddy-filemanager
|
||||
"markdown",
|
||||
|
|
|
@ -2,6 +2,7 @@ package httpserver
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
@ -95,3 +96,18 @@ func (r *ResponseRecorder) CloseNotify() <-chan bool {
|
|||
}
|
||||
panic(NonCloseNotifierError{Underlying: r.ResponseWriter})
|
||||
}
|
||||
|
||||
// Push resource to client
|
||||
func (r *ResponseRecorder) Push(target string, opts *http.PushOptions) error {
|
||||
if pusher, hasPusher := r.ResponseWriter.(http.Pusher); hasPusher {
|
||||
return pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
return errors.New("push is unavailable (probably chained http.ResponseWriter does not implement http.Pusher)")
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var _ http.Pusher = (*ResponseRecorder)(nil)
|
||||
var _ http.Flusher = (*ResponseRecorder)(nil)
|
||||
var _ http.CloseNotifier = (*ResponseRecorder)(nil)
|
||||
var _ http.Hijacker = (*ResponseRecorder)(nil)
|
||||
|
|
|
@ -45,6 +45,7 @@ func NewServer(addr string, group []*SiteConfig) (*Server, error) {
|
|||
sites: group,
|
||||
connTimeout: GracefulTimeout,
|
||||
}
|
||||
|
||||
s.Server.Handler = s // this is weird, but whatever
|
||||
|
||||
// Disable HTTP/2 if desired
|
||||
|
|
70
caddyhttp/push/handler.go
Normal file
70
caddyhttp/push/handler.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
func (h Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
|
||||
pusher, hasPusher := w.(http.Pusher)
|
||||
|
||||
// No Pusher, no cry
|
||||
if !hasPusher {
|
||||
return h.Next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// This is request for the pushed resource - it should not be recursive
|
||||
if _, exists := r.Header[pushHeader]; exists {
|
||||
return h.Next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Serve file first
|
||||
code, err := h.Next.ServeHTTP(w, r)
|
||||
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
outer:
|
||||
for _, rule := range h.Rules {
|
||||
if httpserver.Path(r.URL.Path).Matches(rule.Path) {
|
||||
for _, resource := range rule.Resources {
|
||||
pushErr := pusher.Push(resource.Path, &http.PushOptions{
|
||||
Method: resource.Method,
|
||||
Header: resource.Header,
|
||||
})
|
||||
if pushErr != nil {
|
||||
// If we cannot push (either not supported or concurrent streams are full - break)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if links, exists := w.Header()["Link"]; exists {
|
||||
h.pushLinks(pusher, links)
|
||||
}
|
||||
|
||||
return code, err
|
||||
}
|
||||
|
||||
func (h Middleware) pushLinks(pusher http.Pusher, links []string) {
|
||||
outer:
|
||||
for _, link := range links {
|
||||
parts := strings.Split(link, ";")
|
||||
|
||||
if link == "" || strings.HasSuffix(link, "nopush") {
|
||||
continue
|
||||
}
|
||||
|
||||
target := strings.TrimSuffix(strings.TrimPrefix(parts[0], "<"), ">")
|
||||
|
||||
err := pusher.Push(target, &http.PushOptions{Method: http.MethodGet})
|
||||
if err != nil {
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
282
caddyhttp/push/handler_test.go
Normal file
282
caddyhttp/push/handler_test.go
Normal file
|
@ -0,0 +1,282 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
type MockedPusher struct {
|
||||
http.ResponseWriter
|
||||
pushed map[string]*http.PushOptions
|
||||
returnedError error
|
||||
}
|
||||
|
||||
func (w *MockedPusher) Push(target string, options *http.PushOptions) error {
|
||||
if w.pushed == nil {
|
||||
w.pushed = make(map[string]*http.PushOptions)
|
||||
}
|
||||
|
||||
w.pushed[target] = options
|
||||
return w.returnedError
|
||||
}
|
||||
|
||||
func TestMiddlewareWillPushResources(t *testing.T) {
|
||||
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{
|
||||
{Path: "/index.html", Resources: []Resource{
|
||||
{Path: "/index.css", Method: http.MethodHead, Header: http.Header{"Test": []string{"Value"}}},
|
||||
{Path: "/index2.css", Method: http.MethodGet},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
pushingWriter := &MockedPusher{ResponseWriter: writer}
|
||||
|
||||
// when
|
||||
middleware.ServeHTTP(pushingWriter, request)
|
||||
|
||||
// then
|
||||
expectedPushedResources := map[string]*http.PushOptions{
|
||||
"/index.css": {
|
||||
Method: http.MethodHead,
|
||||
Header: http.Header{"Test": []string{"Value"}},
|
||||
},
|
||||
|
||||
"/index2.css": {
|
||||
Method: http.MethodGet,
|
||||
Header: nil,
|
||||
},
|
||||
}
|
||||
|
||||
comparePushedResources(t, expectedPushedResources, pushingWriter.pushed)
|
||||
}
|
||||
|
||||
func TestMiddlewareShouldntDoRecursivePush(t *testing.T) {
|
||||
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.css", nil)
|
||||
request.Header.Add(pushHeader, "")
|
||||
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{
|
||||
{Path: "/", Resources: []Resource{
|
||||
{Path: "/index.css", Method: http.MethodHead, Header: http.Header{"Test": []string{"Value"}}},
|
||||
{Path: "/index2.css", Method: http.MethodGet},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
pushingWriter := &MockedPusher{ResponseWriter: writer}
|
||||
|
||||
// when
|
||||
middleware.ServeHTTP(pushingWriter, request)
|
||||
|
||||
// then
|
||||
if len(pushingWriter.pushed) > 0 {
|
||||
t.Errorf("Expected 0 pushed resources, actual %d", len(pushingWriter.pushed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareShouldStopPushingOnError(t *testing.T) {
|
||||
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{
|
||||
{Path: "/index.html", Resources: []Resource{
|
||||
{Path: "/only.css", Method: http.MethodHead, Header: http.Header{"Test": []string{"Value"}}},
|
||||
{Path: "/index2.css", Method: http.MethodGet},
|
||||
{Path: "/index3.css", Method: http.MethodGet},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
pushingWriter := &MockedPusher{ResponseWriter: writer, returnedError: errors.New("Cannot push right now")}
|
||||
|
||||
// when
|
||||
middleware.ServeHTTP(pushingWriter, request)
|
||||
|
||||
// then
|
||||
expectedPushedResources := map[string]*http.PushOptions{
|
||||
"/only.css": {
|
||||
Method: http.MethodHead,
|
||||
Header: http.Header{"Test": []string{"Value"}},
|
||||
},
|
||||
}
|
||||
|
||||
comparePushedResources(t, expectedPushedResources, pushingWriter.pushed)
|
||||
}
|
||||
|
||||
func TestMiddlewareWillNotPushResources(t *testing.T) {
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{
|
||||
{Path: "/index.html", Resources: []Resource{
|
||||
{Path: "/index.css", Method: http.MethodHead, Header: http.Header{"Test": []string{"Value"}}},
|
||||
{Path: "/index2.css", Method: http.MethodGet},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
// when
|
||||
_, err2 := middleware.ServeHTTP(writer, request)
|
||||
|
||||
// then
|
||||
if err2 != nil {
|
||||
t.Errorf("Should not return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareShouldInterceptLinkHeader(t *testing.T) {
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
w.Header().Add("Link", "</index.css>; rel=preload; as=stylesheet;")
|
||||
w.Header().Add("Link", "</index2.css>; rel=preload; as=stylesheet;")
|
||||
w.Header().Add("Link", "")
|
||||
w.Header().Add("Link", "</index3.css>")
|
||||
w.Header().Add("Link", "</index4.css>; rel=preload; nopush")
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{},
|
||||
}
|
||||
|
||||
pushingWriter := &MockedPusher{ResponseWriter: writer}
|
||||
|
||||
// when
|
||||
_, err2 := middleware.ServeHTTP(pushingWriter, request)
|
||||
|
||||
// then
|
||||
if err2 != nil {
|
||||
t.Errorf("Should not return error")
|
||||
}
|
||||
|
||||
expectedPushedResources := map[string]*http.PushOptions{
|
||||
"/index.css": {
|
||||
Method: http.MethodGet,
|
||||
Header: nil,
|
||||
},
|
||||
"/index2.css": {
|
||||
Method: http.MethodGet,
|
||||
Header: nil,
|
||||
},
|
||||
"/index3.css": {
|
||||
Method: http.MethodGet,
|
||||
Header: nil,
|
||||
},
|
||||
}
|
||||
|
||||
comparePushedResources(t, expectedPushedResources, pushingWriter.pushed)
|
||||
}
|
||||
|
||||
func TestMiddlewareShouldInterceptLinkHeaderPusherError(t *testing.T) {
|
||||
// given
|
||||
request, err := http.NewRequest(http.MethodGet, "/index.html", nil)
|
||||
writer := httptest.NewRecorder()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Could not create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
middleware := Middleware{
|
||||
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
w.Header().Add("Link", "</index.css>; rel=preload; as=stylesheet;")
|
||||
w.Header().Add("Link", "</index2.css>; rel=preload; as=stylesheet;")
|
||||
return 0, nil
|
||||
}),
|
||||
Rules: []Rule{},
|
||||
}
|
||||
|
||||
pushingWriter := &MockedPusher{ResponseWriter: writer, returnedError: errors.New("Cannot push right now")}
|
||||
|
||||
// when
|
||||
_, err2 := middleware.ServeHTTP(pushingWriter, request)
|
||||
|
||||
// then
|
||||
if err2 != nil {
|
||||
t.Errorf("Should not return error")
|
||||
}
|
||||
|
||||
expectedPushedResources := map[string]*http.PushOptions{
|
||||
"/index.css": {
|
||||
Method: http.MethodGet,
|
||||
Header: nil,
|
||||
},
|
||||
}
|
||||
|
||||
comparePushedResources(t, expectedPushedResources, pushingWriter.pushed)
|
||||
}
|
||||
|
||||
func comparePushedResources(t *testing.T, expected, actual map[string]*http.PushOptions) {
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("Expected %d pushed resources, actual: %d", len(expected), len(actual))
|
||||
}
|
||||
|
||||
for target, expectedTarget := range expected {
|
||||
if actualTarget, exists := actual[target]; exists {
|
||||
|
||||
if expectedTarget.Method != actualTarget.Method {
|
||||
t.Errorf("Expected %s resource method to be %s, actual: %s", target, expectedTarget.Method, actualTarget.Method)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(expectedTarget.Header, actualTarget.Header) {
|
||||
t.Errorf("Expected %s resource push headers to be %v, actual: %v", target, expectedTarget.Header, actualTarget.Header)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected %s to be pushed", target)
|
||||
}
|
||||
}
|
||||
}
|
30
caddyhttp/push/push.go
Normal file
30
caddyhttp/push/push.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
type (
|
||||
// Rule describes conditions on which resources will be pushed
|
||||
Rule struct {
|
||||
Path string
|
||||
Resources []Resource
|
||||
}
|
||||
|
||||
// Resource describes resource to be pushed
|
||||
Resource struct {
|
||||
Path string
|
||||
Method string
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// Middleware supports pushing resources to clients
|
||||
Middleware struct {
|
||||
Next httpserver.Handler
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
ruleOp func([]Resource)
|
||||
)
|
172
caddyhttp/push/setup.go
Normal file
172
caddyhttp/push/setup.go
Normal file
|
@ -0,0 +1,172 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/caddy"
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
func init() {
|
||||
caddy.RegisterPlugin("push", caddy.Plugin{
|
||||
ServerType: "http",
|
||||
Action: setup,
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidHeader = errors.New("header directive requires [name] [value]")
|
||||
|
||||
var errHeaderStartsWithColon = errors.New("header cannot start with colon")
|
||||
var errMethodNotSupported = errors.New("push supports only GET and HEAD methods")
|
||||
|
||||
const pushHeader = "X-Push"
|
||||
|
||||
var emptyRules = []Rule{}
|
||||
|
||||
// setup configures a new Push middleware
|
||||
func setup(c *caddy.Controller) error {
|
||||
rules, err := parsePushRules(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||
return Middleware{Next: next, Rules: rules}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePushRules(c *caddy.Controller) ([]Rule, error) {
|
||||
var rules = make(map[string]*Rule)
|
||||
|
||||
for c.NextLine() {
|
||||
if !c.NextArg() {
|
||||
return emptyRules, c.ArgErr()
|
||||
}
|
||||
|
||||
path := c.Val()
|
||||
args := c.RemainingArgs()
|
||||
|
||||
var rule *Rule
|
||||
var resources []Resource
|
||||
var ops []ruleOp
|
||||
|
||||
if existingRule, ok := rules[path]; ok {
|
||||
rule = existingRule
|
||||
} else {
|
||||
rule = new(Rule)
|
||||
rule.Path = path
|
||||
rules[rule.Path] = rule
|
||||
}
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
resources = append(resources, Resource{
|
||||
Path: args[i],
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
})
|
||||
}
|
||||
|
||||
for c.NextBlock() {
|
||||
val := c.Val()
|
||||
|
||||
switch val {
|
||||
case "method":
|
||||
if !c.NextArg() {
|
||||
return emptyRules, c.ArgErr()
|
||||
}
|
||||
|
||||
method := c.Val()
|
||||
|
||||
if err := validateMethod(method); err != nil {
|
||||
return emptyRules, errMethodNotSupported
|
||||
}
|
||||
|
||||
ops = append(ops, setMethodOp(method))
|
||||
|
||||
case "header":
|
||||
args := c.RemainingArgs()
|
||||
|
||||
if len(args) != 2 {
|
||||
return emptyRules, errInvalidHeader
|
||||
}
|
||||
|
||||
if err := validateHeader(args[0]); err != nil {
|
||||
return emptyRules, err
|
||||
}
|
||||
|
||||
ops = append(ops, setHeaderOp(args[0], args[1]))
|
||||
|
||||
default:
|
||||
resources = append(resources, Resource{
|
||||
Path: val,
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
op(resources)
|
||||
}
|
||||
|
||||
rule.Resources = append(rule.Resources, resources...)
|
||||
}
|
||||
|
||||
var returnRules []Rule
|
||||
|
||||
for path, rule := range rules {
|
||||
if len(rule.Resources) == 0 {
|
||||
return emptyRules, c.Errf("Rule %s has empty push resources list", path)
|
||||
}
|
||||
|
||||
returnRules = append(returnRules, *rule)
|
||||
}
|
||||
|
||||
return returnRules, nil
|
||||
}
|
||||
|
||||
func setHeaderOp(key, value string) func(resources []Resource) {
|
||||
return func(resources []Resource) {
|
||||
for index := range resources {
|
||||
resources[index].Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setMethodOp(method string) func(resources []Resource) {
|
||||
|
||||
return func(resources []Resource) {
|
||||
for index := range resources {
|
||||
resources[index].Method = method
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateHeader(header string) error {
|
||||
if strings.HasPrefix(header, ":") {
|
||||
return errHeaderStartsWithColon
|
||||
}
|
||||
|
||||
switch strings.ToLower(header) {
|
||||
case "content-length", "content-encoding", "trailer", "te", "expect", "host":
|
||||
return fmt.Errorf("push headers cannot include %s", header)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rules based on https://go-review.googlesource.com/#/c/29439/4/http2/go18.go#94
|
||||
func validateMethod(method string) error {
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
return errMethodNotSupported
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
267
caddyhttp/push/setup_test.go
Normal file
267
caddyhttp/push/setup_test.go
Normal file
|
@ -0,0 +1,267 @@
|
|||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mholt/caddy"
|
||||
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||
)
|
||||
|
||||
func TestPushAvailable(t *testing.T) {
|
||||
err := setup(caddy.NewTestController("http", "push /index.html /available.css"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Error %s occurred, expected none", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
shouldErr bool
|
||||
expected []Rule
|
||||
}{
|
||||
{
|
||||
"ParseInvalidEmptyConfig", `push`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidConfig", `push /index.html`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidConfigBlock", `push /index.html /index.css {
|
||||
method
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidHeaderFormat", `push /index.html /index.css {
|
||||
header :invalid value
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseForbiddenHeader", `push /index.html /index.css {
|
||||
header Content-Length 1000
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidMethod", `push /index.html /index.css {
|
||||
method POST
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidHeaderBlock", `push /index.html /index.css {
|
||||
header
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseInvalidHeaderBlock2", `push /index.html /index.css {
|
||||
header name
|
||||
}`, true, []Rule{},
|
||||
},
|
||||
{
|
||||
"ParseProperConfig", `push /index.html /style.css /style2.css`, false, []Rule{
|
||||
{
|
||||
Path: "/index.html",
|
||||
Resources: []Resource{
|
||||
{
|
||||
Path: "/style.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
},
|
||||
{
|
||||
Path: "/style2.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ParseSimpleInlinePush", `push /index.html {
|
||||
/style.css
|
||||
/style2.css
|
||||
}`, false, []Rule{
|
||||
{
|
||||
Path: "/index.html",
|
||||
Resources: []Resource{
|
||||
{
|
||||
Path: "/style.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
},
|
||||
{
|
||||
Path: "/style2.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ParseSimpleInlinePushWithOps", `push /index.html {
|
||||
/style.css
|
||||
/style2.css
|
||||
header Test Value
|
||||
}`, false, []Rule{
|
||||
{
|
||||
Path: "/index.html",
|
||||
Resources: []Resource{
|
||||
{
|
||||
Path: "/style.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}, "Test": []string{"Value"}},
|
||||
},
|
||||
{
|
||||
Path: "/style2.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{pushHeader: []string{}, "Test": []string{"Value"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ParseProperConfigWithBlock", `push /index.html /style.css /style2.css {
|
||||
method HEAD
|
||||
header Own-Header Value
|
||||
header Own-Header2 Value2
|
||||
}`, false, []Rule{
|
||||
{
|
||||
Path: "/index.html",
|
||||
Resources: []Resource{
|
||||
{
|
||||
Path: "/style.css",
|
||||
Method: http.MethodHead,
|
||||
Header: http.Header{
|
||||
"Own-Header": []string{"Value"},
|
||||
"Own-Header2": []string{"Value2"},
|
||||
"X-Push": []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: "/style2.css",
|
||||
Method: http.MethodHead,
|
||||
Header: http.Header{
|
||||
"Own-Header": []string{"Value"},
|
||||
"Own-Header2": []string{"Value2"},
|
||||
"X-Push": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ParseMergesRules", `push /index.html /index.css {
|
||||
header name value
|
||||
}
|
||||
|
||||
push /index.html /index2.css {
|
||||
header name2 value2
|
||||
method HEAD
|
||||
}
|
||||
`, false, []Rule{
|
||||
{
|
||||
Path: "/index.html",
|
||||
Resources: []Resource{
|
||||
{
|
||||
Path: "/index.css",
|
||||
Method: http.MethodGet,
|
||||
Header: http.Header{
|
||||
"Name": []string{"value"},
|
||||
"X-Push": []string{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: "/index2.css",
|
||||
Method: http.MethodHead,
|
||||
Header: http.Header{
|
||||
"Name2": []string{"value2"},
|
||||
"X-Push": []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t2 *testing.T) {
|
||||
actual, err := parsePushRules(caddy.NewTestController("http", test.input))
|
||||
|
||||
if err == nil && test.shouldErr {
|
||||
t2.Errorf("Test %s didn't error, but it should have", test.name)
|
||||
} else if err != nil && !test.shouldErr {
|
||||
t2.Errorf("Test %s errored, but it shouldn't have; got '%v'", test.name, err)
|
||||
}
|
||||
|
||||
if len(actual) != len(test.expected) {
|
||||
t2.Fatalf("Test %s expected %d rules, but got %d",
|
||||
test.name, len(test.expected), len(actual))
|
||||
}
|
||||
|
||||
for j, expectedRule := range test.expected {
|
||||
actualRule := actual[j]
|
||||
|
||||
if actualRule.Path != expectedRule.Path {
|
||||
t.Errorf("Test %s, rule %d: Expected path %s, but got %s",
|
||||
test.name, j, expectedRule.Path, actualRule.Path)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actualRule.Resources, expectedRule.Resources) {
|
||||
t.Errorf("Test %s, rule %d: Expected resources %v, but got %v",
|
||||
test.name, j, expectedRule.Resources, actualRule.Resources)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupInstalledMiddleware(t *testing.T) {
|
||||
|
||||
// given
|
||||
c := caddy.NewTestController("http", `push /index.html /test.js`)
|
||||
|
||||
// when
|
||||
err := setup(c)
|
||||
|
||||
// then
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors, but got: %v", err)
|
||||
}
|
||||
|
||||
middlewares := httpserver.GetConfig(c).Middleware()
|
||||
|
||||
if len(middlewares) != 1 {
|
||||
t.Fatalf("Expected 1 middleware, had %d instead", len(middlewares))
|
||||
}
|
||||
|
||||
handler := middlewares[0](httpserver.EmptyNext)
|
||||
pushHandler, ok := handler.(Middleware)
|
||||
|
||||
if !ok {
|
||||
t.Fatalf("Expected handler to be type Middleware, got: %#v", handler)
|
||||
}
|
||||
|
||||
if !httpserver.SameNext(pushHandler.Next, httpserver.EmptyNext) {
|
||||
t.Error("'Next' field of handler Middleware was not set properly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupWithError(t *testing.T) {
|
||||
// given
|
||||
c := caddy.NewTestController("http", `push /index.html`)
|
||||
|
||||
// when
|
||||
err := setup(c)
|
||||
|
||||
// then
|
||||
if err == nil {
|
||||
t.Error("Expected error but none occurred")
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user