diff --git a/internal/redmine/client.go b/internal/redmine/client.go index 1869713..d36ac81 100644 --- a/internal/redmine/client.go +++ b/internal/redmine/client.go @@ -147,17 +147,25 @@ func (c Client) endpoint(req Request) (string, error) { return "", fmt.Errorf("redmine host must include scheme and host, got %q", c.BaseURL) } - path := req.Operation.Path - path = strings.ReplaceAll(path, "{format}", "json") + basePath := strings.TrimRight(base.Path, "/") + baseEscapedPath := strings.TrimRight(base.EscapedPath(), "/") + + decodedPath := req.Operation.Path + escapedPath := req.Operation.Path + decodedPath = strings.ReplaceAll(decodedPath, "{format}", "json") + escapedPath = strings.ReplaceAll(escapedPath, "{format}", "json") for _, param := range req.Operation.PathParams { value, ok := req.Path[param.Name] if !ok || value == "" { return "", fmt.Errorf("missing path argument %s", param.Name) } - path = strings.ReplaceAll(path, "{"+param.Name+"}", url.PathEscape(value)) + placeholder := "{" + param.Name + "}" + decodedPath = strings.ReplaceAll(decodedPath, placeholder, value) + escapedPath = strings.ReplaceAll(escapedPath, placeholder, url.PathEscape(value)) } - base.Path = strings.TrimRight(base.Path, "/") + path + base.Path = basePath + decodedPath + base.RawPath = baseEscapedPath + escapedPath query := base.Query() for name, values := range req.Query { for _, value := range values { diff --git a/internal/redmine/client_test.go b/internal/redmine/client_test.go index 99b4768..3ec0b30 100644 --- a/internal/redmine/client_test.go +++ b/internal/redmine/client_test.go @@ -1,9 +1,9 @@ package redmine import ( - "context" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/muxx/redmine-cli/internal/openapi" @@ -33,7 +33,7 @@ func TestClientDoBuildsRequest(t *testing.T) { APIKey: "secret", HTTPClient: server.Client(), } - resp, err := client.Do(context.Background(), Request{ + resp, err := client.Do(t.Context(), Request{ Operation: openapi.Operation{ ID: "getIssue", Method: http.MethodGet, @@ -52,3 +52,50 @@ func TestClientDoBuildsRequest(t *testing.T) { t.Fatalf("status = %d", resp.StatusCode) } } + +func TestClientEndpointEscapesPathParamsOnce(t *testing.T) { + client := Client{BaseURL: "https://redmine.example/redmine"} + operation := openapi.Operation{ + ID: "getWikiPage", + Method: http.MethodGet, + Path: "/projects/{project_id}/wiki/{wiki_page_title}.{format}", + PathParams: []openapi.Parameter{ + {Name: "project_id"}, + {Name: "wiki_page_title"}, + }, + } + + tests := []struct { + name string + title string + }{ + { + name: "non-ASCII wiki page title", + title: "AI-трансформация_RD", + }, + { + name: "reserved path characters", + title: "100% done/with space", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := client.endpoint(Request{ + Operation: operation, + Path: map[string]string{ + "project_id": "r-handbook", + "wiki_page_title": tt.title, + }, + }) + if err != nil { + t.Fatal(err) + } + + want := "https://redmine.example/redmine/projects/r-handbook/wiki/" + url.PathEscape(tt.title) + ".json" + if got != want { + t.Fatalf("endpoint = %s, want %s", got, want) + } + }) + } +}