Skip to content

Commit 526aa33

Browse files
committed
Added Cage
1 parent fee259e commit 526aa33

File tree

10 files changed

+396
-17
lines changed

10 files changed

+396
-17
lines changed

lib/docker/test_env/killgrave.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
const defaultKillgraveImage = "friendsofgo/killgrave:v0.5.1-request-dump"
2828

29+
// Deprecated: Transition from Killgrave to Parrot: https://github.com/smartcontractkit/chainlink-testing-framework/tree/main/parrot
2930
type Killgrave struct {
3031
EnvComponent
3132
ExternalEndpoint string
@@ -82,6 +83,7 @@ type KillgraveAdapterResult struct {
8283
// NewKillgrave initializes a new Killgrave instance with specified networks and imposters directory.
8384
// It sets default configurations and allows for optional environment component modifications.
8485
// This function is useful for creating a Killgrave service for testing and simulating APIs.
86+
// Deprecated: Transition from Killgrave to Parrot: https://github.com/smartcontractkit/chainlink-testing-framework/tree/main/parrot
8587
func NewKillgrave(networks []string, impostersDirectoryPath string, opts ...EnvComponentOption) *Killgrave {
8688
k := &Killgrave{
8789
EnvComponent: EnvComponent{

lib/docker/test_env/mockserver.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222

2323
const defaultMockServerImage = "mockserver/mockserver:5.15.0"
2424

25+
// Deprecated: Transition from MockServer to Parrot: https://github.com/smartcontractkit/chainlink-testing-framework/tree/main/parrot
2526
type MockServer struct {
2627
EnvComponent
2728
Client *ctfClient.MockserverClient

parrot/.changeset/v0.2.0.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## Added wildcard routing support
2+
3+
### Bench before
4+
5+
```sh
6+
go test -testLogLevel="" -bench=. -run=^$ ./...
7+
goos: darwin
8+
goarch: arm64
9+
pkg: github.com/smartcontractkit/chainlink-testing-framework/parrot
10+
cpu: Apple M3 Max
11+
BenchmarkRegisterRoute-14 5495581 203.7 ns/op
12+
BenchmarkRouteResponse-14 19639 59799 ns/op
13+
BenchmarkSave-14 6358 178122 ns/op
14+
BenchmarkLoad-14 1411 852377 ns/op
15+
```
16+
17+
### Bench After

parrot/Makefile

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
# Default test log level (can be overridden)
21
PARROT_TEST_LOG_LEVEL ?= ""
3-
4-
# Pass TEST_LOG_LEVEL as a flag to go test
52
TEST_ARGS ?= -testLogLevel=$(PARROT_TEST_LOG_LEVEL)
63

74
.PHONY: lint

parrot/cage.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package parrot
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
"sync"
8+
)
9+
10+
// Cage is a container for all routes and sub-cages to handle routing and wildcard matching for a parrot
11+
// Note: Should only be used internally by the parrot.
12+
type Cage struct {
13+
*CageLevel
14+
}
15+
16+
// CageLevel holds a single level of routes and further sub cages
17+
// Note: Should only be used internally by the parrot.
18+
type CageLevel struct {
19+
// Routes contains all of the plain routes at this current cage level
20+
// path -> route
21+
Routes map[string]*Route `json:"routes"`
22+
routesRWMu sync.RWMutex // sync.Map might be better here, but eh
23+
// WildCardRoutes contains all the wildcard routes at this current cage level
24+
// path -> route
25+
WildCardRoutes map[string]*Route `json:"wild_card_routes"`
26+
wildCardRoutesRWMu sync.RWMutex
27+
// SubCages contains sub cages at this current cage level
28+
// cage name -> cage level
29+
SubCages map[string]*CageLevel `json:"sub_cages"`
30+
subCagesRWMu sync.RWMutex
31+
// WildCardSubCages contains wildcard sub cages at this current cage level
32+
// cage name -> cage level
33+
WildCardSubCages map[string]*CageLevel `json:"wild_card_sub_cages"`
34+
wildCardSubCagesRWMu sync.RWMutex
35+
}
36+
37+
// newCage creates a new cage with an empty cage level for a new parrot instance
38+
func newCage() *Cage {
39+
return &Cage{
40+
CageLevel: newCageLevel(),
41+
}
42+
}
43+
44+
// newCageLevel creates a new cageLevel with empty maps
45+
func newCageLevel() *CageLevel {
46+
return &CageLevel{
47+
Routes: make(map[string]*Route),
48+
WildCardRoutes: make(map[string]*Route),
49+
SubCages: make(map[string]*CageLevel),
50+
WildCardSubCages: make(map[string]*CageLevel),
51+
}
52+
}
53+
54+
// cageLevel searches for a cage level based on the path provided
55+
// If createMode is true, it will create any cage levels if they don't exist
56+
func (c *Cage) cageLevel(path string, createMode bool) (cageLevel *CageLevel, routeSegment string, err error) {
57+
splitPath := strings.Split(path, "/")
58+
routeSegment = splitPath[len(splitPath)-1] // The final path segment is the route
59+
splitPath = splitPath[:len(splitPath)-1] // Only looking for the cage level, exclude the route
60+
if splitPath[0] == "" {
61+
splitPath = splitPath[1:] // Remove the empty string at the beginning of the split
62+
}
63+
currentCageLevel := c.CageLevel
64+
65+
for _, pathSegment := range splitPath { // Iterate through each path segment to look for matches
66+
cageLevel, found, err := currentCageLevel.subCageLevel(pathSegment, createMode)
67+
if err != nil {
68+
return nil, routeSegment, err
69+
}
70+
if found {
71+
currentCageLevel = cageLevel
72+
continue
73+
}
74+
75+
if !found {
76+
return nil, routeSegment, newDynamicError(ErrCageNotFound, fmt.Sprintf("path: '%s'", path))
77+
}
78+
}
79+
80+
return currentCageLevel, routeSegment, nil
81+
}
82+
83+
// getRoute searches for a route based on the path provided
84+
func (c *Cage) getRoute(path string) (*Route, error) {
85+
cageLevel, routeSegment, err := c.cageLevel(path, false)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
route, found, err := cageLevel.route(routeSegment)
91+
if err != nil {
92+
return nil, err
93+
}
94+
if found {
95+
return route, nil
96+
}
97+
98+
return nil, ErrRouteNotFound
99+
}
100+
101+
// newRoute creates a new route, creating new cages if necessary
102+
func (c *Cage) newRoute(route *Route) error {
103+
cageLevel, routeSegment, err := c.cageLevel(route.Path, true)
104+
if err != nil {
105+
return err
106+
}
107+
108+
if strings.Contains(routeSegment, "*") {
109+
cageLevel.wildCardRoutesRWMu.Lock()
110+
defer cageLevel.wildCardRoutesRWMu.Unlock()
111+
cageLevel.WildCardRoutes[routeSegment] = route
112+
} else {
113+
cageLevel.routesRWMu.Lock()
114+
defer cageLevel.routesRWMu.Unlock()
115+
cageLevel.Routes[routeSegment] = route
116+
}
117+
118+
return nil
119+
}
120+
121+
// deleteRoute deletes a route based on the path provided
122+
func (c *Cage) deleteRoute(route *Route) error {
123+
cageLevel, routeSegment, err := c.cageLevel(route.Path, true)
124+
if err != nil {
125+
return err
126+
}
127+
128+
if strings.Contains(routeSegment, "*") {
129+
cageLevel.wildCardRoutesRWMu.Lock()
130+
defer cageLevel.wildCardRoutesRWMu.Unlock()
131+
delete(cageLevel.WildCardRoutes, routeSegment)
132+
} else {
133+
cageLevel.routesRWMu.Lock()
134+
defer cageLevel.routesRWMu.Unlock()
135+
delete(cageLevel.Routes, routeSegment)
136+
}
137+
138+
return nil
139+
}
140+
141+
// route searches for a route based on the route segment provided
142+
func (cl *CageLevel) route(routeSegment string) (route *Route, found bool, err error) {
143+
// First check for an exact match
144+
cl.routesRWMu.Lock()
145+
if route, found = cl.Routes[routeSegment]; found {
146+
defer cl.routesRWMu.Unlock()
147+
return route, true, nil
148+
}
149+
cl.routesRWMu.Unlock()
150+
151+
// if not, look for wildcard routes
152+
cl.wildCardRoutesRWMu.Lock()
153+
defer cl.wildCardRoutesRWMu.Unlock()
154+
for wildCardPattern, route := range cl.WildCardRoutes {
155+
match, err := filepath.Match(wildCardPattern, routeSegment)
156+
if err != nil {
157+
return nil, false, newDynamicError(ErrInvalidPath, err.Error())
158+
}
159+
if match {
160+
return route, true, nil
161+
}
162+
}
163+
164+
return nil, false, nil
165+
}
166+
167+
// subCageLevel searches for a sub cage level based on the segment provided
168+
// if createMode is true, it will create the cage level if it doesn't exist
169+
func (cl *CageLevel) subCageLevel(subCageSegment string, createMode bool) (cageLevel *CageLevel, found bool, err error) {
170+
// First check for an exact match
171+
cl.subCagesRWMu.RLock()
172+
if cageLevel, exists := cl.SubCages[subCageSegment]; exists {
173+
defer cl.subCagesRWMu.RUnlock()
174+
return cageLevel, true, nil
175+
}
176+
cl.subCagesRWMu.RUnlock()
177+
178+
// if not, look for wildcard cages
179+
cl.wildCardSubCagesRWMu.RLock()
180+
for wildCardPattern, cageLevel := range cl.WildCardSubCages {
181+
match, err := filepath.Match(wildCardPattern, subCageSegment)
182+
if err != nil {
183+
cl.wildCardSubCagesRWMu.RUnlock()
184+
return nil, false, newDynamicError(ErrInvalidPath, err.Error())
185+
}
186+
if match {
187+
cl.wildCardSubCagesRWMu.RUnlock()
188+
return cageLevel, true, nil
189+
}
190+
}
191+
cl.wildCardSubCagesRWMu.RUnlock()
192+
193+
// We didn't find a match, so we'll create a new cage level if we're in create mode
194+
if createMode {
195+
newCage := newCageLevel()
196+
if strings.Contains(subCageSegment, "*") {
197+
cl.wildCardSubCagesRWMu.Lock()
198+
defer cl.wildCardSubCagesRWMu.Unlock()
199+
cl.WildCardSubCages[subCageSegment] = newCage
200+
} else {
201+
cl.subCagesRWMu.Lock()
202+
defer cl.subCagesRWMu.Unlock()
203+
cl.SubCages[subCageSegment] = newCage
204+
}
205+
return newCage, true, nil
206+
}
207+
208+
return nil, false, nil
209+
}

parrot/cage_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package parrot
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestCageNewRoutes(t *testing.T) {
12+
t.Parallel()
13+
14+
testCases := []struct {
15+
name string
16+
route *Route
17+
expectedErr error
18+
}{
19+
{
20+
name: "basic route",
21+
route: &Route{
22+
Method: http.MethodGet,
23+
Path: "/test",
24+
RawResponseBody: "Squawk",
25+
ResponseStatusCode: http.StatusOK,
26+
},
27+
},
28+
{
29+
name: "wildcard route",
30+
route: &Route{
31+
Method: http.MethodGet,
32+
Path: "/*",
33+
RawResponseBody: "Squawk",
34+
ResponseStatusCode: http.StatusOK,
35+
},
36+
},
37+
{
38+
name: "nested route",
39+
route: &Route{
40+
Method: http.MethodGet,
41+
Path: "/test/nested",
42+
RawResponseBody: "Squawk",
43+
ResponseStatusCode: http.StatusOK,
44+
},
45+
},
46+
{
47+
name: "nested wild ard route",
48+
route: &Route{
49+
Method: http.MethodGet,
50+
Path: "/test/nested/*",
51+
RawResponseBody: "Squawk",
52+
ResponseStatusCode: http.StatusOK,
53+
},
54+
},
55+
{
56+
name: "multi-nested wild card route",
57+
route: &Route{
58+
Method: http.MethodGet,
59+
Path: "/test/*/nested/*",
60+
RawResponseBody: "Squawk",
61+
ResponseStatusCode: http.StatusOK,
62+
},
63+
},
64+
}
65+
66+
for _, tc := range testCases {
67+
t.Run(tc.name, func(t *testing.T) {
68+
t.Parallel()
69+
70+
c := newCage()
71+
require.NotNil(t, c, "Cage should not be nil")
72+
73+
// Create the new route
74+
err := c.newRoute(tc.route)
75+
require.NoError(t, err, "newRoute should not return an error")
76+
77+
// Check that the proper cage level got created
78+
cageLevel, routeSegment, err := c.cageLevel(tc.route.Path, false)
79+
require.NoError(t, err, "cageLevel should not return an error")
80+
require.NotNil(t, cageLevel, "cageLevel should not return nil")
81+
pathSegments := strings.Split(tc.route.Path, "/")
82+
routePathSegment := pathSegments[len(pathSegments)-1]
83+
require.Equal(t, routePathSegment, routeSegment, "route should be equal to the route in the cage")
84+
// Check that the route was created and can be found from the cage level
85+
route, found, err := cageLevel.route(routeSegment)
86+
require.NoError(t, err, "route should not return an error")
87+
require.True(t, found, "route should be found in the found cage level")
88+
require.Equal(t, tc.route, route, "route should be equal to the route in the cage")
89+
90+
// Check that the route was created and can be found from the base cage
91+
route, err = c.getRoute(tc.route.Path)
92+
require.NoError(t, err, "getRoute should not return an error")
93+
require.NotNil(t, route, "route should not be nil")
94+
require.Equal(t, tc.route, route, "route should be equal to the route in the cage")
95+
96+
// Check that we can properly delete the route
97+
err = c.deleteRoute(tc.route)
98+
require.NoError(t, err, "deleteRoute should not return an error")
99+
_, err = c.getRoute(tc.route.Path)
100+
require.ErrorIs(t, err, ErrRouteNotFound, "should error getting route after deleting it")
101+
102+
})
103+
}
104+
}

parrot/cmd/main.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func main() {
4242
if json {
4343
options = append(options, parrot.WithJSONLogs())
4444
}
45+
options = append(options, parrot.WithRecorders(recorders...))
4546

4647
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
4748
defer cancel()
@@ -51,20 +52,14 @@ func main() {
5152
return err
5253
}
5354

54-
for _, r := range recorders {
55-
err = p.Record(r)
56-
if err != nil {
57-
return err
58-
}
59-
}
60-
6155
c := make(chan os.Signal, 1)
6256
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
6357
<-c
6458
err = p.Shutdown(ctx)
6559
if err != nil {
6660
log.Error().Err(err).Msg("Error putting parrot to sleep")
6761
}
62+
p.WaitShutdown()
6863
return nil
6964
},
7065
}

0 commit comments

Comments
 (0)