github.com/renbou/grpcbridge@v0.0.2-0.20240416012907-bcbd8b12648a/routing/pattern_router_test.go (about) 1 package routing 2 3 import ( 4 "fmt" 5 "net/http" 6 "net/url" 7 "runtime" 8 "sync" 9 "testing" 10 11 "github.com/google/go-cmp/cmp" 12 "github.com/renbou/grpcbridge/bridgedesc" 13 "github.com/renbou/grpcbridge/grpcadapter" 14 "github.com/renbou/grpcbridge/internal/bridgetest" 15 "google.golang.org/grpc/codes" 16 ) 17 18 type httpRouter interface { 19 RouteHTTP(req *http.Request) (grpcadapter.ClientConn, HTTPRoute, error) 20 } 21 22 type patternTestCase struct { 23 method string 24 path string 25 routedMethod string 26 routedPattern string 27 routedParams map[string]string 28 statusCode codes.Code 29 } 30 31 func checkPatternTestCase(t *testing.T, router httpRouter, tc *patternTestCase) { 32 t.Helper() 33 34 // Act 35 _, route, err := router.RouteHTTP(&http.Request{Method: tc.method, URL: &url.URL{RawPath: tc.path}}) 36 37 // Assert 38 if cmpErr := bridgetest.StatusCodeIs(err, tc.statusCode); cmpErr != nil { 39 t.Fatalf("RouteHTTP(%s, %q) returned error = %q with unexpected code: %s", tc.method, tc.path, err, cmpErr) 40 } 41 42 if tc.statusCode != codes.OK { 43 return 44 } 45 46 if route.Binding == nil || route.Method == nil || route.Service == nil || route.Target == nil { 47 t.Fatalf("RouteHTTP(%s, %q) returned route with missing info about binding, method, service, or target: %#v", tc.method, tc.path, route) 48 } 49 50 if route.Binding.Pattern != tc.routedPattern { 51 t.Errorf("RouteHTTP(%s, %q) matched route with pattern = %q, want %q", tc.method, tc.path, route.Binding.Pattern, tc.routedPattern) 52 } 53 54 if route.Method.RPCName != tc.routedMethod { 55 t.Errorf("RouteHTTP(%s, %q) matched route with method = %q, want %q", tc.method, tc.path, route.Method.RPCName, tc.routedMethod) 56 } 57 58 if diff := cmp.Diff(tc.routedParams, route.PathParams); diff != "" { 59 t.Errorf("RouteHTTP(%s, %q) matched route with path params differing from expected (-want+got):\n%s", tc.method, tc.path, diff) 60 } 61 } 62 63 // Test_PatternRouter_RouteHTTP_Ok tests that PatternRouter routes HTTP requests correctly. 64 func Test_PatternRouter_RouteHTTP_Ok(t *testing.T) { 65 t.Parallel() 66 67 // Arrange 68 pr := NewPatternRouter(nilConnPool{}, PatternRouterOpts{}) 69 70 watcher, err := pr.Watch(testTarget.Name) 71 if err != nil { 72 t.Fatalf("Watch(%q) returned non-nil error = %q", testTarget.Name, err) 73 } 74 75 defer watcher.Close() 76 watcher.UpdateDesc(&testTarget) // this returns when the update has been applied 77 78 tests := []patternTestCase{ 79 {method: "POST", path: "not a path", statusCode: codes.InvalidArgument}, 80 {method: "POST", path: "/grpcbridge.routing.v1.PureGRPCSvc/Get", routedMethod: "/grpcbridge.routing.v1.PureGRPCSvc/Get", routedPattern: "/grpcbridge.routing.v1.PureGRPCSvc/Get", routedParams: nil, statusCode: codes.OK}, 81 {method: "GET", path: "/grpcbridge.routing.v1.PureGRPCSvc/Get", statusCode: codes.NotFound}, 82 {method: "POST", path: "/grpcbridge.routing.v1.PureGRPCSvc/List", routedMethod: "/grpcbridge.routing.v1.PureGRPCSvc/List", routedPattern: "/grpcbridge.routing.v1.PureGRPCSvc/List", routedParams: nil, statusCode: codes.OK}, 83 {method: "PATCH", path: "/grpcbridge.routing.v1.PureGRPCSvc/List", statusCode: codes.NotFound}, 84 {method: "POST", path: "/grpcbridge.routing.v1.PureGRPCSvc/Unknown", statusCode: codes.NotFound}, 85 {method: "POST", path: "/api/v1/entities", routedMethod: "/grpcbridge.routing.v1.RestSvc/CreateEntity", routedPattern: "/api/v1/entities", routedParams: nil, statusCode: codes.OK}, 86 {method: "POST", path: "/api/v1/entity", routedMethod: "/grpcbridge.routing.v1.RestSvc/CreateEntity", routedPattern: "/api/v1/entity", routedParams: nil, statusCode: codes.OK}, 87 {method: "GET", path: "/api/v1/entity/1", routedMethod: "/grpcbridge.routing.v1.RestSvc/GetEntity", routedPattern: "/api/v1/entity/{entity_id=*}", routedParams: map[string]string{"entity_id": "1"}, statusCode: codes.OK}, 88 {method: "GET", path: "/api/v1/entity/asdf1234", routedMethod: "/grpcbridge.routing.v1.RestSvc/GetEntity", routedPattern: "/api/v1/entity/{entity_id=*}", routedParams: map[string]string{"entity_id": "asdf1234"}, statusCode: codes.OK}, 89 {method: "GET", path: "/api/v1/entity/asdf1234/sub", statusCode: codes.NotFound}, 90 {method: "GET", path: "/api/v1/entity/1:fakeverb", routedMethod: "/grpcbridge.routing.v1.RestSvc/GetEntity", routedPattern: "/api/v1/entity/{entity_id=*}", routedParams: map[string]string{"entity_id": "1:fakeverb"}, statusCode: codes.OK}, 91 {method: "GET", path: "/api/v1/entities", routedMethod: "/grpcbridge.routing.v1.RestSvc/ListEntities", routedPattern: "/api/v1/entities", routedParams: nil, statusCode: codes.OK}, 92 {method: "PUT", path: "/api/v1/entity/%61%29%30%20%0a%2b", routedMethod: "/grpcbridge.routing.v1.RestSvc/UpdateEntity", routedPattern: "/api/v1/entity/{entity_id}", routedParams: map[string]string{"entity_id": "a)0 \n+"}, statusCode: codes.OK}, 93 {method: "PATCH", path: "/api/v1/entity/entity?+", routedMethod: "/grpcbridge.routing.v1.RestSvc/UpdateEntity", routedPattern: "/api/v1/entity/{entity_id}", routedParams: map[string]string{"entity_id": "entity?+"}, statusCode: codes.OK}, 94 {method: "DELETE", path: "/api/v1/entity/", routedMethod: "/grpcbridge.routing.v1.RestSvc/DeleteEntity", routedPattern: "/api/v1/entity/{entity_id}", routedParams: map[string]string{"entity_id": ""}, statusCode: codes.OK}, 95 {method: "POST", path: "/api/v2/entity:create", routedMethod: "/grpcbridge.routing.v2.RestSvc/CreateEntity", routedPattern: "/api/v2/entity:create", routedParams: nil, statusCode: codes.OK}, 96 {method: "post", path: "/api/v2/entity:create", statusCode: codes.NotFound}, 97 {method: "GET", path: "/api/v2/entity/asdf1234", routedMethod: "/grpcbridge.routing.v2.RestSvc/GetEntity", routedPattern: "/api/v2/entity/{entity_id}", routedParams: map[string]string{"entity_id": "asdf1234"}, statusCode: codes.OK}, 98 {method: "POST", path: "/api/v2/entity/testentity/test/sub:watch", routedMethod: "/grpcbridge.routing.v2.RestSvc/WatchEntity", routedPattern: "/api/v2/entity/{entity_id}/{path=**}:watch", routedParams: map[string]string{"entity_id": "testentity", "path": "test/sub"}, statusCode: codes.OK}, 99 {method: "POST", path: "/api/v2/entity/testentity/test/:watch", statusCode: codes.NotFound}, 100 {method: "POST", path: "/api/v2/entities/all/test/sub:watch", routedMethod: "/grpcbridge.routing.v2.RestSvc/WatchEntity", routedPattern: "/api/v2/entities/{path=all/**}:watch", routedParams: map[string]string{"path": "all/test/sub"}, statusCode: codes.OK}, 101 {method: "POST", path: "/api/v2/entity/testentity/%61%29%30%20%0a%2b/sub:watch", routedMethod: "/grpcbridge.routing.v2.RestSvc/WatchEntity", routedPattern: "/api/v2/entity/{entity_id}/{path=**}:watch", routedParams: map[string]string{"entity_id": "testentity", "path": "a%290 \n%2b/sub"}, statusCode: codes.OK}, 102 } 103 104 // Run in non-parallel subtest so that watcher.Close() runs AFTER all the subtests. 105 t.Run("cases", func(t *testing.T) { 106 for _, tt := range tests { 107 t.Run(fmt.Sprintf("%s %s", tt.method, tt.path), func(t *testing.T) { 108 t.Parallel() 109 110 // Arrange 111 // Need to wait for routes to be synced to other goroutines, since they're stored as an atomic. 112 for { 113 table := pr.routes.static.Load() 114 if len(table.routes) > 0 { 115 break 116 } 117 118 runtime.Gosched() 119 } 120 121 // Act & Assert 122 checkPatternTestCase(t, pr, &tt) 123 }) 124 } 125 }) 126 } 127 128 // Test_PatternRouter_RouteHTTP_Updates tests that PatternRouter properly handles concurrently occurring updates to many targets. 129 // N targets are created with one of the 4 possible "behaviour" templates, which describes which updates come from the target's watcher. 130 // After these updates are applied, a routing test is performed to check that only the active routes for each target are routed. 131 func Test_PatternRouter_RouteHTTP_Updates(t *testing.T) { 132 t.Parallel() 133 134 const N = 100 135 const seed = 1 136 137 // Arrange 138 // A few update templates outlining different modifications which can come to a pattern router watcher. 139 updateTemplates := [][]bridgedesc.Target{ 140 { 141 // 2 methods originally, both will be bound as defaults with POST 142 {Services: []bridgedesc.Service{ 143 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v1/Method"}}}, 144 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v2/Method"}}}, 145 }}, 146 // then, one method receives a proper binding with a new GET method 147 {Services: []bridgedesc.Service{ 148 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v1/Method"}}}, 149 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v2/Method", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/testsvc_%d/v2/{id}"}}}}}, 150 }}, 151 // then, the methods get deleted and a new one is added with a PATCH method 152 {Services: []bridgedesc.Service{ 153 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v3/Method", Bindings: []bridgedesc.Binding{{HTTPMethod: "PATCH", Pattern: "/testsvc_%d/v3/{id}"}}}}}, 154 }}, 155 }, 156 { 157 // 2 methods on a single service, both with bindings 158 {Services: []bridgedesc.Service{ 159 {Methods: []bridgedesc.Method{ 160 {RPCName: "/grpcbridge.routing.testsvc_%d/MethodA", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/testsvc_%d/a"}}}, 161 {RPCName: "/grpcbridge.routing.testsvc_%d/MethodB", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/testsvc_%d/b"}}}, 162 }}, 163 }}, 164 // methods move to different services but keep the same bindings 165 {Services: []bridgedesc.Service{ 166 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v1/MethodA", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/testsvc_%d/a"}}}}}, 167 {Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d.v2/MethodB", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/testsvc_%d/b"}}}}}, 168 }}, 169 }, 170 { 171 // 2 methods on a single service - one with good bindings, other with invalid binding 172 {Services: []bridgedesc.Service{ 173 {Methods: []bridgedesc.Method{ 174 {RPCName: "/grpcbridge.routing.testsvc_%d/MethodOk", Bindings: []bridgedesc.Binding{ 175 {HTTPMethod: "GET", Pattern: "/api/testsvc_%d/ok:verb"}, 176 {HTTPMethod: "POST", Pattern: "/api/testsvc_%d/{vals=sub/*/**}"}, 177 {HTTPMethod: "DELETE", Pattern: "/api/testsvc_%d/action:delete"}, 178 }}, 179 {RPCName: "/grpcbridge.routing.testsvc_%d/MethodBad", Bindings: []bridgedesc.Binding{{HTTPMethod: "GET", Pattern: "/service %d: this isn't a proper path, right?"}}}, 180 }}, 181 }}, 182 // the method with invalid binding is removed, the other one contains one less binding 183 {Services: []bridgedesc.Service{ 184 {Methods: []bridgedesc.Method{ 185 {RPCName: "/grpcbridge.routing.testsvc_%d/MethodOk", Bindings: []bridgedesc.Binding{ 186 {HTTPMethod: "POST", Pattern: "/api/testsvc_%d/{vals=sub/*/**}"}, 187 {HTTPMethod: "DELETE", Pattern: "/api/testsvc_%d/action:delete"}, 188 }}, 189 }}, 190 }}, 191 }, 192 { 193 // template number 4 contains a single binding, and then the watcher gets stopped, so no binding should be left over. 194 {Services: []bridgedesc.Service{{Methods: []bridgedesc.Method{{RPCName: "/grpcbridge.routing.testsvc_%d/DeletedTarget"}}}}}, 195 }, 196 } 197 198 // Test cases for each template, testing that the additions/updates/removals of patterns were handled correctly. 199 templateTests := [][]patternTestCase{ 200 { 201 // First three cases are the ones that shouldn't be present after updates, the last is the only one present. 202 {method: "POST", path: "/grpcbridge.routing.testsvc_%d.v1/Method", statusCode: codes.NotFound}, 203 {method: "POST", path: "/grpcbridge.routing.testsvc_%d.v2/Method", statusCode: codes.NotFound}, 204 {method: "GET", path: "/testsvc_%d/v2/exampleid", statusCode: codes.NotFound}, 205 {method: "PATCH", path: "/testsvc_%d/v3/exampleid", routedMethod: "/grpcbridge.routing.testsvc_%d.v3/Method", routedPattern: "/testsvc_%d/v3/{id}", routedParams: map[string]string{"id": "exampleid"}, statusCode: codes.OK}, 206 }, 207 { 208 // The patterns haven't changed, but requests should be routed to the correct path. 209 {method: "GET", path: "/testsvc_%d/a", routedMethod: "/grpcbridge.routing.testsvc_%d.v1/MethodA", routedPattern: "/testsvc_%d/a", routedParams: nil, statusCode: codes.OK}, 210 {method: "GET", path: "/testsvc_%d/b", routedMethod: "/grpcbridge.routing.testsvc_%d.v2/MethodB", routedPattern: "/testsvc_%d/b", routedParams: nil, statusCode: codes.OK}, 211 }, 212 { 213 // The removed and invalid patterns shouldn't be present, the rest should still be working. 214 {method: "GET", path: "/api/testsvc_%d/ok:verb", statusCode: codes.NotFound}, 215 {method: "GET", path: "/service %d: this isn't a proper path, right?", statusCode: codes.NotFound}, 216 {method: "POST", path: "/api/testsvc_%d/sub/kek/extra/path", routedMethod: "/grpcbridge.routing.testsvc_%d/MethodOk", routedPattern: "/api/testsvc_%d/{vals=sub/*/**}", routedParams: map[string]string{"vals": "sub/kek/extra/path"}, statusCode: codes.OK}, 217 {method: "POST", path: "/api/testsvc_%d/sub", statusCode: codes.NotFound}, 218 {method: "DELETE", path: "/api/testsvc_%d/action:delete", routedMethod: "/grpcbridge.routing.testsvc_%d/MethodOk", routedPattern: "/api/testsvc_%d/action:delete", routedParams: nil, statusCode: codes.OK}, 219 }, 220 { 221 // All routes of this template should be deleted due to the watcher closing. 222 {method: "POST", path: "/grpcbridge.routing.testsvc_%d/DeletedTarget", statusCode: codes.NotFound}, 223 }, 224 } 225 226 targets := buildTemplateTargets(N, seed, updateTemplates) 227 pr := NewPatternRouter(nilConnPool{}, PatternRouterOpts{}) 228 229 watchers := make([]*PatternRouterWatcher, len(targets)) 230 for i, ti := range targets { 231 var err error 232 if watchers[i], err = pr.Watch(ti.name); err != nil { 233 t.Fatalf("Watch(%q) returned non-nil error: %q", ti.name, err) 234 } 235 } 236 237 // Act 238 // Apply all the updates for each templated target. 239 var wg sync.WaitGroup 240 wg.Add(len(targets)) 241 242 for i, ti := range targets { 243 go func() { 244 defer wg.Done() 245 246 var update bridgedesc.Target 247 248 // Templating performed in parallel for faster testing. 249 for updateIdx := range updateTemplates[ti.template] { 250 buildDescTemplate(&updateTemplates[ti.template][updateIdx], &update, ti.name, i) 251 watchers[i].UpdateDesc(&update) 252 } 253 254 // Additionally separately handle targets with template 4, which should be closed completely. 255 if ti.template == 3 { 256 watchers[i].Close() 257 } 258 }() 259 } 260 261 wg.Wait() 262 263 // Assert 264 // Concurrently validate routing for each target. 265 for i, ti := range targets { 266 t.Run(fmt.Sprintf("%d-%s", ti.template, ti.name), func(t *testing.T) { 267 t.Parallel() 268 269 // Apply templating to each case before actually testing it. 270 for _, tt := range templateTests[ti.template] { 271 tt.path = fmt.Sprintf(tt.path, i) 272 tt.routedMethod = fmt.Sprintf(tt.routedMethod, i) 273 tt.routedPattern = fmt.Sprintf(tt.routedPattern, i) 274 275 checkPatternTestCase(t, pr, &tt) 276 } 277 }) 278 } 279 }