3 * Copyright (C) 2009-2024 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.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
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 = LoggerFactory.getLogger(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 protected static final String ERROR_BBC_SERVERS = "Error returned by Bitbucket Cloud";
56 protected static final String UNABLE_TO_CONTACT_BBC_SERVERS = "Unable to contact Bitbucket Cloud servers";
57 protected static final String MISSING_PULL_REQUEST_READ_PERMISSION = "The OAuth consumer in the Bitbucket workspace is not configured with the permission to read pull requests.";
58 protected static final String SCOPE = "Scope is: %s";
59 protected static final String UNAUTHORIZED_CLIENT = "Check your credentials";
60 protected static final String OAUTH_CONSUMER_NOT_PRIVATE = "Configure the OAuth consumer in the Bitbucket workspace to be a private consumer";
61 protected static final String BBC_FAIL_WITH_RESPONSE = "Bitbucket Cloud API call to [%s] failed with %s http code. Bitbucket Cloud response content : [%s]";
62 protected static final String BBC_FAIL_WITH_ERROR = "Bitbucket Cloud API call to [%s] failed with error: %s";
64 protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
66 private final OkHttpClient client;
67 private final String bitbucketCloudEndpoint;
68 private final String accessTokenEndpoint;
72 public BitbucketCloudRestClient(OkHttpClient bitBucketCloudHttpClient) {
73 this(bitBucketCloudHttpClient, ENDPOINT, ACCESS_TOKEN_ENDPOINT);
76 protected BitbucketCloudRestClient(OkHttpClient bitBucketCloudHttpClient, String bitbucketCloudEndpoint, String accessTokenEndpoint) {
77 this.client = bitBucketCloudHttpClient;
78 this.bitbucketCloudEndpoint = bitbucketCloudEndpoint;
79 this.accessTokenEndpoint = accessTokenEndpoint;
83 * Validate parameters provided.
85 public void validate(String clientId, String clientSecret, String workspace) {
86 Token token = validateAccessToken(clientId, clientSecret);
88 if (token.getScopes() == null || !token.getScopes().contains("pullrequest")) {
89 LOG.info(MISSING_PULL_REQUEST_READ_PERMISSION + String.format(SCOPE, token.getScopes()));
90 throw new IllegalArgumentException(ERROR_BBC_SERVERS + ": " + MISSING_PULL_REQUEST_READ_PERMISSION);
94 doGet(token.getAccessToken(), buildUrl("/repositories/" + workspace), r -> null);
95 } catch (NotFoundException | IllegalStateException e) {
96 throw new IllegalArgumentException(e.getMessage());
101 * Validate parameters provided.
103 public void validateAppPassword(String encodedCredentials, String workspace) {
105 doGetWithBasicAuth(encodedCredentials, buildUrl("/repositories/" + workspace), r -> null);
106 } catch (NotFoundException | IllegalStateException e) {
107 throw new IllegalArgumentException(e.getMessage());
111 private Token validateAccessToken(String clientId, String clientSecret) {
112 Request request = createAccessTokenRequest(clientId, clientSecret);
113 try (Response response = client.newCall(request).execute()) {
114 if (response.isSuccessful()) {
115 return buildGson().fromJson(response.body().charStream(), Token.class);
118 ErrorDetails errorMsg = getTokenError(response.body());
119 if (errorMsg.body != null) {
120 LOG.info(String.format(BBC_FAIL_WITH_RESPONSE, response.request().url(), response.code(), errorMsg.body));
121 switch (errorMsg.body) {
122 case "invalid_grant":
123 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": " + OAUTH_CONSUMER_NOT_PRIVATE);
124 case "unauthorized_client":
125 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": " + UNAUTHORIZED_CLIENT);
127 if (errorMsg.parsedErrorMsg != null) {
128 throw new IllegalArgumentException(ERROR_BBC_SERVERS + ": " + errorMsg.parsedErrorMsg);
130 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
134 LOG.info(String.format(BBC_FAIL_WITH_RESPONSE, response.request().url(), response.code(), response.message()));
136 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
138 } catch (IOException e) {
139 LOG.info(String.format(BBC_FAIL_WITH_ERROR, request.url(), e.getMessage()));
140 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
144 public RepositoryList searchRepos(String encodedCredentials, String workspace, @Nullable String repoName, Integer page, Integer pageSize) {
145 String filterQuery = String.format("q=name~\"%s\"", repoName != null ? repoName : "");
146 HttpUrl url = buildUrl(String.format("/repositories/%s?%s&page=%s&pagelen=%s", workspace, filterQuery, page, pageSize));
147 return doGetWithBasicAuth(encodedCredentials, url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class));
150 public Repository getRepo(String encodedCredentials, String workspace, String slug) {
151 HttpUrl url = buildUrl(String.format("/repositories/%s/%s", workspace, slug));
152 return doGetWithBasicAuth(encodedCredentials, url, r -> buildGson().fromJson(r.body().charStream(), Repository.class));
155 public String createAccessToken(String clientId, String clientSecret) {
156 Request request = createAccessTokenRequest(clientId, clientSecret);
157 return doCall(request, r -> buildGson().fromJson(r.body().charStream(), Token.class)).getAccessToken();
160 private Request createAccessTokenRequest(String clientId, String clientSecret) {
161 RequestBody body = new FormBody.Builder()
162 .add("grant_type", "client_credentials")
164 HttpUrl url = HttpUrl.parse(accessTokenEndpoint);
165 String credential = Credentials.basic(clientId, clientSecret);
166 return prepareRequestWithBasicAuthCredentials(credential, "POST", url, body);
169 protected HttpUrl buildUrl(String relativeUrl) {
170 return HttpUrl.parse(removeEnd(bitbucketCloudEndpoint, "/") + "/" + VERSION + relativeUrl);
173 protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler) {
174 Request request = prepareRequestWithAccessToken(accessToken, GET, url, null);
175 return doCall(request, handler);
178 protected <G> G doGetWithBasicAuth(String encodedCredentials, HttpUrl url, Function<Response, G> handler) {
179 Request request = prepareRequestWithBasicAuthCredentials("Basic " + encodedCredentials, GET, url, null);
180 return doCall(request, handler);
183 protected <G> G doCall(Request request, Function<Response, G> handler) {
184 try (Response response = client.newCall(request).execute()) {
185 if (!response.isSuccessful()) {
186 handleError(response);
188 return handler.apply(response);
189 } catch (IOException e) {
190 LOG.info(ERROR_BBC_SERVERS + ": {}", e.getMessage());
191 throw new IllegalStateException(ERROR_BBC_SERVERS, e);
195 private static void handleError(Response response) throws IOException {
196 ErrorDetails error = getError(response.body());
197 LOG.info(String.format(BBC_FAIL_WITH_RESPONSE, response.request().url(), response.code(), error.body));
198 if (error.parsedErrorMsg != null) {
199 throw new IllegalStateException(ERROR_BBC_SERVERS + ": " + error.parsedErrorMsg);
201 throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS);
205 private static ErrorDetails getError(@Nullable ResponseBody body) throws IOException {
206 return getErrorDetails(body, s -> {
207 Error gsonError = buildGson().fromJson(s, Error.class);
208 if (gsonError != null && gsonError.errorMsg != null && gsonError.errorMsg.message != null) {
209 return gsonError.errorMsg.message;
215 private static ErrorDetails getTokenError(@Nullable ResponseBody body) throws IOException {
217 return new ErrorDetails(null, null);
219 String bodyStr = body.string();
220 if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
222 TokenError gsonError = buildGson().fromJson(bodyStr, TokenError.class);
223 if (gsonError != null && gsonError.error != null) {
224 return new ErrorDetails(gsonError.error, gsonError.errorDescription);
226 } catch (JsonParseException e) {
231 return new ErrorDetails(bodyStr, null);
234 private static class ErrorDetails {
236 private final String body;
238 private final String parsedErrorMsg;
240 public ErrorDetails(@Nullable String body, @Nullable String parsedErrorMsg) {
242 this.parsedErrorMsg = parsedErrorMsg;
246 private static ErrorDetails getErrorDetails(@Nullable ResponseBody body, UnaryOperator<String> parser) throws IOException {
248 return new ErrorDetails("", null);
250 String bodyStr = body.string();
251 if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
253 return new ErrorDetails(bodyStr, parser.apply(bodyStr));
254 } catch (JsonParseException e) {
258 return new ErrorDetails(bodyStr, null);
261 protected static Request prepareRequestWithAccessToken(String accessToken, String method, HttpUrl url, @Nullable RequestBody body) {
262 return new Request.Builder()
263 .method(method, body)
265 .header(AUTHORIZATION, "Bearer " + accessToken)
269 protected static Request prepareRequestWithBasicAuthCredentials(String encodedCredentials, String method,
270 HttpUrl url, @Nullable RequestBody body) {
271 return new Request.Builder()
272 .method(method, body)
274 .header(AUTHORIZATION, encodedCredentials)
278 public static Gson buildGson() {
279 return new GsonBuilder().create();