From 75e3089496f0aeb014913ec749ef0bbee0c07bdc Mon Sep 17 00:00:00 2001 From: alfadb Date: Tue, 7 Jul 2026 09:45:04 +0800 Subject: [PATCH] fix: normalize inbound endpoints from raw request path --- backend/internal/handler/endpoint.go | 19 +++-- backend/internal/handler/endpoint_test.go | 95 ++++++++++++++++++++--- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index 5661d763ee..0b9930c5cc 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -235,10 +235,13 @@ func responsesSubpathSuffix(rawPath string) string { // Apply this middleware to all gateway route groups. func InboundEndpointMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - path := c.FullPath() - if path == "" && c.Request != nil && c.Request.URL != nil { + path := "" + if c.Request != nil && c.Request.URL != nil { path = c.Request.URL.Path } + if path == "" { + path = c.FullPath() + } c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path)) c.Next() } @@ -251,7 +254,11 @@ func InboundEndpointMiddleware() gin.HandlerFunc { // GetInboundEndpoint returns the canonical inbound endpoint stored by // InboundEndpointMiddleware. If the middleware did not run (e.g. in -// tests), it falls back to normalizing c.FullPath() on the fly. +// tests), it falls back to normalizing c.Request.URL.Path on the fly +// (preferring the raw request path over c.FullPath(), which collapses +// wildcard route patterns such as "/v1/responses/*subpath" and would +// otherwise mis-normalize concrete requests like "/v1/responses/compact" +// to the root Responses endpoint). func GetInboundEndpoint(c *gin.Context) string { if v, ok := c.Get(ctxKeyInboundEndpoint); ok { if s, ok := v.(string); ok && s != "" { @@ -261,10 +268,12 @@ func GetInboundEndpoint(c *gin.Context) string { // Fallback: normalize on the fly. path := "" if c != nil { - path = c.FullPath() - if path == "" && c.Request != nil && c.Request.URL != nil { + if c.Request != nil && c.Request.URL != nil { path = c.Request.URL.Path } + if path == "" { + path = c.FullPath() + } } return NormalizeInboundEndpoint(path) } diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 8d92487c8a..5756c27339 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -63,16 +63,6 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/foo/responses", "/foo/responses"}, {"/foo/responses/compact", "/foo/responses/compact"}, - // Gin route patterns with wildcards. The literal wildcard token - // ("*subpath") is not the "compact" segment itself, so these - // generic FullPath patterns normalize to the root Responses - // endpoint; only a concrete "compact" path segment (tested above) - // resolves to EndpointResponsesCompact. - {"/v1beta/models/*modelAction", EndpointGeminiModels}, - {"/v1/responses/*subpath", EndpointResponses}, - {"/responses/*subpath", EndpointResponses}, - {"/backend-api/codex/responses/*subpath", EndpointResponses}, - // Unknown path is returned as-is. {"/v1/embeddings", "/v1/embeddings"}, {"", ""}, @@ -208,6 +198,91 @@ func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) { require.Equal(t, EndpointMessages, got) } +// TestInboundEndpointMiddleware_WildcardRoutes verifies that, when a +// gateway route is registered with a Gin wildcard pattern (e.g. +// "/v1/responses/*subpath"), InboundEndpointMiddleware normalizes based +// on the concrete request path (c.Request.URL.Path) rather than the +// route pattern (c.FullPath()). Using c.FullPath() here would collapse +// every request under the wildcard — including "/v1/responses/compact" +// — down to the literal pattern string, which never matches the +// "compact" alias detection and would incorrectly normalize to the root +// Responses endpoint. +func TestInboundEndpointMiddleware_WildcardRoutes(t *testing.T) { + tests := []struct { + name string + routePath string + requestPath string + want string + }{ + { + name: "v1 responses wildcard route, compact request", + routePath: "/v1/responses/*subpath", + requestPath: "/v1/responses/compact", + want: EndpointResponsesCompact, + }, + { + name: "bare responses wildcard route, compact request", + routePath: "/responses/*subpath", + requestPath: "/responses/compact", + want: EndpointResponsesCompact, + }, + { + name: "codex direct wildcard route, compact request", + routePath: "/backend-api/codex/responses/*subpath", + requestPath: "/backend-api/codex/responses/compact", + want: EndpointResponsesCompact, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.Use(InboundEndpointMiddleware()) + + var captured string + router.POST(tt.routePath, func(c *gin.Context) { + captured = GetInboundEndpoint(c) + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, tt.requestPath, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, tt.want, captured) + }) + } +} + +// TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware verifies +// that when InboundEndpointMiddleware did NOT run (so no value is stored +// in gin.Context), the GetInboundEndpoint fallback path still prefers +// c.Request.URL.Path over c.FullPath(). This guards against the fallback +// regressing to prefer c.FullPath() again, which would misnormalize +// concrete requests matched by a wildcard route pattern (e.g. +// "/v1/responses/*subpath" matching "/v1/responses/compact") down to +// the root Responses endpoint. +func TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware(t *testing.T) { + router := gin.New() + // Deliberately do NOT register InboundEndpointMiddleware. + + var captured string + router.POST("/v1/responses/*subpath", func(c *gin.Context) { + // Sanity check: FullPath returns the route pattern, not the + // concrete request path, when a wildcard route matches. + require.Equal(t, "/v1/responses/*subpath", c.FullPath()) + captured = GetInboundEndpoint(c) + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, EndpointResponsesCompact, captured) +} + func TestGetUpstreamEndpoint_FullFlow(t *testing.T) { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec)