diff --git a/src/main/java/org/kohsuke/github/GitHubRequest.java b/src/main/java/org/kohsuke/github/GitHubRequest.java index 20cf1462a4..8270e263dd 100644 --- a/src/main/java/org/kohsuke/github/GitHubRequest.java +++ b/src/main/java/org/kohsuke/github/GitHubRequest.java @@ -589,7 +589,15 @@ static URL getApiURL(String apiUrl, String tailApiUrl) { // backward compatibility apiUrl = GitHubClient.GITHUB_URL; } - return new URI(apiUrl + tailApiUrl).toURL(); + + String fullApiUrl = apiUrl + tailApiUrl; + try { + return new URI(fullApiUrl).toURL(); + } catch (URISyntaxException e) { + // Some API URL fields include unescaped square brackets in path segments. + // Keep existing escaping as-is while making the URL URI-safe for connectors. + return new URI(encodeSquareBrackets(fullApiUrl)).toURL(); + } } catch (Exception e) { // The data going into constructing this URL should be controlled by the GitHub API framework, // so a malformed URL here is a framework runtime error. @@ -598,6 +606,35 @@ static URL getApiURL(String apiUrl, String tailApiUrl) { throw new GHException("Unable to build GitHub API URL", e); } } + + @Nonnull + private static String encodeSquareBrackets(@Nonnull String url) { + URL parsedUrl; + try { + parsedUrl = new URL(url); + } catch (MalformedURLException e) { + // Preserve the original input when URL parsing fails so existing error behavior is unchanged. + return url; + } + + String path = parsedUrl.getPath(); + String query = parsedUrl.getQuery(); + String ref = parsedUrl.getRef(); + + StringBuilder encodedUrl = new StringBuilder(); + encodedUrl.append(parsedUrl.getProtocol()).append("://").append(parsedUrl.getAuthority()); + if (path != null) { + encodedUrl.append(path.replace("[", "%5B").replace("]", "%5D")); + } + if (query != null) { + encodedUrl.append('?').append(query.replace("[", "%5B").replace("]", "%5D")); + } + if (ref != null) { + encodedUrl.append('#').append(ref.replace("[", "%5B").replace("]", "%5D")); + } + + return encodedUrl.toString(); + } /** * Create a new {@link Builder}. * diff --git a/src/test/java/org/kohsuke/github/GitHubStaticTest.java b/src/test/java/org/kohsuke/github/GitHubStaticTest.java index 5db7bc1ead..8e22c609c5 100644 --- a/src/test/java/org/kohsuke/github/GitHubStaticTest.java +++ b/src/test/java/org/kohsuke/github/GitHubStaticTest.java @@ -282,6 +282,13 @@ public void testGitHubRequest_getApiURL() { equalTo("ftp://whoa.github.com/endpoint")); assertThat(GitHubRequest.getApiURL(null, "ftp://api.test.github.com/endpoint").toString(), equalTo("ftp://api.test.github.com/endpoint")); + assertThat( + GitHubRequest + .getApiURL(null, + "https://api.github.com/repositories/694641495/contents/Alfred.alfredpreferences/workflows/menubar-search%20[3rd-Party]/menu/Package.swift?ref=073e7b4493d088fdf1995a74cf5da201a5795181") + .toString(), + equalTo( + "https://api.github.com/repositories/694641495/contents/Alfred.alfredpreferences/workflows/menubar-search%20%5B3rd-Party%5D/menu/Package.swift?ref=073e7b4493d088fdf1995a74cf5da201a5795181")); GHException e; e = Assert.assertThrows(GHException.class, diff --git a/src/test/java/org/kohsuke/github/GitHubTest.java b/src/test/java/org/kohsuke/github/GitHubTest.java index 794eff5297..6e9b3bc95c 100644 --- a/src/test/java/org/kohsuke/github/GitHubTest.java +++ b/src/test/java/org/kohsuke/github/GitHubTest.java @@ -6,6 +6,7 @@ import org.kohsuke.github.example.dataobject.ReadOnlyObjects; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.*; import static org.hamcrest.Matchers.*; @@ -305,6 +306,31 @@ public void searchContent() throws Exception { assertThat(e.getMessage(), equalTo("qualifier cannot be null or empty")); } + /** + * Search content where the API item URL contains special characters in path segments. + * + * @throws Exception + * the exception + */ + @Test + public void searchContentSpecialCharactersInUrl() throws Exception { + GHContent content = gitHub.searchContent() + .q("filename:Package.swift repo:chrisgrieser/.config") + .list() + .iterator() + .next(); + + assertThat(content.getPath(), + equalTo("Alfred.alfredpreferences/workflows/menubar-search [3rd-Party]/menu/Package.swift")); + + byte[] data; + try (java.io.InputStream inputStream = content.read()) { + data = inputStream.readAllBytes(); + } + + assertThat(new String(data, StandardCharsets.UTF_8), equalTo("// content with special path chars\n")); + } + /** * Search content with forks. */ diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/1-user.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/1-user.json new file mode 100644 index 0000000000..4e4adb1568 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/1-user.json @@ -0,0 +1,22 @@ +{ + "login": "bitwiseman", + "id": 1958953, + "node_id": "MDQ6VXNlcjE5NTg5NTM=", + "avatar_url": "https://avatars.githubusercontent.com/u/1958953?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/bitwiseman", + "html_url": "https://github.com/bitwiseman", + "followers_url": "https://api.github.com/users/bitwiseman/followers", + "following_url": "https://api.github.com/users/bitwiseman/following{/other_user}", + "gists_url": "https://api.github.com/users/bitwiseman/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bitwiseman/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bitwiseman/subscriptions", + "organizations_url": "https://api.github.com/users/bitwiseman/orgs", + "repos_url": "https://api.github.com/users/bitwiseman/repos", + "events_url": "https://api.github.com/users/bitwiseman/events{/privacy}", + "received_events_url": "https://api.github.com/users/bitwiseman/received_events", + "type": "User", + "site_admin": false, + "name": "Liam Newman" +} + diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/2-search_code.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/2-search_code.json new file mode 100644 index 0000000000..736d67f1ea --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/2-search_code.json @@ -0,0 +1,28 @@ +{ + "total_count": 1, + "incomplete_results": false, + "items": [ + { + "name": "Package.swift", + "path": "Alfred.alfredpreferences/workflows/menubar-search [3rd-Party]/menu/Package.swift", + "sha": "f13c7ef5735f02330063eb233273ccf0bb37f332", + "url": "https://api.github.com/repositories/694641495/contents/Alfred.alfredpreferences/workflows/menubar-search%20[3rd-Party]/menu/Package.swift?ref=073e7b4493d088fdf1995a74cf5da201a5795181", + "git_url": "https://api.github.com/repositories/694641495/git/blobs/f13c7ef5735f02330063eb233273ccf0bb37f332", + "html_url": "https://github.com/chrisgrieser/.config/blob/073e7b4493d088fdf1995a74cf5da201a5795181/Alfred.alfredpreferences/workflows/menubar-search%20%5B3rd-Party%5D/menu/Package.swift", + "repository": { + "id": 694641495, + "name": ".config", + "full_name": "chrisgrieser/.config", + "private": false, + "owner": { + "login": "chrisgrieser", + "id": 1106439, + "type": "User", + "site_admin": false + } + }, + "score": 1 + } + ] +} + diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/3-repositories_694641495_contents_package.swift.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/3-repositories_694641495_contents_package.swift.json new file mode 100644 index 0000000000..8caf395858 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/__files/3-repositories_694641495_contents_package.swift.json @@ -0,0 +1,14 @@ +{ + "name": "Package.swift", + "path": "Alfred.alfredpreferences/workflows/menubar-search [3rd-Party]/menu/Package.swift", + "sha": "f13c7ef5735f02330063eb233273ccf0bb37f332", + "size": 35, + "url": "https://api.github.com/repositories/694641495/contents/Alfred.alfredpreferences/workflows/menubar-search%20[3rd-Party]/menu/Package.swift?ref=073e7b4493d088fdf1995a74cf5da201a5795181", + "html_url": "https://github.com/chrisgrieser/.config/blob/073e7b4493d088fdf1995a74cf5da201a5795181/Alfred.alfredpreferences/workflows/menubar-search%20%5B3rd-Party%5D/menu/Package.swift", + "git_url": "https://api.github.com/repositories/694641495/git/blobs/f13c7ef5735f02330063eb233273ccf0bb37f332", + "download_url": "https://raw.githubusercontent.com/chrisgrieser/.config/073e7b4493d088fdf1995a74cf5da201a5795181/Alfred.alfredpreferences/workflows/menubar-search%20%5B3rd-Party%5D/menu/Package.swift", + "type": "file", + "content": "Ly8gY29udGVudCB3aXRoIHNwZWNpYWwgcGF0aCBjaGFycwo=", + "encoding": "base64" +} + diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/1-user.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/1-user.json new file mode 100644 index 0000000000..22a33fba5d --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/1-user.json @@ -0,0 +1,24 @@ +{ + "id": "e173c8a1-fa95-4d0c-8b5c-b18f3ecc8bab", + "name": "user", + "request": { + "url": "/user", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "1-user.json", + "headers": { + "Content-Type": "application/json; charset=utf-8" + } + }, + "uuid": "e173c8a1-fa95-4d0c-8b5c-b18f3ecc8bab", + "persistent": true, + "insertionIndex": 1 +} + diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/2-search_code.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/2-search_code.json new file mode 100644 index 0000000000..85be373abc --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/2-search_code.json @@ -0,0 +1,24 @@ +{ + "id": "4ecf8d7f-5a76-4f31-9e2c-2f770fa64eb3", + "name": "search_code", + "request": { + "url": "/search/code?q=filename%3APackage.swift+repo%3Achrisgrieser%2F.config", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "2-search_code.json", + "headers": { + "Content-Type": "application/json; charset=utf-8" + } + }, + "uuid": "4ecf8d7f-5a76-4f31-9e2c-2f770fa64eb3", + "persistent": true, + "insertionIndex": 2 +} + diff --git a/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/3-repositories_694641495_contents_package.swift.json b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/3-repositories_694641495_contents_package.swift.json new file mode 100644 index 0000000000..2649b9f6d7 --- /dev/null +++ b/src/test/resources/org/kohsuke/github/GitHubTest/wiremock/searchContentSpecialCharactersInUrl/mappings/3-repositories_694641495_contents_package.swift.json @@ -0,0 +1,24 @@ +{ + "id": "be779a3f-c503-4d48-9f35-0f8d39bb70a8", + "name": "repositories_694641495_contents_package_swift", + "request": { + "url": "/repositories/694641495/contents/Alfred.alfredpreferences/workflows/menubar-search%20%5B3rd-Party%5D/menu/Package.swift?ref=073e7b4493d088fdf1995a74cf5da201a5795181", + "method": "GET", + "headers": { + "Accept": { + "equalTo": "application/vnd.github+json" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "3-repositories_694641495_contents_package.swift.json", + "headers": { + "Content-Type": "application/json; charset=utf-8" + } + }, + "uuid": "be779a3f-c503-4d48-9f35-0f8d39bb70a8", + "persistent": true, + "insertionIndex": 3 +} +