From 215b06a12e601182ca212d8fc7202b772dc4cbb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Sanz?= Date: Thu, 5 Nov 2026 15:18:03 +0100 Subject: [PATCH 1/2] fix(serverless): call an endpoint whose path has more than one segment --- docs/runware_serverless_apps_invoke.md | 5 ++- internal/api/serverless/client.go | 1 + internal/api/serverless/tasks.go | 42 ++++++++++++++++---- internal/api/serverless/tasks_test.go | 44 ++++++++++++++++++--- internal/cmd/serverless/apps_invoke.go | 5 ++- internal/cmd/serverless/apps_invoke_test.go | 12 +++++- 6 files changed, 92 insertions(+), 17 deletions(-) diff --git a/docs/runware_serverless_apps_invoke.md b/docs/runware_serverless_apps_invoke.md index 98739dd..1d674ec 100644 --- a/docs/runware_serverless_apps_invoke.md +++ b/docs/runware_serverless_apps_invoke.md @@ -6,8 +6,9 @@ Invoke an application endpoint Submit a JSON payload to a named application endpoint. -endpointPath is a bare lowercase segment as returned by apps endpoints -(e.g. infer). A leading slash is rejected. +endpointPath is the path apps endpoints reports, one or more lowercase +segments joined by slashes (e.g. infer, or v1/chat/completions for a server +that answers on a nested route). A leading slash is rejected. The default is async: the command prints the accepted task id. Pass --wait to poll until the task is completed or failed, and --timeout to bound that diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 29b7933..b16ee2a 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -289,6 +289,7 @@ func newGeneratedClient(apiKey, baseURL string, httpClient gen.HttpRequestDoer) req.Header.Set("Accept", "application/json") return nil }), + gen.WithRequestEditorFn(restoreEndpointPathSeparators), ) if err != nil { // NewClientWithResponses only fails if an option returns an error; diff --git a/internal/api/serverless/tasks.go b/internal/api/serverless/tasks.go index 266f5a9..86351d6 100644 --- a/internal/api/serverless/tasks.go +++ b/internal/api/serverless/tasks.go @@ -15,8 +15,32 @@ import ( "github.com/runware/runware-cli/internal/api/transport" ) -// endpointPathPattern is ADR-034: a bare lowercase segment, no leading slash. -var endpointPathPattern = regexp.MustCompile(`^[a-z]([a-z0-9-]{0,62}[a-z0-9])?$`) +// endpointPathPattern and endpointPathMaxLen mirror the EndpointPath schema in +// api/serverless/openapi.yaml. A path is one or more lowercase segments joined +// by slashes, so an endpoint that answers on a nested route, as an +// OpenAI-compatible server does, is addressable here too. +var endpointPathPattern = regexp.MustCompile(`^[a-z]([a-z0-9-]{0,62}[a-z0-9])?(/[a-z]([a-z0-9-]{0,62}[a-z0-9])?)*$`) + +const endpointPathMaxLen = 255 + +// restoreEndpointPathSeparators puts the slashes of a multi-segment endpoint +// path back into the request URL. The generated client styles a path parameter +// with simple escaping, which percent-encodes those slashes, and the API +// rejects an endpointPath that arrives as %2F. No other path parameter here +// admits a slash, so this is safe to run on every request. +func restoreEndpointPathSeparators(_ context.Context, req *http.Request) error { + if req.URL == nil || req.URL.RawPath == "" { + return nil + } + restored := strings.ReplaceAll(req.URL.RawPath, "%2F", "/") + restored = strings.ReplaceAll(restored, "%2f", "/") + if restored == req.URL.Path { + req.URL.RawPath = "" + return nil + } + req.URL.RawPath = restored + return nil +} // taskNotFoundRetry is how long WaitTask retries getTask 404s. A freshly // accepted id can temporarily miss the result store. @@ -25,8 +49,9 @@ const taskNotFoundRetry = 30 * time.Second // defaultTaskPollInterval is used when WaitTask is called with a non-positive interval. const defaultTaskPollInterval = 2 * time.Second -// ValidateEndpointPath checks ADR-034. A leading slash is rejected by the API -// with 422; fail locally with a hint naming the bare segment. +// ValidateEndpointPath checks a path against the API's own EndpointPath schema. +// A leading slash is rejected by the API with 422; fail locally with a hint +// naming the path without it. func ValidateEndpointPath(path string) error { if path == "" { return fmt.Errorf("endpoint path is required") @@ -34,12 +59,15 @@ func ValidateEndpointPath(path string) error { if strings.HasPrefix(path, "/") { bare := strings.TrimLeft(path, "/") if endpointPathPattern.MatchString(bare) { - return fmt.Errorf("endpoint path %q must be a bare segment without a leading slash (e.g. %q)", path, bare) + return fmt.Errorf("endpoint path %q must carry no leading slash (e.g. %q)", path, bare) } - return fmt.Errorf("endpoint path %q must be a bare lowercase segment without a leading slash", path) + return fmt.Errorf("endpoint path %q must carry no leading slash", path) + } + if len(path) > endpointPathMaxLen { + return fmt.Errorf("endpoint path is %d characters, over the %d the API accepts", len(path), endpointPathMaxLen) } if !endpointPathPattern.MatchString(path) { - return fmt.Errorf("endpoint path %q is invalid: use a lowercase segment of 1-64 characters (letters, digits, hyphens)", path) + return fmt.Errorf("endpoint path %q is invalid: use the path apps endpoints reports, one or more lowercase segments joined by slashes, such as generate or v1/chat/completions", path) } return nil } diff --git a/internal/api/serverless/tasks_test.go b/internal/api/serverless/tasks_test.go index fd2f806..2f24570 100644 --- a/internal/api/serverless/tasks_test.go +++ b/internal/api/serverless/tasks_test.go @@ -26,6 +26,8 @@ const ( ) func TestValidateEndpointPath(t *testing.T) { + const wantInvalid = "invalid" + cases := []struct { path string wantErr string @@ -33,12 +35,21 @@ func TestValidateEndpointPath(t *testing.T) { {path: "infer"}, {path: "a"}, {path: "my-endpoint"}, + {path: "v1/chat/completions"}, + {path: "v1/models"}, + {path: "a/b/c/d/e"}, + {path: strings.Repeat("ab/", 84) + "ab"}, {path: "", wantErr: "required"}, - {path: "/infer", wantErr: "bare segment"}, + {path: "/infer", wantErr: "leading slash"}, {path: "/infer", wantErr: `"infer"`}, - {path: "Infer", wantErr: "invalid"}, - {path: "my_endpoint", wantErr: "invalid"}, - {path: "infer/", wantErr: "invalid"}, + {path: "/v1/chat/completions", wantErr: `"v1/chat/completions"`}, + {path: "Infer", wantErr: wantInvalid}, + {path: "my_endpoint", wantErr: wantInvalid}, + {path: "infer/", wantErr: wantInvalid}, + {path: "v1//completions", wantErr: wantInvalid}, + {path: "v1/Chat", wantErr: wantInvalid}, + {path: "v1/chat/completions/", wantErr: wantInvalid}, + {path: strings.Repeat("ab/", 85) + "ab", wantErr: "over the 255"}, {path: "/", wantErr: "leading slash"}, } for _, tc := range cases { @@ -104,7 +115,7 @@ func TestInvokeAsync(t *testing.T) { func TestInvokeAsync_RejectsLeadingSlash(t *testing.T) { c := NewClient("test-key", "https://example.invalid", slog.Default()) _, err := c.InvokeAsync(context.Background(), testAppID, "/infer", "", nil) - if err == nil || !strings.Contains(err.Error(), "bare segment") { + if err == nil || !strings.Contains(err.Error(), "leading slash") { t.Fatalf("expected leading-slash error, got %v", err) } } @@ -419,3 +430,26 @@ func TestWaitTask_DoesNotPost(t *testing.T) { t.Fatalf("WaitTask: %v", err) } } + +func TestRestoreEndpointPathSeparators(t *testing.T) { + cases := []struct { + url string + want string + }{ + {url: "https://example.invalid/v1/apps/my-app/tasks/v1%2Fchat%2Fcompletions", want: "/v1/apps/my-app/tasks/v1/chat/completions"}, + {url: "https://example.invalid/v1/apps/my-app/tasks/v1%2fchat%2fcompletions", want: "/v1/apps/my-app/tasks/v1/chat/completions"}, + {url: "https://example.invalid/v1/apps/my-app/tasks/infer", want: "/v1/apps/my-app/tasks/infer"}, + } + for _, tc := range cases { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, tc.url, nil) + if err != nil { + t.Fatalf("NewRequest(%q): %v", tc.url, err) + } + if err := restoreEndpointPathSeparators(context.Background(), req); err != nil { + t.Fatalf("restoreEndpointPathSeparators(%q): %v", tc.url, err) + } + if got := req.URL.EscapedPath(); got != tc.want { + t.Errorf("EscapedPath for %q = %q, want %q", tc.url, got, tc.want) + } + } +} diff --git a/internal/cmd/serverless/apps_invoke.go b/internal/cmd/serverless/apps_invoke.go index e3b1ef2..4df48a8 100644 --- a/internal/cmd/serverless/apps_invoke.go +++ b/internal/cmd/serverless/apps_invoke.go @@ -28,8 +28,9 @@ func newAppsInvokeCmd(logger *log.Logger) *cobra.Command { Short: "Invoke an application endpoint", Long: `Submit a JSON payload to a named application endpoint. -endpointPath is a bare lowercase segment as returned by apps endpoints -(e.g. infer). A leading slash is rejected. +endpointPath is the path apps endpoints reports, one or more lowercase +segments joined by slashes (e.g. infer, or v1/chat/completions for a server +that answers on a nested route). A leading slash is rejected. The default is async: the command prints the accepted task id. Pass --wait to poll until the task is completed or failed, and --timeout to bound that diff --git a/internal/cmd/serverless/apps_invoke_test.go b/internal/cmd/serverless/apps_invoke_test.go index 9a26d14..f1b2bd1 100644 --- a/internal/cmd/serverless/apps_invoke_test.go +++ b/internal/cmd/serverless/apps_invoke_test.go @@ -11,11 +11,21 @@ import ( func TestParseInvokeInput_RejectsLeadingSlash(t *testing.T) { _, _, err := parseInvokeInput("/infer", "", strings.NewReader("")) - if err == nil || !strings.Contains(err.Error(), "bare segment") { + if err == nil || !strings.Contains(err.Error(), "leading slash") { t.Fatalf("expected leading-slash error, got %v", err) } } +func TestParseInvokeInput_AcceptsNestedPath(t *testing.T) { + path, _, err := parseInvokeInput("v1/chat/completions", "", strings.NewReader("")) + if err != nil { + t.Fatalf("parseInvokeInput: %v", err) + } + if path != "v1/chat/completions" { + t.Fatalf("path = %q", path) + } +} + func TestParseInvokeInput_DefaultEmptyObject(t *testing.T) { path, payload, err := parseInvokeInput("infer", "", strings.NewReader("")) if err != nil { From ae8b691e6c58afc4e268478b4003c538588ba403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Sanz?= Date: Thu, 5 Nov 2026 15:35:09 +0100 Subject: [PATCH 2/2] fix(serverless): restore the endpoint path separators only on the invoke routes --- internal/api/serverless/tasks.go | 25 ++++++++++++++++++++----- internal/api/serverless/tasks_test.go | 8 +++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/internal/api/serverless/tasks.go b/internal/api/serverless/tasks.go index 86351d6..547fcb4 100644 --- a/internal/api/serverless/tasks.go +++ b/internal/api/serverless/tasks.go @@ -23,22 +23,37 @@ var endpointPathPattern = regexp.MustCompile(`^[a-z]([a-z0-9-]{0,62}[a-z0-9])?(/ const endpointPathMaxLen = 255 +// endpointPathRoutes are the operation paths that carry an endpointPath. +var endpointPathRoutes = []string{"/invoke-async/", "/invoke-sync/"} + // restoreEndpointPathSeparators puts the slashes of a multi-segment endpoint // path back into the request URL. The generated client styles a path parameter // with simple escaping, which percent-encodes those slashes, and the API -// rejects an endpointPath that arrives as %2F. No other path parameter here -// admits a slash, so this is safe to run on every request. +// rejects an endpointPath that arrives as %2F. Only the part after the route +// marker is rewritten, so an escaped slash in a configured base URL is left as +// the caller wrote it. func restoreEndpointPathSeparators(_ context.Context, req *http.Request) error { if req.URL == nil || req.URL.RawPath == "" { return nil } - restored := strings.ReplaceAll(req.URL.RawPath, "%2F", "/") + raw := req.URL.RawPath + cut := -1 + for _, marker := range endpointPathRoutes { + if i := strings.LastIndex(raw, marker); i >= 0 && i+len(marker) > cut { + cut = i + len(marker) + } + } + if cut < 0 { + return nil + } + head, tail := raw[:cut], raw[cut:] + restored := strings.ReplaceAll(tail, "%2F", "/") restored = strings.ReplaceAll(restored, "%2f", "/") - if restored == req.URL.Path { + if head+restored == req.URL.Path { req.URL.RawPath = "" return nil } - req.URL.RawPath = restored + req.URL.RawPath = head + restored return nil } diff --git a/internal/api/serverless/tasks_test.go b/internal/api/serverless/tasks_test.go index 2f24570..169ff8e 100644 --- a/internal/api/serverless/tasks_test.go +++ b/internal/api/serverless/tasks_test.go @@ -436,9 +436,11 @@ func TestRestoreEndpointPathSeparators(t *testing.T) { url string want string }{ - {url: "https://example.invalid/v1/apps/my-app/tasks/v1%2Fchat%2Fcompletions", want: "/v1/apps/my-app/tasks/v1/chat/completions"}, - {url: "https://example.invalid/v1/apps/my-app/tasks/v1%2fchat%2fcompletions", want: "/v1/apps/my-app/tasks/v1/chat/completions"}, - {url: "https://example.invalid/v1/apps/my-app/tasks/infer", want: "/v1/apps/my-app/tasks/infer"}, + {url: "https://example.invalid/v1/apps/my-app/invoke-sync/v1%2Fchat%2Fcompletions", want: "/v1/apps/my-app/invoke-sync/v1/chat/completions"}, + {url: "https://example.invalid/v1/apps/my-app/invoke-async/v1%2fchat%2fcompletions", want: "/v1/apps/my-app/invoke-async/v1/chat/completions"}, + {url: "https://example.invalid/v1/apps/my-app/invoke-sync/infer", want: "/v1/apps/my-app/invoke-sync/infer"}, + {url: "https://proxy.invalid/tenant%2Fname/v1/apps/my-app/invoke-sync/v1%2Fchat%2Fcompletions", want: "/tenant%2Fname/v1/apps/my-app/invoke-sync/v1/chat/completions"}, + {url: "https://proxy.invalid/tenant%2Fname/v1/apps/my-app/tasks/7c9e6679", want: "/tenant%2Fname/v1/apps/my-app/tasks/7c9e6679"}, } for _, tc := range cases { req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, tc.url, nil)