You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AzureDevOpsHttpClient.java 8.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. package org.sonar.alm.client.azure;
  21. import com.google.common.base.Strings;
  22. import com.google.gson.Gson;
  23. import com.google.gson.GsonBuilder;
  24. import com.google.gson.JsonSyntaxException;
  25. import java.io.IOException;
  26. import java.net.HttpURLConnection;
  27. import java.nio.charset.StandardCharsets;
  28. import java.util.function.Function;
  29. import java.util.stream.Stream;
  30. import javax.annotation.Nullable;
  31. import okhttp3.OkHttpClient;
  32. import okhttp3.Request;
  33. import okhttp3.RequestBody;
  34. import okhttp3.Response;
  35. import okhttp3.ResponseBody;
  36. import org.apache.commons.codec.binary.Base64;
  37. import org.apache.commons.lang3.StringUtils;
  38. import org.slf4j.Logger;
  39. import org.slf4j.LoggerFactory;
  40. import org.sonar.alm.client.TimeoutConfiguration;
  41. import org.sonar.api.server.ServerSide;
  42. import org.sonarqube.ws.client.OkHttpClientBuilder;
  43. import static java.util.stream.Collectors.joining;
  44. import static org.sonar.api.internal.apachecommons.lang.StringUtils.isBlank;
  45. import static org.sonar.api.internal.apachecommons.lang.StringUtils.substringBeforeLast;
  46. @ServerSide
  47. public class AzureDevOpsHttpClient {
  48. private static final Logger LOG = LoggerFactory.getLogger(AzureDevOpsHttpClient.class);
  49. public static final String API_VERSION_3 = "api-version=3.0";
  50. protected static final String GET = "GET";
  51. protected static final String UNABLE_TO_CONTACT_AZURE_SERVER = "Unable to contact Azure DevOps server";
  52. protected final OkHttpClient client;
  53. public AzureDevOpsHttpClient(TimeoutConfiguration timeoutConfiguration) {
  54. client = new OkHttpClientBuilder()
  55. .setConnectTimeoutMs(timeoutConfiguration.getConnectTimeout())
  56. .setReadTimeoutMs(timeoutConfiguration.getReadTimeout())
  57. .setFollowRedirects(false)
  58. .build();
  59. }
  60. public void checkPAT(String serverUrl, String token) {
  61. String url = String.format("%s/_apis/projects?%s", getTrimmedUrl(serverUrl), API_VERSION_3);
  62. LOG.debug(String.format("check pat : [%s]", url));
  63. doGet(token, url);
  64. }
  65. public GsonAzureProjectList getProjects(String serverUrl, String token) {
  66. String url = String.format("%s/_apis/projects?%s", getTrimmedUrl(serverUrl), API_VERSION_3);
  67. LOG.debug(String.format("get projects : [%s]", url));
  68. return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureProjectList.class));
  69. }
  70. public GsonAzureProject getProject(String serverUrl, String token, String projectName) {
  71. String url = String.format("%s/_apis/projects/%s?%s", getTrimmedUrl(serverUrl), projectName, API_VERSION_3);
  72. LOG.debug(String.format("get project : [%s]", url));
  73. return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureProject.class));
  74. }
  75. public GsonAzureRepoList getRepos(String serverUrl, String token, @Nullable String projectName) {
  76. String url = Stream.of(getTrimmedUrl(serverUrl), projectName, "_apis/git/repositories?" + API_VERSION_3)
  77. .filter(StringUtils::isNotBlank)
  78. .collect(joining("/"));
  79. LOG.debug(String.format("get repos : [%s]", url));
  80. return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepoList.class));
  81. }
  82. public GsonAzureRepo getRepo(String serverUrl, String token, String projectName, String repositoryName) {
  83. String url = Stream.of(getTrimmedUrl(serverUrl), projectName, "_apis/git/repositories", repositoryName + "?" + API_VERSION_3)
  84. .filter(StringUtils::isNotBlank)
  85. .collect(joining("/"));
  86. LOG.debug(String.format("get repo : [%s]", url));
  87. return doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), GsonAzureRepo.class));
  88. }
  89. private void doGet(String token, String url) {
  90. Request request = prepareRequestWithToken(token, GET, url, null);
  91. doCall(request);
  92. }
  93. protected void doCall(Request request) {
  94. try (Response response = client.newCall(request).execute()) {
  95. checkResponseIsSuccessful(response);
  96. } catch (IOException e) {
  97. LOG.error(String.format(UNABLE_TO_CONTACT_AZURE_SERVER + " for request [%s]: [%s]", request.url(), e.getMessage()));
  98. throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER, e);
  99. }
  100. }
  101. protected <G> G doGet(String token, String url, Function<Response, G> handler) {
  102. Request request = prepareRequestWithToken(token, GET, url, null);
  103. return doCall(request, handler);
  104. }
  105. protected <G> G doCall(Request request, Function<Response, G> handler) {
  106. try (Response response = client.newCall(request).execute()) {
  107. checkResponseIsSuccessful(response);
  108. return handler.apply(response);
  109. } catch (JsonSyntaxException e) {
  110. LOG.error(String.format("Response from Azure for request [%s] could not be parsed: [%s]",
  111. request.url(),
  112. e.getMessage()));
  113. throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER + ", got an unexpected response", e);
  114. } catch (IOException e) {
  115. LOG.error(String.format(UNABLE_TO_CONTACT_AZURE_SERVER + " for request [%s]: [%s]", request.url(), e.getMessage()));
  116. throw new IllegalArgumentException(UNABLE_TO_CONTACT_AZURE_SERVER, e);
  117. }
  118. }
  119. protected static Request prepareRequestWithToken(String token, String method, String url, @Nullable RequestBody body) {
  120. return new Request.Builder()
  121. .method(method, body)
  122. .url(url)
  123. .addHeader("Authorization", encodeToken("accessToken:" + token))
  124. .build();
  125. }
  126. protected static void checkResponseIsSuccessful(Response response) throws IOException {
  127. if (!response.isSuccessful()) {
  128. if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
  129. LOG.error(String.format(UNABLE_TO_CONTACT_AZURE_SERVER + " for request [%s]: Invalid personal access token",
  130. response.request().url()));
  131. throw new AzureDevopsServerException(response.code(), "Invalid personal access token");
  132. }
  133. if (response.code() == HttpURLConnection.HTTP_NOT_FOUND) {
  134. LOG.error(String.format(UNABLE_TO_CONTACT_AZURE_SERVER + " for request [%s]: URL Not Found",
  135. response.request().url()));
  136. throw new AzureDevopsServerException(response.code(), "Invalid Azure URL");
  137. }
  138. ResponseBody responseBody = response.body();
  139. String body = responseBody == null ? "" : responseBody.string();
  140. String errorMessage = generateErrorMessage(body, UNABLE_TO_CONTACT_AZURE_SERVER);
  141. LOG.error(String.format("Azure API call to [%s] failed with %s http code. Azure response content : [%s]", response.request().url(), response.code(), body));
  142. throw new AzureDevopsServerException(response.code(), errorMessage);
  143. }
  144. }
  145. protected static String generateErrorMessage(String body, String defaultMessage) {
  146. GsonAzureError gsonAzureError = null;
  147. try {
  148. gsonAzureError = buildGson().fromJson(body, GsonAzureError.class);
  149. } catch (JsonSyntaxException e) {
  150. // not a json payload, ignore the error
  151. }
  152. if (gsonAzureError != null && !Strings.isNullOrEmpty(gsonAzureError.message())) {
  153. return defaultMessage + " : " + gsonAzureError.message();
  154. } else {
  155. return defaultMessage;
  156. }
  157. }
  158. protected static String getTrimmedUrl(String rawUrl) {
  159. if (isBlank(rawUrl)) {
  160. return rawUrl;
  161. }
  162. if (rawUrl.endsWith("/")) {
  163. return substringBeforeLast(rawUrl, "/");
  164. }
  165. return rawUrl;
  166. }
  167. protected static String encodeToken(String token) {
  168. return String.format("BASIC %s", Base64.encodeBase64String(token.getBytes(StandardCharsets.UTF_8)));
  169. }
  170. protected static Gson buildGson() {
  171. return new GsonBuilder()
  172. .create();
  173. }
  174. }