Skip to content

Commit

Permalink
encode: use ReponseMatcher for conditional encoding of content
Browse files Browse the repository at this point in the history
  • Loading branch information
ueffel committed Mar 3, 2021
1 parent 83f5795 commit b1fdbf0
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 45 deletions.
123 changes: 111 additions & 12 deletions modules/caddyhttp/encode/caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
package encode

import (
"fmt"
"net/http"
"strconv"
"strings"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
Expand Down Expand Up @@ -45,20 +46,25 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
// zstd
// minimum_length <length>
// prefer <formats...>
// types <glob-patterns...>
// match <matcher>
// @name {
// status <code...>
// header <field> [<value>]
// }
// }
//
// Specifying the formats on the first line will use those formats' defaults.
func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
responseMatchers := map[string]caddyhttp.ResponseMatcher{}
for d.Next() {
for _, arg := range d.RemainingArgs() {
mod, err := caddy.GetModule("http.encoders." + arg)
if err != nil {
return fmt.Errorf("finding encoder module '%s': %v", mod, err)
return d.Errf("finding encoder module '%s': %v", mod, err)
}
encoding, ok := mod.New().(Encoding)
if !ok {
return fmt.Errorf("module %s is not an HTTP encoding", mod)
return d.Errf("module %s is not an HTTP encoding", mod)
}
if enc.EncodingsRaw == nil {
enc.EncodingsRaw = make(caddy.ModuleMap)
Expand All @@ -68,6 +74,17 @@ func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {

for d.NextBlock(0) {
name := d.Val()

// if the subdirective has an "@" prefix then we
// parse it as a response matcher for use with "match"
if strings.HasPrefix(name, matcherPrefix) {
err := enc.parseNamedResponseMatcher(d.NewFromNextSegment(), responseMatchers)
if err != nil {
return err
}
continue
}

switch name {
case "minimum_length":
if !d.NextArg() {
Expand All @@ -87,15 +104,30 @@ func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.ArgErr()
}
enc.Prefer = encs
case "types":
var types []string
for d.NextArg() {
types = append(types, d.Val())
case "match":
var matcher *caddyhttp.ResponseMatcher
args := d.RemainingArgs()
// the first arg should be a matcher (optional)
// the second arg should be a status code (optional)
// any more than that isn't currently supported
if len(args) > 1 {
return d.Errf("too many arguments for 'if_matches': %s", args)
}
if len(types) == 0 {
return d.ArgErr()

// the first arg should always be a matcher.
// it doesn't really make sense to support status code without a matcher.
if len(args) > 0 {
if !strings.HasPrefix(args[0], matcherPrefix) {
return d.Errf("must use a named response matcher, starting with '@'")
}

foundMatcher, ok := responseMatchers[args[0]]
if !ok {
return d.Errf("no named response matcher defined with name '%s'", args[0][1:])
}
matcher = &foundMatcher
}
enc.Types = types
enc.Matcher = matcher
default:
modID := "http.precompressed." + name
unm, err := caddyfile.UnmarshalModule(d, modID)
Expand All @@ -104,7 +136,7 @@ func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
encoding, ok := unm.(Encoding)
if !ok {
return fmt.Errorf("module %s is not an HTTP encoding; is %T", modID, unm)
return d.Errf("module %s is not an HTTP encoding; is %T", modID, unm)
}
if enc.EncodingsRaw == nil {
enc.EncodingsRaw = make(caddy.ModuleMap)
Expand All @@ -117,5 +149,72 @@ func (enc *Encode) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}

// Parse the tokens of a named response matcher.
//
// @name {
// header <field> [<value>]
// status <code...>
// }
//
// Or, single line syntax:
//
// @name [header <field> [<value>]] | [status <code...>]
//
func (enc *Encode) parseNamedResponseMatcher(d *caddyfile.Dispenser, matchers map[string]caddyhttp.ResponseMatcher) error {
for d.Next() {
definitionName := d.Val()

if _, ok := matchers[definitionName]; ok {
return d.Errf("matcher is defined more than once: %s", definitionName)
}

matcher := caddyhttp.ResponseMatcher{}
for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); {
switch d.Val() {
case "header":
if matcher.Headers == nil {
matcher.Headers = http.Header{}
}

// reuse the header request matcher's unmarshaler
headerMatcher := caddyhttp.MatchHeader(matcher.Headers)
err := headerMatcher.UnmarshalCaddyfile(d.NewFromNextSegment())
if err != nil {
return err
}

matcher.Headers = http.Header(headerMatcher)
case "status":
if matcher.StatusCode == nil {
matcher.StatusCode = []int{}
}

args := d.RemainingArgs()
if len(args) == 0 {
return d.ArgErr()
}

for _, arg := range args {
if len(arg) == 3 && strings.HasSuffix(arg, "xx") {
arg = arg[:1]
}
statusNum, err := strconv.Atoi(arg)
if err != nil {
return d.Errf("bad status value '%s': %v", arg, err)
}
matcher.StatusCode = append(matcher.StatusCode, statusNum)
}
default:
return d.Errf("unrecognized response matcher %s", d.Val())
}
}

matchers[definitionName] = matcher
}
return nil
}

const matcherPrefix = "@"

// Interface guard
var _ caddyfile.Unmarshaler = (*Encode)(nil)
57 changes: 24 additions & 33 deletions modules/caddyhttp/encode/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"io"
"math"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
Expand All @@ -51,8 +50,11 @@ type Encode struct {
// Only encode responses that are at least this many bytes long.
MinLength int `json:"minimum_length,omitempty"`

// Only encode responses that match at least one of the content-types.
Types []string `json:"types,omitempty"`
Matcher *caddyhttp.ResponseMatcher

// collect the response matchers defined as subdirectives prefixed with "@"
// for use with "handle_response" blocks
responseMatchers map[string]caddyhttp.ResponseMatcher

writerPools map[string]*sync.Pool // TODO: these pools do not get reused through config reloads...
}
Expand Down Expand Up @@ -81,17 +83,21 @@ func (enc *Encode) Provision(ctx caddy.Context) error {
enc.MinLength = defaultMinLength
}

if len(enc.Types) == 0 {
enc.Types = []string{"*"} // backwards compatibility

// sane default for text-based content ?
// enc.Types = []string{
// "text/*",
// "application/json",
// "application/javascript",
// "application/*+xml",
// "image/svg+xml",
// }
if enc.Matcher == nil {
// common text-based content types
enc.Matcher = &caddyhttp.ResponseMatcher{
Headers: http.Header{
"Content-Type": []string{
"text/*",
"application/json",
"application/javascript",
"application/xhtml+xml",
"application/atom+xml",
"application/rss+xml",
"image/svg+xml",
},
},
}
}

return nil
Expand All @@ -111,13 +117,6 @@ func (enc *Encode) Validate() error {
check[encName] = true
}

for _, t := range enc.Types {
_, err := filepath.Match(t, "")
if err != nil {
return fmt.Errorf("invalid type pattern %s: %v", t, err)
}
}

return nil
}

Expand Down Expand Up @@ -193,17 +192,9 @@ func (rw *responseWriter) WriteHeader(status int) {
rw.statusCode = status
}

// MatchType determines if encoding should be done based on the Content-Type header
func (enc *Encode) MatchType(ctHeader string) bool {
ctParts := strings.Split(ctHeader, ";")
ct := strings.ToLower(strings.TrimSpace(ctParts[0]))
for _, t := range enc.Types {
matches, _ := filepath.Match(t, ct)
if matches {
return true
}
}
return false
// Match determines if encoding should be done based on the ResponseMatcher
func (enc *Encode) Match(rw *responseWriter) bool {
return enc.Matcher.Match(rw.statusCode, rw.Header())
}

// Write writes to the response. If the response qualifies,
Expand Down Expand Up @@ -298,7 +289,7 @@ func (rw *responseWriter) Close() error {
func (rw *responseWriter) init() {
if rw.Header().Get("Content-Encoding") == "" &&
rw.buf.Len() >= rw.config.MinLength &&
rw.config.MatchType(rw.Header().Get("Content-Type")) {
rw.config.Match(rw) {

rw.w = rw.config.writerPools[rw.encodingName].Get().(Encoder)
rw.w.Reset(rw.ResponseWriter)
Expand Down

0 comments on commit b1fdbf0

Please sign in to comment.