diff --git a/caddyhttp/caddyhttp.go b/caddyhttp/caddyhttp.go index 0bda8d096..8dacb0606 100644 --- a/caddyhttp/caddyhttp.go +++ b/caddyhttp/caddyhttp.go @@ -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" diff --git a/caddyhttp/caddyhttp_test.go b/caddyhttp/caddyhttp_test.go index f8a5331cc..59857beba 100644 --- a/caddyhttp/caddyhttp_test.go +++ b/caddyhttp/caddyhttp_test.go @@ -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) diff --git a/caddyhttp/gzip/gzip.go b/caddyhttp/gzip/gzip.go index 29fccac7f..d2a790a6a 100644 --- a/caddyhttp/gzip/gzip.go +++ b/caddyhttp/gzip/gzip.go @@ -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) diff --git a/caddyhttp/header/header.go b/caddyhttp/header/header.go index 9421ea1e0..c2c3fad8d 100644 --- a/caddyhttp/header/header.go +++ b/caddyhttp/header/header.go @@ -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) diff --git a/caddyhttp/httpserver/plugin.go b/caddyhttp/httpserver/plugin.go index 04ab9c5c4..7736fcf36 100644 --- a/caddyhttp/httpserver/plugin.go +++ b/caddyhttp/httpserver/plugin.go @@ -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", diff --git a/caddyhttp/httpserver/recorder.go b/caddyhttp/httpserver/recorder.go index c893f51fa..f9f70bccd 100644 --- a/caddyhttp/httpserver/recorder.go +++ b/caddyhttp/httpserver/recorder.go @@ -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) diff --git a/caddyhttp/httpserver/server.go b/caddyhttp/httpserver/server.go index b1ba8e2b9..b686b5d79 100644 --- a/caddyhttp/httpserver/server.go +++ b/caddyhttp/httpserver/server.go @@ -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 diff --git a/caddyhttp/push/handler.go b/caddyhttp/push/handler.go new file mode 100644 index 000000000..532e7a2b0 --- /dev/null +++ b/caddyhttp/push/handler.go @@ -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 + } + } +} diff --git a/caddyhttp/push/handler_test.go b/caddyhttp/push/handler_test.go new file mode 100644 index 000000000..ad6802459 --- /dev/null +++ b/caddyhttp/push/handler_test.go @@ -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", "; rel=preload; as=stylesheet;") + w.Header().Add("Link", "; rel=preload; as=stylesheet;") + w.Header().Add("Link", "") + w.Header().Add("Link", "") + w.Header().Add("Link", "; 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", "; rel=preload; as=stylesheet;") + w.Header().Add("Link", "; 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) + } + } +} diff --git a/caddyhttp/push/push.go b/caddyhttp/push/push.go new file mode 100644 index 000000000..b2063bd72 --- /dev/null +++ b/caddyhttp/push/push.go @@ -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) +) diff --git a/caddyhttp/push/setup.go b/caddyhttp/push/setup.go new file mode 100644 index 000000000..e13d77d0c --- /dev/null +++ b/caddyhttp/push/setup.go @@ -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 +} diff --git a/caddyhttp/push/setup_test.go b/caddyhttp/push/setup_test.go new file mode 100644 index 000000000..871b2ea4b --- /dev/null +++ b/caddyhttp/push/setup_test.go @@ -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") + } +}