3 * Copyright (C) 2009-2022 SonarSource SA
4 * mailto:info AT sonarsource DOT com
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.
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.
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.
20 package org.sonar.alm.client.bitbucket.bitbucketcloud;
22 import com.google.gson.Gson;
23 import com.google.gson.GsonBuilder;
24 import com.google.gson.JsonParseException;
25 import java.io.IOException;
26 import java.util.Objects;
27 import java.util.function.Function;
28 import java.util.function.UnaryOperator;
29 import javax.annotation.Nullable;
30 import javax.inject.Inject;
31 import okhttp3.Credentials;
32 import okhttp3.FormBody;
33 import okhttp3.HttpUrl;
34 import okhttp3.MediaType;
35 import okhttp3.OkHttpClient;
36 import okhttp3.Request;
37 import okhttp3.RequestBody;
38 import okhttp3.Response;
39 import okhttp3.ResponseBody;
40 import org.sonar.api.server.ServerSide;
41 import org.sonar.api.utils.log.Logger;
42 import org.sonar.api.utils.log.Loggers;
43 import org.sonar.server.exceptions.NotFoundException;
45 import static org.sonar.api.internal.apachecommons.lang.StringUtils.removeEnd;
48 public class BitbucketCloudRestClient {
49 private static final Logger LOG = Loggers.get(BitbucketCloudRestClient.class);
50 private static final String AUTHORIZATION = "Authorization";
51 private static final String GET = "GET";
52 private static final String ENDPOINT = "https://api.bitbucket.org";
53 private static final String ACCESS_TOKEN_ENDPOINT = "https://bitbucket.org/site/oauth2/access_token";
54 private static final String VERSION = "2.0";
55 private static final String UNABLE_TO_CONTACT_BBC_SERVERS = "Unable to contact Bitbucket Cloud servers";
56 private static final String ERROR_BBC_SERVERS = "Error returned by Bitbucket Cloud";
58 protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
60 private final OkHttpClient client;
61 private final String bitbucketCloudEndpoint;
62 private final String accessTokenEndpoint;
65 public BitbucketCloudRestClient(OkHttpClient okHttpClient) {
66 this(okHttpClient, ENDPOINT, ACCESS_TOKEN_ENDPOINT);
69 protected BitbucketCloudRestClient(OkHttpClient okHttpClient, String bitbucketCloudEndpoint, String accessTokenEndpoint) {
70 this.client = okHttpClient;
71 this.bitbucketCloudEndpoint = bitbucketCloudEndpoint;
72 this.accessTokenEndpoint = accessTokenEndpoint;
76 * Validate parameters provided.
78 public void validate(String clientId, String clientSecret, String workspace) {
79 Token token = validateAccessToken(clientId, clientSecret);
81 if (token.getScopes() == null || !token.getScopes().contains("pullrequest")) {
82 String msg = "The OAuth consumer in the Bitbucket workspace is not configured with the permission to read pull requests";
83 LOG.info("Validation failed. {}}: {}", msg, token.getScopes());
84 throw new IllegalArgumentException(ERROR_BBC_SERVERS + ": " + msg);
88 doGet(token.getAccessToken(), buildUrl("/repositories/" + workspace), r -> null);
89 } catch (NotFoundException | IllegalStateException e) {
90 throw new IllegalArgumentException(e.getMessage());
95 * Validate parameters provided.
97 public void validateAppPassword(String encodedCredentials, String workspace) {
99 doGetWithBasicAuth(encodedCredentials, buildUrl("/repositories/" + workspace), r -> null);
100 } catch (NotFoundException | IllegalStateException e) {
101 throw new IllegalArgumentException(e.getMessage());
105 private Token validateAccessToken(String clientId, String clientSecret) {
106 Request request = createAccessTokenRequest(clientId, clientSecret);
107 try (Response response = client.newCall(request).execute()) {
108 if (response.isSuccessful()) {
109 return buildGson().fromJson(response.body().charStream(), Token.class);
112 ErrorDetails errorMsg = getTokenError(response.body());
113 if (errorMsg.body != null) {
114 switch (errorMsg.body) {
115 case "invalid_grant":
116 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS +
117 ": Configure the OAuth consumer in the Bitbucket workspace to be a private consumer");
118 case "unauthorized_client":
119 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": Check your credentials");
121 if (errorMsg.parsedErrorMsg != null) {
122 LOG.info("Validation failed: " + errorMsg.parsedErrorMsg);
123 throw new IllegalArgumentException(ERROR_BBC_SERVERS + ": " + errorMsg.parsedErrorMsg);
125 LOG.info("Validation failed: " + errorMsg.body);
126 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
130 LOG.info("Validation failed");
132 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
134 } catch (IOException e) {
135 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
139 public RepositoryList searchRepos(String encodedCredentials, String workspace, @Nullable String repoName, Integer page, Integer pageSize) {
140 String filterQuery = String.format("q=name~\"%s\"", repoName != null ? repoName : "");
141 HttpUrl url = buildUrl(String.format("/repositories/%s?%s&page=%s&pagelen=%s", workspace, filterQuery, page, pageSize));
142 return doGetWithBasicAuth(encodedCredentials, url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class));
145 public Repository getRepo(String encodedCredentials, String workspace, String slug) {
146 HttpUrl url = buildUrl(String.format("/repositories/%s/%s", workspace, slug));
147 return doGetWithBasicAuth(encodedCredentials, url, r -> buildGson().fromJson(r.body().charStream(), Repository.class));
150 public String createAccessToken(String clientId, String clientSecret) {
151 Request request = createAccessTokenRequest(clientId, clientSecret);
152 return doCall(request, r -> buildGson().fromJson(r.body().charStream(), Token.class)).getAccessToken();
155 private Request createAccessTokenRequest(String clientId, String clientSecret) {
156 RequestBody body = new FormBody.Builder()
157 .add("grant_type", "client_credentials")
159 HttpUrl url = HttpUrl.parse(accessTokenEndpoint);
160 String credential = Credentials.basic(clientId, clientSecret);
161 return prepareRequestWithBasicAuthCredentials(credential, "POST", url, body);
164 protected HttpUrl buildUrl(String relativeUrl) {
165 return HttpUrl.parse(removeEnd(bitbucketCloudEndpoint, "/") + "/" + VERSION + relativeUrl);
168 protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler) {
169 Request request = prepareRequestWithAccessToken(accessToken, GET, url, null);
170 return doCall(request, handler);
173 protected <G> G doGetWithBasicAuth(String encodedCredentials, HttpUrl url, Function<Response, G> handler) {
174 Request request = prepareRequestWithBasicAuthCredentials("Basic " + encodedCredentials, GET, url, null);
175 return doCall(request, handler);
178 protected <G> G doCall(Request request, Function<Response, G> handler) {
179 try (Response response = client.newCall(request).execute()) {
180 if (!response.isSuccessful()) {
181 handleError(response);
183 return handler.apply(response);
184 } catch (IOException e) {
185 throw new IllegalStateException(ERROR_BBC_SERVERS, e);
189 private static void handleError(Response response) throws IOException {
190 int responseCode = response.code();
191 ErrorDetails error = getError(response.body());
192 LOG.info(ERROR_BBC_SERVERS + ": {} {}", responseCode, error.parsedErrorMsg != null ? error.parsedErrorMsg : error.body);
194 if (error.parsedErrorMsg != null) {
195 throw new IllegalStateException(ERROR_BBC_SERVERS + ": " + error.parsedErrorMsg);
197 throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS);
201 private static ErrorDetails getError(@Nullable ResponseBody body) throws IOException {
202 return getErrorDetails(body, s -> {
203 Error gsonError = buildGson().fromJson(s, Error.class);
204 if (gsonError != null && gsonError.errorMsg != null && gsonError.errorMsg.message != null) {
205 return gsonError.errorMsg.message;
211 private static ErrorDetails getTokenError(@Nullable ResponseBody body) throws IOException {
213 return new ErrorDetails(null, null);
215 String bodyStr = body.string();
216 if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
218 TokenError gsonError = buildGson().fromJson(bodyStr, TokenError.class);
219 if (gsonError != null && gsonError.error != null) {
220 return new ErrorDetails(gsonError.error, gsonError.errorDescription);
222 } catch (JsonParseException e) {
227 return new ErrorDetails(bodyStr, null);
230 private static class ErrorDetails {
232 private final String body;
234 private final String parsedErrorMsg;
236 public ErrorDetails(@Nullable String body, @Nullable String parsedErrorMsg) {
238 this.parsedErrorMsg = parsedErrorMsg;
242 private static ErrorDetails getErrorDetails(@Nullable ResponseBody body, UnaryOperator<String> parser) throws IOException {
244 return new ErrorDetails("", null);
246 String bodyStr = body.string();
247 if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
249 return new ErrorDetails(bodyStr, parser.apply(bodyStr));
250 } catch (JsonParseException e) {
254 return new ErrorDetails(bodyStr, null);
257 protected static Request prepareRequestWithAccessToken(String accessToken, String method, HttpUrl url, @Nullable RequestBody body) {
258 return new Request.Builder()
259 .method(method, body)
261 .header(AUTHORIZATION, "Bearer " + accessToken)
265 protected static Request prepareRequestWithBasicAuthCredentials(String encodedCredentials, String method,
266 HttpUrl url, @Nullable RequestBody body) {
267 return new Request.Builder()
268 .method(method, body)
270 .header(AUTHORIZATION, encodedCredentials)
274 public static Gson buildGson() {
275 return new GsonBuilder().create();