From ad32f0c66942ef2e3a9729b4f325f9bf03fcec2b Mon Sep 17 00:00:00 2001 From: Jochen Bedersdorfer Date: Mon, 20 Apr 2015 15:07:04 -0700 Subject: [PATCH] Added OAuth authentication, RestClient is now an abstract base class with two implementations (necessary to support OAuth), added Project.searchAssignableUsers --- AUTHORS.md | 1 + README.md | 3 + pom.xml | 11 + .../java/net/rcarz/jiraclient/OAuthTest.java | 42 ++ .../net/rcarz/jiraclient/ProjectTest.java | 49 +++ .../jiraclient/ApacheHttpRestClient.java | 411 ++++++++++++++++++ .../java/net/rcarz/jiraclient/Attachment.java | 20 +- .../java/net/rcarz/jiraclient/JiraClient.java | 14 +- .../java/net/rcarz/jiraclient/Project.java | 26 ++ .../java/net/rcarz/jiraclient/RestClient.java | 372 +--------------- .../net/rcarz/jiraclient/oauth/JiraApi.java | 74 ++++ .../jiraclient/oauth/OAuthRestClient.java | 300 +++++++++++++ .../java/net/rcarz/jiraclient/StatusTest.java | 4 +- .../java/net/rcarz/jiraclient/UserTest.java | 4 +- 14 files changed, 959 insertions(+), 372 deletions(-) create mode 100644 src/integrationtest/java/net/rcarz/jiraclient/OAuthTest.java create mode 100644 src/integrationtest/java/net/rcarz/jiraclient/ProjectTest.java create mode 100644 src/main/java/net/rcarz/jiraclient/ApacheHttpRestClient.java create mode 100644 src/main/java/net/rcarz/jiraclient/oauth/JiraApi.java create mode 100644 src/main/java/net/rcarz/jiraclient/oauth/OAuthRestClient.java diff --git a/AUTHORS.md b/AUTHORS.md index 9f293577..df3895c0 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -2,3 +2,4 @@ Bob Carroll @rcarz Kyle Chaplin @chaplinkyle Alesandro Lang @alesandroLang Javier Molina @javinovich +Jochen Bedersdorfer @beders diff --git a/README.md b/README.md index 03446265..16ec5b96 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ jira-client is still under heavy development. Here's what works: * Add and remove issue links * Create sub-tasks * Retrieval of Rapid Board backlog and sprints +* Search assignable users for a project +* Use OAuth to authenticate (see src/integrationtest/net/rcarz/jiraclient/OAuthTest for an example) + ## Maven Dependency ## diff --git a/pom.xml b/pom.xml index 58591c3c..d3c39fb4 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,11 @@ Bob Carroll bob.carroll@alum.rit.edu + + beders + Jochen Bedersdorfer + beders@yahoo.com + @@ -71,5 +76,11 @@ test + + org.scribe + scribe + 1.3.7 + + diff --git a/src/integrationtest/java/net/rcarz/jiraclient/OAuthTest.java b/src/integrationtest/java/net/rcarz/jiraclient/OAuthTest.java new file mode 100644 index 00000000..3c7e55dd --- /dev/null +++ b/src/integrationtest/java/net/rcarz/jiraclient/OAuthTest.java @@ -0,0 +1,42 @@ +package net.rcarz.jiraclient; + +import net.rcarz.jiraclient.oauth.OAuthRestClient; + +import java.net.URI; + +/** + * Example on how to use OAuth with JIRA and the Java Scribe project + * Follow instructions here: https://developer.atlassian.com/jiradev/api-reference/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-oauth-authentication + * + * See https://github.com/fernandezpablo85/scribe-java for an example on how to get a tokenSecret and accessToken. + * i.e + *
+ *  JiraApi jiraApi = new JiraApi(url, privateKey)
+ *  OAuthService service = new ServiceBuilder().provider(jiraApi).apiKey(consumerKey).apiSecret(privateKey).callback(yourCallbackURL).build();
+ *  Token requestToken = service.getRequestToken();
+ *  ...
+ *  on callback from Jira with params.oauth_verifier:
+ * Verifier v = new Verifier(params.oauth_verifier.toString())
+ *String accessToken = service.getAccessToken(requestToken, v);
+ * 
+ * + * Created by beders on 3/27/15. + */ +public class OAuthTest { + static String accessToken = "..."; // access token received for a user after the OAuth challenge is complete + static String tokenSecret = "..."; // acquired by service.getRequestToken().getSecret() + static String privateKey = "..."; // generated using keytool -genkeypair + static String endpointURL = "https://blabla.atlassian.net"; + static String consumerName = "hardcoded-consumer"; // part of the OAuth configuration for JIRA + + public static void main(String... args) { + JiraClient client = new JiraClient(endpointURL, null, new OAuthRestClient(URI.create(endpointURL), privateKey, consumerName, tokenSecret, accessToken)); + try { + for (Project p : client.getProjects()) { + System.out.println(p); + } + } catch (JiraException e) { + e.printStackTrace(); + } + } +} diff --git a/src/integrationtest/java/net/rcarz/jiraclient/ProjectTest.java b/src/integrationtest/java/net/rcarz/jiraclient/ProjectTest.java new file mode 100644 index 00000000..07fcf6a2 --- /dev/null +++ b/src/integrationtest/java/net/rcarz/jiraclient/ProjectTest.java @@ -0,0 +1,49 @@ +package net.rcarz.jiraclient; + +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class ProjectTest { + static String url() { + return System.getenv("URL"); + } + static String user() { + return System.getenv("USER"); + } + + static String pwd() { + return System.getenv("PASSWORD"); + } + static String project() { + return System.getenv("PROJECT"); + } + + JiraClient client; + @Before + public void setUp() throws Exception { + client = new JiraClient(url(), new BasicCredentials(user(), pwd())); + } + + @Test + public void listAssignableUsers() throws JiraException { + Project p = client.getProject(project()); + List users = p.searchAssignableUsers(); + assertNotNull(users); + System.out.println(users); + } + + @Test + public void listAssignableUsersIndex() throws JiraException { + Project p = client.getProject(project()); + List users = p.searchAssignableUsers(0,1); + assertNotNull(users); + assertEquals(1,users.size()); + } + + +} \ No newline at end of file diff --git a/src/main/java/net/rcarz/jiraclient/ApacheHttpRestClient.java b/src/main/java/net/rcarz/jiraclient/ApacheHttpRestClient.java new file mode 100644 index 00000000..524d08c7 --- /dev/null +++ b/src/main/java/net/rcarz/jiraclient/ApacheHttpRestClient.java @@ -0,0 +1,411 @@ +/** + * jira-client - a simple JIRA REST client + * Copyright (c) 2013 Bob Carroll (bob.carroll@alum.rit.edu) + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package net.rcarz.jiraclient; + +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +import net.sf.json.JSON; +import net.sf.json.JSONObject; +import net.sf.json.JSONSerializer; + +import org.apache.http.Header; +import org.apache.http.HeaderElement; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.entity.mime.MultipartEntity; +import org.apache.http.entity.mime.content.FileBody; +import org.apache.http.protocol.HttpContext; + +/** + * A simple REST client that speaks JSON. + */ +public class ApacheHttpRestClient extends RestClient { + + private HttpClient httpClient = null; + private ICredentials creds = null; + private HttpContext context; + + /** + * Creates a REST client instance with a URI. + * + * @param httpclient Underlying HTTP client to use + * @param uri Base URI of the remote REST service + */ + public ApacheHttpRestClient(HttpClient httpclient, URI uri) { + this(httpclient, null, uri); + } + + /** + * Creates an authenticated REST client instance with a URI. + * + * @param httpclient Underlying HTTP client to use + * @param creds Credentials to send with each request + * @param uri Base URI of the remote REST service + */ + public ApacheHttpRestClient(HttpClient httpclient, ICredentials creds, URI uri) { + super(uri); + this.httpClient = httpclient; + this.creds = creds; + } + + + private JSON request(HttpRequestBase req) throws RestException, IOException { + req.addHeader("Accept", "application/json"); + + if (creds != null) + creds.authenticate(req); + + HttpResponse resp = httpClient.execute(req, context); + HttpEntity ent = resp.getEntity(); + StringBuilder result = new StringBuilder(); + + if (ent != null) { + String encoding = null; + if (ent.getContentEncoding() != null) { + encoding = ent.getContentEncoding().getValue(); + } + + if (encoding == null) { + Header contentTypeHeader = resp.getFirstHeader("Content-Type"); + HeaderElement[] contentTypeElements = contentTypeHeader.getElements(); + for (HeaderElement he : contentTypeElements) { + NameValuePair nvp = he.getParameterByName("charset"); + if (nvp != null) { + encoding = nvp.getValue(); + } + } + } + + InputStreamReader isr = encoding != null ? + new InputStreamReader(ent.getContent(), encoding) : + new InputStreamReader(ent.getContent()); + BufferedReader br = new BufferedReader(isr); + String line = ""; + + while ((line = br.readLine()) != null) + result.append(line); + } + + StatusLine sl = resp.getStatusLine(); + + if (sl.getStatusCode() >= 300) + throw new RestException(sl.getReasonPhrase(), sl.getStatusCode(), result.toString()); + + return result.length() > 0 ? JSONSerializer.toJSON(result.toString()): null; + } + + private JSON request(HttpEntityEnclosingRequestBase req, String payload) + throws RestException, IOException { + + if (payload != null) { + StringEntity ent = null; + + try { + ent = new StringEntity(payload, "UTF-8"); + ent.setContentType("application/json"); + } catch (UnsupportedEncodingException ex) { + /* utf-8 should always be supported... */ + } + + req.addHeader("Content-Type", "application/json"); + req.setEntity(ent); + } + + return request(req); + } + + private JSON request(HttpEntityEnclosingRequestBase req, File file) + throws RestException, IOException { + if (file != null) { + File fileUpload = file; + req.setHeader("X-Atlassian-Token","nocheck"); + MultipartEntity ent = new MultipartEntity(); + ent.addPart("file", new FileBody(fileUpload)); + req.setEntity(ent); + } + return request(req); + } + + private JSON request(HttpEntityEnclosingRequestBase req, JSON payload) + throws RestException, IOException { + + return request(req, payload != null ? payload.toString() : null); + } + + /** + * Executes an HTTP DELETE with the given URI. + * + * @param uri Full URI of the remote endpoint + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + @Override + public JSON delete(URI uri) throws RestException, IOException { + return request(new HttpDelete(uri)); + } + + /** + * Executes an HTTP DELETE with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON delete(String path) throws RestException, IOException, URISyntaxException { + return delete(buildURI(path)); + } + + /** + * Executes an HTTP GET with the given URI. + * + * @param uri Full URI of the remote endpoint + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + @Override + public JSON get(URI uri) throws RestException, IOException { + return request(new HttpGet(uri)); + } + + /** + * Executes an HTTP GET with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param params Map of key value pairs + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON get(String path, Map params) throws RestException, IOException, URISyntaxException { + return get(buildURI(path, params)); + } + + /** + * Executes an HTTP GET with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON get(String path) throws RestException, IOException, URISyntaxException { + return get(path, null); + } + + + /** + * Executes an HTTP POST with the given URI and payload. + * + * @param uri Full URI of the remote endpoint + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + @Override + public JSON post(URI uri, JSON payload) throws RestException, IOException { + return request(new HttpPost(uri), payload); + } + + /** + * Executes an HTTP POST with the given URI and payload. + * + * At least one JIRA REST endpoint expects malformed JSON. The payload + * argument is quoted and sent to the server with the application/json + * Content-Type header. You should not use this function when proper JSON + * is expected. + * + * @see https://jira.atlassian.com/browse/JRA-29304 + * + * @param uri Full URI of the remote endpoint + * @param payload Raw string to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + @Override + public JSON post(URI uri, String payload) throws RestException, IOException { + String quoted = null; + if(payload != null && !payload.equals(new JSONObject())){ + quoted = String.format("\"%s\"", payload); + } + return request(new HttpPost(uri), quoted); + } + + /** + * Executes an HTTP POST with the given path and payload. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON post(String path, JSON payload) + throws RestException, IOException, URISyntaxException { + + return post(buildURI(path), payload); + } + + /** + * Executes an HTTP POST with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON post(String path) + throws RestException, IOException, URISyntaxException { + + return post(buildURI(path), new JSONObject()); + } + + /** + * Executes an HTTP POST with the given path and file payload. + * + * @param uri Full URI of the remote endpoint + * @param file java.io.File + * + * @throws URISyntaxException + * @throws IOException + * @throws RestException + */ + @Override + public JSON post(String path, File file) throws RestException, IOException, URISyntaxException{ + return request(new HttpPost(buildURI(path)), file); + } + + /** + * Executes an HTTP PUT with the given URI and payload. + * + * @param uri Full URI of the remote endpoint + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + @Override + public JSON put(URI uri, JSON payload) throws RestException, IOException { + return request(new HttpPut(uri), payload); + } + + /** + * Executes an HTTP PUT with the given path and payload. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + @Override + public JSON put(String path, JSON payload) + throws RestException, IOException, URISyntaxException { + + return put(buildURI(path), payload); + } + + /** Download the contents of the URI as byte array */ + @Override + public byte[] download(String uri) throws JiraException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try{ + HttpGet get = new HttpGet(uri); + HttpResponse response = httpClient.execute(get); + HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream inputStream = entity.getContent(); + int next = inputStream.read(); + while (next > -1) { + bos.write(next); + next = inputStream.read(); + } + bos.flush(); + } + }catch(IOException e){ + throw new JiraException(String.format("Failed downloading attachment from %s: %s", this.uri, e.getMessage())); + } + return bos.toByteArray(); + } + + /** + * Exposes the http client. + * + * @return the httpClient property + */ + public HttpClient getHttpClient(){ + return this.httpClient; + } + + public void setContext(HttpContext context) { + this.context = context; + } +} + diff --git a/src/main/java/net/rcarz/jiraclient/Attachment.java b/src/main/java/net/rcarz/jiraclient/Attachment.java index b5a4fc34..5d5d2f5b 100644 --- a/src/main/java/net/rcarz/jiraclient/Attachment.java +++ b/src/main/java/net/rcarz/jiraclient/Attachment.java @@ -106,24 +106,8 @@ public static Attachment get(RestClient restclient, String id) */ public byte[] download() throws JiraException{ - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - try{ - HttpGet get = new HttpGet(content); - HttpResponse response = restclient.getHttpClient().execute(get); - HttpEntity entity = response.getEntity(); - if (entity != null) { - InputStream inputStream = entity.getContent(); - int next = inputStream.read(); - while (next > -1) { - bos.write(next); - next = inputStream.read(); - } - bos.flush(); - } - }catch(IOException e){ - throw new JiraException(String.format("Failed downloading attachment from %s: %s", this.content, e.getMessage())); - } - return bos.toByteArray(); + return restclient.download(content); + } @Override diff --git a/src/main/java/net/rcarz/jiraclient/JiraClient.java b/src/main/java/net/rcarz/jiraclient/JiraClient.java index 1ef745a1..8eff36ea 100644 --- a/src/main/java/net/rcarz/jiraclient/JiraClient.java +++ b/src/main/java/net/rcarz/jiraclient/JiraClient.java @@ -47,7 +47,17 @@ public class JiraClient { * @param uri Base URI of the JIRA server */ public JiraClient(String uri) { - this(uri, null); + this(uri, (ICredentials)null); + } + + /** + * Creates a JIRA client. + * + * @param uri Base URI of the JIRA server + */ + public JiraClient(String uri, ICredentials creds, RestClient restClient) { + this(uri, creds); + this.restclient = restClient; } /** @@ -59,7 +69,7 @@ public JiraClient(String uri) { public JiraClient(String uri, ICredentials creds) { DefaultHttpClient httpclient = new DefaultHttpClient(); - restclient = new RestClient(httpclient, creds, URI.create(uri)); + restclient = new ApacheHttpRestClient(httpclient, creds, URI.create(uri)); if (creds != null) username = creds.getLogonName(); diff --git a/src/main/java/net/rcarz/jiraclient/Project.java b/src/main/java/net/rcarz/jiraclient/Project.java index f60f5cd2..2bf8f9d1 100644 --- a/src/main/java/net/rcarz/jiraclient/Project.java +++ b/src/main/java/net/rcarz/jiraclient/Project.java @@ -19,8 +19,13 @@ package net.rcarz.jiraclient; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import net.sf.json.JSON; import net.sf.json.JSONArray; @@ -170,5 +175,26 @@ public List getVersions() { public Map getRoles() { return roles; } + + public List searchAssignableUsers() throws JiraException { + return searchAssignableUsers(0, 50); + } + + public List searchAssignableUsers(int startIndex, int count) throws JiraException { + try { + Map param = new HashMap<>(); + param.put("project", key); + param.put("startAt", Integer.toString(startIndex)); + param.put("maxResults", Integer.toString(count)); + + URI uri = restclient.buildURI(Resource.getBaseUri() + "user/assignable/search", param); + JSONArray response = (JSONArray) restclient.get(uri); + List users = (List)response.stream().map(o -> new User(restclient, (JSONObject) o)).collect(Collectors.toList()); + + return users; + } catch (Exception ex) { + throw new JiraException(ex.getMessage(), ex); + } + } } diff --git a/src/main/java/net/rcarz/jiraclient/RestClient.java b/src/main/java/net/rcarz/jiraclient/RestClient.java index 701b72fb..32896a81 100644 --- a/src/main/java/net/rcarz/jiraclient/RestClient.java +++ b/src/main/java/net/rcarz/jiraclient/RestClient.java @@ -1,84 +1,22 @@ -/** - * jira-client - a simple JIRA REST client - * Copyright (c) 2013 Bob Carroll (bob.carroll@alum.rit.edu) - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - package net.rcarz.jiraclient; -import java.io.BufferedReader; +import net.sf.json.JSON; +import org.apache.http.client.utils.URIBuilder; + import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.util.Map; -import net.sf.json.JSON; -import net.sf.json.JSONObject; -import net.sf.json.JSONSerializer; - -import org.apache.http.Header; -import org.apache.http.HeaderElement; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.NameValuePair; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpDelete; -import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpPut; -import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.entity.StringEntity; -import org.apache.http.entity.mime.MultipartEntity; -import org.apache.http.entity.mime.content.FileBody; - /** - * A simple REST client that speaks JSON. + * Abstract base class for rest clients running HTTP requests over the wire. + * Created by beders on 3/28/15. */ -public class RestClient { - - private HttpClient httpClient = null; - private ICredentials creds = null; - private URI uri = null; - - /** - * Creates a REST client instance with a URI. - * - * @param httpclient Underlying HTTP client to use - * @param uri Base URI of the remote REST service - */ - public RestClient(HttpClient httpclient, URI uri) { - this(httpclient, null, uri); - } +abstract public class RestClient { + protected URI uri = null; - /** - * Creates an authenticated REST client instance with a URI. - * - * @param httpclient Underlying HTTP client to use - * @param creds Credentials to send with each request - * @param uri Base URI of the remote REST service - */ - public RestClient(HttpClient httpclient, ICredentials creds, URI uri) { - this.httpClient = httpclient; - this.creds = creds; + public RestClient(URI uri) { this.uri = uri; } @@ -117,294 +55,32 @@ public URI buildURI(String path, Map params) throws URISyntaxExc return ub.build(); } - private JSON request(HttpRequestBase req) throws RestException, IOException { - req.addHeader("Accept", "application/json"); - - if (creds != null) - creds.authenticate(req); - - HttpResponse resp = httpClient.execute(req); - HttpEntity ent = resp.getEntity(); - StringBuilder result = new StringBuilder(); - - if (ent != null) { - String encoding = null; - if (ent.getContentEncoding() != null) { - encoding = ent.getContentEncoding().getValue(); - } - - if (encoding == null) { - Header contentTypeHeader = resp.getFirstHeader("Content-Type"); - HeaderElement[] contentTypeElements = contentTypeHeader.getElements(); - for (HeaderElement he : contentTypeElements) { - NameValuePair nvp = he.getParameterByName("charset"); - if (nvp != null) { - encoding = nvp.getValue(); - } - } - } - - InputStreamReader isr = encoding != null ? - new InputStreamReader(ent.getContent(), encoding) : - new InputStreamReader(ent.getContent()); - BufferedReader br = new BufferedReader(isr); - String line = ""; - - while ((line = br.readLine()) != null) - result.append(line); - } - - StatusLine sl = resp.getStatusLine(); - - if (sl.getStatusCode() >= 300) - throw new RestException(sl.getReasonPhrase(), sl.getStatusCode(), result.toString()); - - return result.length() > 0 ? JSONSerializer.toJSON(result.toString()): null; - } - - private JSON request(HttpEntityEnclosingRequestBase req, String payload) - throws RestException, IOException { + abstract public JSON delete(URI uri) throws RestException, IOException; - if (payload != null) { - StringEntity ent = null; + abstract public JSON delete(String path) throws RestException, IOException, URISyntaxException; - try { - ent = new StringEntity(payload, "UTF-8"); - ent.setContentType("application/json"); - } catch (UnsupportedEncodingException ex) { - /* utf-8 should always be supported... */ - } - - req.addHeader("Content-Type", "application/json"); - req.setEntity(ent); - } - - return request(req); - } - - private JSON request(HttpEntityEnclosingRequestBase req, File file) - throws RestException, IOException { - if (file != null) { - File fileUpload = file; - req.setHeader("X-Atlassian-Token","nocheck"); - MultipartEntity ent = new MultipartEntity(); - ent.addPart("file", new FileBody(fileUpload)); - req.setEntity(ent); - } - return request(req); - } - - private JSON request(HttpEntityEnclosingRequestBase req, JSON payload) - throws RestException, IOException { - - return request(req, payload != null ? payload.toString() : null); - } - - /** - * Executes an HTTP DELETE with the given URI. - * - * @param uri Full URI of the remote endpoint - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - */ - public JSON delete(URI uri) throws RestException, IOException { - return request(new HttpDelete(uri)); - } - - /** - * Executes an HTTP DELETE with the given path. - * - * @param path Path to be appended to the URI supplied in the construtor - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON delete(String path) throws RestException, IOException, URISyntaxException { - return delete(buildURI(path)); - } - - /** - * Executes an HTTP GET with the given URI. - * - * @param uri Full URI of the remote endpoint - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - */ - public JSON get(URI uri) throws RestException, IOException { - return request(new HttpGet(uri)); - } + abstract public JSON get(URI uri) throws RestException, IOException; - /** - * Executes an HTTP GET with the given path. - * - * @param path Path to be appended to the URI supplied in the construtor - * @param params Map of key value pairs - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON get(String path, Map params) throws RestException, IOException, URISyntaxException { - return get(buildURI(path, params)); - } + abstract public JSON get(String path, Map params) throws RestException, IOException, URISyntaxException; - /** - * Executes an HTTP GET with the given path. - * - * @param path Path to be appended to the URI supplied in the construtor - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON get(String path) throws RestException, IOException, URISyntaxException { - return get(path, null); - } + abstract public JSON get(String path) throws RestException, IOException, URISyntaxException; + abstract public JSON post(URI uri, JSON payload) throws RestException, IOException; - /** - * Executes an HTTP POST with the given URI and payload. - * - * @param uri Full URI of the remote endpoint - * @param payload JSON-encoded data to send to the remote service - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - */ - public JSON post(URI uri, JSON payload) throws RestException, IOException { - return request(new HttpPost(uri), payload); - } + abstract public JSON post(URI uri, String payload) throws RestException, IOException; - /** - * Executes an HTTP POST with the given URI and payload. - * - * At least one JIRA REST endpoint expects malformed JSON. The payload - * argument is quoted and sent to the server with the application/json - * Content-Type header. You should not use this function when proper JSON - * is expected. - * - * @see https://jira.atlassian.com/browse/JRA-29304 - * - * @param uri Full URI of the remote endpoint - * @param payload Raw string to send to the remote service - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - */ - public JSON post(URI uri, String payload) throws RestException, IOException { - String quoted = null; - if(payload != null && !payload.equals(new JSONObject())){ - quoted = String.format("\"%s\"", payload); - } - return request(new HttpPost(uri), quoted); - } + abstract public JSON post(String path, JSON payload) + throws RestException, IOException, URISyntaxException; - /** - * Executes an HTTP POST with the given path and payload. - * - * @param path Path to be appended to the URI supplied in the construtor - * @param payload JSON-encoded data to send to the remote service - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON post(String path, JSON payload) - throws RestException, IOException, URISyntaxException { + abstract public JSON post(String path) + throws RestException, IOException, URISyntaxException; - return post(buildURI(path), payload); - } - - /** - * Executes an HTTP POST with the given path. - * - * @param path Path to be appended to the URI supplied in the construtor - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON post(String path) - throws RestException, IOException, URISyntaxException { - - return post(buildURI(path), new JSONObject()); - } - - /** - * Executes an HTTP POST with the given path and file payload. - * - * @param uri Full URI of the remote endpoint - * @param file java.io.File - * - * @throws URISyntaxException - * @throws IOException - * @throws RestException - */ - public JSON post(String path, File file) throws RestException, IOException, URISyntaxException{ - return request(new HttpPost(buildURI(path)), file); - } + abstract public JSON post(String path, File file) throws RestException, IOException, URISyntaxException; - /** - * Executes an HTTP PUT with the given URI and payload. - * - * @param uri Full URI of the remote endpoint - * @param payload JSON-encoded data to send to the remote service - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - */ - public JSON put(URI uri, JSON payload) throws RestException, IOException { - return request(new HttpPut(uri), payload); - } + abstract public JSON put(URI uri, JSON payload) throws RestException, IOException; - /** - * Executes an HTTP PUT with the given path and payload. - * - * @param path Path to be appended to the URI supplied in the construtor - * @param payload JSON-encoded data to send to the remote service - * - * @return JSON-encoded result or null when there's no content returned - * - * @throws RestException when an HTTP-level error occurs - * @throws IOException when an error reading the response occurs - * @throws URISyntaxException when an error occurred appending the path to the URI - */ - public JSON put(String path, JSON payload) - throws RestException, IOException, URISyntaxException { + abstract public JSON put(String path, JSON payload) + throws RestException, IOException, URISyntaxException; - return put(buildURI(path), payload); - } - - /** - * Exposes the http client. - * - * @return the httpClient property - */ - public HttpClient getHttpClient(){ - return this.httpClient; - } + public abstract byte[] download(String uri) throws JiraException; } - diff --git a/src/main/java/net/rcarz/jiraclient/oauth/JiraApi.java b/src/main/java/net/rcarz/jiraclient/oauth/JiraApi.java new file mode 100644 index 00000000..f474a53c --- /dev/null +++ b/src/main/java/net/rcarz/jiraclient/oauth/JiraApi.java @@ -0,0 +1,74 @@ +package net.rcarz.jiraclient.oauth; + +import org.scribe.builder.api.DefaultApi10a; +import org.scribe.model.Token; +import org.scribe.services.RSASha1SignatureService; +import org.scribe.services.SignatureService; + +import javax.xml.bind.DatatypeConverter; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; + +public class JiraApi extends DefaultApi10a { + private static final String SERVLET_BASE_URL = "/plugins/servlet"; + + private static final String AUTHORIZE_URL = "/oauth/authorize?oauth_token=%s"; + + private static final String REQUEST_TOKEN_RESOURCE = "/oauth/request-token"; + + private static final String ACCESS_TOKEN_RESOURCE = "/oauth/access-token"; + + private String serverBaseUrl = null; + + private String privateKey = null; + + public JiraApi(String serverBaseUrl, String privateKey) { + this.serverBaseUrl = serverBaseUrl; + this.privateKey = privateKey; + } + + @Override + public String getAccessTokenEndpoint() { + if (null == serverBaseUrl || 0 == serverBaseUrl.length()) { + throw new RuntimeException("serverBaseUrl is not properly initialized"); + } + + return serverBaseUrl + SERVLET_BASE_URL + ACCESS_TOKEN_RESOURCE; + } + + @Override + public String getRequestTokenEndpoint() { + if (null == serverBaseUrl || 0 == serverBaseUrl.length()) { + throw new RuntimeException("serverBaseUrl is not properly initialized"); + } + + return serverBaseUrl + SERVLET_BASE_URL + REQUEST_TOKEN_RESOURCE; + } + + @Override + public String getAuthorizationUrl(Token requestToken) { + if (null == serverBaseUrl || 0 == serverBaseUrl.length()) { + throw new RuntimeException("serverBaseUrl is not properly initialized"); + } + + return String.format(serverBaseUrl + SERVLET_BASE_URL + AUTHORIZE_URL, requestToken.getToken()); + } + + @Override + public SignatureService getSignatureService() { + if (null == privateKey || 0 == privateKey.length()) { + throw new RuntimeException("privateKey is not properly initialized"); + } + + try { + KeyFactory fac = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec privKeySpec = new PKCS8EncodedKeySpec(DatatypeConverter.parseBase64Binary(privateKey)); + PrivateKey privateKey = fac.generatePrivate(privKeySpec); + return new RSASha1SignatureService(privateKey); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/net/rcarz/jiraclient/oauth/OAuthRestClient.java b/src/main/java/net/rcarz/jiraclient/oauth/OAuthRestClient.java new file mode 100644 index 00000000..d10a31ec --- /dev/null +++ b/src/main/java/net/rcarz/jiraclient/oauth/OAuthRestClient.java @@ -0,0 +1,300 @@ +package net.rcarz.jiraclient.oauth; + +import net.rcarz.jiraclient.JiraException; +import net.rcarz.jiraclient.RestClient; +import net.rcarz.jiraclient.RestException; +import net.sf.json.JSON; +import net.sf.json.JSONObject; +import net.sf.json.JSONSerializer; + +import org.apache.http.entity.mime.MultipartEntity; +import org.apache.http.entity.mime.content.FileBody; +import org.scribe.builder.ServiceBuilder; +import org.scribe.model.OAuthRequest; +import org.scribe.model.Response; +import org.scribe.model.Token; +import org.scribe.model.Verb; +import org.scribe.oauth.OAuthService; +import sun.misc.IOUtils; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +/** + * RestClient based on the scribe request/response classes + * Created by beders on 3/30/15. + */ +public class OAuthRestClient extends RestClient { + OAuthService service; + private Token token; + + public OAuthRestClient(URI uri, String privateKey, String consumerKey, String tokenSecret, String accessKey) { + super(uri); + JiraApi jiraApi = new JiraApi(uri.toString(), privateKey); + service = new ServiceBuilder().provider(jiraApi).apiKey(consumerKey).apiSecret(privateKey).build(); + token = new Token(accessKey, tokenSecret); + } + + private JSON request(OAuthRequest req) throws RestException, IOException { + req.addHeader("Accept", "application/json"); + + service.signRequest(token, req); + Response response = req.send(); + String result = response.getBody(); // note: this assumes body content is a string with UTF-8 encoding! + + int status = response.getCode(); + if (status >= 300) + throw new RestException(response.toString(), status, result); + + return result.length() > 0 ? JSONSerializer.toJSON(result) : null; + } + + private JSON request(OAuthRequest req, String payload) + throws RestException, IOException { + + if (payload != null) { + req.addHeader("Content-Type", "application/json"); + req.setCharset("UTF-8"); + req.addPayload(payload); + } + return request(req); + } + + private JSON request(OAuthRequest req, File file) + throws RestException, IOException { + if (file != null) { + req.addHeader("X-Atlassian-Token", "nocheck"); + + MultipartEntity ent = new MultipartEntity(); + ent.addPart("file", new FileBody(file)); + ByteArrayOutputStream bos = new ByteArrayOutputStream((int) file.length()); // yup, will fail with VERY large files + ent.writeTo(bos); + req.addHeader("Content-Type", "multipart/form-data"); + req.addPayload(bos.toByteArray()); + } + return request(req); + } + + private JSON request(OAuthRequest req, JSON payload) + throws RestException, IOException { + + return request(req, payload != null ? payload.toString() : null); + } + + + /** + * Executes an HTTP DELETE with the given URI. + * + * @param uri Full URI of the remote endpoint + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + public JSON delete(URI uri) throws RestException, IOException { + return request(new OAuthRequest(Verb.DELETE, uri.toString())); + } + + /** + * Executes an HTTP DELETE with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws java.net.URISyntaxException when an error occurred appending the path to the URI + */ + public JSON delete(String path) throws RestException, IOException, URISyntaxException { + return delete(buildURI(path)); + } + + /** + * Executes an HTTP GET with the given URI. + * + * @param uri Full URI of the remote endpoint + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + public JSON get(URI uri) throws RestException, IOException { + return request(new OAuthRequest(Verb.GET, uri.toString())); + } + + /** + * Executes an HTTP GET with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param params Map of key value pairs + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + public JSON get(String path, Map params) throws RestException, IOException, URISyntaxException { + return get(buildURI(path, params)); + } + + /** + * Executes an HTTP GET with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + public JSON get(String path) throws RestException, IOException, URISyntaxException { + return get(path, null); + } + + + /** + * Executes an HTTP POST with the given URI and payload. + * + * @param uri Full URI of the remote endpoint + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + public JSON post(URI uri, JSON payload) throws RestException, IOException { + return request(new OAuthRequest(Verb.POST, uri.toString()), payload.toString()); + } + + /** + * Executes an HTTP POST with the given URI and payload. + * + * At least one JIRA REST endpoint expects malformed JSON. The payload + * argument is quoted and sent to the server with the application/json + * Content-Type header. You should not use this function when proper JSON + * is expected. + * + * @see https://jira.atlassian.com/browse/JRA-29304 + * + * @param uri Full URI of the remote endpoint + * @param payload Raw string to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + public JSON post(URI uri, String payload) throws RestException, IOException { + String quoted = null; + if(payload != null && !payload.equals(new JSONObject())){ + quoted = String.format("\"%s\"", payload); + } + return request(new OAuthRequest(Verb.POST, uri.toString()), quoted); + } + + /** + * Executes an HTTP POST with the given path and payload. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + public JSON post(String path, JSON payload) + throws RestException, IOException, URISyntaxException { + + return post(buildURI(path), payload); + } + + /** + * Executes an HTTP POST with the given path. + * + * @param path Path to be appended to the URI supplied in the construtor + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + public JSON post(String path) + throws RestException, IOException, URISyntaxException { + + return post(buildURI(path), new JSONObject()); + } + + /** + * Executes an HTTP POST with the given path and file payload. + * + * @param uri Full URI of the remote endpoint + * @param file java.io.File + * + * @throws URISyntaxException + * @throws IOException + * @throws RestException + */ + public JSON post(String path, File file) throws RestException, IOException, URISyntaxException{ + return request(new OAuthRequest(Verb.POST, path), file); + } + + /** + * Executes an HTTP PUT with the given URI and payload. + * + * @param uri Full URI of the remote endpoint + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + */ + public JSON put(URI uri, JSON payload) throws RestException, IOException { + return request(new OAuthRequest(Verb.PUT, uri.toString()), payload); + } + + /** + * Executes an HTTP PUT with the given path and payload. + * + * @param path Path to be appended to the URI supplied in the construtor + * @param payload JSON-encoded data to send to the remote service + * + * @return JSON-encoded result or null when there's no content returned + * + * @throws RestException when an HTTP-level error occurs + * @throws IOException when an error reading the response occurs + * @throws URISyntaxException when an error occurred appending the path to the URI + */ + public JSON put(String path, JSON payload) + throws RestException, IOException, URISyntaxException { + + return put(buildURI(path), payload); + } + + @Override + public byte[] download(String uri) throws JiraException { + OAuthRequest request = new OAuthRequest(Verb.GET, uri); + service.signRequest(token, request); + Response response = request.send(); + try { + byte[] bytes = IOUtils.readFully(response.getStream(), -1, true); + return bytes; + } catch (IOException e) { + throw new JiraException(String.format("Failed downloading attachment from %s: %s", this.uri, e.getMessage()),e); + } + } + +} diff --git a/src/test/java/net/rcarz/jiraclient/StatusTest.java b/src/test/java/net/rcarz/jiraclient/StatusTest.java index 4f30186d..7f9625cf 100644 --- a/src/test/java/net/rcarz/jiraclient/StatusTest.java +++ b/src/test/java/net/rcarz/jiraclient/StatusTest.java @@ -17,7 +17,7 @@ public class StatusTest { @Test public void testJSONDeserializer() throws IOException, URISyntaxException { - Status status = new Status(new RestClient(null, new URI("/123/asd")), getTestJSON()); + Status status = new Status(new ApacheHttpRestClient(null, new URI("/123/asd")), getTestJSON()); assertEquals(status.getDescription(), description); assertEquals(status.getIconUrl(), iconURL); assertEquals(status.getName(), "Open"); @@ -36,7 +36,7 @@ private JSONObject getTestJSON() { @Test public void testStatusToString() throws URISyntaxException { - Status status = new Status(new RestClient(null, new URI("/123/asd")), getTestJSON()); + Status status = new Status(new ApacheHttpRestClient(null, new URI("/123/asd")), getTestJSON()); assertEquals("Open",status.toString()); } diff --git a/src/test/java/net/rcarz/jiraclient/UserTest.java b/src/test/java/net/rcarz/jiraclient/UserTest.java index 4f4ca33c..c1490eba 100644 --- a/src/test/java/net/rcarz/jiraclient/UserTest.java +++ b/src/test/java/net/rcarz/jiraclient/UserTest.java @@ -22,7 +22,7 @@ public class UserTest { @Test public void testJSONDeserializer() throws IOException, URISyntaxException { - User user = new User(new RestClient(null, new URI("/123/asd")), getTestJSON()); + User user = new User(new ApacheHttpRestClient(null, new URI("/123/asd")), getTestJSON()); assertEquals(user.getName(), username); assertEquals(user.getDisplayName(), displayName); assertEquals(user.getEmail(), email); @@ -61,7 +61,7 @@ private JSONObject getTestJSON() { @Test public void testStatusToString() throws URISyntaxException { - User user = new User(new RestClient(null, new URI("/123/asd")), getTestJSON()); + User user = new User(new ApacheHttpRestClient(null, new URI("/123/asd")), getTestJSON()); assertEquals(username, user.toString()); } }