Skip to content

Commit 4c1b61e

Browse files
authored
Merge pull request #588 from wenhug/regexcache
Add configurable regex caching for CurlyRouter path tokens and custom verbs
2 parents 6a74e9b + 54fe5bb commit 4c1b61e

File tree

4 files changed

+309
-6
lines changed

4 files changed

+309
-6
lines changed

curly.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,35 @@ import (
99
"regexp"
1010
"sort"
1111
"strings"
12+
"sync"
1213
)
1314

1415
// CurlyRouter expects Routes with paths that contain zero or more parameters in curly brackets.
1516
type CurlyRouter struct{}
1617

18+
var (
19+
regexCache sync.Map // Cache for compiled regex patterns
20+
pathTokenCacheEnabled = true // Enable/disable path token regex caching
21+
)
22+
23+
// SetPathTokenCacheEnabled enables or disables path token regex caching for CurlyRouter.
24+
// When disabled, regex patterns will be compiled on every request.
25+
// When enabled (default), compiled regex patterns are cached for better performance.
26+
func SetPathTokenCacheEnabled(enabled bool) {
27+
pathTokenCacheEnabled = enabled
28+
}
29+
30+
// getCachedRegexp retrieves a compiled regex from the cache if found and valid.
31+
// Returns the regex and true if found and valid, nil and false otherwise.
32+
func getCachedRegexp(cache *sync.Map, pattern string) (*regexp.Regexp, bool) {
33+
if cached, found := cache.Load(pattern); found {
34+
if regex, ok := cached.(*regexp.Regexp); ok {
35+
return regex, true
36+
}
37+
}
38+
return nil, false
39+
}
40+
1741
// SelectRoute is part of the Router interface and returns the best match
1842
// for the WebService and its Route for the given Request.
1943
func (c CurlyRouter) SelectRoute(
@@ -113,8 +137,28 @@ func (c CurlyRouter) regularMatchesPathToken(routeToken string, colon int, reque
113137
}
114138
return true, true
115139
}
116-
matched, err := regexp.MatchString(regPart, requestToken)
117-
return (matched && err == nil), false
140+
141+
// Check cache first (if enabled)
142+
if pathTokenCacheEnabled {
143+
if regex, found := getCachedRegexp(&regexCache, regPart); found {
144+
matched := regex.MatchString(requestToken)
145+
return matched, false
146+
}
147+
}
148+
149+
// Compile the regex
150+
regex, err := regexp.Compile(regPart)
151+
if err != nil {
152+
return false, false
153+
}
154+
155+
// Cache the regex (if enabled)
156+
if pathTokenCacheEnabled {
157+
regexCache.Store(regPart, regex)
158+
}
159+
160+
matched := regex.MatchString(requestToken)
161+
return matched, false
118162
}
119163

