Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/runware_serverless_apps_invoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ func newGeneratedClient(apiKey, baseURL string, httpClient gen.HttpRequestDoer)
req.Header.Set("Accept", "application/json")
return nil
}),
gen.WithRequestEditorFn(restoreEndpointPathSeparators),
Comment thread
danmrichards marked this conversation as resolved.
Resolved
Comment thread
danmrichards marked this conversation as resolved.
Resolved
)
if err != nil {
// NewClientWithResponses only fails if an option returns an error;
Expand Down
57 changes: 50 additions & 7 deletions internal/api/serverless/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,47 @@ 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])?)*$`)
Comment thread
danmrichards marked this conversation as resolved.
Resolved

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. 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
}
raw := req.URL.RawPath
cut := -1
for _, marker := range endpointPathRoutes {
if i := strings.LastIndex(raw, marker); i >= 0 && i+len(marker) > cut {
Comment thread
danmrichards marked this conversation as resolved.
Resolved
cut = i + len(marker)
Comment on lines +42 to +43
}
}
if cut < 0 {
return nil
}
head, tail := raw[:cut], raw[cut:]
restored := strings.ReplaceAll(tail, "%2F", "/")
restored = strings.ReplaceAll(restored, "%2f", "/")
if head+restored == req.URL.Path {
req.URL.RawPath = ""
return nil
}
req.URL.RawPath = head + restored
return nil
}

// taskNotFoundRetry is how long WaitTask retries getTask 404s. A freshly
// accepted id can temporarily miss the result store.
Expand All @@ -25,21 +64,25 @@ 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")
}
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
}
Expand Down
46 changes: 41 additions & 5 deletions internal/api/serverless/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,30 @@ const (
)

func TestValidateEndpointPath(t *testing.T) {
const wantInvalid = "invalid"

cases := []struct {
path string
wantErr string
}{
{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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -419,3 +430,28 @@ 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/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)
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)
}
}
}
5 changes: 3 additions & 2 deletions internal/cmd/serverless/apps_invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
danmrichards marked this conversation as resolved.
Resolved
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
Expand Down
12 changes: 11 additions & 1 deletion internal/cmd/serverless/apps_invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading