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  }