120164
var jsr311Router = RouterJSR311{}
@@ -168,7 +212,7 @@ func (c CurlyRouter) computeWebserviceScore(requestTokens []string, routeTokens
168212
if matchesToken {
169213
score++ // extra score for regex match
170214
}
171-
}
215+
}
172216
} else {
173217
// not a parameter
174218
if eachRequestToken != eachRouteToken {

curly_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package restful
33
import (
44
"io"
55
"net/http"
6+
"regexp"
7+
"sync"
68
"testing"
79
)
810

@@ -262,3 +264,138 @@ func TestCurly_ISSUE_137_2(t *testing.T) {
262264
}
263265

264266
func curlyDummy(req *Request, resp *Response) { io.WriteString(resp.ResponseWriter, "curlyDummy") }
267+
268+
func TestRegexCaching(t *testing.T) {
269+
// Store original state and enable caching for this test
270+
originalEnabled := pathTokenCacheEnabled
271+
defer func() {
272+
SetPathTokenCacheEnabled(originalEnabled)
273+
}()
274+
SetPathTokenCacheEnabled(true)
275+
276+
// Clear cache before test
277+
regexCache = sync.Map{}
278+
279+
router := CurlyRouter{}
280+
281+
// Test with regex pattern
282+
routeToken := "{id:[0-9]+}"
283+
requestToken := "123"
284+
285+
// First call should cache the regex
286+
matches1, _ := router.regularMatchesPathToken(routeToken, 3, requestToken)
287+
if !matches1 {
288+
t.Error("Expected first call to match")
289+
}
290+
291+
// Verify cache contains the pattern
292+
pattern := "[0-9]+"
293+
_, found := regexCache.Load(pattern)
294+
if !found {
295+
t.Error("Expected pattern to be cached")
296+
}
297+
298+
// Second call should use cached regex
299+
matches2, _ := router.regularMatchesPathToken(routeToken, 3, requestToken)
300+
if !matches2 {
301+
t.Error("Expected second call to match using cache")
302+
}
303+
304+
// Test with different pattern to ensure separate caching
305+
routeToken2 := "{name:[a-z]+}"
306+
requestToken2 := "john"
307+
308+
matches3, _ := router.regularMatchesPathToken(routeToken2, 5, requestToken2)
309+
if !matches3 {
310+
t.Error("Expected name pattern to match")
311+
}
312+
313+
// Verify both patterns are cached
314+
pattern2 := "[a-z]+"
315+
_, found2 := regexCache.Load(pattern2)
316+
if !found2 {
317+
t.Error("Expected name pattern to be cached")
318+
}
319+
}
320+
321+
func TestRegexCacheDisabled(t *testing.T) {
322+
// Store original state
323+
originalEnabled := pathTokenCacheEnabled
324+
defer func() {
325+
SetPathTokenCacheEnabled(originalEnabled)
326+
}()
327+
328+
// Clear cache before test
329+
regexCache = sync.Map{}
330+
331+
// Disable caching
332+
SetPathTokenCacheEnabled(false)
333+
334+
router := CurlyRouter{}
335+
routeToken := "{id:[0-9]+}"
336+
requestToken := "123"
337+
338+
// Call should work but not cache
339+
matches, _ := router.regularMatchesPathToken(routeToken, 3, requestToken)
340+
if !matches {
341+
t.Error("Expected call to match")
342+
}
343+
344+
// Verify pattern is not cached
345+
pattern := "[0-9]+"
346+
_, found := regexCache.Load(pattern)
347+
if found {
348+
t.Error("Expected pattern to not be cached when caching is disabled")
349+
}
350+
351+
// Re-enable caching
352+
SetPathTokenCacheEnabled(true)
353+
354+
// Now it should cache
355+
matches2, _ := router.regularMatchesPathToken(routeToken, 3, requestToken)
356+
if !matches2 {
357+
t.Error("Expected call to match")
358+
}
359+
360+
// Verify pattern is now cached
361+
_, found2 := regexCache.Load(pattern)
362+
if !found2 {
363+
t.Error("Expected pattern to be cached when caching is re-enabled")
364+
}
365+
}
366+
367+
func TestRegexCachePanicSafety(t *testing.T) {
368+
// Store original state
369+
originalEnabled := pathTokenCacheEnabled
370+
defer func() {
371+
SetPathTokenCacheEnabled(originalEnabled)
372+
regexCache = sync.Map{} // Clean up
373+
}()
374+
375+
SetPathTokenCacheEnabled(true)
376+
377+
// Poison cache with wrong type
378+
pattern := "[0-9]+"
379+
regexCache.Store(pattern, "not a regex")
380+
381+
router := CurlyRouter{}
382+
routeToken := "{id:[0-9]+}"
383+
requestToken := "123"
384+
385+
// Should not panic, should handle invalid cache entry gracefully
386+
matches, _ := router.regularMatchesPathToken(routeToken, 3, requestToken)
387+
if !matches {
388+
t.Error("Expected call to match even with corrupted cache")
389+
}
390+
391+
// After the call, the invalid entry should be overwritten with valid regex
392+
if cached, found := regexCache.Load(pattern); found {
393+
if _, ok := cached.(*regexp.Regexp); ok {
394+
// Success: cache entry is now a valid regex
395+
} else {
396+
t.Error("Expected invalid cache entry to be overwritten with valid regex")
397+
}
398+
} else {
399+
t.Error("Expected valid regex to be cached")
400+
}
401+
}

custom_verb.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ package restful
33
import (
44
"fmt"
55
"regexp"
6+
"sync"
67
)
78

89
var (
9-
customVerbReg = regexp.MustCompile(":([A-Za-z]+)$")
10+
customVerbReg = regexp.MustCompile(":([A-Za-z]+)$")
11+
customVerbCache sync.Map // Cache for compiled custom verb regexes
12+
customVerbCacheEnabled = true // Enable/disable custom verb regex caching
1013
)
1114

15+
// SetCustomVerbCacheEnabled enables or disables custom verb regex caching.
16+
// When disabled, custom verb regex patterns will be compiled on every request.
17+
// When enabled (default), compiled custom verb regex patterns are cached for better performance.
18+
func SetCustomVerbCacheEnabled(enabled bool) {
19+
customVerbCacheEnabled = enabled
20+
}
21+
1222
func hasCustomVerb(routeToken string) bool {
1323
return customVerbReg.MatchString(routeToken)
1424
}
@@ -20,7 +30,23 @@ func isMatchCustomVerb(routeToken string, pathToken string) bool {
2030
}
2131

2232
customVerb := rs[1]
23-
specificVerbReg := regexp.MustCompile(fmt.Sprintf(":%s$", customVerb))
33+
regexPattern := fmt.Sprintf(":%s$", customVerb)
34+
35+
// Check cache first (if enabled)
36+
if customVerbCacheEnabled {
37+
if specificVerbReg, found := getCachedRegexp(&customVerbCache, regexPattern); found {
38+
return specificVerbReg.MatchString(pathToken)
39+
}
40+
}
41+
42+
// Compile the regex
43+
specificVerbReg := regexp.MustCompile(regexPattern)
44+
45+
// Cache the regex (if enabled)
46+
if customVerbCacheEnabled {
47+
customVerbCache.Store(regexPattern, specificVerbReg)
48+
}
49+
2450
return specificVerbReg.MatchString(pathToken)
2551
}
2652

custom_verb_test.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package restful
22

3-
import "testing"
3+
import (
4+
"sync"
5+
"testing"
6+
)
47

58
func TestHasCustomVerb(t *testing.T) {
69
testCase := []struct {
@@ -40,6 +43,7 @@ func TestRemoveCustomVerb(t *testing.T) {
4043
}
4144
}
4245
}
46+
4347
func TestMatchCustomVerb(t *testing.T) {
4448
testCase := []struct {
4549
routeToken string
@@ -60,3 +64,95 @@ func TestMatchCustomVerb(t *testing.T) {
6064
}
6165
}
6266
}
67+
68+
func TestCustomVerbCaching(t *testing.T) {
69+
// Store original state and enable caching for this test
70+
originalEnabled := customVerbCacheEnabled
71+
defer func() {
72+
SetCustomVerbCacheEnabled(originalEnabled)
73+
}()
74+
SetCustomVerbCacheEnabled(true)
75+
76+
// Clear cache before test
77+
customVerbCache = sync.Map{}
78+
79+
routeToken := "{userId:regex}:POST"
80+
pathToken := "user123:POST"
81+
82+
// First call should cache the regex
83+
result1 := isMatchCustomVerb(routeToken, pathToken)
84+
if !result1 {
85+
t.Error("Expected first call to match")
86+
}
87+
88+
// Verify cache contains the pattern
89+
pattern := ":POST$"
90+
_, found := customVerbCache.Load(pattern)
91+
if !found {
92+
t.Error("Expected pattern to be cached")
93+
}
94+
95+
// Second call should use cached regex
96+
result2 := isMatchCustomVerb(routeToken, pathToken)
97+
if !result2 {
98+
t.Error("Expected second call to match using cache")
99+
}
100+
101+
// Test with different verb to ensure separate caching
102+
routeToken2 := "{userId:regex}:GET"
103+
pathToken2 := "user123:GET"
104+
105+
result3 := isMatchCustomVerb(routeToken2, pathToken2)
106+
if !result3 {
107+
t.Error("Expected GET verb to match")
108+
}
109+
110+
// Verify both patterns are cached
111+
pattern2 := ":GET$"
112+
_, found2 := customVerbCache.Load(pattern2)
113+
if !found2 {
114+
t.Error("Expected GET pattern to be cached")
115+
}
116+
}
117+
118+
func TestCustomVerbCacheDisabled(t *testing.T) {
119+
// Store original state
120+
originalEnabled := customVerbCacheEnabled
121+
defer func() {
122+
SetCustomVerbCacheEnabled(originalEnabled)
123+
}()
124+
125+
// Disable caching
126+
SetCustomVerbCacheEnabled(false)
127+
128+
routeToken := "{userId:regex}:DELETE"
129+
pathToken := "user123:DELETE"
130+
131+
// Call should work but not cache
132+
result := isMatchCustomVerb(routeToken, pathToken)
133+
if !result {
134+
t.Error("Expected call to match")
135+
}
136+
137+
// Verify pattern is not cached
138+
pattern := ":DELETE$"
139+
_, found := customVerbCache.Load(pattern)
140+
if found {
141+
t.Error("Expected pattern to not be cached when caching is disabled")
142+
}
143+
144+
// Re-enable caching
145+
SetCustomVerbCacheEnabled(true)
146+
147+
// Now it should cache
148+
result2 := isMatchCustomVerb(routeToken, pathToken)
149+
if !result2 {
150+
t.Error("Expected call to match")
151+
}
152+
153+
// Verify pattern is now cached
154+
_, found2 := customVerbCache.Load(pattern)
155+
if !found2 {
156+
t.Error("Expected pattern to be cached when caching is re-enabled")
157+
}
158+
}

0 commit comments

Comments
 (0)