001/*
002 * SonarQube
003 * Copyright (C) 2009-2016 SonarSource SA
004 * mailto:contact AT sonarsource DOT com
005 *
006 * This program is free software; you can redistribute it and/or
007 * modify it under the terms of the GNU Lesser General Public
008 * License as published by the Free Software Foundation; either
009 * version 3 of the License, or (at your option) any later version.
010 *
011 * This program is distributed in the hope that it will be useful,
012 * but WITHOUT ANY WARRANTY; without even the implied warranty of
013 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
014 * Lesser General Public License for more details.
015 *
016 * You should have received a copy of the GNU Lesser General Public License
017 * along with this program; if not, write to the Free Software Foundation,
018 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
019 */
020package org.sonarqube.ws.client;
021
022import com.google.common.annotations.VisibleForTesting;
023import com.squareup.okhttp.Call;
024import com.squareup.okhttp.ConnectionSpec;
025import com.squareup.okhttp.Credentials;
026import com.squareup.okhttp.Headers;
027import com.squareup.okhttp.HttpUrl;
028import com.squareup.okhttp.MediaType;
029import com.squareup.okhttp.MultipartBuilder;
030import com.squareup.okhttp.OkHttpClient;
031import com.squareup.okhttp.Request;
032import com.squareup.okhttp.RequestBody;
033import com.squareup.okhttp.Response;
034import java.io.IOException;
035import java.net.Proxy;
036import java.util.Map;
037import java.util.concurrent.TimeUnit;
038import javax.annotation.CheckForNull;
039import javax.annotation.Nullable;
040import javax.net.ssl.SSLSocketFactory;
041
042import static com.google.common.base.Preconditions.checkArgument;
043import static com.google.common.base.Strings.isNullOrEmpty;
044import static com.google.common.base.Strings.nullToEmpty;
045import static java.lang.String.format;
046import static java.util.Arrays.asList;
047
048/**
049 * Connect to any SonarQube server available through HTTP or HTTPS.
050 * <p>TLS 1.0, 1.1 and 1.2 are supported on both Java 7 and 8. SSLv3 is not supported.</p>
051 * <p>The JVM system proxies are used.</p>
052 */
053public class HttpConnector implements WsConnector {
054
055  public static final int DEFAULT_CONNECT_TIMEOUT_MILLISECONDS = 30_000;
056  public static final int DEFAULT_READ_TIMEOUT_MILLISECONDS = 60_000;
057
058  /**
059   * Base URL with trailing slash, for instance "https://localhost/sonarqube/".
060   * It is required for further usage of {@link HttpUrl#resolve(String)}.
061   */
062  private final HttpUrl baseUrl;
063  private final String userAgent;
064  private final String credentials;
065  private final String proxyCredentials;
066  private final OkHttpClient okHttpClient = new OkHttpClient();
067
068  private HttpConnector(Builder builder, JavaVersion javaVersion) {
069    this.baseUrl = HttpUrl.parse(builder.url.endsWith("/") ? builder.url : format("%s/", builder.url));
070    checkArgument(this.baseUrl!=null, "Malformed URL: '%s'", builder.url);
071    this.userAgent = builder.userAgent;
072
073    if (isNullOrEmpty(builder.login)) {
074      // no login nor access token
075      this.credentials = null;
076    } else {
077      // password is null when login represents an access token. In this case
078      // the Basic credentials consider an empty password.
079      this.credentials = Credentials.basic(builder.login, nullToEmpty(builder.password));
080    }
081
082    if (builder.proxy != null) {
083      this.okHttpClient.setProxy(builder.proxy);
084    }
085    // proxy credentials can be used on system-wide proxies, so even if builder.proxy is null
086    if (isNullOrEmpty(builder.proxyLogin)) {
087      this.proxyCredentials = null;
088    } else {
089      this.proxyCredentials = Credentials.basic(builder.proxyLogin, nullToEmpty(builder.proxyPassword));
090    }
091
092    this.okHttpClient.setConnectTimeout(builder.connectTimeoutMs, TimeUnit.MILLISECONDS);
093    this.okHttpClient.setReadTimeout(builder.readTimeoutMs, TimeUnit.MILLISECONDS);
094
095    ConnectionSpec tls = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
096      .allEnabledTlsVersions()
097      .allEnabledCipherSuites()
098      .supportsTlsExtensions(true)
099      .build();
100    this.okHttpClient.setConnectionSpecs(asList(tls, ConnectionSpec.CLEARTEXT));
101    this.okHttpClient.setSslSocketFactory(createSslSocketFactory(javaVersion));
102  }
103
104  private static SSLSocketFactory createSslSocketFactory(JavaVersion javaVersion) {
105    try {
106      SSLSocketFactory sslSocketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
107      return enableTls12InJava7(sslSocketFactory, javaVersion);
108    } catch (Exception e) {
109      throw new IllegalStateException("Fail to init TLS context", e);
110    }
111  }
112
113  private static SSLSocketFactory enableTls12InJava7(SSLSocketFactory sslSocketFactory, JavaVersion javaVersion) {
114    if (javaVersion.isJava7()) {
115      // OkHttp executes SSLContext.getInstance("TLS") by default (see
116      // https://github.com/square/okhttp/blob/c358656/okhttp/src/main/java/com/squareup/okhttp/OkHttpClient.java#L616)
117      // As only TLS 1.0 is enabled by default in Java 7, the SSLContextFactory must be changed
118      // in order to support all versions from 1.0 to 1.2.
119      // Note that this is not overridden for Java 8 as TLS 1.2 is enabled by default.
120      // Keeping getInstance("TLS") allows to support potential future versions of TLS on Java 8.
121      return new Tls12Java7SocketFactory(sslSocketFactory);
122    }
123    return sslSocketFactory;
124  }
125
126  @Override
127  public String baseUrl() {
128    return baseUrl.url().toExternalForm();
129  }
130
131  @CheckForNull
132  public String userAgent() {
133    return userAgent;
134  }
135
136  public OkHttpClient okHttpClient() {
137    return okHttpClient;
138  }
139
140  @Override
141  public WsResponse call(WsRequest httpRequest) {
142    if (httpRequest instanceof GetRequest) {
143      return get((GetRequest) httpRequest);
144    }
145    if (httpRequest instanceof PostRequest) {
146      return post((PostRequest) httpRequest);
147    }
148    throw new IllegalArgumentException(format("Unsupported implementation: %s", httpRequest.getClass()));
149  }
150
151  private WsResponse get(GetRequest getRequest) {
152    HttpUrl.Builder urlBuilder = prepareUrlBuilder(getRequest);
153    Request.Builder okRequestBuilder = prepareOkRequestBuilder(getRequest, urlBuilder).get();
154    return doCall(okRequestBuilder.build());
155  }
156
157  private WsResponse post(PostRequest postRequest) {
158    HttpUrl.Builder urlBuilder = prepareUrlBuilder(postRequest);
159    Request.Builder okRequestBuilder = prepareOkRequestBuilder(postRequest, urlBuilder);
160
161    Map<String, PostRequest.Part> parts = postRequest.getParts();
162    if (parts.isEmpty()) {
163      okRequestBuilder.post(RequestBody.create(null, ""));
164    } else {
165      MultipartBuilder body = new MultipartBuilder().type(MultipartBuilder.FORM);
166      for (Map.Entry<String, PostRequest.Part> param : parts.entrySet()) {
167        PostRequest.Part part = param.getValue();
168        body.addPart(
169          Headers.of("Content-Disposition", format("form-data; name=\"%s\"", param.getKey())),
170          RequestBody.create(MediaType.parse(part.getMediaType()), part.getFile()));
171      }
172      okRequestBuilder.post(body.build());
173    }
174
175    return doCall(okRequestBuilder.build());
176  }
177
178  private HttpUrl.Builder prepareUrlBuilder(WsRequest wsRequest) {
179    String path = wsRequest.getPath();
180    HttpUrl.Builder urlBuilder = baseUrl
181      .resolve(path.startsWith("/") ? path.replaceAll("^(/)+", "") : path)
182      .newBuilder();
183    for (Map.Entry<String, String> param : wsRequest.getParams().entrySet()) {
184      urlBuilder.addQueryParameter(param.getKey(), param.getValue());
185    }
186    return urlBuilder;
187  }
188
189  private Request.Builder prepareOkRequestBuilder(WsRequest getRequest, HttpUrl.Builder urlBuilder) {
190    Request.Builder okHttpRequestBuilder = new Request.Builder()
191      .url(urlBuilder.build())
192      .addHeader("Accept", getRequest.getMediaType())
193      .addHeader("Accept-Charset", "UTF-8");
194    if (credentials != null) {
195      okHttpRequestBuilder.header("Authorization", credentials);
196    }
197    if (proxyCredentials != null) {
198      okHttpRequestBuilder.header("Proxy-Authorization", proxyCredentials);
199    }
200    if (userAgent != null) {
201      okHttpRequestBuilder.addHeader("User-Agent", userAgent);
202    }
203    return okHttpRequestBuilder;
204  }
205
206  private OkHttpResponse doCall(Request okRequest) {
207    Call call = okHttpClient.newCall(okRequest);
208    try {
209      Response okResponse = call.execute();
210      return new OkHttpResponse(okResponse);
211    } catch (IOException e) {
212      throw new IllegalStateException("Fail to request " + okRequest.urlString(), e);
213    }
214  }
215
216  /**
217   * @since 5.5
218   */
219  public static Builder newBuilder() {
220    return new Builder();
221  }
222
223  public static class Builder {
224    private String url;
225    private String userAgent;
226    private String login;
227    private String password;
228    private Proxy proxy;
229    private String proxyLogin;
230    private String proxyPassword;
231    private int connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLISECONDS;
232    private int readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLISECONDS;
233
234    /**
235     * Private since 5.5.
236     * @see HttpConnector#newBuilder()
237     */
238    private Builder() {
239    }
240
241    /**
242     * Optional User  Agent
243     */
244    public Builder userAgent(@Nullable String userAgent) {
245      this.userAgent = userAgent;
246      return this;
247    }
248
249    /**
250     * Mandatory HTTP server URL, eg "http://localhost:9000"
251     */
252    public Builder url(String url) {
253      this.url = url;
254      return this;
255    }
256
257    /**
258     * Optional login/password, for example "admin"
259     */
260    public Builder credentials(@Nullable String login, @Nullable String password) {
261      this.login = login;
262      this.password = password;
263      return this;
264    }
265
266    /**
267     * Optional access token, for example {@code "ABCDE"}. Alternative to {@link #credentials(String, String)}
268     */
269    public Builder token(@Nullable String token) {
270      this.login = token;
271      this.password = null;
272      return this;
273    }
274
275    /**
276     * Sets a specified timeout value, in milliseconds, to be used when opening HTTP connection.
277     * A timeout of zero is interpreted as an infinite timeout. Default value is {@link #DEFAULT_CONNECT_TIMEOUT_MILLISECONDS}
278     */
279    public Builder connectTimeoutMilliseconds(int i) {
280      this.connectTimeoutMs = i;
281      return this;
282    }
283
284    /**
285     * Sets the read timeout to a specified timeout, in milliseconds.
286     * A timeout of zero is interpreted as an infinite timeout. Default value is {@link #DEFAULT_READ_TIMEOUT_MILLISECONDS}
287     */
288    public Builder readTimeoutMilliseconds(int i) {
289      this.readTimeoutMs = i;
290      return this;
291    }
292
293    public Builder proxy(@Nullable Proxy proxy) {
294      this.proxy = proxy;
295      return this;
296    }
297
298    public Builder proxyCredentials(@Nullable String proxyLogin, @Nullable String proxyPassword) {
299      this.proxyLogin = proxyLogin;
300      this.proxyPassword = proxyPassword;
301      return this;
302    }
303
304    public HttpConnector build() {
305      return build(new JavaVersion());
306    }
307
308    @VisibleForTesting
309    HttpConnector build(JavaVersion javaVersion) {
310      checkArgument(!isNullOrEmpty(url), "Server URL is not defined");
311      return new HttpConnector(this, javaVersion);
312    }
313  }
314
315  static class JavaVersion {
316    boolean isJava7() {
317      return System.getProperty("java.version").startsWith("1.7.");
318    }
319  }
320}