// Copyright (C) 2013 GerritForge www.gerritforge.com
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mobi.jenkinsci.alm.assembla.client;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.List;
import mobi.jenkinsci.alm.assembla.objects.AssemblaMilestone;
import mobi.jenkinsci.alm.assembla.objects.AssemblaMilestones;
import mobi.jenkinsci.alm.assembla.objects.AssemblaSpace;
import mobi.jenkinsci.alm.assembla.objects.AssemblaSpaces;
import mobi.jenkinsci.alm.assembla.objects.AssemblaTicket;
import mobi.jenkinsci.alm.assembla.objects.AssemblaTicketComment;
import mobi.jenkinsci.alm.assembla.objects.AssemblaTickets;
import mobi.jenkinsci.net.*;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.CookieStore;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpCoreContext;
import org.apache.log4j.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.inject.Inject;
public class AssemblaClient {
private static Logger LOG = Logger.getLogger(AssemblaClient.class);
private static URL ASSEMBLA_SITE;
static {
try {
ASSEMBLA_SITE = new URL("https://api.assembla.com");
} catch (final MalformedURLException e) {
// This cannot happen as URL string is hardcoded
LOG.fatal("Cannot get Assembla URL", e);
}
}
private static String ASSEMBLA_SITE_APP_AUTH =
"https://%s:%s@api.assembla.com";
private static final String MY_SPACES = "/v1/spaces.json";
private static final String SPACE_TICKETS = "/v1/spaces/%s/tickets.json";
private static final String SPACE_TICKET_COMMENTS =
"/v1/spaces/%s/tickets/%d/ticket_comments.json";
private static final String SPACE_MILESTONES =
"/v1/spaces/%s/milestones.json";
private static final String AUTH =
"/authorization?client_id=%s&response_type=pin_code";
private static final String PIN_AUTH =
"/token?grant_type=pin_code&pin_code=%s";
private static final String TOKEN_REFRESH =
"/token?grant_type=refresh_token&refresh_token=%s";
private static final String LOGIN = "/login";
private final String appId;
private final String appSecret;
private final String username;
private final String password;
private AssemblaAccessToken accessToken;
@Inject
private HttpClientFactory httpClientFactory;
private final HttpClient httpClient;
private final HttpContext httpContext;
private static Gson gson;
static {
final GsonBuilder gsonB = new GsonBuilder();
gsonB
.registerTypeAdapter(GregorianCalendar.class, new CalendarSerializer());
gson = gsonB.create();
}
public AssemblaClient(final String appId, final String appSecret,
final String username, final String password) {
this.appId = appId;
this.appSecret = appSecret;
this.httpClient = httpClientFactory.getHttpClient();
httpContext = new BasicHttpContext();
final CookieStore cookieStore = new BasicCookieStore();
httpContext.setAttribute(HttpClientContext.COOKIE_STORE, cookieStore);
this.username = username;
this.password = password;
}
private URL getLatestRedirectedUrl() {
try {
final HttpRequest request =
(HttpRequest) httpContext.getAttribute(HttpCoreContext.HTTP_REQUEST);
final HttpHost host =
(HttpHost) httpContext.getAttribute(HttpCoreContext.HTTP_TARGET_HOST);
if (host.getPort() <= 0) {
return new URL(host.getSchemeName(), host.getHostName(), request
.getRequestLine().getUri());
} else {
return new URL(host.getSchemeName(), host.getHostName(),
host.getPort(), request.getRequestLine().getUri());
}
} catch (final MalformedURLException e) {
throw new IllegalArgumentException(
"Cannot get last redirected URL from HTTP Context", e);
}
}
public void login() throws IOException {
Document pinDoc = Jsoup.parse(getData(String.format(AUTH, appId), false));
if (getLatestRedirectedUrl().getPath().startsWith(LOGIN)) {
pinDoc = postLoginForm(pinDoc);
}
final Element pinBox = pinDoc.select("div[class=box]").first();
if (pinBox == null) {
throw new IOException("Missing PIN code from Assembla auth response");
}
final Element pinLabel = pinBox.select("p").first();
final Element pinValue = pinBox.select("h1").first();
if (pinLabel == null || pinValue == null) {
throw new IOException("Missing PIN code from Assembla auth response");
}
final String pin = pinValue.childNode(0).toString();
final HttpPost authPost =
new HttpPost(String.format(ASSEMBLA_SITE_APP_AUTH, appId, appSecret)
+ String.format(PIN_AUTH, pin));
final HttpResponse pinResponse = httpClient.execute(authPost);
try {
if (pinResponse.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) {
throw new IOException("Post " + authPost.getURI()
+ " for a PIN failed: " + pinResponse.getStatusLine());
}
accessToken =
gson.fromJson(new JsonReader(new InputStreamReader(pinResponse
.getEntity().getContent(), "UTF-8")), AssemblaAccessToken.class);
} finally {
authPost.releaseConnection();
}
}
public void loginRefresh() throws IOException {
if (accessToken == null) {
login();
} else {
accessToken.access_token = "";
final HttpPost authPost =
new HttpPost(String.format(ASSEMBLA_SITE_APP_AUTH, appId, appSecret)
+ String.format(TOKEN_REFRESH, accessToken.refresh_token));
final HttpResponse response = httpClient.execute(authPost);
try {
if (response.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) {
throw new IOException("Post " + authPost.getURI()
+ " for Token refresh failed: " + response.getStatusLine());
}
final AssemblaAccessToken refreshToken =
gson.fromJson(new JsonReader(new InputStreamReader(response
.getEntity().getContent(), "UTF-8")), AssemblaAccessToken.class);
accessToken.renew(refreshToken);
} finally {
authPost.releaseConnection();
}
}
}
private Document postLoginForm(final Document pinDoc) throws IOException {
final List<NameValuePair> formNvps = new ArrayList<NameValuePair>();
final Element form = pinDoc.select("form[id=login-box]").first();
final String formAction = form.attr("action");
final HttpPost formPost = new HttpPost(getUrl(formAction).toString());
final Elements formFields = form.select("input");
for (final Element element : formFields) {
final String fieldName = element.attr("name");
String fieldValue = element.attr("value");
final String fieldId = element.attr("id");
final String fieldType = element.attr("type");
if (fieldId.equalsIgnoreCase("user_login")) {
fieldValue = username;;
} else if (fieldId.equalsIgnoreCase("user_password")) {
fieldValue = password;
}
if (fieldType.equals("submit")) {
if (!fieldName.equalsIgnoreCase("commit")) {
continue;
}
}
LOG.debug(String.format(
"Processing form field: name='%s' value='%s' id='%s'", fieldName,
fieldValue, fieldId));
formNvps.add(new BasicNameValuePair(fieldName, fieldValue));
}
try {
formPost.setEntity(new UrlEncodedFormEntity(formNvps, "UTF-8"));
} catch (final UnsupportedEncodingException e) {
// This would never happen
throw new IllegalArgumentException("UTF-8 not recognised");
}
HttpResponse response;
LOG.debug("Login via posting form-data to " + formPost.getURI());
try {
response = sendHttpPost(formPost);
if (response.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_MOVED_TEMP) {
throw new IOException("Form-based login to Assembla failed: "
+ response.getStatusLine());
}
return Jsoup.parse(getData(
response.getFirstHeader("Location").getValue(), false));
} finally {
formPost.releaseConnection();
}
}
private HttpResponse sendHttpPost(final HttpPost formPost) throws IOException {
HttpResponse response;
response = httpClient.execute(formPost, httpContext);
return response;
}
private URL getUrl(final String formAction) {
try {
return new URL(ASSEMBLA_SITE, formAction);
} catch (final MalformedURLException e) {
throw new IllegalArgumentException(
"Cannot create URL from formAction target " + formAction, e);
}
}
public AssemblaSpaces spaces() throws IOException {
final List<AssemblaSpace> spaces =
gson.fromJson(getData(MY_SPACES, true),
new TypeToken<List<AssemblaSpace>>() {}.getType());
return (AssemblaSpaces) new AssemblaSpaces(spaces).init(this);
}
public AssemblaTickets getTickets(final String spaceId) throws IOException {
final List<AssemblaTicket> tickets =
gson.fromJson(getData(String.format(SPACE_TICKETS, spaceId), true),
new TypeToken<List<AssemblaTicket>>() {}.getType());
return (AssemblaTickets) new AssemblaTickets(tickets).init(this);
}
public AssemblaMilestones getMilestones(final String spaceId)
throws Exception {
final List<AssemblaMilestone> milestones =
gson.fromJson(getData(String.format(SPACE_MILESTONES, spaceId), true),
new TypeToken<List<AssemblaMilestone>>() {}.getType());
return (AssemblaMilestones) new AssemblaMilestones(milestones).init(this);
}
private String getData(final String dataSuffix, final boolean autoLogin)
throws IOException {
loginWhenNecessary(autoLogin);
final HttpGet get = new HttpGet(getTargetUrl(dataSuffix));
setRequestHeaders(get, autoLogin);
try {
final HttpResponse response = httpClient.execute(get, httpContext);
switch (response.getStatusLine().getStatusCode()) {
case HttpURLConnection.HTTP_OK:
final ByteArrayOutputStream bout = new ByteArrayOutputStream();
final InputStream in = response.getEntity().getContent();
IOUtils.copy(in, bout);
return new String(bout.toByteArray());
case HttpURLConnection.HTTP_NO_CONTENT:
return null;
case HttpURLConnection.HTTP_NOT_FOUND:
LOG.warn("Resource at " + dataSuffix
+ " was not found: returning NULL");
return null;
case HttpURLConnection.HTTP_UNAUTHORIZED:
if (autoLogin) {
loginRefresh();
return getData(dataSuffix, false);
}
default:
throw new IOException("Cannot GET " + dataSuffix + " from Assembla: "
+ response.getStatusLine());
}
} finally {
get.releaseConnection();
}
}
private String postData(final String dataSuffix, final byte[] postData,
final ContentType contentType) throws IOException {
loginWhenNecessary(true);
final HttpPost post = new HttpPost(getTargetUrl(dataSuffix));
setRequestHeaders(post, true);
post.setEntity(new ByteArrayEntity(postData, contentType));
try {
final HttpResponse response = httpClient.execute(post, httpContext);
switch (response.getStatusLine().getStatusCode()) {
case HttpURLConnection.HTTP_CREATED:
final ByteArrayOutputStream bout = new ByteArrayOutputStream();
final InputStream in = response.getEntity().getContent();
IOUtils.copy(in, bout);
return new String(bout.toByteArray());
default:
throw new IOException("Cannot POST " + dataSuffix + " to Assembla: "
+ response.getStatusLine());
}
} finally {
post.releaseConnection();
}
}
private String getTargetUrl(final String dataSuffix) {
final String targetUrl =
(dataSuffix.startsWith("http") ? dataSuffix : getUrl(dataSuffix)
.toString());
return targetUrl;
}
private void setRequestHeaders(final HttpRequestBase req,
final boolean autoLogin) {
if (accessToken != null) {
req.addHeader("Authorization", "Bearer " + accessToken.access_token);
}
if (req.getURI().getPath().endsWith(".json")) {
req.addHeader(HttpHeaders.ACCEPT, "application/json");
}
}
private void loginWhenNecessary(final boolean autoLogin) throws IOException {
if (accessToken == null && autoLogin) {
login();
}
}
public AssemblaTicketComment addComment(final String spaceId,
final int ticketNumber, final String commentText) throws IOException {
final JsonObject comment = new JsonObject();
comment.add("comment", new JsonPrimitive(commentText));
final JsonObject ticketComment = new JsonObject();
ticketComment.add("ticket_comment", comment);
final AssemblaTicketComment assemblaComment =
gson.fromJson(
postData(
String.format(SPACE_TICKET_COMMENTS, spaceId, ticketNumber),
ticketComment.toString().getBytes(),
ContentType.APPLICATION_JSON), AssemblaTicketComment.class);
return (AssemblaTicketComment) assemblaComment.init(this);
}
}