Path IDs used in REST API routes are double-escaped

Bug Report

As part of the refactor to use Request Handlers, there appears to be a regression in the escaping of API routes which include a path (such as a project or group path) as part of the API route URI. The project/group portion of the route is incorrectly double-escaped.

For a project path, provided as an ID, such as test-project, using a function such as ListProjectIssues previously would produce an API route URI such as /api/v4/projects/test%2Fproject/issues. Noting that %2F is the correct HTTP/URI encoded form of -. After the refactor, it now double-escapes the dash in the project path, producing /api/v4/projects/test%252Fproject/issues. %252F is a unicode character rather than -, and is the result of double-escaping %2F. This can lead to issues with the project not being found (404 errors) or issues with intermediary reverse proxies which may block the double-escaped request as being invalid.

Relevant Code

Starting with ListProjectIsues we can see that when sending the request, the withPath helper is used, with a ProjectID interpolated into the path.

// ListProjectIssues gets a list of project issues. This function accepts
// pagination parameters page and per_page to return the list of project issues.
//
// GitLab API docs: https://docs.gitlab.com/api/issues/#list-project-issues
func (s *IssuesService) ListProjectIssues(pid any, opt *ListProjectIssuesOptions, options ...RequestOptionFunc) ([]*Issue, *Response, error) {
	return do[[]*Issue](s.client,
		withPath("projects/%s/issues", ProjectID{pid}),
		withAPIOpts(opt),
		withRequestOpts(options...),
	)
}

Calling ProjectID with a string/path value calls this code via the Pather interface, which escapes the project path portion once, producing test%2Fproject as the ID.

func (i ProjectID) forPath() (string, error) {
	id, err := parseID(i.Value)
	if err != nil {
		return "", err
	}

	return PathEscape(id), nil
}

When withPath is called, we can see it is escaped again, even though the interpolated test%2Fproject has been interpolated into the path and already escaped:

func withPath(path string, args ...any) doOption {
	return func(c *doConfig) error {
		as := make([]any, len(args))
		for i, a := range args {
			switch v := a.(type) {
			case Pather:
				project, err := v.forPath()
				if err != nil {
					return err
				}
				as[i] = PathEscape(project)
			case string:
				as[i] = PathEscape(v)
			default:
				as[i] = v
			}
		}
		c.path = fmt.Sprintf(path, as...)

		return nil
	}
}

Looking at the tests in request_handler_test.go, I notice that all the interpolated routes like this only test numeric IDs, which would not exercise this codepath.

Relevant Log Output

N/A

Additional Details

  • GitLab Client Go Version: v0.160.1
  • GitLab Instance Version: 18.7.0-pre
  • Go Version: 1.25.3
  • License Tier: Ultimate
Edited by James Hebden