3 * Copyright (C) 2009-2021 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.net.HttpURLConnection;
27 import java.util.Objects;
28 import java.util.function.Function;
29 import java.util.function.UnaryOperator;
30 import javax.annotation.Nullable;
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.internal.apachecommons.io.IOUtils;
41 import org.sonar.api.server.ServerSide;
42 import org.sonar.api.utils.log.Logger;
43 import org.sonar.api.utils.log.Loggers;
44 import org.sonar.server.exceptions.NotFoundException;
46 import static org.sonar.api.internal.apachecommons.lang.StringUtils.removeEnd;
49 public class BitbucketCloudRestClient {
50 private static final Logger LOG = Loggers.get(BitbucketCloudRestClient.class);
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 protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
58 private final OkHttpClient client;
59 private final String bitbucketCloudEndpoint;
60 private final String accessTokenEndpoint;
62 public BitbucketCloudRestClient(OkHttpClient okHttpClient) {
63 this(okHttpClient, ENDPOINT, ACCESS_TOKEN_ENDPOINT);
66 protected BitbucketCloudRestClient(OkHttpClient okHttpClient, String bitbucketCloudEndpoint, String accessTokenEndpoint) {
67 this.client = okHttpClient;
68 this.bitbucketCloudEndpoint = bitbucketCloudEndpoint;
69 this.accessTokenEndpoint = accessTokenEndpoint;
73 * Validate parameters provided.
75 public void validate(String clientId, String clientSecret, String workspace) {
76 String accessToken = validateAccessToken(clientId, clientSecret);
77 doGet(accessToken, buildUrl("/workspaces/" + workspace + "/permissions"), r -> null, true);
80 private String validateAccessToken(String clientId, String clientSecret) {
81 Response response = null;
83 Request request = createAccessTokenRequest(clientId, clientSecret);
84 response = client.newCall(request).execute();
85 if (response.isSuccessful()) {
86 return buildGson().fromJson(response.body().charStream(), Token.class).getAccessToken();
89 ErrorDetails errorMsg = getTokenError(response.body());
90 if (errorMsg.parsedErrorMsg != null) {
91 switch (errorMsg.parsedErrorMsg) {
93 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS +
94 ": Configure the OAuth consumer in the Bitbucket workspace to be a private consumer");
95 case "unauthorized_client":
96 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": Check your credentials");
98 LOG.info("Validation failed: " + errorMsg.parsedErrorMsg);
101 LOG.info("Validation failed: " + errorMsg.body);
103 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS);
105 } catch (IOException e) {
106 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
108 if (response != null && response.body() != null) {
109 IOUtils.closeQuietly(response);
114 public String createAccessToken(String clientId, String clientSecret) {
115 Request request = createAccessTokenRequest(clientId, clientSecret);
116 return doCall(request, r -> buildGson().fromJson(r.body().charStream(), Token.class), false).getAccessToken();
119 private Request createAccessTokenRequest(String clientId, String clientSecret) {
120 RequestBody body = new FormBody.Builder()
121 .add("grant_type", "client_credentials")
123 HttpUrl url = HttpUrl.parse(accessTokenEndpoint);
124 String credential = Credentials.basic(clientId, clientSecret);
125 return new Request.Builder()
126 .method("POST", body)
128 .header("Authorization", credential)
132 protected HttpUrl buildUrl(String relativeUrl) {
133 return HttpUrl.parse(removeEnd(bitbucketCloudEndpoint, "/") + "/" + VERSION + relativeUrl);
136 protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler, boolean throwErrorDetails) {
137 Request request = prepareRequestWithAccessToken(accessToken, GET, url, null);
138 return doCall(request, handler, throwErrorDetails);
141 protected <G> G doGet(String accessToken, HttpUrl url, Function<Response, G> handler) {
142 return doGet(accessToken, url, handler, false);
145 protected void doPost(String accessToken, HttpUrl url, RequestBody body) {
146 Request request = prepareRequestWithAccessToken(accessToken, "POST", url, body);
147 doCall(request, r -> null, false);
150 protected void doPut(String accessToken, HttpUrl url, String json) {
151 RequestBody body = RequestBody.create(json, JSON_MEDIA_TYPE);
152 Request request = prepareRequestWithAccessToken(accessToken, "PUT", url, body);
153 doCall(request, r -> null, false);
156 protected void doDelete(String accessToken, HttpUrl url) {
157 Request request = prepareRequestWithAccessToken(accessToken, "DELETE", url, null);
158 doCall(request, r -> null, false);
161 private <G> G doCall(Request request, Function<Response, G> handler, boolean throwErrorDetails) {
162 try (Response response = client.newCall(request).execute()) {
163 if (!response.isSuccessful()) {
164 handleError(response, throwErrorDetails);
166 return handler.apply(response);
167 } catch (IOException e) {
168 throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS, e);
172 private static Request prepareRequestWithAccessToken(String accessToken, String method, HttpUrl url, @Nullable RequestBody body) {
173 return new Request.Builder()
174 .method(method, body)
176 .header("Authorization", "Bearer " + accessToken)
180 public static Gson buildGson() {
181 return new GsonBuilder().create();
184 private static ErrorDetails getTokenError(@Nullable ResponseBody body) throws IOException {
185 return getErrorDetails(body, s -> {
186 TokenError gsonError = buildGson().fromJson(s, TokenError.class);
187 if (gsonError != null && gsonError.error != null) {
188 return gsonError.error;
194 private static ErrorDetails getErrorDetails(@Nullable ResponseBody body, UnaryOperator<String> parser) throws IOException {
196 return new ErrorDetails("", null);
198 String bodyStr = body.string();
199 if (body.contentType() != null && Objects.equals(JSON_MEDIA_TYPE.type(), body.contentType().type())) {
201 return new ErrorDetails(bodyStr, parser.apply(bodyStr));
202 } catch (JsonParseException e) {
206 return new ErrorDetails(bodyStr, null);
209 private static void handleError(Response response, boolean throwErrorDetails) throws IOException {
210 int responseCode = response.code();
211 ErrorDetails error = getError(response.body());
212 if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
213 String errorMsg = error.parsedErrorMsg != null ? error.parsedErrorMsg : "";
214 if (throwErrorDetails) {
215 throw new IllegalArgumentException(errorMsg);
217 throw new NotFoundException(errorMsg);
220 LOG.info(UNABLE_TO_CONTACT_BBC_SERVERS + ": {} {}", responseCode, error.parsedErrorMsg != null ? error.parsedErrorMsg : error.body);
222 if (throwErrorDetails && error.parsedErrorMsg != null) {
223 throw new IllegalArgumentException(UNABLE_TO_CONTACT_BBC_SERVERS + ": " + error.parsedErrorMsg);
225 throw new IllegalStateException(UNABLE_TO_CONTACT_BBC_SERVERS);
229 private static ErrorDetails getError(@Nullable ResponseBody body) throws IOException {
230 return getErrorDetails(body, s -> {
231 Error gsonError = buildGson().fromJson(s, Error.class);
232 if (gsonError != null && gsonError.errorMsg != null && gsonError.errorMsg.message != null) {
233 return gsonError.errorMsg.message;
239 private static class ErrorDetails {
242 private String parsedErrorMsg;
244 public ErrorDetails(String body, @Nullable String parsedErrorMsg) {
246 this.parsedErrorMsg = parsedErrorMsg;