@marcel91200 / caddy Mirror · read-only / commits / 8ae0d6a5
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 14, 05:15 UTC

caddyhttp: Implement better HTTP matchers including regexp; add tests

Matthew Holt committed May 10, 2019 at 21:07 UTC 8ae0d6a509fd1b871457cf742369af04346933a8
5 files changed +519 -66
modules/caddyhttp/matchers.go
+160 -39
@@ -1,8 +1,12 @@
1 package caddyhttp
2
3 import (
4 + "fmt"
5 "log"
6 "net/http"
7 + "net/textproto"
8 + "net/url"
9 + "regexp"
10 "strings"
11
12 "bitbucket.org/lightcodelabs/caddy2"
@@ -10,15 +14,16 @@ import (
14 "go.starlark.net/starlark"
15 )
16
13 -// TODO: Matchers should probably support regex of some sort... performance trade-offs?
17 type (
18 matchHost []string
19 matchPath []string
20 + matchPathRE struct{ matchRegexp }
21 matchMethod []string
18 - matchQuery map[string][]string
19 - matchHeader map[string][]string
22 + matchQuery url.Values
23 + matchHeader http.Header
24 + matchHeaderRE map[string]*matchRegexp
25 matchProtocol string
21 - matchScript string
26 + matchStarlark string
27 )
28
29 func init() {
@@ -30,6 +35,10 @@ func init() {
35 Name: "http.matchers.path",
36 New: func() (interface{}, error) { return matchPath{}, nil },
37 })
38 + caddy2.RegisterModule(caddy2.Module{
39 + Name: "http.matchers.path_regexp",
40 + New: func() (interface{}, error) { return new(matchPathRE), nil },
41 + })
42 caddy2.RegisterModule(caddy2.Module{
43 Name: "http.matchers.method",
44 New: func() (interface{}, error) { return matchMethod{}, nil },
@@ -42,45 +51,39 @@ func init() {
51 Name: "http.matchers.header",
52 New: func() (interface{}, error) { return matchHeader{}, nil },
53 })
54 + caddy2.RegisterModule(caddy2.Module{
55 + Name: "http.matchers.header_regexp",
56 + New: func() (interface{}, error) { return matchHeaderRE{}, nil },
57 + })
58 caddy2.RegisterModule(caddy2.Module{
59 Name: "http.matchers.protocol",
60 New: func() (interface{}, error) { return new(matchProtocol), nil },
61 })
62 caddy2.RegisterModule(caddy2.Module{
63 Name: "http.matchers.caddyscript",
51 - New: func() (interface{}, error) { return new(matchScript), nil },
64 + New: func() (interface{}, error) { return new(matchStarlark), nil },
65 })
66 }
67
55 -func (m matchScript) Match(r *http.Request) bool {
56 - input := string(m)
57 - thread := new(starlark.Thread)
58 - env := caddyscript.MatcherEnv(r)
59 - val, err := starlark.Eval(thread, "", input, env)
60 - if err != nil {
61 - log.Printf("caddyscript for matcher is invalid: attempting to evaluate expression `%v` error `%v`", input, err)
62 - return false
63 - }
64 -
65 - return val.String() == "True"
66 -}
67 -
68 -func (m matchProtocol) Match(r *http.Request) bool {
69 - switch string(m) {
70 - case "grpc":
71 - return r.Header.Get("content-type") == "application/grpc"
72 - case "https":
73 - return r.TLS != nil
74 - case "http":
75 - return r.TLS == nil
76 - }
77 -
78 - return false
79 -}
80 -
68 func (m matchHost) Match(r *http.Request) bool {
69 +outer:
70 for _, host := range m {
83 - if r.Host == host {
71 + if strings.Contains(host, "*") {
72 + patternParts := strings.Split(host, ".")
73 + incomingParts := strings.Split(r.Host, ".")
74 + if len(patternParts) != len(incomingParts) {
75 + continue
76 + }
77 + for i := range patternParts {
78 + if patternParts[i] == "*" {
79 + continue
80 + }
81 + if !strings.EqualFold(patternParts[i], incomingParts[i]) {
82 + continue outer
83 + }
84 + }
85 + return true
86 + } else if strings.EqualFold(r.Host, host) {
87 return true
88 }
89 }
@@ -96,6 +99,11 @@ func (m matchPath) Match(r *http.Request) bool {
99 return false
100 }
101
102 +func (m matchPathRE) Match(r *http.Request) bool {
103 + repl := r.Context().Value(ReplacerCtxKey).(*Replacer)
104 + return m.match(r.URL.Path, repl, "path_regexp")
105 +}
106 +
107 func (m matchMethod) Match(r *http.Request) bool {
108 for _, method := range m {
109 if r.Method == method {
@@ -118,26 +126,139 @@ func (m matchQuery) Match(r *http.Request) bool {
126 }
127
128 func (m matchHeader) Match(r *http.Request) bool {
121 - for field, vals := range m {
122 - fieldVals := r.Header[field]
123 - for _, fieldVal := range fieldVals {
124 - for _, v := range vals {
125 - if fieldVal == v {
126 - return true
129 + for field, allowedFieldVals := range m {
130 + var match bool
131 + actualFieldVals := r.Header[textproto.CanonicalMIMEHeaderKey(field)]
132 + fieldVals:
133 + for _, actualFieldVal := range actualFieldVals {
134 + for _, allowedFieldVal := range allowedFieldVals {
135 + if actualFieldVal == allowedFieldVal {
136 + match = true
137 + break fieldVals
138 }
139 }
140 }
141 + if !match {
142 + return false
143 + }
144 + }
145 + return true
146 +}
147 +
148 +func (m matchHeaderRE) Match(r *http.Request) bool {
149 + for field, rm := range m {
150 + repl := r.Context().Value(ReplacerCtxKey).(*Replacer)
151 + match := rm.match(r.Header.Get(field), repl, "header_regexp")
152 + if !match {
153 + return false
154 + }
155 + }
156 + return true
157 +}
158 +
159 +func (m matchHeaderRE) Provision() error {
160 + for _, rm := range m {
161 + err := rm.Provision()
162 + if err != nil {
163 + return err
164 + }
165 + }
166 + return nil
167 +}
168 +
169 +func (m matchHeaderRE) Validate() error {
170 + for _, rm := range m {
171 + err := rm.Validate()
172 + if err != nil {
173 + return err
174 + }
175 + }
176 + return nil
177 +}
178 +
179 +func (m matchProtocol) Match(r *http.Request) bool {
180 + switch string(m) {
181 + case "grpc":
182 + return r.Header.Get("content-type") == "application/grpc"
183 + case "https":
184 + return r.TLS != nil
185 + case "http":
186 + return r.TLS == nil
187 }
188 return false
189 }
190
191 +func (m matchStarlark) Match(r *http.Request) bool {
192 + input := string(m)
193 + thread := new(starlark.Thread)
194 + env := caddyscript.MatcherEnv(r)
195 + val, err := starlark.Eval(thread, "", input, env)
196 + if err != nil {
197 + // TODO: Can we detect this in Provision or Validate instead?
198 + log.Printf("caddyscript for matcher is invalid: attempting to evaluate expression `%v` error `%v`", input, err)
199 + return false
200 + }
201 + return val.String() == "True"
202 +}
203 +
204 +// matchRegexp is just the fields common among
205 +// matchers that can use regular expressions.
206 +type matchRegexp struct {
207 + Name string `json:"name"`
208 + Pattern string `json:"pattern"`
209 + compiled *regexp.Regexp
210 +}
211 +
212 +func (mre *matchRegexp) Provision() error {
213 + re, err := regexp.Compile(mre.Pattern)
214 + if err != nil {
215 + return fmt.Errorf("compiling matcher regexp %s: %v", mre.Pattern, err)
216 + }
217 + mre.compiled = re
218 + return nil
219 +}
220 +
221 +func (mre *matchRegexp) Validate() error {
222 + if mre.Name != "" && !wordRE.MatchString(mre.Name) {
223 + return fmt.Errorf("invalid regexp name (must contain only word characters): %s", mre.Name)
224 + }
225 + return nil
226 +}
227 +
228 +func (mre *matchRegexp) match(input string, repl *Replacer, scope string) bool {
229 + matches := mre.compiled.FindStringSubmatch(input)
230 + if matches == nil {
231 + return false
232 + }
233 +
234 + // save all capture groups, first by index
235 + for i, match := range matches {
236 + key := fmt.Sprintf("http.matchers.%s.%s.%d", scope, mre.Name, i)
237 + repl.Map(key, match)
238 + }
239 +
240 + // then by name
241 + for i, name := range mre.compiled.SubexpNames() {
242 + if i != 0 && name != "" {
243 + key := fmt.Sprintf("http.matchers.%s.%s.%s", scope, mre.Name, name)
244 + repl.Map(key, matches[i])
245 + }
246 + }
247 +
248 + return true
249 +}
250 +
251 +var wordRE = regexp.MustCompile(`\w+`)
252 +
253 // Interface guards
254 var (
255 _ RouteMatcher = (*matchHost)(nil)
256 _ RouteMatcher = (*matchPath)(nil)
257 + _ RouteMatcher = (*matchPathRE)(nil)
258 _ RouteMatcher = (*matchMethod)(nil)
259 _ RouteMatcher = (*matchQuery)(nil)
260 _ RouteMatcher = (*matchHeader)(nil)
261 + _ RouteMatcher = (*matchHeaderRE)(nil)
262 _ RouteMatcher = (*matchProtocol)(nil)
142 - _ RouteMatcher = (*matchScript)(nil)
263 + _ RouteMatcher = (*matchStarlark)(nil)
264 )
modules/caddyhttp/matchers_test.go new
+346
@@ -0,0 +1,346 @@
1 +package caddyhttp
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "net/http"
7 + "net/http/httptest"
8 + "net/url"
9 + "testing"
10 +)
11 +
12 +func TestHostMatcher(t *testing.T) {
13 + for i, tc := range []struct {
14 + match matchHost
15 + input string
16 + expect bool
17 + }{
18 + {
19 + match: matchHost{},
20 + input: "example.com",
21 + expect: false,
22 + },
23 + {
24 + match: matchHost{"example.com"},
25 + input: "example.com",
26 + expect: true,
27 + },
28 + {
29 + match: matchHost{"example.com"},
30 + input: "foo.example.com",
31 + expect: false,
32 + },
33 + {
34 + match: matchHost{"example.com"},
35 + input: "EXAMPLE.COM",
36 + expect: true,
37 + },
38 + {
39 + match: matchHost{"foo.example.com"},
40 + input: "foo.example.com",
41 + expect: true,
42 + },
43 + {
44 + match: matchHost{"foo.example.com"},
45 + input: "bar.example.com",
46 + expect: false,
47 + },
48 + {
49 + match: matchHost{"*.example.com"},
50 + input: "example.com",
51 + expect: false,
52 + },
53 + {
54 + match: matchHost{"*.example.com"},
55 + input: "foo.example.com",
56 + expect: true,
57 + },
58 + {
59 + match: matchHost{"*.example.com"},
60 + input: "foo.bar.example.com",
61 + expect: false,
62 + },
63 + {
64 + match: matchHost{"*.example.com", "example.net"},
65 + input: "example.net",
66 + expect: true,
67 + },
68 + {
69 + match: matchHost{"example.net", "*.example.com"},
70 + input: "foo.example.com",
71 + expect: true,
72 + },
73 + {
74 + match: matchHost{"*.example.net", "*.*.example.com"},
75 + input: "foo.bar.example.com",
76 + expect: true,
77 + },
78 + {
79 + match: matchHost{"*.example.net", "sub.*.example.com"},
80 + input: "sub.foo.example.com",
81 + expect: true,
82 + },
83 + {
84 + match: matchHost{"*.example.net", "sub.*.example.com"},
85 + input: "sub.foo.example.net",
86 + expect: false,
87 + },
88 + } {
89 + req := &http.Request{Host: tc.input}
90 + actual := tc.match.Match(req)
91 + if actual != tc.expect {
92 + t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
93 + continue
94 + }
95 + }
96 +}
97 +
98 +func TestPathMatcher(t *testing.T) {
99 + for i, tc := range []struct {
100 + match matchPath
101 + input string
102 + expect bool
103 + }{
104 + {
105 + match: matchPath{},
106 + input: "/",
107 + expect: false,
108 + },
109 + {
110 + match: matchPath{"/"},
111 + input: "/",
112 + expect: true,
113 + },
114 + {
115 + match: matchPath{"/foo/bar"},
116 + input: "/",
117 + expect: false,
118 + },
119 + {
120 + match: matchPath{"/foo/bar"},
121 + input: "/foo/bar",
122 + expect: true,
123 + },
124 + {
125 + match: matchPath{"/foo/bar/"},
126 + input: "/foo/bar",
127 + expect: false,
128 + },
129 + {
130 + match: matchPath{"/foo/bar/", "/other"},
131 + input: "/other/",
132 + expect: true,
133 + },
134 + } {
135 + req := &http.Request{URL: &url.URL{Path: tc.input}}
136 + actual := tc.match.Match(req)
137 + if actual != tc.expect {
138 + t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
139 + continue
140 + }
141 + }
142 +}
143 +
144 +func TestPathREMatcher(t *testing.T) {
145 + for i, tc := range []struct {
146 + match matchPathRE
147 + input string
148 + expect bool
149 + expectRepl map[string]string
150 + }{
151 + {
152 + match: matchPathRE{},
153 + input: "/",
154 + expect: true,
155 + },
156 + {
157 + match: matchPathRE{matchRegexp{Pattern: "/"}},
158 + input: "/",
159 + expect: true,
160 + },
161 + {
162 + match: matchPathRE{matchRegexp{Pattern: "/foo"}},
163 + input: "/foo",
164 + expect: true,
165 + },
166 + {
167 + match: matchPathRE{matchRegexp{Pattern: "/foo"}},
168 + input: "/foo/",
169 + expect: true,
170 + },
171 + {
172 + match: matchPathRE{matchRegexp{Pattern: "/bar"}},
173 + input: "/foo/",
174 + expect: false,
175 + },
176 + {
177 + match: matchPathRE{matchRegexp{Pattern: "^/bar"}},
178 + input: "/foo/bar",
179 + expect: false,
180 + },
181 + {
182 + match: matchPathRE{matchRegexp{Pattern: "^/foo/(.*)/baz$", Name: "name"}},
183 + input: "/foo/bar/baz",
184 + expect: true,
185 + expectRepl: map[string]string{"name.1": "bar"},
186 + },
187 + {
188 + match: matchPathRE{matchRegexp{Pattern: "^/foo/(?P<myparam>.*)/baz$", Name: "name"}},
189 + input: "/foo/bar/baz",
190 + expect: true,
191 + expectRepl: map[string]string{"name.myparam": "bar"},
192 + },
193 + } {
194 + // compile the regexp and validate its name
195 + err := tc.match.Provision()
196 + if err != nil {
197 + t.Errorf("Test %d %v: Provisioning: %v", i, tc.match, err)
198 + continue
199 + }
200 + err = tc.match.Validate()
201 + if err != nil {
202 + t.Errorf("Test %d %v: Validating: %v", i, tc.match, err)
203 + continue
204 + }
205 +
206 + // set up the fake request and its Replacer
207 + req := &http.Request{URL: &url.URL{Path: tc.input}}
208 + repl := &Replacer{req: req, resp: httptest.NewRecorder(), custom: make(map[string]string)}
209 + ctx := context.WithValue(req.Context(), ReplacerCtxKey, repl)
210 + req = req.WithContext(ctx)
211 +
212 + actual := tc.match.Match(req)
213 + if actual != tc.expect {
214 + t.Errorf("Test %d [%v]: Expected %t, got %t for input '%s'",
215 + i, tc.match.Pattern, tc.expect, actual, tc.input)
216 + continue
217 + }
218 +
219 + for key, expectVal := range tc.expectRepl {
220 + placeholder := fmt.Sprintf("{http.matchers.path_regexp.%s}", key)
221 + actualVal := repl.Replace(placeholder, "<empty>")
222 + if actualVal != expectVal {
223 + t.Errorf("Test %d [%v]: Expected placeholder {http.matchers.path_regexp.%s} to be '%s' but got '%s'",
224 + i, tc.match.Pattern, key, expectVal, actualVal)
225 + continue
226 + }
227 + }
228 + }
229 +}
230 +
231 +func TestHeaderMatcher(t *testing.T) {
232 + for i, tc := range []struct {
233 + match matchHeader
234 + input http.Header // make sure these are canonical cased (std lib will do that in a real request)
235 + expect bool
236 + }{
237 + {
238 + match: matchHeader{"Field": []string{"foo"}},
239 + input: http.Header{"Field": []string{"foo"}},
240 + expect: true,
241 + },
242 + {
243 + match: matchHeader{"Field": []string{"foo", "bar"}},
244 + input: http.Header{"Field": []string{"bar"}},
245 + expect: true,
246 + },
247 + {
248 + match: matchHeader{"Field": []string{"foo", "bar"}},
249 + input: http.Header{"Alakazam": []string{"kapow"}},
250 + expect: false,
251 + },
252 + {
253 + match: matchHeader{"Field": []string{"foo", "bar"}},
254 + input: http.Header{"Field": []string{"kapow"}},
255 + expect: false,
256 + },
257 + {
258 + match: matchHeader{"Field": []string{"foo", "bar"}},
259 + input: http.Header{"Field": []string{"kapow", "foo"}},
260 + expect: true,
261 + },
262 + {
263 + match: matchHeader{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
264 + input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
265 + expect: true,
266 + },
267 + {
268 + match: matchHeader{"field1": []string{"foo"}, "field2": []string{"bar"}},
269 + input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
270 + expect: true,
271 + },
272 + {
273 + match: matchHeader{"field1": []string{"foo"}, "field2": []string{"bar"}},
274 + input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"kapow"}},
275 + expect: false,
276 + },
277 + } {
278 + req := &http.Request{Header: tc.input}
279 + actual := tc.match.Match(req)
280 + if actual != tc.expect {
281 + t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
282 + continue
283 + }
284 + }
285 +}
286 +
287 +func TestHeaderREMatcher(t *testing.T) {
288 + for i, tc := range []struct {
289 + match matchHeaderRE
290 + input http.Header // make sure these are canonical cased (std lib will do that in a real request)
291 + expect bool
292 + expectRepl map[string]string
293 + }{
294 + {
295 + match: matchHeaderRE{"Field": &matchRegexp{Pattern: "foo"}},
296 + input: http.Header{"Field": []string{"foo"}},
297 + expect: true,
298 + },
299 + {
300 + match: matchHeaderRE{"Field": &matchRegexp{Pattern: "$foo^"}},
301 + input: http.Header{"Field": []string{"foobar"}},
302 + expect: false,
303 + },
304 + {
305 + match: matchHeaderRE{"Field": &matchRegexp{Pattern: "^foo(.*)$", Name: "name"}},
306 + input: http.Header{"Field": []string{"foobar"}},
307 + expect: true,
308 + expectRepl: map[string]string{"name.1": "bar"},
309 + },
310 + } {
311 + // compile the regexp and validate its name
312 + err := tc.match.Provision()
313 + if err != nil {
314 + t.Errorf("Test %d %v: Provisioning: %v", i, tc.match, err)
315 + continue
316 + }
317 + err = tc.match.Validate()
318 + if err != nil {
319 + t.Errorf("Test %d %v: Validating: %v", i, tc.match, err)
320 + continue
321 + }
322 +
323 + // set up the fake request and its Replacer
324 + req := &http.Request{Header: tc.input, URL: new(url.URL)}
325 + repl := &Replacer{req: req, resp: httptest.NewRecorder(), custom: make(map[string]string)}
326 + ctx := context.WithValue(req.Context(), ReplacerCtxKey, repl)
327 + req = req.WithContext(ctx)
328 +
329 + actual := tc.match.Match(req)
330 + if actual != tc.expect {
331 + t.Errorf("Test %d [%v]: Expected %t, got %t for input '%s'",
332 + i, tc.match, tc.expect, actual, tc.input)
333 + continue
334 + }
335 +
336 + for key, expectVal := range tc.expectRepl {
337 + placeholder := fmt.Sprintf("{http.matchers.header_regexp.%s}", key)
338 + actualVal := repl.Replace(placeholder, "<empty>")
339 + if actualVal != expectVal {
340 + t.Errorf("Test %d [%v]: Expected placeholder {http.matchers.header_regexp.%s} to be '%s' but got '%s'",
341 + i, tc.match, key, expectVal, actualVal)
342 + continue
343 + }
344 + }
345 + }
346 +}
modules/caddyhttp/replacer.go
+5 -4
@@ -8,7 +8,9 @@ import (
8 )
9
10 // Replacer can replace values in strings based
11 -// on a request and/or response writer.
11 +// on a request and/or response writer. The zero
12 +// Replacer is not valid; it must be initialized
13 +// within this package.
14 type Replacer struct {
15 req *http.Request
16 resp http.ResponseWriter
@@ -75,18 +77,17 @@ func (r *Replacer) defaults() map[string]string {
77 }(),
78 }
79
80 + // TODO: why should header fields, cookies, and query params get special treatment like this?
81 + // maybe they should be scoped by words like "request.header." just like everything else.
82 for field, vals := range r.req.Header {
83 m[">"+strings.ToLower(field)] = strings.Join(vals, ",")
84 }
81 -
85 for field, vals := range r.resp.Header() {
86 m["<"+strings.ToLower(field)] = strings.Join(vals, ",")
87 }
85 -
88 for _, cookie := range r.req.Cookies() {
89 m["~"+cookie.Name] = cookie.Value
90 }
89 -
91 for param, vals := range r.req.URL.Query() {
92 m["?"+param] = strings.Join(vals, ",")
93 }
modules/caddyhttp/responsewriter.go
+6 -19
@@ -7,16 +7,13 @@ import (
7 "net/http"
8 )
9
10 -// TODO: Is this type really required? Wouldn't embedding the
11 -// default ResponseWriter always work too, when wrapping it?
12 -
10 // ResponseWriterWrapper wraps an underlying ResponseWriter and
14 -// promotes its Pusher/Flusher/CloseNotifier/Hijacker methods
15 -// as well. To use this type, embed a pointer to it within your
16 -// own struct type that implements the http.ResponseWriter
17 -// interface, then call methods on the embedded value. You can
18 -// make sure your type wraps correctly by asserting that it
19 -// implements the HTTPInterfaces interface.
11 +// promotes its Pusher/Flusher/Hijacker methods as well. To use
12 +// this type, embed a pointer to it within your own struct type
13 +// that implements the http.ResponseWriter interface, then call
14 +// methods on the embedded value. You can make sure your type
15 +// wraps correctly by asserting that it implements the
16 +// HTTPInterfaces interface.
17 type ResponseWriterWrapper struct {
18 http.ResponseWriter
19 }
@@ -40,15 +37,6 @@ func (rww *ResponseWriterWrapper) Flush() {
37 }
38 }
39
43 -// CloseNotify implements http.CloseNotifier. It simply calls the underlying
44 -// ResponseWriter's CloseNotify method if there is one, or panics.
45 -func (rww *ResponseWriterWrapper) CloseNotify() <-chan bool {
46 - if cn, ok := rww.ResponseWriter.(http.CloseNotifier); ok {
47 - return cn.CloseNotify()
48 - }
49 - panic("not a close notifier")
50 -}
51 -
40 // Push implements http.Pusher. It simply calls the underlying
41 // ResponseWriter's Push method if there is one, or returns an error.
42 func (rww *ResponseWriterWrapper) Push(target string, opts *http.PushOptions) error {
@@ -63,7 +51,6 @@ type HTTPInterfaces interface {
51 http.ResponseWriter
52 http.Pusher
53 http.Flusher
66 - http.CloseNotifier
54 http.Hijacker
55 }
56
modules/caddyhttp/routes.go
+2 -4
@@ -13,7 +13,7 @@ type serverRoute struct {
13 Apply []json.RawMessage `json:"apply"`
14 Respond json.RawMessage `json:"respond"`
15
16 - Exclusive bool `json:"exclusive"`
16 + Terminal bool `json:"terminal"`
17
18 // decoded values
19 matchers []RouteMatcher
@@ -49,9 +49,7 @@ routeLoop:
49 if responder == nil {
50 responder = route.responder
51 }
52 - // TODO: Should exclusive apply to only middlewares, or responder too?
53 - // i.e. what if they haven't set a responder yet, but the first middleware chain is exclusive...
54 - if route.Exclusive {
52 + if route.Terminal {
53 break
54 }
55 }