diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c93d2de --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: java +install: gradle assemble +script: gradle test +notifications: + email: false diff --git a/CHANGES.md b/CHANGES.md index 87e5ba3..484bf13 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,49 @@ +## 1.3.0 2013-04-22 + + * Default to UTF-8 for string payloads + * Include client_id for unauthenticated requests + * Support gzip encoding + * Fixed non-english request formatting issue + * Preserve query parameters for 'withContent` + +## 1.2.1 2013-01-10 + + * Added ApiResponseException to wrap HTTP error codes when logging in + * Fixed: Token.java: JSONObject["expires_in"] not a string [8] + * Fixed: NoSuchMethodError on CharsetUtil.getCharset() [7] + * Fixed: impossible to access a public resource without being logged [6] + +## 1.2.0 2012-11-05 + + * Handle more broken HTTP client behaviour + * Remove sandbox environment (it is no longer supported) + +## 1.1.2 2012-10-04 + + * Compatibility fixes with broken httpclient versions used in Android pre-gingerbread + * Add support for default parameters + +## 1.1.1 2012-04-30 + + * Respect system proxy settings + * Added skip_logging parameter to resolveStreamUrl + * Added workarounds for some HTTPClient bugs + * Fixed some Android compatibility problems (IOException constructor) + +## 1.1.0 2011-11-09 + + * Support httpmime-4.1.x (GH-2) + * Support for conditional GETs + * Support for Range requests + * added CloudApi#resolveStreamUrl(String) + * added CloudApi#getHttpClient() + * Changed the handling of max connections per route + * Added some endpoints + * Added PostResource example + * Added support for HEAD requests + * Added stream resolving + * Added Facebook login example + ## 1.0.1 2011-07-04 * Support for non-expiring scope @@ -11,3 +57,8 @@ ## 1.0.0 2011-05-19 * Initial release + + +[6]: https://github.com/soundcloud/java-api-wrapper/issues/6 +[7]: https://github.com/soundcloud/java-api-wrapper/issues/7 +[8]: https://github.com/soundcloud/java-api-wrapper/issues/8 diff --git a/README.md b/README.md index 6c40070..0741d42 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,57 @@ # java-api-wrapper -OAuth2 SoundCloud API wrapper written in Java ([javadoc][]), extracted from the -[SoundCloud Android][] codebase. +OAuth2 SoundCloud API wrapper written in Java ([javadoc][]). It is simple to use and requires a minimum of external dependencies (compared to the OAuth1 wrapper) so should be easily embeddable in both desktop and mobile applications. +## Android + +The wrapper works well on Android (although it has no dependencies on it) +since it is an extraction from our [SoundCloud Android][] codebase. However, if +all you want is to share sounds from your own application we recommend to check out the +[Android Sharing Kit][] which delegates all the hard work to the SoundCloud +app and makes use of Android's [intent][] framework. + ## Usage Create a wrapper instance: - ApiWrapper wrapper = new ApiWrapper("client_id", "client_secret", - null, null, Env.LIVE); +```java +ApiWrapper wrapper = new ApiWrapper("client_id", "client_secret", null, null); +``` Obtain a token: - wrapper.login("username", "password"); +```java +wrapper.login("username", "password"); +``` Execute a request: - HttpResponse resp = wrapper.get(Request.to("/me")); +```java +HttpResponse resp = wrapper.get(Request.to("/me")); +``` Update a resource: - HttpResponse resp = - wrapper.put(Request.to("/me") - .with("user[full_name]", "Che Flute", - "user[website]", "http://cheflute.com") - .withFile("user[avatar_data]", new File("flute.jpg"))); +```java +HttpResponse resp = + wrapper.put(Request.to("/me") + .with("user[full_name]", "Che Flute", + "user[website]", "http://cheflute.com") + .withFile("user[avatar_data]", new File("flute.jpg"))); +``` ## Migrating from OAuth1 If your app uses OAuth1 and already has users with access tokens you can easily migrate to OAuth2 without requiring anybody to reauthenticate: - Token token = wrapper.exchangeOAuth1Token("validoauth1token"); +```java +Token token = wrapper.exchangeOAuth1Token("validoauth1token"); +``` Note that this is specific to SoundCloud and not part of the current OAuth2 draft. @@ -55,14 +71,22 @@ the authentication process. If you don't want to use them you can request non-expiring tokens by specifying the scope "non-expiring" when exchanging the tokens: - Token token = wrapper.login("username", "password", Token.SCOPE_NON_EXPIRING); +```java +Token token = wrapper.login("username", "password", Token.SCOPE_NON_EXPIRING); +``` The resulting token will be valid until revoked manually. For the `authorization_code` grant type you need to request the scope like so: - URI uri = wrapper.authorizationCodeUrl(Endpoints.CONNECT, Token.SCOPE_NON_EXPIRING); - // open uri in browser / WebView etc. +```java +URI uri = wrapper.authorizationCodeUrl(Endpoints.CONNECT, Token.SCOPE_NON_EXPIRING); +// open uri in browser / WebView etc. +``` + +## Login via Facebook + +Please see [FacebookConnect][] for an example of this login flow. ## Requirements @@ -99,10 +123,9 @@ The wrapper ships with a few examples in `src/examples/java`: * [PutResource][] performs a PUT request to update a resource and prints the JSON result * [UploadFile][] uploads a file to SoundCloud. + * [FacebookConnect][] obtain an access token via Facebook login You can use gradle tasks to compile and run these examples with one command. -If you don't want to use gradle there is also a precompiled jar with all -dependencies available ([jar-all][]). First create a wrapper and remember to substitute all credentials with real ones ([register an app][register-app] if you need client_id/secret): @@ -111,8 +134,7 @@ First create a wrapper and remember to substitute all credentials with real ones $ gradle createWrapper -Pclient_id=my_client_id \ -Pclient_secret=mys3cr3t \ -Plogin=api-testing \ - -Ppassword=testing \ - -Penv=live + -Ppassword=testing # with plain java $ java -classpath java-api-wrapper-1.x.y-all.jar \ @@ -223,12 +245,14 @@ Includes portions of code (c) 2010 Xtreme Labs and Pivotal Labs and (c) 2009 urb See LICENSE for details. +[![Build Status](https://secure.travis-ci.org/soundcloud/java-api-wrapper.png?branch=master)](http://travis-ci.org/soundcloud/java-api-wrapper) + [gradle]: http://www.gradle.org/ [urbanstew]: http://urbanstew.org/ [Apache HttpClient]: http://hc.apache.org/httpcomponents-client-ga/ [HttpMime]: http://hc.apache.org/httpcomponents-client-ga/httpmime [json-java]: http://json.org/java/ -[javadoc]: http://soundcloud.github.com/java-api-wrapper/javadoc/1.0.1/com/soundcloud/api/package-summary.html +[javadoc]: http://soundcloud.github.com/java-api-wrapper/javadoc/1.3.0/com/soundcloud/api/package-summary.html [soundcloudapi-java]: http://code.google.com/p/soundcloudapi-java/ [soundcloudapi-java-annouce]: http://blog.soundcloud.com/2010/01/08/java-wrapper/ [CreateWrapper]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java @@ -236,11 +260,14 @@ See LICENSE for details. [PutResource]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/PutResource.java [PostResource]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/PostResource.java [UploadFile]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/UploadFile.java -[SoundCloud Android]: https://market.android.com/details?id=com.soundcloud.android +[FacebookConnect]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java +[SoundCloud Android]: https://play.google.com/store/apps/details?id=com.soundcloud.android [register-app]: http://soundcloud.com/you/apps/new [Apache Maven]: http://maven.apache.org/ -[jar-all]: https://github.com/downloads/soundcloud/java-api-wrapper/java-api-wrapper-1.0.1-all.jar [downloads]: https://github.com/soundcloud/java-api-wrapper/archives/master [snapshots]: https://oss.sonatype.org/content/repositories/snapshots/com/soundcloud/java-api-wrapper/ [releases]: https://oss.sonatype.org/content/repositories/releases/com/soundcloud/java-api-wrapper/ [maven-central]: http://repo1.maven.org/maven2/com/soundcloud/java-api-wrapper/ +[Android Sharing Kit]: https://github.com/soundcloud/android-intent-sharing/wiki +[android-token-sharing]: https://github.com/soundcloud/android-token-sharing +[intent]: http://developer.android.com/reference/android/content/Intent.html diff --git a/RELEASE.md b/RELEASE.md index 8955bf9..ea7cdc3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,23 +11,19 @@ Bump version constants in * build.gradle * src/main/java/com/soundcloud/api/package-info.java * src/main/java/com/soundcloud/api/CloudAPI.java - * README.md (javadoc, jar-all) + * README.md (javadoc) -Regenerate + publish javadoc: - - $ ./update_javadoc.sh - -Regenerate pom.xml +Regenerate pom.xml (only if build.gradle has changed) $ gradle writePom This doesn't work properly at the moment - use `gradle uploadArchive` and copy `build/poms/pom-default.xml` to `pom.xml`. -Releasing to Sonatype OSS (staging) +## Releasing to Sonatype OSS (staging) - (make sure there are no uncommitted changes in the repo) - $ mvn -Dresume=false release:prepare # tag repo, bump pom.xml (needs SNAPSHOT tag in pom) + (make sure there are no uncommitted changes in the repo and pom has SNAPSHOT tag) + $ mvn -Dresume=false release:prepare # tag repo, bump pom.xml $ mvn release:perform -Darguments="-Dgpg.keyname=jan@soundcloud.com -Dgpg.passphrase=" This will build and sign all artifcats and upload them to the staging server. @@ -39,7 +35,12 @@ staging repository which can be used for testing. Once everything works you select "Release" to actually release it to the [release repo][]. The release repo is synced with [Maven Central][]. -Releasing snapshot versions +Regenerate + publish javadoc (no SNAPSHOT tag in build.gradle): + + $ ./update_javadoc.sh + + +## Releasing snapshot versions This is for releasing developer version of the package and can be done anytime, just make sure `build.gradle` version contains a `-SNAPSHOT` suffix, then run: diff --git a/build.gradle b/build.gradle index 5c425a1..2d6f359 100644 --- a/build.gradle +++ b/build.gradle @@ -4,14 +4,14 @@ apply plugin: 'eclipse' apply plugin: 'maven' sourceCompatibility = 1.6 -version = '1.0.2-SNAPSHOT' +version = '1.3.0-SNAPSHOT' group = 'com.soundcloud' repositories { mavenCentral() } sourceSets { examples { - compileClasspath = sourceSets.main.classes + sourceSets.main.runtimeClasspath + compileClasspath = sourceSets.main.output + sourceSets.main.runtimeClasspath } } @@ -27,7 +27,8 @@ dependencies { } uploadArchives { - repositories.mavenDeployer { + repositories { + mavenDeployer { snapshotRepository(url: 'https://oss.sonatype.org/content/repositories/snapshots', id: 'sonatype-nexus-snapshots') { authentication(getAuth('sonatype-nexus-snapshots')) @@ -53,13 +54,14 @@ uploadArchives { withXml { xml -> new XmlParser().parse(new File("pom-include.xml")).children().each { kid -> xml.asNode().append(kid) } } + } } } } [compileJava, compileTestJava]*.options*.compilerArgs = ['-Xlint:all'] -httpDebug = ['org.apache.commons.logging.Log': 'org.apache.commons.logging.impl.SimpleLog', +ext.httpDebug = ['org.apache.commons.logging.Log': 'org.apache.commons.logging.impl.SimpleLog', 'org.apache.commons.logging.simplelog.showdatetime': 'true', 'org.apache.commons.logging.simplelog.log.org.apache.http': 'DEBUG', 'org.apache.commons.logging.simplelog.log.org.apache.http.wire': 'ERROR'] @@ -68,13 +70,14 @@ def example(name, mainClass, arguments) { task(name, dependsOn: ['compileJava', 'compileExamplesJava']) << { javaexec { main = 'com.soundcloud.api.examples.'+mainClass - classpath = sourceSets.main.runtimeClasspath + sourceSets.examples.classes - if (logger.debugEnabled) systemProperties httpDebug + classpath = sourceSets.main.runtimeClasspath + sourceSets.examples.output + if (logger.debugEnabled) systemProperties ext.httpDebug args arguments.call() } } } -example('createWrapper', 'CreateWrapper', { [client_id, client_secret, login, password, env] }) +example('createWrapper', 'CreateWrapper', { [client_id, client_secret, login, password] }) +example('facebookLogin', 'FacebookLogin', { [] }) example('getResource', 'GetResource', { resource }) example('putResource', 'PutResource', { [resource, content, contentType] }) example('postResource', 'PostResource', { [resource, content, contentType] }) @@ -82,7 +85,7 @@ example('uploadFile', 'UploadFile', { file }) example('dumpToken', 'DumpToken', { [] }) task writePom << { - repositories.mavenDeployer().getPom().writeTo("pom.xml") + uploadArchives.repositories.mavenDeployer().getPom().writeTo("pom.xml") } task doc(type: Javadoc) { @@ -100,19 +103,23 @@ task doc(type: Javadoc) { task jarAll(type: Jar) { description = "Build a jar file with all dependencies" - dependsOn configurations.runtime, sourceSets.main.classes, sourceSets.examples.classes + dependsOn configurations.runtime, sourceSets.main.output, sourceSets.examples.output archiveName = project.name + "-" + version +"-all.jar" - from { (configurations.runtime + sourceSets.main.classes + sourceSets.examples.classes ).collect { + from { (configurations.runtime + sourceSets.main.output + sourceSets.examples.output ).collect { it.isDirectory() ? it : zipTree(it) } } } def getAuth(repo_id) { - m2_settings = new File("${System.getProperty('user.home')}/.m2/settings.xml") + def m2_settings = new File("${System.getProperty('user.home')}/.m2/settings.xml") if (m2_settings.exists()) { - settings = new XmlSlurper().parse(m2_settings) - repo = settings.servers.server.find { it.id.text() == repo_id } + def settings = new XmlSlurper().parse(m2_settings) + def repo = settings.servers.server.find { it.id.text() == repo_id } if (repo != null) return [userName: repo.username.text(), password: repo.password.text()] } [:] } + +task printDebug << { + println ext.httpDebug.collect { "-D"+it.key+"="+it.value }.join(' ') +} diff --git a/pom.xml b/pom.xml index 308424a..3cad005 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 com.soundcloud java-api-wrapper - 1.0.2-SNAPSHOT + 1.3.1-SNAPSHOT SoundCloud Java API wrapper SoundCloud Java API wrapper (OAuth2 only), works on Android https://github.com/soundcloud/java-api-wrapper#readme diff --git a/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java b/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java index a40594c..a7e76d4 100644 --- a/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java +++ b/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java @@ -1,7 +1,6 @@ package com.soundcloud.api.examples; import com.soundcloud.api.ApiWrapper; -import com.soundcloud.api.Env; import com.soundcloud.api.Token; import java.io.File; @@ -16,15 +15,14 @@ public final class CreateWrapper { public static void main(String[] args) throws Exception { if (args.length < 4) { - System.err.println("CreateWrapper client_id client_secret login password [live|sandbox] [scope]"); + System.err.println("CreateWrapper client_id client_secret login password"); System.exit(1); } else { final ApiWrapper wrapper = new ApiWrapper( args[0] /* client_id */, args[1] /* client_secret */, null /* redirect URI */, - null /* token */, - args.length == 5 ? Env.valueOf(args[4].toUpperCase()) : Env.LIVE); + null /* token */); Token token; if (args.length < 6) { diff --git a/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java b/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java new file mode 100644 index 0000000..7368e79 --- /dev/null +++ b/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java @@ -0,0 +1,146 @@ +package com.soundcloud.api.examples; + +import com.soundcloud.api.ApiWrapper; +import com.soundcloud.api.CloudAPI; +import com.soundcloud.api.Endpoints; +import com.soundcloud.api.Token; + +import java.awt.*; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + + +/** + * This example shows how to get an API token by logging in w/ Facebook. + */ +public class FacebookConnect { + // http://sandbox-soundcloud.com/you/apps/java-api-wrapper-test-app + // user: api-testing + + //https://skitch.com/jberkel/ggb18/edit-java-api-wrapper-test-app-on-soundcloud-create-record-and-share-your-sounds-for-free + static final String CLIENT_ID = "yH1Jv2C5fhIbZfGTpKtujQ"; + static final String CLIENT_SECRET = "C6o8jc517b6PIw0RKtcfQsbOK3BjGpxWFLg977UiguY"; + static final URI REDIRECT_URI = URI.create("http://localhost:8000"); + + public static void main(String[] args) throws IOException { + final ApiWrapper wrapper = new ApiWrapper( + CLIENT_ID, + CLIENT_SECRET, + REDIRECT_URI, + null /* token */); + + + // generate the URL the user needs to open in the browser + URI url = wrapper.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT, Token.SCOPE_NON_EXPIRING); + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(url); + } else { + System.err.println("open \"" + url + "\" in a browser"); + } + + // start a web server to get the redirect information + startServer(wrapper); + + // note: on Android you would use a WebView instead an override 'shouldOverrideUrlLoading': + + /* + WebView webView = (WebView) findViewById(R.id.webview); + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(final WebView view, String url) { + if (url.startsWith(REDIRECT_URI)) { + Uri result = Uri.parse(url); + String error = result.getQueryParameter("error"); + String code = result.getQueryParameter("code"); + } + } + }); + + webView.loadUrl(wrapper.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT, ...); + */ + } + + static void startServer(ApiWrapper wrapper) throws IOException { + ServerSocket socket = new ServerSocket(8000); + for (;;) { + final Socket client = socket.accept(); + try { + InputStream is = client.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192); + PrintStream out = new PrintStream(client.getOutputStream()); + String line = reader.readLine(); + if (line == null) throw new IOException("client closed connection without a request."); + + final String[] request = line.split(" ", 3); + if (request.length != 3) throw new IOException("invalid request:" + line); + if (!"GET".equals(request[0])) throw new IOException("invalid method:" + line); + + Map params = parseParameters(request[1]); + + if (params.containsKey("error")) { + // error logging in, redirect mismatch etc. + + reply(out, "Error: " + params.get("error_description")); + } else if (params.containsKey("code")) { + // we got a code back, try to exchange it for a token + try { + Token token = wrapper.authorizationCode(params.get("code")); + reply(out, "Got token: " + token); + } catch (CloudAPI.InvalidTokenException e) { + reply(out, e.getMessage()); + } + } else { + // unexpected redirect + reply(out, "invalid request:"+request[1]); + } + break; + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + client.close(); + } catch (IOException ignored) { + } + } + } + } + + static void reply(PrintStream out, String text) { + System.out.println(text); + + out.println("HTTP/1.1 200 OK"); + out.println("Content-Type: text/plain"); + out.println(); + out.println(text); + out.flush(); + } + + static Map parseParameters(String request) { + Map params = new HashMap(); + if (request.contains("?")) { + String query = request.substring(Math.min(request.length(), request.indexOf("?") + 1), + request.length()); + for (String s : query.split("&")) { + String[] kv = s.split("=", 2); + if (kv != null && kv.length == 2) { + try { + params.put(URLDecoder.decode(kv[0], "UTF-8"), + URLDecoder.decode(kv[1], "UTF-8")); + } catch (UnsupportedEncodingException ignored) { + } + } + } + } + return params; + } +} diff --git a/src/examples/java/com/soundcloud/api/examples/GetResource.java b/src/examples/java/com/soundcloud/api/examples/GetResource.java index 353dfc5..ac1d5a3 100644 --- a/src/examples/java/com/soundcloud/api/examples/GetResource.java +++ b/src/examples/java/com/soundcloud/api/examples/GetResource.java @@ -6,9 +6,6 @@ import com.soundcloud.api.Request; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; import java.io.File; @@ -36,7 +33,7 @@ public static void main(String[] args) throws Exception { try { HttpResponse resp = wrapper.get(resource); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { - System.out.println("\n" + formatJSON(Http.getString(resp))); + System.out.println("\n" + Http.formatJSON(Http.getString(resp))); } else { System.err.println("Invalid status received: " + resp.getStatusLine()); } @@ -47,15 +44,5 @@ public static void main(String[] args) throws Exception { } } - static String formatJSON(String s) { - try { - return new JSONObject(s).toString(4); - } catch (JSONException e) { - try { - return new JSONArray(s).toString(4); - } catch (JSONException e2) { - return s; - } - } - } + } diff --git a/src/examples/java/com/soundcloud/api/examples/PostResource.java b/src/examples/java/com/soundcloud/api/examples/PostResource.java index d1ccaca..0727708 100644 --- a/src/examples/java/com/soundcloud/api/examples/PostResource.java +++ b/src/examples/java/com/soundcloud/api/examples/PostResource.java @@ -5,9 +5,6 @@ import com.soundcloud.api.Request; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; import java.io.File; @@ -34,7 +31,7 @@ public static void main(String[] args) throws Exception { try { HttpResponse resp = wrapper.post(resource); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_CREATED) { - System.out.println("\n" + formatJSON(Http.getString(resp))); + System.out.println("\n" + Http.formatJSON(Http.getString(resp))); } else { System.err.println("Invalid status received: " + resp.getStatusLine()); } @@ -44,17 +41,4 @@ public static void main(String[] args) throws Exception { } } } - - - static String formatJSON(String s) { - try { - return new JSONObject(s).toString(4); - } catch (JSONException e) { - try { - return new JSONArray(s).toString(4); - } catch (JSONException e2) { - return s; - } - } - } } diff --git a/src/examples/java/com/soundcloud/api/examples/PutResource.java b/src/examples/java/com/soundcloud/api/examples/PutResource.java index 2059536..f3faf81 100644 --- a/src/examples/java/com/soundcloud/api/examples/PutResource.java +++ b/src/examples/java/com/soundcloud/api/examples/PutResource.java @@ -6,9 +6,6 @@ import com.soundcloud.api.Request; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; import java.io.File; @@ -40,7 +37,7 @@ public static void main(String[] args) throws Exception { try { HttpResponse resp = wrapper.put(resource); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { - System.out.println("\n" + formatJSON(Http.getString(resp))); + System.out.println("\n" + Http.formatJSON(Http.getString(resp))); } else { System.err.println("Invalid status received: " + resp.getStatusLine()); } @@ -50,16 +47,4 @@ public static void main(String[] args) throws Exception { } } } - - static String formatJSON(String s) { - try { - return new JSONObject(s).toString(4); - } catch (JSONException e) { - try { - return new JSONArray(s).toString(4); - } catch (JSONException e2) { - return s; - } - } - } } diff --git a/src/main/java/com/soundcloud/api/ApiWrapper.java b/src/main/java/com/soundcloud/api/ApiWrapper.java index 8eef9c4..408c7de 100644 --- a/src/main/java/com/soundcloud/api/ApiWrapper.java +++ b/src/main/java/com/soundcloud/api/ApiWrapper.java @@ -2,9 +2,14 @@ import org.apache.http.ConnectionReuseStrategy; import org.apache.http.Header; -import org.apache.http.HttpRequest; +import org.apache.http.HeaderElement; +import org.apache.http.HttpEntity; +import org.apache.http.HttpException; +import org.apache.http.HttpHost; import org.apache.http.HttpResponse; +import org.apache.http.HttpResponseInterceptor; import org.apache.http.HttpStatus; +import org.apache.http.NameValuePair; import org.apache.http.auth.AUTH; import org.apache.http.auth.AuthScope; import org.apache.http.client.AuthenticationHandler; @@ -15,13 +20,21 @@ import org.apache.http.client.UserTokenHandler; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpHead; 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.methods.HttpUriRequest; import org.apache.http.client.params.HttpClientParams; import org.apache.http.client.protocol.ClientContext; import org.apache.http.conn.ClientConnectionManager; import org.apache.http.conn.ConnectionKeepAliveStrategy; +import org.apache.http.conn.params.ConnManagerPNames; +import org.apache.http.conn.params.ConnManagerParams; +import org.apache.http.conn.params.ConnPerRoute; +import org.apache.http.conn.params.ConnPerRouteBean; +import org.apache.http.conn.params.ConnRoutePNames; +import org.apache.http.conn.routing.HttpRoute; import org.apache.http.conn.routing.HttpRoutePlanner; import org.apache.http.conn.scheme.PlainSocketFactory; import org.apache.http.conn.scheme.Scheme; @@ -32,6 +45,8 @@ import org.apache.http.impl.client.DefaultRequestDirector; import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; import org.apache.http.message.BasicHeader; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpConnectionParams; import org.apache.http.params.HttpParams; import org.apache.http.params.HttpProtocolParams; import org.apache.http.protocol.BasicHttpProcessor; @@ -68,14 +83,16 @@ * HttpResponse response = wrapper.get(Request.to("/tracks")); * * - * @see CloudAPI + * @see Using the SoundCloud API */ public class ApiWrapper implements CloudAPI, Serializable { - private static final long serialVersionUID = 3662083416905771921L; public static final String DEFAULT_CONTENT_TYPE = "application/json"; - /** The current environment */ - public final Env env; + private static final long serialVersionUID = 3662083416905771921L; + private static final Token EMPTY_TOKEN = new Token(null, null); + + /** The current environment, only live possible for now */ + public final Env env = Env.LIVE; private Token mToken; private final String mClientId, mClientSecret; @@ -83,12 +100,21 @@ public class ApiWrapper implements CloudAPI, Serializable { transient private HttpClient httpClient; transient private TokenListener listener; + private String mDefaultContentType; + private String mDefaultAcceptEncoding; + + public static final int BUFFER_SIZE = 8192; + /** Connection timeout */ + public static final int TIMEOUT = 20 * 1000; + /** Keepalive timeout */ + public static final long KEEPALIVE_TIMEOUT = 20 * 1000; + /* maximum number of connections allowed */ + public static final int MAX_TOTAL_CONNECTIONS = 10; + /** debug request details to stderr */ public boolean debugRequests; - private String mDefaultContentType; - /** * Constructs a new ApiWrapper instance. * @@ -96,95 +122,95 @@ public class ApiWrapper implements CloudAPI, Serializable { * @param clientSecret the application client secret * @param redirectUri the registered redirect url, or null * @param token an valid token, or null if not known - * @param env the environment to use (LIVE/SANDBOX) - * @see API documentation + * @see API authentication documentation */ public ApiWrapper(String clientId, String clientSecret, URI redirectUri, - Token token, - Env env) { + Token token) { mClientId = clientId; mClientSecret = clientSecret; mRedirectUri = redirectUri; - mToken = token == null ? new Token(null, null) : token; - this.env = env; + mToken = token == null ? EMPTY_TOKEN : token; } - @Override public Token login(String username, String password) throws IOException { - return login(username, password, null); - } - - @Override public Token login(String username, String password, String scope) throws IOException { + @Override public Token login(String username, String password, String... scopes) throws IOException { if (username == null || password == null) { throw new IllegalArgumentException("username or password is null"); } - final Request request = Request.to(Endpoints.TOKEN).with( - "grant_type", PASSWORD, - "client_id", mClientId, - "client_secret", mClientSecret, - "username", username, - "password", password); - if (scope != null) request.add("scope", scope); + final Request request = addScope(Request.to(Endpoints.TOKEN).with( + GRANT_TYPE, PASSWORD, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret, + USERNAME, username, + PASSWORD, password), scopes); mToken = requestToken(request); return mToken; } - @Override public Token authorizationCode(String code) throws IOException { - return authorizationCode(code, null); - } - @Override public Token authorizationCode(String code, String scope) throws IOException { + + @Override public Token authorizationCode(String code, String... scopes) throws IOException { if (code == null) { - throw new IllegalArgumentException("username or password is null"); + throw new IllegalArgumentException("code is null"); } - final Request request = Request.to(Endpoints.TOKEN).with( - "grant_type", AUTHORIZATION_CODE, - "client_id", mClientId, - "client_secret", mClientSecret, - "redirect_uri", mRedirectUri, - "code", code); - if (scope != null) request.add("scope", scope); - + final Request request = addScope(Request.to(Endpoints.TOKEN).with( + GRANT_TYPE, AUTHORIZATION_CODE, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret, + REDIRECT_URI, mRedirectUri, + CODE, code), scopes); mToken = requestToken(request); return mToken; } - @Override public Token clientCredentials() throws IOException { - return clientCredentials(Token.SCOPE_SIGNUP); - } - @Override public Token clientCredentials(String scope) throws IOException { - final Request req = Request.to(Endpoints.TOKEN).with( - "grant_type", CLIENT_CREDENTIALS, - "client_id", mClientId, - "client_secret", mClientSecret); - if (scope != null) req.add("scope", scope); + @Override public Token clientCredentials(String... scopes) throws IOException { + final Request req = addScope(Request.to(Endpoints.TOKEN).with( + GRANT_TYPE, CLIENT_CREDENTIALS, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret), scopes); + final Token token = requestToken(req); - if (scope != null && !token.scoped(scope)) { - throw new InvalidTokenException(-1, "Could not obtain requested scope '"+scope+"' (got: '" + + if (scopes != null) { + for (String scope : scopes) { + if (!token.scoped(scope)) { + throw new InvalidTokenException(-1, "Could not obtain requested scope '"+scope+"' (got: '" + token.scope + "')"); + } + } } return token; } + @Override + public Token extensionGrantType(String grantType, String... scopes) throws IOException { + final Request req = addScope(Request.to(Endpoints.TOKEN).with( + GRANT_TYPE, grantType, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret), scopes); + + mToken = requestToken(req); + return mToken; + } + @Override public Token refreshToken() throws IOException { if (mToken == null || mToken.refresh == null) throw new IllegalStateException("no refresh token available"); mToken = requestToken(Request.to(Endpoints.TOKEN).with( - "grant_type", REFRESH_TOKEN, - "client_id", mClientId, - "client_secret", mClientSecret, - "refresh_token", mToken.refresh)); + GRANT_TYPE, REFRESH_TOKEN, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret, + REFRESH_TOKEN, mToken.refresh)); return mToken; } @Override public Token exchangeOAuth1Token(String oauth1AccessToken) throws IOException { if (oauth1AccessToken == null) throw new IllegalArgumentException("need access token"); mToken = requestToken(Request.to(Endpoints.TOKEN).with( - "grant_type", OAUTH1_TOKEN, - "client_id", mClientId, - "client_secret", mClientSecret, - "refresh_token", oauth1AccessToken)); + GRANT_TYPE, OAUTH1_TOKEN_GRANT_TYPE, + CLIENT_ID, mClientId, + CLIENT_SECRET, mClientSecret, + REFRESH_TOKEN, oauth1AccessToken)); return mToken; } @@ -205,10 +231,10 @@ public ApiWrapper(String clientId, @Override public URI authorizationCodeUrl(String... options) { final Request req = Request.to(options.length == 0 ? Endpoints.CONNECT : options[0]).with( - "redirect_uri", mRedirectUri, - "client_id", mClientId, - "response_type", "code"); - if (options.length == 2) req.add("scope", options[1]); + REDIRECT_URI, mRedirectUri, + CLIENT_ID, mClientId, + RESPONSE_TYPE, CODE); + if (options.length == 2) req.add(SCOPE, options[1]); return getURI(req, false, true); } @@ -220,7 +246,17 @@ public ApiWrapper(String clientId, * @return a valid URI */ public URI getURI(Request request, boolean api, boolean secure) { - return URI.create((api ? env.getResourceHost(secure) : env.getAuthResourceHost(secure)).toURI()).resolve(request.toUrl()); + final URI uri = api ? env.getResourceURI(secure) : env.getAuthResourceURI(secure); + return uri.resolve(request.toUrl()); + } + + /** + * User-Agent to identify ourselves with - defaults to USER_AGENT + * @return the agent to use + * @see CloudAPI#USER_AGENT + */ + public String getUserAgent() { + return USER_AGENT; } /** @@ -229,35 +265,104 @@ public URI getURI(Request request, boolean api, boolean secure) { * @return the token * @throws java.io.IOException network error * @throws com.soundcloud.api.CloudAPI.InvalidTokenException unauthorized + * @throws com.soundcloud.api.CloudAPI.ApiResponseException http error */ protected Token requestToken(Request request) throws IOException { - HttpResponse response = getHttpClient().execute(env.sslResourceHost, request.buildRequest(HttpPost.class)); + HttpResponse response = safeExecute(env.sslResourceHost, request.buildRequest(HttpPost.class)); final int status = response.getStatusLine().getStatusCode(); - if (status == HttpStatus.SC_OK) { - final Token token = new Token(Http.getJSON(response)); - if (listener != null) listener.onTokenRefreshed(token); - return token; - } else { - String error = ""; - try { + String error; + try { + if (status == HttpStatus.SC_OK) { + final Token token = new Token(Http.getJSON(response)); + if (listener != null) listener.onTokenRefreshed(token); + return token; + } else { error = Http.getJSON(response).getString("error"); - } catch (IOException ignored) { - } catch (JSONException ignored) { } - throw status == HttpStatus.SC_UNAUTHORIZED ? - new InvalidTokenException(status, error) : - new IOException(status+" "+response.getStatusLine().getReasonPhrase()+" "+error); + } catch (IOException ignored) { + error = ignored.getMessage(); + } catch (JSONException ignored) { + error = ignored.getMessage(); } + throw status == HttpStatus.SC_UNAUTHORIZED ? + new InvalidTokenException(status, error) : + new ApiResponseException(response, error); } - - /** - * @return parameters used by the underlying HttpClient + * @return the default HttpParams + * @see + * android.net.http.AndroidHttpClient#newInstance(String, Context) */ protected HttpParams getParams() { - return Http.defaultParams(); + final HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setConnectionTimeout(params, TIMEOUT); + HttpConnectionParams.setSoTimeout(params, TIMEOUT); + HttpConnectionParams.setSocketBufferSize(params, BUFFER_SIZE); + ConnManagerParams.setMaxTotalConnections(params, MAX_TOTAL_CONNECTIONS); + + // Turn off stale checking. Our connections break all the time anyway, + // and it's not worth it to pay the penalty of checking every time. + HttpConnectionParams.setStaleCheckingEnabled(params, false); + + // fix contributed by Bjorn Roche XXX check if still needed + params.setBooleanParameter("http.protocol.expect-continue", false); + params.setParameter(ConnManagerPNames.MAX_CONNECTIONS_PER_ROUTE, new ConnPerRoute() { + @Override + public int getMaxForRoute(HttpRoute httpRoute) { + if (env.isApiHost(httpRoute.getTargetHost())) { + // there will be a lot of concurrent request to the API host + return MAX_TOTAL_CONNECTIONS; + } else { + return ConnPerRouteBean.DEFAULT_MAX_CONNECTIONS_PER_ROUTE; + } + } + }); + // apply system proxy settings + final String proxyHost = System.getProperty("http.proxyHost"); + final String proxyPort = System.getProperty("http.proxyPort"); + if (proxyHost != null) { + int port = 80; + try { + port = Integer.parseInt(proxyPort); + } catch (NumberFormatException ignored) { + } + params.setParameter(ConnRoutePNames.DEFAULT_PROXY, new HttpHost(proxyHost, port)); + } + return params; + } + + /** + * @param proxy the proxy to use for the wrapper, or null to clear the current one. + */ + public void setProxy(URI proxy) { + final HttpHost host; + if (proxy != null) { + Scheme scheme = getHttpClient() + .getConnectionManager() + .getSchemeRegistry() + .getScheme(proxy.getScheme()); + + host = new HttpHost(proxy.getHost(), scheme.resolvePort(proxy.getPort()), scheme.getName()); + } else { + host = null; + } + getHttpClient().getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, host); + } + + + public URI getProxy() { + Object proxy = getHttpClient().getParams().getParameter(ConnRoutePNames.DEFAULT_PROXY); + if (proxy instanceof HttpHost) { + return URI.create(((HttpHost)proxy).toURI()); + } else { + return null; + } + } + + public boolean isProxySet() { + return getProxy() != null; } /** @@ -274,14 +379,6 @@ protected SSLSocketFactory getSSLSocketFactory() { return SSLSocketFactory.getSocketFactory(); } - /** - * User-Agent to identify ourselves with - defaults to USER_AGENT - * @return the agent to use - * @see CloudAPI#USER_AGENT - */ - protected String getUserAgent() { - return USER_AGENT; - } /** @return The HttpClient instance used to make the calls */ public HttpClient getHttpClient() { @@ -293,10 +390,6 @@ public HttpClient getHttpClient() { final SchemeRegistry registry = new SchemeRegistry(); registry.register(new Scheme("http", getSocketFactory(), 80)); final SSLSocketFactory sslFactory = getSSLSocketFactory(); - if (env == Env.SANDBOX) { - // disable strict checks on sandbox XXX remove when certificate is fixed - sslFactory.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); - } registry.register(new Scheme("https", sslFactory, 443)); httpClient = new DefaultHttpClient( new ThreadSafeClientConnManager(params, registry), @@ -305,7 +398,7 @@ public HttpClient getHttpClient() { setKeepAliveStrategy(new ConnectionKeepAliveStrategy() { @Override public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) { - return 20 * 1000; // milliseconds + return KEEPALIVE_TIMEOUT; } }); @@ -314,6 +407,25 @@ public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpCont OAuth2Scheme.EmptyCredentials.INSTANCE); getAuthSchemes().register(CloudAPI.OAUTH_SCHEME, new OAuth2Scheme.Factory(ApiWrapper.this)); + + addResponseInterceptor(new HttpResponseInterceptor() { + @Override + public void process(HttpResponse response, HttpContext context) + throws HttpException, IOException { + if (response == null || response.getEntity() == null) return; + + HttpEntity entity = response.getEntity(); + Header header = entity.getContentEncoding(); + if (header != null) { + for (HeaderElement codec : header.getElements()) { + if (codec.getName().equalsIgnoreCase("gzip")) { + response.setEntity(new GzipDecompressingEntity(entity)); + break; + } + } + } + } + }); } @Override protected HttpContext createHttpContext() { @@ -360,13 +472,56 @@ public long resolve(String url) throws IOException { if (s.contains("/")) { try { return Integer.parseInt(s.substring(s.lastIndexOf("/") + 1, s.length())); - } catch (NumberFormatException ignored) { - // ignored + } catch (NumberFormatException e) { + throw new ResolverException(e, resp); + } + } else { + throw new ResolverException("Invalid string:"+s, resp); + } + } else { + throw new ResolverException("No location header", resp); + } + } else { + throw new ResolverException("Invalid status code", resp); + } + } + + @Override + public Stream resolveStreamUrl(final String url, boolean skipLogging) throws IOException { + HttpResponse resp = head(Request.to(url)); + if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_MOVED_TEMPORARILY) { + Header location = resp.getFirstHeader("Location"); + if (location != null && location.getValue() != null) { + final String headRedirect = location.getValue(); + resp = safeExecute(null, new HttpHead(headRedirect)); + if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { + Stream stream = new Stream(url, headRedirect, resp); + // need to do another GET request to have a URL ready for client usage + Request req = Request.to(url); + if (skipLogging) { + // skip logging + req.with("skip_logging", "1"); + } + resp = get(req); + if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_MOVED_TEMPORARILY) { + return stream.withNewStreamUrl(resp.getFirstHeader("Location").getValue()); + } else { + throw new ResolverException("Unexpected response code", resp); } + } else { + throw new ResolverException("Unexpected response code", resp); } + } else { + throw new ResolverException("Location header not set", resp); } + } else { + throw new ResolverException("Unexpected response code", resp); } - return -1; + } + + @Override + public HttpResponse head(Request request) throws IOException { + return execute(request, HttpHead.class); } @Override public HttpResponse get(Request request) throws IOException { @@ -390,7 +545,7 @@ public long resolve(String url) throws IOException { } @Override public void setToken(Token newToken) { - mToken = newToken; + mToken = newToken == null ? EMPTY_TOKEN : newToken; } @Override @@ -400,17 +555,82 @@ public synchronized void setTokenListener(TokenListener listener) { /** * Execute an API request, adds the necessary headers. - * @param req the HTTP request + * @param request the HTTP request * @return the HTTP response * @throws java.io.IOException network error etc. */ - public HttpResponse execute(HttpRequest req) throws IOException { - return getHttpClient().execute(env.sslResourceHost, addHeaders(req)); + public HttpResponse execute(HttpUriRequest request) throws IOException { + return safeExecute(env.sslResourceHost, addHeaders(request)); + } + + public HttpResponse safeExecute(HttpHost target, HttpUriRequest request) throws IOException { + if (target == null) { + target = determineTarget(request); + } + + try { + return getHttpClient().execute(target, request); + } catch (NullPointerException e) { + // this is a workaround for a broken httpclient version, + // cf. http://code.google.com/p/android/issues/detail?id=5255 + // NPE in DefaultRequestDirector.java:456 + if (!request.isAborted() && request.getParams().isParameterFalse("npe-retried")) { + request.getParams().setBooleanParameter("npe-retried", true); + return safeExecute(target, request); + } else { + request.abort(); + throw new BrokenHttpClientException(e); + } + } catch (IllegalArgumentException e) { + // more brokenness + // cf. http://code.google.com/p/android/issues/detail?id=2690 + request.abort(); + throw new BrokenHttpClientException(e); + } catch (ArrayIndexOutOfBoundsException e) { + // Caused by: java.lang.ArrayIndexOutOfBoundsException: length=7; index=-9 + // org.apache.harmony.security.asn1.DerInputStream.readBitString(DerInputStream.java:72)) + // org.apache.harmony.security.asn1.ASN1BitString.decode(ASN1BitString.java:64) + // ... + // org.apache.http.conn.ssl.SSLSocketFactory.createSocket(SSLSocketFactory.java:375) + request.abort(); + throw new BrokenHttpClientException(e); + } } protected HttpResponse execute(Request req, Class reqType) throws IOException { - if (debugRequests) System.err.println(reqType.getSimpleName()+" "+req); - return execute(req.buildRequest(reqType)); + Request defaults = ApiWrapper.defaultParams.get(); + if (defaults != null && !defaults.getParams().isEmpty()) { + // copy + merge in default parameters + for (NameValuePair nvp : defaults) { + req = new Request(req); + req.add(nvp.getName(), nvp.getValue()); + } + } + logRequest(reqType, req); + return execute(addClientIdIfNecessary(req).buildRequest(reqType)); + } + + protected Request addClientIdIfNecessary(Request req) { + return (mToken != EMPTY_TOKEN || req.getParams().containsKey(CLIENT_ID)) ? + req : new Request(req).add(CLIENT_ID, mClientId); + } + + protected void logRequest( Class reqType, Request request) { + if (debugRequests) System.err.println(reqType.getSimpleName()+" "+request); + } + + protected HttpHost determineTarget(HttpUriRequest request) { + // A null target may be acceptable if there is a default target. + // Otherwise, the null target is detected in the director. + URI requestURI = request.getURI(); + if (requestURI.isAbsolute()) { + return new HttpHost( + requestURI.getHost(), + requestURI.getPort(), + requestURI.getScheme()); + } else { + return null; + } } /** @@ -433,6 +653,27 @@ public void setDefaultContentType(String contentType) { mDefaultContentType = contentType; } + public String getDefaultAcceptEncoding() { + return mDefaultAcceptEncoding; + } + + public void setDefaultAcceptEncoding(String encoding) { + mDefaultAcceptEncoding = encoding; + } + + + /* package */ static Request addScope(Request request, String[] scopes) { + if (scopes != null && scopes.length > 0) { + StringBuilder scope = new StringBuilder(); + for (int i=0; i defaultParams = new ThreadLocal() { + @Override protected Request initialValue() { + return new Request(); + } + }; + + /** + * Adds a default parameter which will get added to all requests in this thread. + * Use this method carefully since it might lead to unexpected side-effects. + * @param name the name of the parameter + * @param value the value of the parameter. + */ + public static void setDefaultParameter(String name, String value) { + defaultParams.get().set(name, value); + } + + /** + * Clears the default parameters. + */ + public static void clearDefaultParameters() { + defaultParams.remove(); + } } diff --git a/src/main/java/com/soundcloud/api/CloudAPI.java b/src/main/java/com/soundcloud/api/CloudAPI.java index cb015be..6468bea 100644 --- a/src/main/java/com/soundcloud/api/CloudAPI.java +++ b/src/main/java/com/soundcloud/api/CloudAPI.java @@ -1,6 +1,9 @@ package com.soundcloud.api; +import org.apache.http.HttpHost; import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpUriRequest; import java.io.IOException; import java.net.URI; @@ -12,17 +15,31 @@ * @see ApiWrapper */ public interface CloudAPI { - // grant types + // OAuth2 parameters + String GRANT_TYPE = "grant_type"; + String CLIENT_ID = "client_id"; + String CLIENT_SECRET = "client_secret"; + String USERNAME = "username"; + String REDIRECT_URI = "redirect_uri"; + String CODE = "code"; + String RESPONSE_TYPE = "response_type"; + String SCOPE = "scope"; + + // standard oauth2 grant types String PASSWORD = "password"; String AUTHORIZATION_CODE = "authorization_code"; String REFRESH_TOKEN = "refresh_token"; - String OAUTH1_TOKEN = "oauth1_token"; String CLIENT_CREDENTIALS = "client_credentials"; + // custom + String OAUTH1_TOKEN_GRANT_TYPE = "oauth1_token"; // soundcloud + String FACEBOOK_GRANT_TYPE = "urn:soundcloud:oauth2:grant-type:facebook&access_token="; // oauth2 extension + String GOOGLE_PLUS_GRANT_TYPE = "urn:soundcloud:oauth2:grant-type:google_plus&access_token="; + // other constants String REALM = "SoundCloud"; String OAUTH_SCHEME = "oauth"; - String VERSION = "1.0.1"; + String VERSION = "1.3.0"; String USER_AGENT = "SoundCloud Java Wrapper ("+VERSION+")"; /** @@ -31,25 +48,13 @@ public interface CloudAPI { * * @param username SoundCloud username * @param password SoundCloud password - * @return a valid token - * @throws com.soundcloud.api.CloudAPI.InvalidTokenException invalid token - * @throws IOException In case of network/server errors - */ - Token login(String username, String password) throws IOException; - - /** - * Request a token using - * Resource Owner Password Credentials. - * - * @param username SoundCloud username - * @param password SoundCloud password - * @param scope the desired scope + * @param scopes the desired scope(s), or empty for default scope * @return a valid token * @throws com.soundcloud.api.CloudAPI.InvalidTokenException * invalid token * @throws IOException In case of network/server errors */ - Token login(String username, String password, String scope) throws IOException; + Token login(String username, String password, String... scopes) throws IOException; /** @@ -57,23 +62,13 @@ public interface CloudAPI { * Authorization Code, requesting a default scope. * * @param code the authorization code + * @param scopes the desired scope(s), or empty for default scope * @return a valid token * @throws com.soundcloud.api.CloudAPI.InvalidTokenException invalid token * @throws IOException In case of network/server errors */ - Token authorizationCode(String code) throws IOException; + Token authorizationCode(String code, String... scopes) throws IOException; - /** - * Request a token using - * Authorization Code with a specified scope. - * - * @param code the authorization code - * @param scope the desired scope - * @return a valid token - * @throws com.soundcloud.api.CloudAPI.InvalidTokenException invalid token - * @throws IOException In case of network/server errors - */ - Token authorizationCode(String code, String scope) throws IOException; /** * Request a "signup" token using @@ -84,27 +79,23 @@ public interface CloudAPI { * Also note that not all apps are allowed to request this token type (the wrapper throws * InvalidTokenException in this case). * + * @param scopes the desired scope(s), or empty for default scope * @return a valid token * @throws IOException IO/Error * @throws com.soundcloud.api.CloudAPI.InvalidTokenException if requested scope is not available */ - Token clientCredentials() throws IOException; + Token clientCredentials(String... scopes) throws IOException; - /** - * Requests a token using - * Client Credentials. - * - * Note that this token is not set as the current token in the wrapper - it should only be used - * for one request (typically the signup / user creation request). - * Also note that not all apps are allowed to request for all scopes (the wrapper throws - * InvalidTokenException in this case). - * - * @param scope the requested scope - * @return a valid token - * @throws IOException IO/Error - * @throws com.soundcloud.api.CloudAPI.InvalidTokenException if requested scope is not available + + /** + * Request a token using an + * extension grant type. + * @param grantType + * @param scopes + * @return + * @throws IOException */ - Token clientCredentials(String scope) throws IOException; + Token extensionGrantType(String grantType, String... scopes) throws IOException; /** * Tries to refresh the currently used access token with the refresh token. @@ -135,6 +126,13 @@ public interface CloudAPI { */ Token invalidateToken(); + /** + * @param request resource to HEAD + * @return the HTTP response + * @throws IOException IO/Error + */ + HttpResponse head(Request request) throws IOException; + /** * @param request resource to GET * @return the HTTP response @@ -163,15 +161,44 @@ public interface CloudAPI { */ HttpResponse delete(Request request) throws IOException; + /** + * @return the used httpclient + */ + HttpClient getHttpClient(); + + /** + * Generic execute method, with added workarounds for various HTTPClient bugs. + * + * @param target the target host (can be null) + * @param request the request + * @return the HTTP response + * @throws IOException network errors + * @throws BrokenHttpClientException in case of HTTPClient framework bugs + */ + HttpResponse safeExecute(HttpHost target, HttpUriRequest request) throws IOException; + /** * Resolve the given SoundCloud URI * * @param uri SoundCloud model URI, e.g. http://soundcloud.com/bob - * @return the id or -1 if uri not found + * @return the id * @throws IOException network errors + * @throws ResolverException if object could not be resolved */ long resolve(String uri) throws IOException; + /** + * Resolve the given SoundCloud stream URI + * + * @param uri SoundCloud stream URI, e.g. https://api.soundcloud.com/tracks/25272620/stream + * @param skipLogging skip logging the play of this track (client needs + * {@link com.soundcloud.api.Token#SCOPE_PLAYCOUNT}) + * @return the resolved stream + * @throws IOException network errors + * @throws com.soundcloud.api.CloudAPI.ResolverException resolver error (invalid status etc) + */ + Stream resolveStreamUrl(String uri, boolean skipLogging) throws IOException; + /** @return the current token */ Token getToken(); @@ -197,7 +224,7 @@ public interface CloudAPI { * * @param options auth endpoint to use (leave out for default), requested scope (leave out for default) * @return the URI to open in a browser/WebView etc. - * @see CloudAPI#authorizationCode(String) + * @see CloudAPI#authorizationCode(String, String...) */ URI authorizationCodeUrl(String... options); @@ -208,6 +235,7 @@ public interface CloudAPI { * @param contentType the request mime type. */ void setDefaultContentType(String contentType); + void setDefaultAcceptEncoding(String encoding); /** * Interested in changes to the current token. @@ -241,4 +269,57 @@ public InvalidTokenException(int code, String status) { super("HTTP error:" + code + " (" + status + ")"); } } + + /** + * Thrown if resolving the audio stream of a SoundCloud sound fails. + */ + class ResolverException extends ApiResponseException { + + public ResolverException(String s, HttpResponse resp) { + super(resp, s); + } + + public ResolverException(Throwable throwable, HttpResponse response) { + super(throwable, response); + } + } + + /** + * Thrown if the service API responds in error. The HTTP status code can be obtained via {@link #getStatusCode()}. + */ + class ApiResponseException extends IOException { + private static final long serialVersionUID = -2990651725862868387L; + + public final HttpResponse response; + + public ApiResponseException(HttpResponse resp, String error) { + super(resp.getStatusLine().getStatusCode() + ": [" + resp.getStatusLine().getReasonPhrase() + "] " + + (error != null ? error : "")); + this.response = resp; + } + + public ApiResponseException(Throwable throwable, HttpResponse response) { + super(throwable == null ? null : throwable.toString()); + initCause(throwable); + this.response = response; + } + + public int getStatusCode() { + return response.getStatusLine().getStatusCode(); + } + + @Override + public String getMessage() { + return super.getMessage()+" "+(response != null ? response.getStatusLine() : ""); + } + } + + class BrokenHttpClientException extends IOException { + private static final long serialVersionUID = -4764332412926419313L; + + BrokenHttpClientException(Throwable throwable) { + super(throwable == null ? null : throwable.toString()); + initCause(throwable); + } + } } diff --git a/src/main/java/com/soundcloud/api/CountingMultipartEntity.java b/src/main/java/com/soundcloud/api/CountingMultipartEntity.java index 64eff78..fdb8d55 100644 --- a/src/main/java/com/soundcloud/api/CountingMultipartEntity.java +++ b/src/main/java/com/soundcloud/api/CountingMultipartEntity.java @@ -1,13 +1,13 @@ package com.soundcloud.api; +import org.apache.http.Header; +import org.apache.http.HttpEntity; + import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import org.apache.http.Header; -import org.apache.http.HttpEntity; - class CountingMultipartEntity implements HttpEntity { private HttpEntity mDelegate; private Request.TransferProgressListener mListener; diff --git a/src/main/java/com/soundcloud/api/DecompressingEntity.java b/src/main/java/com/soundcloud/api/DecompressingEntity.java new file mode 100644 index 0000000..a6e0e01 --- /dev/null +++ b/src/main/java/com/soundcloud/api/DecompressingEntity.java @@ -0,0 +1,74 @@ +package com.soundcloud.api; + +import org.apache.http.HttpEntity; +import org.apache.http.entity.HttpEntityWrapper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +abstract class DecompressingEntity extends HttpEntityWrapper { + /** + * Default buffer size. + */ + private static final int BUFFER_SIZE = 1024 * 2; + + /** + * {@link #getContent()} method must return the same {@link InputStream} + * instance when DecompressingEntity is wrapping a streaming entity. + */ + private InputStream content; + + /** + * Creates a new {@link DecompressingEntity}. + * + * @param wrapped the non-null {@link HttpEntity} to be wrapped + */ + public DecompressingEntity(final HttpEntity wrapped) { + super(wrapped); + } + + abstract InputStream decorate(final InputStream wrapped) throws IOException; + + private InputStream getDecompressingStream() throws IOException { + final InputStream in = wrappedEntity.getContent(); + try { + return decorate(in); + } catch (final IOException ex) { + in.close(); + throw ex; + } + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream getContent() throws IOException { + if (wrappedEntity.isStreaming()) { + if (content == null) { + content = getDecompressingStream(); + } + return content; + } else { + return getDecompressingStream(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void writeTo(final OutputStream outstream) throws IOException { + final InputStream instream = getContent(); + try { + final byte[] buffer = new byte[BUFFER_SIZE]; + int l; + while ((l = instream.read(buffer)) != -1) { + outstream.write(buffer, 0, l); + } + } finally { + instream.close(); + } + } +} diff --git a/src/main/java/com/soundcloud/api/Endpoints.java b/src/main/java/com/soundcloud/api/Endpoints.java index 2a02ae6..362dfe2 100644 --- a/src/main/java/com/soundcloud/api/Endpoints.java +++ b/src/main/java/com/soundcloud/api/Endpoints.java @@ -2,7 +2,7 @@ /** * Various SoundCloud API endpoints. - * See the API docs for the most + * See the API docs for the most * recent listing. */ @SuppressWarnings({"UnusedDeclaration"}) @@ -13,6 +13,12 @@ public interface Endpoints { String TRACK_DETAILS = "/tracks/%d"; String TRACK_COMMENTS = "/tracks/%d/comments"; String TRACK_FAVORITERS = "/tracks/%d/favoriters"; + String TRACK_PLAYS = "/tracks/%d/plays"; + String TRACK_PERMISSIONS = "/tracks/%d/permissions"; + + String PLAYLISTS = "/playlists"; + String PLAYLIST_DETAILS = "/playlists/%d"; + String PLAYLIST_TRACKS = "/playlists/%d/tracks"; String USERS = "/users"; String USER_DETAILS = "/users/%d"; @@ -37,6 +43,7 @@ public interface Endpoints { String MY_FOLLOWING = "/me/followings/%d"; String MY_CONFIRMATION = "/me/email-confirmations"; String MY_FRIENDS = "/me/connections/friends"; + String MY_DEVICES = "/me/devices"; String SUGGESTED_USERS = "/users/suggested"; @@ -45,4 +52,6 @@ public interface Endpoints { String SEND_PASSWORD = "/passwords/reset-instructions"; String CONNECT = "/connect"; String FACEBOOK_CONNECT = "/connect/via/facebook"; + + } diff --git a/src/main/java/com/soundcloud/api/Env.java b/src/main/java/com/soundcloud/api/Env.java index 4494af1..2b1aead 100644 --- a/src/main/java/com/soundcloud/api/Env.java +++ b/src/main/java/com/soundcloud/api/Env.java @@ -2,15 +2,18 @@ import org.apache.http.HttpHost; +import java.net.URI; +import java.net.URISyntaxException; + /** * The environment to operate against. * Use SANDBOX for testing your app, and LIVE for production applications. */ -@SuppressWarnings({"UnusedDeclaration"}) public enum Env { /** The main production site, http://soundcloud.com */ LIVE("api.soundcloud.com", "soundcloud.com"), /** For testing, http://sandbox-soundcloud.com */ + @Deprecated SANDBOX("api.sandbox-soundcloud.com", "sandbox-soundcloud.com"); public final HttpHost resourceHost, sslResourceHost, authResourceHost, sslAuthResourceHost; @@ -20,10 +23,11 @@ public enum Env { * @param authResourceHost the authentication resource host */ Env(String resourceHost, String authResourceHost) { - this.resourceHost = new HttpHost(resourceHost, -1, "http"); - sslResourceHost = new HttpHost(resourceHost, -1, "https"); - this.authResourceHost = new HttpHost(authResourceHost, -1, "http"); - sslAuthResourceHost = new HttpHost(authResourceHost, -1, "https"); + this.resourceHost = new HttpHost(resourceHost, 80, "http"); + sslResourceHost = new HttpHost(resourceHost, 443, "https"); + + this.authResourceHost = new HttpHost(authResourceHost, 80, "http"); + sslAuthResourceHost = new HttpHost(authResourceHost, 443, "https"); } public HttpHost getResourceHost(boolean secure) { @@ -33,4 +37,26 @@ public HttpHost getResourceHost(boolean secure) { public HttpHost getAuthResourceHost(boolean secure) { return secure ? sslAuthResourceHost : authResourceHost; } + + public URI getResourceURI(boolean secure) { + return hostToUri(getResourceHost(secure)); + } + + public URI getAuthResourceURI(boolean secure) { + return hostToUri(getAuthResourceHost(secure)); + } + + public boolean isApiHost(HttpHost host) { + return ("http".equals(host.getSchemeName()) || + "https".equals(host.getSchemeName())) && + resourceHost.getHostName().equals(host.getHostName()); + } + + private static URI hostToUri(HttpHost host) { + try { + return new URI(host.getSchemeName(), host.getHostName(), null, null); + } catch (URISyntaxException ignored) { + throw new RuntimeException(); + } + } } diff --git a/src/main/java/com/soundcloud/api/GzipDecompressingEntity.java b/src/main/java/com/soundcloud/api/GzipDecompressingEntity.java new file mode 100644 index 0000000..61c1b84 --- /dev/null +++ b/src/main/java/com/soundcloud/api/GzipDecompressingEntity.java @@ -0,0 +1,45 @@ +package com.soundcloud.api; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + +public class GzipDecompressingEntity extends DecompressingEntity { + + /** + * Creates a new {@link GzipDecompressingEntity} which will wrap the specified + * {@link HttpEntity}. + * + * @param entity + * the non-null {@link HttpEntity} to be wrapped + */ + public GzipDecompressingEntity(final HttpEntity entity) { + super(entity); + } + + @Override + InputStream decorate(final InputStream wrapped) throws IOException { + return new GZIPInputStream(wrapped); + } + + /** + * {@inheritDoc} + */ + @Override + public Header getContentEncoding() { + /* This HttpEntityWrapper has dealt with the Content-Encoding. */ + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public long getContentLength() { + /* length of ungzipped content is not known */ + return -1; + } +} \ No newline at end of file diff --git a/src/main/java/com/soundcloud/api/Http.java b/src/main/java/com/soundcloud/api/Http.java index fa80579..3fda503 100644 --- a/src/main/java/com/soundcloud/api/Http.java +++ b/src/main/java/com/soundcloud/api/Http.java @@ -2,14 +2,8 @@ import org.apache.http.Header; import org.apache.http.HttpResponse; -import org.apache.http.conn.params.ConnManagerPNames; -import org.apache.http.conn.params.ConnPerRoute; -import org.apache.http.conn.params.ConnPerRouteBean; -import org.apache.http.conn.routing.HttpRoute; -import org.apache.http.params.BasicHttpParams; -import org.apache.http.params.HttpConnectionParams; -import org.apache.http.params.HttpParams; import org.apache.http.protocol.HTTP; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -20,10 +14,6 @@ * Helper class for various HTTP related functions. */ public class Http { - public static final int BUFFER_SIZE = 8192; - /** Connection timeout */ - public static final int TIMEOUT = 20 * 1000; - private Http() { } @@ -38,7 +28,7 @@ public static String getString(HttpResponse response) throws IOException { InputStream is = response.getEntity().getContent(); if (is == null) return null; - int length = BUFFER_SIZE; + int length = ApiWrapper.BUFFER_SIZE; Header contentLength = null; try { contentLength = response.getFirstHeader(HTTP.CONTENT_LEN); @@ -54,13 +44,13 @@ public static String getString(HttpResponse response) throws IOException { final StringBuilder sb = new StringBuilder(length); int n; - byte[] buffer = new byte[BUFFER_SIZE]; + byte[] buffer = new byte[ApiWrapper.BUFFER_SIZE]; while ((n = is.read(buffer)) != -1) sb.append(new String(buffer, 0, n)); return sb.toString(); } public static JSONObject getJSON(HttpResponse response) throws IOException { - final String json = Http.getString(response); + final String json = getString(response); if (json == null || json.length() == 0) throw new IOException("JSON response is empty"); try { return new JSONObject(json); @@ -70,29 +60,20 @@ public static JSONObject getJSON(HttpResponse response) throws IOException { } } - /** - * @return the default HttpParams - * @see - * android.net.http.AndroidHttpClient#newInstance(String, Context) - */ - public static HttpParams defaultParams() { - final HttpParams params = new BasicHttpParams(); - HttpConnectionParams.setConnectionTimeout(params, TIMEOUT); - HttpConnectionParams.setSoTimeout(params, TIMEOUT); - HttpConnectionParams.setSocketBufferSize(params, 8192); - - // Turn off stale checking. Our connections break all the time anyway, - // and it's not worth it to pay the penalty of checking every time. - HttpConnectionParams.setStaleCheckingEnabled(params, false); + public static String etag(HttpResponse resp) { + Header etag = resp.getFirstHeader("Etag"); + return etag != null ? etag.getValue() : null; + } - // fix contributed by Bjorn Roche XXX check if still needed - params.setBooleanParameter("http.protocol.expect-continue", false); - params.setParameter(ConnManagerPNames.MAX_CONNECTIONS_PER_ROUTE, new ConnPerRoute() { - @Override - public int getMaxForRoute(HttpRoute httpRoute) { - return ConnPerRouteBean.DEFAULT_MAX_CONNECTIONS_PER_ROUTE * 3; + public static String formatJSON(String s) { + try { + return new JSONObject(s).toString(4); + } catch (JSONException e) { + try { + return new JSONArray(s).toString(4); + } catch (JSONException e2) { + return s; } - }); - return params; + } } } diff --git a/src/main/java/com/soundcloud/api/Params.java b/src/main/java/com/soundcloud/api/Params.java index 8e596b6..66b908b 100644 --- a/src/main/java/com/soundcloud/api/Params.java +++ b/src/main/java/com/soundcloud/api/Params.java @@ -5,7 +5,7 @@ */ public interface Params { /** - * Tracks + * see developers.soundcloud.com/docs/api/tracks */ @SuppressWarnings({"UnusedDeclaration"}) interface Track { @@ -35,13 +35,14 @@ interface Track { String BPM = "track[bpm]"; String LICENSE = "track[license]"; String SHARED_EMAILS = "track[shared_to][emails][][address]"; + String SHARED_IDS = "track[shared_to][users][][id]"; String SHARING_NOTE = "track[sharing_note]"; String PUBLIC = "public"; String PRIVATE = "private"; } /** - * Users + * see developers.soundcloud.com/docs/api/users */ @SuppressWarnings({"UnusedDeclaration"}) interface User { @@ -62,7 +63,7 @@ interface User { } /** - * Comments + * see developers.soundcloud.com/docs/api/comments */ @SuppressWarnings({"UnusedDeclaration"}) interface Comment { diff --git a/src/main/java/com/soundcloud/api/Request.java b/src/main/java/com/soundcloud/api/Request.java index 0f3c1f7..e0ee8c9 100644 --- a/src/main/java/com/soundcloud/api/Request.java +++ b/src/main/java/com/soundcloud/api/Request.java @@ -4,14 +4,16 @@ import org.apache.http.NameValuePair; import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.utils.URLEncodedUtils; import org.apache.http.entity.StringEntity; +import org.apache.http.entity.mime.HttpMultipartMode; import org.apache.http.entity.mime.MIME; import org.apache.http.entity.mime.MultipartEntity; import org.apache.http.entity.mime.content.AbstractContentBody; +import org.apache.http.entity.mime.content.ContentBody; import org.apache.http.entity.mime.content.FileBody; import org.apache.http.entity.mime.content.StringBody; import org.apache.http.message.BasicNameValuePair; +import org.apache.http.protocol.HTTP; import java.io.File; import java.io.IOException; @@ -19,11 +21,14 @@ import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; +import java.net.URLEncoder; import java.nio.ByteBuffer; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -42,14 +47,18 @@ * */ public class Request implements Iterable { + public static final String UTF_8 = "UTF-8"; + private List mParams = new ArrayList(); // XXX should probably be lazy - private Map mFiles; - private Map mByteBuffers; + private Map mFiles; + private HttpEntity mEntity; private Token mToken; private String mResource; private TransferProgressListener listener; + private String mIfNoneMatch; + private long[] mRange; /** Empty request */ public Request() {} @@ -58,17 +67,30 @@ public Request() {} * @param resource the base resource */ public Request(String resource) { - if (resource != null && resource.contains("?")) { + if (resource == null) throw new IllegalArgumentException("resource is null"); + + // make sure paths start with a slash + if (!(resource.startsWith("http:") || resource.startsWith("https:")) + && !resource.startsWith("/")) { + resource = "/"+resource; + } + + if (resource.contains("?")) { String query = resource.substring(Math.min(resource.length(), resource.indexOf("?")+1), resource.length()); for (String s : query.split("&")) { String[] kv = s.split("=", 2); - if (kv != null && kv.length == 2) { + if (kv != null) { try { - mParams.add(new BasicNameValuePair( - URLDecoder.decode(kv[0], "UTF-8"), - URLDecoder.decode(kv[1], "UTF-8"))); - } catch (UnsupportedEncodingException ignored) {} + if (kv.length == 2) { + mParams.add(new BasicNameValuePair( + URLDecoder.decode(kv[0], UTF_8), + URLDecoder.decode(kv[1], UTF_8))); + } else if (kv.length == 1) { + mParams.add(new BasicNameValuePair(URLDecoder.decode(kv[0], UTF_8), null)); + } + } catch (UnsupportedEncodingException ignored) { + } } } mResource = resource.substring(0, resource.indexOf("?")); @@ -77,6 +99,15 @@ public Request(String resource) { } } + /** + * constructs a a request from URI. the hostname+scheme will be ignored + * @param uri - the uri + */ + public Request(URI uri) { + this(uri.getPath() == null ? "/" : uri.getPath() + + (uri.getQuery() == null ? "" : "?"+uri.getQuery())); + } + /** * @param request the request to be copied */ @@ -85,7 +116,9 @@ public Request(Request request) { mToken = request.mToken; listener = request.listener; mParams = new ArrayList(request.mParams); - if (request.mFiles != null) mFiles = new HashMap(request.mFiles); + mIfNoneMatch = request.mIfNoneMatch; + mEntity = request.mEntity; + if (request.mFiles != null) mFiles = new HashMap(request.mFiles); } /** @@ -98,7 +131,7 @@ public Request(Request request) { public static Request to(String resource, Object... args) { if (args != null && args.length > 0) { - resource = String.format(resource, args); + resource = String.format(Locale.ENGLISH, resource, args); } return new Request(resource); } @@ -114,6 +147,32 @@ public Request add(String name, Object value) { return this; } + + /** + * Sets a new parameter, overwriting previous value. + * @param name the name + * @param value the value + * @return this + */ + public Request set(String name, Object value) { + return clear(name).add(name, value); + } + + /** + * Clears a parameter + * @param name name of the parameter + * @return this + */ + public Request clear(String name) { + Iterator it = mParams.iterator(); + while (it.hasNext()) { + if (it.next().getName().equals(name)) { + it.remove(); + } + } + return this; + } + /** * @param args a list of arguments * @return this @@ -128,6 +187,16 @@ public Request with(Object... args) { return this; } + /** + * @param resource the new resource + * @return a new request with identical parameters except for the specified resource. + */ + public Request newResource(String resource) { + Request newRequest = new Request(this); + newRequest.mResource = resource; + return newRequest; + } + /** * The request should be made with a specific token. * @param token the token @@ -148,7 +217,7 @@ public int size() { * list of parameters in an HTTP PUT or HTTP POST. */ public String queryString() { - return URLEncodedUtils.format(mParams, "UTF-8"); + return format(mParams, UTF_8); } /** @@ -170,8 +239,19 @@ public String toUrl() { * @return this */ public Request withFile(String name, File file) { - if (mFiles == null) mFiles = new HashMap(); - if (file != null) mFiles.put(name, file); + return file != null ? withFile(name, file, file.getName()) : this; + } + + /** + * Registers a file to be uploaded with a POST or PUT request. + * @param name the name of the parameter + * @param file the file to be submitted + * @param fileName the name of the uploaded file (over rides file parameter) + * @return this + */ + public Request withFile(String name, File file, String fileName) { + if (mFiles == null) mFiles = new HashMap(); + if (file != null) mFiles.put(name, new Attachment(file, fileName)); return this; } @@ -179,21 +259,45 @@ public Request withFile(String name, File file) { * Registers binary data to be uploaded with a POST or PUT request. * @param name the name of the parameter * @param data the data to be submitted + * @deprecated use {@link #withFile(String, byte[], String)} instead * @return this */ - public Request withFile(String name, byte[] data) { + @Deprecated public Request withFile(String name, byte[] data) { return withFile(name, ByteBuffer.wrap(data)); } + /** + * Registers binary data to be uploaded with a POST or PUT request. + * @param name the name of the parameter + * @param data the data to be submitted + * @param fileName the name of the uploaded file + * @return this + */ + public Request withFile(String name, byte[] data, String fileName) { + return withFile(name, ByteBuffer.wrap(data), fileName); + } + /** * Registers binary data to be uploaded with a POST or PUT request. * @param name the name of the parameter * @param data the data to be submitted * @return this + * @deprecated use {@link #withFile(String, java.nio.ByteBuffer), String} instead */ - public Request withFile(String name, ByteBuffer data) { - if (mByteBuffers == null) mByteBuffers = new HashMap(); - if (data != null) mByteBuffers.put(name, data); + @Deprecated public Request withFile(String name, ByteBuffer data) { + return withFile(name, data, "upload"); + } + + /** + * Registers binary data to be uploaded with a POST or PUT request. + * @param name the name of the parameter + * @param data the data to be submitted + * @param fileName the name of the uploaded file + * @return this + */ + public Request withFile(String name, ByteBuffer data, String fileName) { + if (mFiles == null) mFiles = new HashMap(); + if (data != null) mFiles.put(name, new Attachment(data, fileName)); return this; } @@ -215,7 +319,7 @@ public Request withEntity(HttpEntity entity) { */ public Request withContent(String content, String contentType) { try { - StringEntity stringEntity = new StringEntity(content); + StringEntity stringEntity = new StringEntity(content, UTF_8); if (contentType != null) { stringEntity.setContentType(contentType); } @@ -225,6 +329,11 @@ public Request withContent(String content, String contentType) { } } + public Request range(long... ranges) { + mRange = ranges; + return this; + } + /** * @param listener a listener for receiving notifications about transfer progress * @return this @@ -235,8 +344,25 @@ public Request setProgressListener(TransferProgressListener listener) { } public boolean isMultipart() { - return (mFiles != null && !mFiles.isEmpty()) || - (mByteBuffers != null && !mByteBuffers.isEmpty()); + return mFiles != null && !mFiles.isEmpty(); + } + + /** + * Conditional GET + * @param etag the etag to check for (If-None-Match: etag) + * @return this + */ + public Request ifNoneMatch(String etag) { + mIfNoneMatch = etag; + return this; + } + + public Map getParams() { + Map params = new HashMap(); + for (NameValuePair p : mParams) { + params.put(p.getName(), p.getValue()); + } + return params; } /** @@ -253,38 +379,50 @@ public T buildRequest(Class method) { HttpEntityEnclosingRequestBase enclosingRequest = (HttpEntityEnclosingRequestBase) request; + final Charset charSet = java.nio.charset.Charset.forName(UTF_8); if (isMultipart()) { - MultipartEntity multiPart = new MultipartEntity(); + MultipartEntity multiPart = new MultipartEntity( + HttpMultipartMode.BROWSER_COMPATIBLE, // XXX change this to STRICT once rack on server is upgraded + null, + charSet); if (mFiles != null) { - for (Map.Entry e : mFiles.entrySet()) { - multiPart.addPart(e.getKey(), new FileBody(e.getValue())); - } - } - - if (mByteBuffers != null) { - for (Map.Entry e : mByteBuffers.entrySet()) { - multiPart.addPart(e.getKey(), new ByteBufferBody(e.getValue())); + for (Map.Entry e : mFiles.entrySet()) { + multiPart.addPart(e.getKey(), e.getValue().toContentBody()); } } for (NameValuePair pair : mParams) { - multiPart.addPart(pair.getName(), new StringBodyNoHeaders(pair.getValue())); + multiPart.addPart(pair.getName(), new StringBody(pair.getValue(), "text/plain", charSet)); } enclosingRequest.setEntity(listener == null ? multiPart : new CountingMultipartEntity(multiPart, listener)); + + request.setURI(URI.create(mResource)); + // form-urlencoded? - } else if (!mParams.isEmpty()) { - request.setHeader("Content-Type", "application/x-www-form-urlencoded"); - enclosingRequest.setEntity(new StringEntity(queryString())); } else if (mEntity != null) { request.setHeader(mEntity.getContentType()); enclosingRequest.setEntity(mEntity); + request.setURI(URI.create(toUrl())); // include the params + + } else { + if (!mParams.isEmpty()) { + request.setHeader("Content-Type", "application/x-www-form-urlencoded"); + enclosingRequest.setEntity(new StringEntity(queryString())); + } + request.setURI(URI.create(mResource)); + } + + } else { // just plain GET/HEAD/DELETE/... + if (mRange != null) { + request.addHeader("Range", formatRange(mRange)); } - request.setURI(URI.create(mResource)); - } else { // just plain GET/DELETE/... + if (mIfNoneMatch != null) { + request.addHeader("If-None-Match", mIfNoneMatch); + } request.setURI(URI.create(toUrl())); } @@ -297,11 +435,24 @@ public T buildRequest(Class method) { } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (UnsupportedEncodingException e) { - // XXX really rethrow? throw new RuntimeException(e); } } + public static String formatRange(long... range) { + switch (range.length) { + case 0: return "bytes=0-"; + case 1: + if (range[0] < 0) throw new IllegalArgumentException("negative range"); + return "bytes="+range[0]+"-"; + case 2: + if (range[0] < 0) throw new IllegalArgumentException("negative range"); + if (range[0] > range[1]) throw new IllegalArgumentException(range[0] + ">" + range[1]); + return "bytes="+range[0]+"-"+range[1]; + default: throw new IllegalArgumentException("invalid range specified"); + } + } + @Override public Iterator iterator() { return mParams.iterator(); } @@ -309,11 +460,11 @@ public T buildRequest(Class method) { @Override public String toString() { return "Request{" + - "params=" + mParams + + "mResource='" + mResource + '\'' + + ", params=" + mParams + ", files=" + mFiles + ", entity=" + mEntity + ", mToken=" + mToken + - ", mResource='" + mResource + '\'' + ", listener=" + listener + '}'; } @@ -326,7 +477,6 @@ public String toString() { return listener; } - /** * Updates about the amount of bytes already transferred. */ @@ -338,23 +488,7 @@ public static interface TransferProgressListener { public void transferred(long amount) throws IOException; } - - - static class StringBodyNoHeaders extends StringBody { - public StringBodyNoHeaders(String value) throws UnsupportedEncodingException { - super(value); - } - - @Override public String getMimeType() { - return null; - } - - @Override public String getTransferEncoding() { - return null; - } - } - - static class ByteBufferBody extends AbstractContentBody { + /* package */ static class ByteBufferBody extends AbstractContentBody { private ByteBuffer mBuffer; public ByteBufferBody(ByteBuffer buffer) { @@ -391,4 +525,90 @@ public void writeTo(OutputStream out) throws IOException { } } } + + /* package */ static class Attachment { + public final File file; + public final ByteBuffer data; + public final String fileName; + + /** @noinspection UnusedDeclaration*/ + Attachment(File file) { + this(file, file.getName()); + } + + Attachment(File file, String fileName) { + if (file == null) throw new IllegalArgumentException("file cannot be null"); + this.fileName = fileName; + this.file = file; + this.data = null; + } + + /** @noinspection UnusedDeclaration*/ + Attachment(ByteBuffer data) { + this(data, null); + } + + Attachment(ByteBuffer data, String fileName) { + if (data == null) throw new IllegalArgumentException("data cannot be null"); + + this.data = data; + this.fileName = fileName; + this.file = null; + } + + public ContentBody toContentBody() { + if (file != null) { + return new FileBody(file) { + @Override + public String getFilename() { + return fileName; + } + }; + } else if (data != null) { + return new ByteBufferBody(data) { + @Override + public String getFilename() { + return fileName; + } + }; + } else { + // never happens + throw new IllegalStateException("no upload data"); + } + } + } + + /** + * Returns a String that is suitable for use as an application/x-www-form-urlencoded + * list of parameters in an HTTP PUT or HTTP POST. + * + * @param parameters The parameters to include. + * @param encoding The encoding to use. + */ + public static String format( + final List parameters, + final String encoding) { + final StringBuilder result = new StringBuilder(); + for (final NameValuePair parameter : parameters) { + final String encodedName = encode(parameter.getName(), encoding); + final String value = parameter.getValue(); + final String encodedValue = value != null ? encode(value, encoding) : ""; + if (result.length() > 0) + result.append("&"); + result.append(encodedName); + if (value != null) { + result.append("="); + result.append(encodedValue); + } + } + return result.toString(); + } + + private static String encode(final String content, final String encoding) { + try { + return URLEncoder.encode(content, encoding != null ? encoding : HTTP.DEFAULT_CONTENT_CHARSET); + } catch (UnsupportedEncodingException problem) { + throw new IllegalArgumentException(problem); + } + } } diff --git a/src/main/java/com/soundcloud/api/Stream.java b/src/main/java/com/soundcloud/api/Stream.java new file mode 100644 index 0000000..a7a08fd --- /dev/null +++ b/src/main/java/com/soundcloud/api/Stream.java @@ -0,0 +1,137 @@ +package com.soundcloud.api; + +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.http.impl.cookie.DateUtils; + +import java.io.Serializable; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Locale; + +/** + * Class representing a remote audio stream object, including metadata. + */ +public class Stream implements Serializable { + public static final String AMZ_BITRATE = "x-amz-meta-bitrate"; + public static final String AMZ_DURATION = "x-amz-meta-duration"; + static final String EXPIRES = "Expires"; + + public static final long DEFAULT_URL_LIFETIME = 60 * 1000; // expire after 1 minute + public static final DateFormat DATE_FORMAT = new SimpleDateFormat(DateUtils.PATTERN_RFC1123, Locale.US); + + private static final long serialVersionUID = -2054788615389851590L; + + public final String url; + public final String streamUrl; + public final String eTag; + public final long contentLength; + public final long lastModified; + public final int duration; + public final int bitRate; + public final long expires; + + public Stream(String url, String streamUrl, HttpResponse resp) throws CloudAPI.ResolverException { + this(url, streamUrl, getHeaderValue(resp, "ETag"), + getLongHeader(resp, "Content-Length"), + getDateHeader(resp, "Last-Modified"), + getIntHeader(resp, AMZ_DURATION), + getIntHeader(resp, AMZ_BITRATE), + getExpires(streamUrl)); + } + + public Stream(String url, String streamUrl, String eTag, long contentLength, long lastModified, + int duration, int bitRate, long expires) { + this.url = url; + this.streamUrl = streamUrl; + this.eTag = eTag; + this.contentLength = contentLength; + this.lastModified = lastModified; + this.duration = duration; + this.bitRate = bitRate; + this.expires = expires; + } + + public Request streamUrl() { + return Request.to(streamUrl); + } + + public Request url() { + return Request.to(url); + } + + public Stream withNewStreamUrl(String newStreamUrl) { + return new Stream(url, newStreamUrl, eTag, contentLength, lastModified, duration, bitRate, getExpires(newStreamUrl)); + } + + public static long getLongHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException { + try { + return Long.parseLong(getHeaderValue(resp, name)); + } catch (NumberFormatException e) { + throw new CloudAPI.ResolverException(e, resp); + } + } + + public static int getIntHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException { + try { + return Integer.parseInt(getHeaderValue(resp, name)); + } catch (NumberFormatException e) { + throw new CloudAPI.ResolverException(e, resp); + } + } + + public static long getDateHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException { + try { + return DATE_FORMAT.parse(getHeaderValue(resp, name)).getTime(); + } catch (ParseException e) { + throw new CloudAPI.ResolverException(e, resp); + } + } + + private static String getHeaderValue(HttpResponse resp, String name) throws CloudAPI.ResolverException { + Header h = resp.getFirstHeader(name); + if (h != null && h.getValue() != null) { + return h.getValue(); + } else { + throw new CloudAPI.ResolverException("header " + name + " not set", resp); + } + } + + private static long getExpires(String resource) { + String query = resource.substring(Math.min(resource.length(), resource.indexOf("?")+1), + resource.length()); + for (String s : query.split("&")) { + String[] kv = s.split("=", 2); + if (kv != null && kv.length == 2) { + try { + String name = URLDecoder.decode(kv[0], Request.UTF_8); + if (EXPIRES.equalsIgnoreCase(name)) { + String value = URLDecoder.decode(kv[1], Request.UTF_8); + try { + return Long.parseLong(value) * 1000L; + } catch (NumberFormatException ignored) { + } + } + } catch (UnsupportedEncodingException ignored) {} + } + } + return System.currentTimeMillis() + DEFAULT_URL_LIFETIME; + } + + @Override + public String toString() { + return "Stream{" + + "url='" + url + '\'' + + ", streamUrl='" + streamUrl + '\'' + + ", eTag='" + eTag + '\'' + + ", contentLength=" + contentLength + + ", lastModified=" + lastModified + + ", duration=" + duration + + ", bitRate=" + bitRate + + ", expires=" + expires + + '}'; + } +} diff --git a/src/main/java/com/soundcloud/api/Token.java b/src/main/java/com/soundcloud/api/Token.java index 83139b6..1fc6f1f 100644 --- a/src/main/java/com/soundcloud/api/Token.java +++ b/src/main/java/com/soundcloud/api/Token.java @@ -6,7 +6,9 @@ import java.io.IOException; import java.io.Serializable; import java.util.Date; -import java.util.Set; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; /** * Represents an OAuth2 access/refresh token pair. @@ -14,24 +16,26 @@ public class Token implements Serializable { private static final long serialVersionUID = 766168501082045382L; - public static final String ACCESS_TOKEN = "access_token"; - public static final String REFRESH_TOKEN = "refresh_token"; - public static final String SCOPE = "scope"; - public static final String EXPIRES_IN = "expires_in"; - public static final String SCOPE_DEFAULT = "*"; /** Special scope for signup / password recovery */ public static final String SCOPE_SIGNUP = "signup"; - public static final String SCOPE_PLAYCOUNT = "playcount"; + public static final String SCOPE_PLAYCOUNT = "playcount"; /** Don't expire access token - returned tokens won't include a refresh token */ public static final String SCOPE_NON_EXPIRING = "non-expiring"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + private static final String SCOPE = "scope"; + private static final String EXPIRES_IN = "expires_in"; + // XXX these should be private public String access, refresh, scope; public long expiresIn; + public final Map customParameters = new HashMap(); + /** * Constructs a new token with the given sub-tokens * @param access A token used by the client to make authenticated requests on behalf of the resource owner. @@ -55,13 +59,22 @@ public Token(String access, String refresh, String scope) { */ public Token(JSONObject json) throws IOException { try { - access = json.getString(ACCESS_TOKEN); - if (json.has(REFRESH_TOKEN)) { - // refresh token won't be set if we don't expire - refresh = json.getString(REFRESH_TOKEN); - expiresIn = System.currentTimeMillis() + json.getLong(EXPIRES_IN) * 1000; + for (Iterator it = json.keys(); it.hasNext(); ) { + final String key = it.next().toString(); + if (ACCESS_TOKEN.equals(key)) { + access = json.getString(key); + } else if (REFRESH_TOKEN.equals(key)) { + // refresh token won't be set if we don't expire + refresh = json.getString(key); + } else if (EXPIRES_IN.equals(key)) { + expiresIn = System.currentTimeMillis() + json.getLong(key) * 1000; + } else if (SCOPE.equals(key)) { + scope = json.getString(key); + } else { + // custom parameter + customParameters.put(key, json.get(key).toString()); + } } - scope = json.getString(SCOPE); } catch (JSONException e) { throw new IOException(e.getMessage()); } @@ -101,6 +114,11 @@ public boolean valid() { return access != null && (scoped(SCOPE_NON_EXPIRING) || refresh != null); } + /** indicates whether this token was issued after a signup */ + public String getSignup() { + return customParameters.get("soundcloud:user:sign-up"); + } + @Override public String toString() { return "Token{" + diff --git a/src/main/java/com/soundcloud/api/package-info.java b/src/main/java/com/soundcloud/api/package-info.java index 0136bd8..4ed2a31 100644 --- a/src/main/java/com/soundcloud/api/package-info.java +++ b/src/main/java/com/soundcloud/api/package-info.java @@ -7,6 +7,6 @@ * * @see com.soundcloud.api.ApiWrapper * @author Jan Berkel - * @version 1.0.1, 07/04/11 + * @version 1.3.0, 22/04/13 */ package com.soundcloud.api; diff --git a/src/test/java/com/soundcloud/api/ApiWrapperTest.java b/src/test/java/com/soundcloud/api/ApiWrapperTest.java index 1414978..c82744a 100644 --- a/src/test/java/com/soundcloud/api/ApiWrapperTest.java +++ b/src/test/java/com/soundcloud/api/ApiWrapperTest.java @@ -1,10 +1,21 @@ package com.soundcloud.api; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.fail; -import static org.hamcrest.CoreMatchers.*; -import static org.junit.Assert.*; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,10 +29,13 @@ import org.apache.http.HttpResponse; import org.apache.http.StatusLine; import org.apache.http.client.AuthenticationHandler; +import org.apache.http.client.HttpClient; import org.apache.http.client.HttpRequestRetryHandler; import org.apache.http.client.RedirectHandler; import org.apache.http.client.RequestDirector; import org.apache.http.client.UserTokenHandler; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.conn.ClientConnectionManager; import org.apache.http.conn.ConnectionKeepAliveStrategy; import org.apache.http.conn.routing.HttpRoutePlanner; @@ -39,10 +53,13 @@ public class ApiWrapperTest { private ApiWrapper api; + private final static String TEST_CLIENT_ID = "testClientId"; + private final static String TEST_CLIENT_SECRET = "testClientSecret"; final FakeHttpLayer layer = new FakeHttpLayer(); @Before public void setup() { - api = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null, Env.SANDBOX) { + api = new ApiWrapper(TEST_CLIENT_ID, TEST_CLIENT_SECRET, URI.create("redirect://me"), null) { + private static final long serialVersionUID = 12345; // silence warnings @Override protected RequestDirector getRequestDirector(HttpRequestExecutor requestExec, ClientConnectionManager conman, @@ -183,8 +200,8 @@ public void shouldGetTokensWhenLoggingInViaAuthorizationCodeAndScope() throws Ex assertNull(t.getExpiresIn()); } - @Test(expected = IOException.class) - public void shouldThrowIOExceptionWhenLoginFailed() throws Exception { + @Test(expected = CloudAPI.InvalidTokenException.class) + public void shouldThrowInvalidTokenExceptionWhenLoginFailed() throws Exception { layer.addPendingHttpResponse(401, "{\n" + " \"error\": \"Error!\"\n" + "}"); @@ -192,8 +209,8 @@ public void shouldThrowIOExceptionWhenLoginFailed() throws Exception { } - @Test(expected = IOException.class) - public void shouldThrowIOExceptionWhenInvalidJSONReturned() throws Exception { + @Test(expected = CloudAPI.ApiResponseException.class) + public void shouldThrowApiResponseExceptionWhenInvalidJSONReturned() throws Exception { layer.addPendingHttpResponse(200, "I'm invalid JSON!"); api.login("foo", "bar"); } @@ -204,7 +221,7 @@ public void shouldContainInvalidJSONInExceptionMessage() throws Exception { try { api.login("foo", "bar"); fail("expected IOException"); - } catch (IOException e) { + } catch (CloudAPI.ApiResponseException e) { assertThat(e.getMessage(), containsString("I'm invalid JSON!")); } } @@ -249,15 +266,15 @@ public boolean matches(HttpRequest request) { assertThat(api.resolve("http://soundcloud.com/crazybob"), is(1000L)); } - @Test + @Test(expected = CloudAPI.ResolverException.class) public void resolveShouldReturnNegativeOneWhenInvalid() throws Exception { layer.addPendingHttpResponse(404, "Not found"); - assertThat(api.resolve("http://soundcloud.com/nonexisto"), equalTo(-1L)); + api.resolve("http://soundcloud.com/nonexisto"); } @Test public void shouldGetContent() throws Exception { - layer.addHttpResponseRule("/some/resource?a=1", "response"); + layer.addHttpResponseRule("/some/resource?a=1&client_id=" + TEST_CLIENT_ID, "response"); assertThat(Http.getString(api.get(Request.to("/some/resource").with("a", "1"))), equalTo("response")); } @@ -281,7 +298,7 @@ public void shouldPutContent() throws Exception { @Test public void shouldDeleteContent() throws Exception { HttpResponse resp = mock(HttpResponse.class); - layer.addHttpResponseRule("DELETE", "/foo/something", resp); + layer.addHttpResponseRule("DELETE", "/foo/something?client_id=" + TEST_CLIENT_ID, resp); assertThat(api.delete(new Request("/foo/something")), equalTo(resp)); } @@ -303,7 +320,7 @@ public void testGetOAuthHeaderNullToken() throws Exception { public void shouldGenerateUrlWithoutParameters() throws Exception { assertThat( api.getURI(new Request("/my-resource"), true, true).toString(), - equalTo("https://api.sandbox-soundcloud.com/my-resource") + equalTo("https://api.soundcloud.com/my-resource") ); } @@ -311,7 +328,7 @@ public void shouldGenerateUrlWithoutParameters() throws Exception { public void shouldGenerateUrlWithoutSSL() throws Exception { assertThat( api.getURI(new Request("/my-resource"), true, false).toString(), - equalTo("http://api.sandbox-soundcloud.com/my-resource") + equalTo("http://api.soundcloud.com/my-resource") ); } @@ -319,7 +336,7 @@ public void shouldGenerateUrlWithoutSSL() throws Exception { public void shouldGenerateUrlWithParameters() throws Exception { assertThat( api.getURI(Request.to("/my-resource").with("foo", "bar"), true, true).toString(), - equalTo("https://api.sandbox-soundcloud.com/my-resource?foo=bar") + equalTo("https://api.soundcloud.com/my-resource?foo=bar") ); } @@ -327,7 +344,7 @@ public void shouldGenerateUrlWithParameters() throws Exception { public void shouldGenerateUrlForWebHost() throws Exception { assertThat( api.getURI(Request.to("/my-resource"), false, true).toString(), - equalTo("https://sandbox-soundcloud.com/my-resource") + equalTo("https://soundcloud.com/my-resource") ); } @@ -336,8 +353,8 @@ public void shouldGenerateUrlForWebHost() throws Exception { public void shouldGenerateURIForLoginAuthCode() throws Exception { assertThat( api.authorizationCodeUrl().toString(), - equalTo("https://sandbox-soundcloud.com/connect"+ - "?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code") + equalTo("https://soundcloud.com/connect"+ + "?redirect_uri=redirect%3A%2F%2Fme&client_id=" + TEST_CLIENT_ID + "&response_type=code") ); } @@ -346,8 +363,8 @@ public void shouldGenerateURIForLoginAuthCode() throws Exception { public void shouldGenerateURIForLoginAuthCodeWithDifferentEndPoint() throws Exception { assertThat( api.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT).toString(), - equalTo("https://sandbox-soundcloud.com/connect/via/facebook"+ - "?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code") + equalTo("https://soundcloud.com/connect/via/facebook"+ + "?redirect_uri=redirect%3A%2F%2Fme&client_id=" + TEST_CLIENT_ID + "&response_type=code") ); } @@ -355,8 +372,8 @@ public void shouldGenerateURIForLoginAuthCodeWithDifferentEndPoint() throws Exce public void shouldIncludeScopeInAuthorizationUrl() throws Exception { assertThat( api.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT, Token.SCOPE_NON_EXPIRING).toString(), - equalTo("https://sandbox-soundcloud.com/connect/via/facebook"+ - "?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code&scope=non-expiring") + equalTo("https://soundcloud.com/connect/via/facebook"+ + "?redirect_uri=redirect%3A%2F%2Fme&client_id=" + TEST_CLIENT_ID + "&response_type=code&scope=non-expiring") ); } @@ -402,7 +419,7 @@ public void shouldCallTokenStateListenerWhenTokenIsRefreshed() throws Exception @Test public void shouldSerializeAndDeserializeWrapper() throws Exception { - ApiWrapper wrapper = new ApiWrapper("client", "secret", null, new Token("1", "2"), Env.SANDBOX); + ApiWrapper wrapper = new ApiWrapper("client", "secret", null, new Token("1", "2")); File ser = File.createTempFile("serialized_wrapper", "ser"); wrapper.toFile(ser); ApiWrapper other = ApiWrapper.fromFile(ser); @@ -417,4 +434,166 @@ public void shouldSerializeAndDeserializeWrapper() throws Exception { other.invalidateToken(); verify(listener).onTokenInvalid(old); } + + @Test + public void testAddScope() throws Exception { + assertThat(ApiWrapper.addScope(new Request(), new String[] { "foo", "bar"}).getParams().get("scope"), + equalTo("foo bar")); + + assertFalse(ApiWrapper.addScope(new Request(), new String[] {}).getParams().containsKey("scope")); + assertFalse(ApiWrapper.addScope(new Request(), null).getParams().containsKey("scope")); + } + + @Test + public void shouldSetProxy() throws Exception { + assertFalse(api.isProxySet()); + URI proxy = URI.create("https://foo.com"); + assertEquals(proxy.getPort(), -1); + api.setProxy(proxy); + assertTrue(api.isProxySet()); + assertEquals("https://foo.com:443", api.getProxy().toString()); + + + api.setProxy(URI.create("https://foo.com:12345")); + assertEquals(URI.create("https://foo.com:12345"), api.getProxy()); + } + + @Test @SuppressWarnings("serial") + public void shouldHandleBrokenHttpClientNPE() throws Exception { + final HttpClient client = mock(HttpClient.class); + ApiWrapper broken = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null) { + @Override + public HttpClient getHttpClient() { + return client; + } + }; + when(client.execute(any(HttpHost.class), any(HttpUriRequest.class))).thenThrow(new NullPointerException()); + try { + broken.execute(new HttpGet("/foo")); + fail("expected BrokenHttpClientException"); + } catch (ApiWrapper.BrokenHttpClientException expected) { + // make sure client retried request + verify(client, times(2)).execute(any(HttpHost.class), any(HttpUriRequest.class)); + } + } + + @Test @SuppressWarnings("serial") + public void shouldHandleBrokenHttpClientIAE() throws Exception { + final HttpClient client = mock(HttpClient.class); + ApiWrapper broken = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null) { + @Override + public HttpClient getHttpClient() { + return client; + } + }; + when(client.execute(any(HttpHost.class), any(HttpUriRequest.class))).thenThrow(new IllegalArgumentException()); + try { + broken.execute(new HttpGet("/foo")); + fail("expected BrokenHttpClientException"); + } catch (ApiWrapper.BrokenHttpClientException expected) { + verify(client, times(1)).execute(any(HttpHost.class), any(HttpUriRequest.class)); + } + } + + @SuppressWarnings("serial") + @Test + public void shouldSafeExecute() throws Exception { + + final HttpClient client = mock(HttpClient.class); + ApiWrapper broken = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null) { + @Override + public HttpClient getHttpClient() { + return client; + } + }; + when(client.execute(any(HttpHost.class), any(HttpUriRequest.class))).thenThrow(new IllegalArgumentException()); + try { + broken.safeExecute(null, new HttpGet("/foo")); + fail("expected BrokenHttpClientException"); + } catch (ApiWrapper.BrokenHttpClientException expected) { + verify(client, times(1)).execute(any(HttpHost.class), any(HttpUriRequest.class)); + } + + reset(client); + when(client.execute(any(HttpHost.class), any(HttpUriRequest.class))).thenThrow(new NullPointerException()); + try { + broken.execute(new HttpGet("/foo")); + fail("expected BrokenHttpClientException"); + } catch (ApiWrapper.BrokenHttpClientException expected) { + // make sure client retried request + verify(client, times(2)).execute(any(HttpHost.class), any(HttpUriRequest.class)); + } + } + + @Test + public void testAddClientIdWithoutToken() throws Exception { + assertThat(api.addClientIdIfNecessary(Request.to("/foo")).toUrl(), equalTo("/foo?client_id=" + TEST_CLIENT_ID)); + } + + @Test + public void testDontAddClientIdWithToken() throws Exception { + api.setToken(new Token("access", "refresh")); + assertThat(api.addClientIdIfNecessary(Request.to("/foo")).toUrl(), equalTo("/foo")); + } + + @Test + public void testDontAddClientIdIfManuallyAdded() throws Exception { + final Request req = Request.to("/foo").with("client_id", "12345"); + assertThat(api.addClientIdIfNecessary(req).toUrl(), equalTo("/foo?client_id=12345")); + } + + @Test + public void testAddDefaultParameters() throws Exception { + layer.addHttpResponseRule("/foo?client_id=" + TEST_CLIENT_ID, "Hi"); + layer.addHttpResponseRule("/foo?t=1&client_id=" + TEST_CLIENT_ID, "Hi t1"); + layer.addHttpResponseRule("/foo?t=2&client_id=" + TEST_CLIENT_ID, "Hi t2"); + + final Request foo = Request.to("/foo"); + for (int i = 0; i < 1000; i++) { + final Exception throwable[] = new Exception[2]; + Thread t1 = new Thread("t1") { + @Override + public void run() { + ApiWrapper.setDefaultParameter("t", "1"); + try { + assertEquals("Hi t1", Http.getString(api.get(foo))); + } catch (Exception e) { + throwable[0] = e; + } + ApiWrapper.clearDefaultParameters(); + try { + assertEquals("Hi", Http.getString(api.get(foo))); + } catch (Exception e) { + throwable[0] = e; + } + } + }; + + Thread t2 = new Thread("t2") { + @Override + public void run() { + ApiWrapper.setDefaultParameter("t", "2"); + try { + assertEquals("Hi t2", Http.getString(api.get(foo))); + } catch (Exception e) { + throwable[1] = e; + } + ApiWrapper.clearDefaultParameters(); + try { + assertEquals("Hi", Http.getString(api.get(foo))); + } catch (Exception e) { + throwable[1] = e; + } + } + }; + t1.start(); + t2.start(); + t1.join(); + t2.join(); + if (throwable[0] != null) throw throwable[0]; + if (throwable[1] != null) throw throwable[1]; + + assertEquals("Hi", Http.getString(api.get(foo))); + } + } } diff --git a/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java b/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java index 5c14bb6..4f48a22 100644 --- a/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java +++ b/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java @@ -1,12 +1,27 @@ package com.soundcloud.api; -import static org.hamcrest.CoreMatchers.*; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; +import org.apache.http.Header; import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.util.EntityUtils; +import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; +import org.json.JSONTokener; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; import java.io.File; @@ -14,15 +29,29 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.UnknownHostException; import java.nio.ByteBuffer; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; public class CloudAPIIntegrationTest implements Params.Track, Endpoints { - // http://sandbox-soundcloud.com/you/apps/java-api-wrapper-test-app - static final String CLIENT_ID = "yH1Jv2C5fhIbZfGTpKtujQ"; - static final String CLIENT_SECRET = "C6o8jc517b6PIw0RKtcfQsbOK3BjGpxWFLg977UiguY"; + // https://soundcloud.com/you/apps/java-api-wrapper + // user: api-testing + static final String CLIENT_ID = "40d3111c6b4d02096c6ce35fdf90bf58"; + static final String CLIENT_SECRET = "ff3685dbf02ce789a16631b0028e0512"; + + public static final String TRACK_PERMALINK = "http://soundcloud.com/jberkel/nobody-home"; + public static final long USER_ID = 18173653L; + public static final long CHE_FLUTE_TRACK_ID = 274334; + public static final long FLICKERMOOD_TRACK_ID = 293; + public static final long TRACK_LENGTH = 224861L; CloudAPI api; + static final String USERNAME = "android-testing"; + static final String PASSWORD = "android-testing"; + /* To get full HTTP logging, add the following system properties: -Dorg.apache.commons.logging.Log=org.apache.commons.logging.impl.SimpleLog @@ -37,16 +66,18 @@ public void setUp() throws Exception { CLIENT_ID, CLIENT_SECRET, null, - null, - Env.SANDBOX); + null); + } - private Token login() throws IOException { - return login(null); + private Token login(String... scopes) throws IOException { + return api.login(USERNAME, PASSWORD, scopes); } - private Token login(String scope) throws IOException { - return api.login("api-testing", "testing", scope); + @Test + public void shouldBeAbleToMakePublicRequests() throws Exception { + HttpResponse response = api.get(Request.to("/tracks").with("order", "hotness")); + assertEquals(200, response.getStatusLine().getStatusCode()); } @Test @@ -59,6 +90,71 @@ public void shouldUploadASimpleAudioFile() throws Exception { int status = resp.getStatusLine().getStatusCode(); assertThat(status, is(201)); + + Header location = resp.getFirstHeader("Location"); + assertNotNull(location); + } + + @Test + public void shouldCreateAPlaylistAndAddTracksToIt() throws Exception { + login(); + + HttpResponse resp = api.post(Request.to(PLAYLISTS) + .with("playlist[title]", "test playlist")); + + int status = resp.getStatusLine().getStatusCode(); + assertThat(status, is(201)); + + Header location = resp.getFirstHeader("Location"); + assertNotNull(location); + + String playlistUrl = location.getValue(); + assertNotNull(playlistUrl); + + String title = "a new title:" + System.currentTimeMillis(); + resp = api.put(Request.to(playlistUrl) + .with("playlist[title]", title) + .with("playlist[tracks][][id]", CHE_FLUTE_TRACK_ID) + .with("playlist[tracks][][id]", FLICKERMOOD_TRACK_ID)); + + status = resp.getStatusLine().getStatusCode(); + assertThat(status, is(200)); + + JSONObject obj = new JSONObject(EntityUtils.toString(resp.getEntity())); + assertThat(obj.getString("kind"), equalTo("playlist")); + assertThat(obj.getString("title"), equalTo(title)); + assertThat(obj.getInt("track_count"), equalTo(2)); + } + + @Test + public void shouldCreateAPlaylistAndAddTracksToItWithJSON() throws Exception { + login(); + + HttpResponse resp = api.post(Request.to(PLAYLISTS) + .with("playlist[title]", "test playlist")); + + int status = resp.getStatusLine().getStatusCode(); + assertThat(status, is(201)); + + Header location = resp.getFirstHeader("Location"); + assertNotNull(location); + + String playlistUrl = location.getValue(); + assertNotNull(playlistUrl); + + String title = "a new tîtle:" + System.currentTimeMillis(); + JSONObject json = createJSONPlaylist(title, CHE_FLUTE_TRACK_ID, FLICKERMOOD_TRACK_ID); + + resp = api.put(Request.to(playlistUrl) + .withContent(json.toString(), "application/json")); + + status = resp.getStatusLine().getStatusCode(); + assertThat(status, is(200)); + + JSONObject obj = new JSONObject(EntityUtils.toString(resp.getEntity())); + assertThat(obj.getString("kind"), equalTo("playlist")); + assertThat(obj.getString("title"), equalTo(title)); + assertThat(obj.getInt("track_count"), equalTo(2)); } @Test @@ -73,19 +169,25 @@ public void shouldUploadASimpleAudioFileBytes() throws Exception { HttpResponse resp = api.post(Request.to(TRACKS).with( TITLE, "Hello Android", POST_TO_EMPTY, "") - .withFile(ASSET_DATA, bb)); + .withFile(ASSET_DATA, bb, "hello.aiff")); int status = resp.getStatusLine().getStatusCode(); assertThat(status, is(201)); } - @Test(expected = IOException.class) + @Test(expected = IOException.class) @Ignore public void shouldNotGetASignupTokenWhenInofficialApp() throws Exception { login(); api.clientCredentials(); } + @Test(expected = CloudAPI.InvalidTokenException.class) + public void shouldGetATokenUsingExtensionGrantTypes() throws Exception { + // TODO ? + api.extensionGrantType(CloudAPI.FACEBOOK_GRANT_TYPE + "fbToken"); + } + @Test public void shouldReturn401WithInvalidToken() throws Exception { login(); @@ -94,6 +196,14 @@ public void shouldReturn401WithInvalidToken() throws Exception { assertThat(resp.getStatusLine().getStatusCode(), is(401)); } + + @Test + public void shouldWorkWithRelativeUrls() throws Exception { + login(); + HttpResponse resp = api.get(Request.to("me")); + assertThat(resp.getStatusLine().getStatusCode(), is(200)); + } + @Test public void shouldRefreshAutomaticallyWhenTokenExpired() throws Exception { login(); @@ -115,8 +225,82 @@ public void shouldRefreshAutomaticallyWhenTokenExpired() throws Exception { public void shouldResolveUrls() throws Exception { login(); - long id = api.resolve("http://sandbox-soundcloud.com/api-testing"); - assertThat(id, is(1862213L)); + long id = api.resolve("http://soundcloud.com/" + USERNAME); + assertThat(id, is(USER_ID)); + + try { + id = api.resolve("http://soundcloud.com/i-do-no-exist-no-no-no"); + fail("expected resolver exception, got: "+id); + } catch (CloudAPI.ResolverException e) { + // expected + assertThat(e.getStatusCode(), is(404)); + } + } + + @Test + public void shouldResolveStreamUrls() throws Exception { + login(); + + String streamUrl = getApiUrlFromPermalink(TRACK_PERMALINK) + "/stream"; + Stream resolved = api.resolveStreamUrl(streamUrl, false); + + assertThat(resolved.url, equalTo(streamUrl)); + assertThat(resolved.streamUrl, containsString("https://ec-media.soundcloud.com/")); + + assertTrue("expire should be in the future", resolved.expires > System.currentTimeMillis()); + assertThat(resolved.eTag, equalTo("\"980f61d6d6ee26ffe0c78aef618d786f\"")); + } + + @Test @Ignore /* playcounts not deployed on sandbox */ + public void shouldResolveStreamUrlAndSkipPlaycountLogging() throws Exception { + // need the playcount scope for this to work + assertTrue(login(Token.SCOPE_PLAYCOUNT).scoped(Token.SCOPE_PLAYCOUNT)); + + long trackId = api.resolve(TRACK_PERMALINK); + + int count = Http.getJSON(api.get(Request.to("/tracks/"+trackId))).getInt("playback_count"); + api.resolveStreamUrl("https://api.soundcloud.com/tracks/"+trackId+"/stream", false); + int count2 = Http.getJSON(api.get(Request.to("/tracks/"+trackId))).getInt("playback_count"); + + assertTrue(String.format("%d !> %d", count2, count), count2 > count); + + // resolve again, this time skipping count + api.resolveStreamUrl("https://api.soundcloud.com/tracks/"+trackId+"/stream", true); + + int count3 = Http.getJSON(api.get(Request.to("/tracks/"+trackId))).getInt("playback_count"); + assertTrue(String.format("%d != %d", count3, count2), count3 == count2); + } + + @Test + public void shouldThrowResolverExceptionWhenStreamCannotBeResolved() throws Exception { + login(); + try { + Stream s = api.resolveStreamUrl("https://api.soundcloud.com/tracks/999919191/stream", false); + fail("expected resolver exception, got: "+s); + } catch (CloudAPI.ResolverException e) { + // expected + assertThat(e.getStatusCode(), is(404)); + } + } + + @Test + public void shouldSupportRangeRequest() throws Exception { + login(); + + String streamUrl = getApiUrlFromPermalink(TRACK_PERMALINK)+"/stream"; + + Stream resolved = api.resolveStreamUrl(streamUrl, false); + assertThat(resolved.contentLength, is(TRACK_LENGTH)); + + HttpResponse resp = api + .getHttpClient() + .execute(resolved.streamUrl().range(50, 100).buildRequest(HttpGet.class)); + + assertThat(resp.getStatusLine().toString(), resp.getStatusLine().getStatusCode(), is(206)); + Header range = resp.getFirstHeader("Content-Range"); + assertThat(range, notNullValue()); + assertThat(range.getValue(), equalTo("bytes 50-100/"+TRACK_LENGTH)); + assertThat(resp.getEntity().getContentLength(), is(51L)); } @Test @@ -132,10 +316,32 @@ public void readMyDetails() throws Exception { JSONObject me = Http.getJSON(resp); - assertThat(me.getString("username"), equalTo("api-testing")); + assertThat(me.getString("username"), equalTo(USERNAME)); // writeResponse(resp, "me.json"); } + @Test + public void readGzipCompressedData() throws Exception { + api.setDefaultAcceptEncoding("gzip"); + + login(); + + HttpResponse resp = api.get(Request.to(Endpoints.TRACKS)); + assertThat(resp.getStatusLine().getStatusCode(), is(200)); + + assertThat( + resp.getFirstHeader("Content-Type").getValue(), + containsString("application/json")); + + assertThat( + resp.getFirstHeader("Content-Encoding").getValue(), + equalTo("gzip")); + + JSONArray array = new JSONArray(new JSONTokener(new InputStreamReader(resp.getEntity().getContent()))); + + assertTrue("array is empty", array.length() > 0); + } + @Test public void shouldLoginWithNonExpiringScope() throws Exception { Token token = login(Token.SCOPE_NON_EXPIRING); @@ -170,8 +376,72 @@ public void shouldChangeContentType() throws Exception { containsString("application/xml")); } - /* + @Test + public void shouldSupportConditionalGets() throws Exception { + login(); + + HttpResponse resp = api.get(Request.to(Endpoints.MY_DETAILS)); + assertThat(resp.getStatusLine().getStatusCode(), is(200) /* ok */); + String etag = Http.etag(resp); + assertNotNull(etag); + + resp = api.get(Request.to(Endpoints.MY_DETAILS).ifNoneMatch(etag)); + assertThat(resp.getStatusLine().getStatusCode(), is(304) /* not-modified */); + } + + @Test(expected = UnknownHostException.class) + public void shouldRespectProxySettings() throws Exception { + System.setProperty("http.proxyHost", "http://doesnotexist.example.com"); + try { + login(); + } finally { + System.clearProperty("http.proxyHost"); + } + } + + @Test @Ignore + public void shouldSupportConcurrentConnectionsToApiHost() throws Exception { + login(); + + int num = 20; + final CyclicBarrier start = new CyclicBarrier(num, new Runnable() { + @Override + public void run() { + System.err.println("starting..."); + } + }); + final CyclicBarrier end = new CyclicBarrier(num); + while (num-- > 0) { + new Thread("t-"+num) { + @Override public void run() { + try { + start.await(); + System.err.println("running: "+toString()); + try { + HttpResponse resp = api.get(Request.to(Endpoints.MY_DETAILS)); + resp.getEntity().consumeContent(); + assertThat(resp.getStatusLine().getStatusCode(), is(200)); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + System.err.println("finished: "+toString()); + end.await(); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (BrokenBarrierException e) { + throw new RuntimeException(e); + } + } + }.start(); + } + start.await(); + end.await(); + System.err.println("all threads finished"); + } + + @Test @Ignore public void updateMyDetails() throws Exception { Request updateMe = Request.to(MY_DETAILS).with( Params.User.WEBSITE, "http://mywebsite.com") @@ -180,7 +450,6 @@ public void updateMyDetails() throws Exception { HttpResponse resp = api.put(updateMe); assertThat(resp.getStatusLine().getStatusCode(), is(200)); } - */ @SuppressWarnings({"UnusedDeclaration"}) private void writeResponse(HttpResponse resp, String file) throws IOException { @@ -193,4 +462,28 @@ private void writeResponse(HttpResponse resp, String file) throws IOException { is.close(); fos.close(); } + + private String getApiUrlFromPermalink(String permalink) throws IOException { + long trackId = api.resolve(permalink); + return "https://api.soundcloud.com/tracks/" + trackId; + } + + + private JSONObject createJSONPlaylist(String title, long... trackIds) throws JSONException { + JSONObject playlist = new JSONObject(); + playlist.put("title", title); + + JSONObject json = new JSONObject(); + json.put("playlist", playlist); + + JSONArray tracks = new JSONArray(); + playlist.put("tracks", tracks); + + for (long id : trackIds) { + JSONObject track = new JSONObject(); + track.put("id", id); + tracks.put(track); + } + return json; + } } diff --git a/src/test/java/com/soundcloud/api/EnvTest.java b/src/test/java/com/soundcloud/api/EnvTest.java new file mode 100644 index 0000000..28ebb44 --- /dev/null +++ b/src/test/java/com/soundcloud/api/EnvTest.java @@ -0,0 +1,27 @@ +package com.soundcloud.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.apache.http.HttpHost; +import org.junit.Test; + + +public class EnvTest { + @Test + public void testIsApiHost() throws Exception { + assertTrue(Env.LIVE.isApiHost(new HttpHost("api.soundcloud.com", 80, "http"))); + assertTrue(Env.LIVE.isApiHost(new HttpHost("api.soundcloud.com", 443, "https"))); + assertFalse(Env.LIVE.isApiHost(new HttpHost("foo.soundcloud.com", 443, "https"))); + } + + @Test + public void shouldHostsShouldExplicitlySpecifyPorts() throws Exception { + assertEquals(80, Env.LIVE.authResourceHost.getPort()); + assertEquals(443, Env.LIVE.sslAuthResourceHost.getPort()); + + assertEquals(80, Env.LIVE.resourceHost.getPort()); + assertEquals(443, Env.LIVE.sslResourceHost.getPort()); + } +} diff --git a/src/test/java/com/soundcloud/api/OAuth2HttpRequestInterceptorTest.java b/src/test/java/com/soundcloud/api/OAuth2HttpRequestInterceptorTest.java index 7baf4a5..eb0034f 100644 --- a/src/test/java/com/soundcloud/api/OAuth2HttpRequestInterceptorTest.java +++ b/src/test/java/com/soundcloud/api/OAuth2HttpRequestInterceptorTest.java @@ -1,6 +1,8 @@ package com.soundcloud.api; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.apache.http.Header; import org.apache.http.HttpRequest; diff --git a/src/test/java/com/soundcloud/api/OAuth2SchemeTest.java b/src/test/java/com/soundcloud/api/OAuth2SchemeTest.java index b3588ca..57b878f 100644 --- a/src/test/java/com/soundcloud/api/OAuth2SchemeTest.java +++ b/src/test/java/com/soundcloud/api/OAuth2SchemeTest.java @@ -5,7 +5,10 @@ import static org.hamcrest.CoreMatchers.nullValue; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.apache.http.Header; import org.apache.http.auth.AUTH; diff --git a/src/test/java/com/soundcloud/api/RequestTest.java b/src/test/java/com/soundcloud/api/RequestTest.java index be719ef..1a63626 100644 --- a/src/test/java/com/soundcloud/api/RequestTest.java +++ b/src/test/java/com/soundcloud/api/RequestTest.java @@ -1,8 +1,16 @@ package com.soundcloud.api; -import static org.hamcrest.CoreMatchers.*; -import static org.junit.Assert.*; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -15,12 +23,14 @@ import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.entity.mime.MultipartEntity; +import org.apache.http.util.EntityUtils; import org.hamcrest.CoreMatchers; import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.net.URI; import java.util.IllegalFormatException; import java.util.Iterator; import java.util.NoSuchElementException; @@ -44,6 +54,18 @@ public void shouldGenerateUrlWithParameters() throws Exception { assertThat(p.toUrl("http://foo.com"), equalTo("http://foo.com?foo=100&baz=22.3")); } + + @Test + public void shouldNotModifyOriginalRequest() throws Exception { + String url = "http://ec-media.soundcloud.com/SdPniMt7cZzj.128.mp3?ff61182e3c2ecefa438cd02102d0e385713f0c1f" + + "af3b0339595660fd0603ed1dd95c308fdf4dfe37b272d5fc302cd60875f62fda2557f961990ca6e770fdb81c291f729" + + "2cb&AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1337966965&Signature=dFluZNnDMGZiXCACfRru9VrB%2" + + "Bbg%3D"; + + Request r = new Request(url); + assertThat(r.toUrl(), equalTo(url)); + } + @Test public void shouldHaveSizeMethod() throws Exception { Request p = new Request().with("foo", 100, "baz", 22.3f); @@ -58,6 +80,30 @@ public void shouldSupportWith() throws Exception { assertThat(p.queryString(), equalTo("foo=100&baz=22.3&baz=66")); } + @Test + public void shouldSupportOverwritingParameters() { + Request r = new Request(); + r.add("foo", 1) + .add("foo", 2); + + assertThat(r.queryString(), equalTo("foo=1&foo=2")); + + r.set("foo", 3); + assertThat(r.queryString(), equalTo("foo=3")); + + r.clear("foo"); + assertThat(r.queryString(), equalTo("")); + } + + @Test + public void shouldCopyRequestWithNewResource() throws Exception { + Request p = new Request().with("foo", 100, "baz", 22.3f); + Request p2 = p.newResource("baz"); + assertThat(p, not(sameInstance(p2))); + assertThat(p2.toString(), + equalTo("Request{mResource='baz', params=[foo=100, baz=22.3], files=null, entity=null, mToken=null, listener=null}")); + } + @Test public void shouldImplementIterable() throws Exception { Request p = new Request().with("foo", 100, "baz", 22.3f); @@ -99,6 +145,17 @@ public void shouldAddTokenToHeaderIfSpecified() throws Exception { assertThat(auth.getValue(), CoreMatchers.containsString("acc3ss")); } + @Test + public void shouldAddRangeHeaderIfSpecified() throws Exception { + HttpGet request = Request.to("/foo") + .range(1,200) + .buildRequest(HttpGet.class); + + Header auth = request.getFirstHeader("Range"); + assertNotNull(auth); + assertThat(auth.getValue(), equalTo("bytes=1-200")); + } + @Test public void shouldCreateMultipartRequestWhenFilesAreAdded() throws Exception { File f = File.createTempFile("testing", "test"); @@ -116,7 +173,28 @@ public void shouldCreateMultipartRequestWhenFilesAreAdded() throws Exception { assertThat(encoded, containsString("foo")); assertThat(encoded, containsString("key")); assertThat(encoded, containsString("value")); - assertThat(encoded, containsString("testing")); + assertThat(encoded, containsString("filename=\"testing")); + } + + @Test + public void shouldOverrideFilenameInUpload() throws Exception { + File f = File.createTempFile("testing", "test"); + + HttpPost request = Request.to("/foo") + .with("key", "value") + .withFile("foo", f, "music.mp3") + .buildRequest(HttpPost.class); + + assertTrue(request.getEntity() instanceof MultipartEntity); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + request.getEntity().writeTo(os); + String encoded = os.toString(); + + assertThat(encoded, containsString("foo")); + assertThat(encoded, containsString("key")); + assertThat(encoded, containsString("value")); + assertThat(encoded, containsString("filename=\"music.mp3\"")); } @Test @@ -129,6 +207,30 @@ public void shouldDetectMultipartRequests() throws Exception { .withFile("foo", "foo".getBytes()).isMultipart()); } + @Test + public void shouldUploadByteDataWithFilename() throws Exception { + HttpPost request = Request.to("/foo") + .with("key", "value") + .withFile("testing", "foo".getBytes(), "music.mp3") + .buildRequest(HttpPost.class); + + assertTrue(request.getEntity() instanceof MultipartEntity); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + request.getEntity().writeTo(os); + String encoded = os.toString(); + assertThat(encoded, containsString("filename=\"music.mp3\"")); + } + + @Test + public void shouldPreservePostUri() throws Exception { + HttpPost request = Request.to("/foo") + .buildRequest(HttpPost.class); + + assertThat(request.getURI(), notNullValue()); + assertThat(request.getURI().toString(), equalTo("/foo")); + } + @Test public void shouldCreateMultipartRequestWhenFilesAreAddedWithByteArray() throws Exception { HttpPost request = Request.to("/foo") @@ -174,6 +276,35 @@ public void shouldIncludeContentInRequest() throws Exception { assertThat("content", equalTo(body)); } + @Test + public void shouldUseUTF8AsDefaultEncodingForStringPayloads() throws Exception { + HttpPost request = Request.to("/too") + .withContent("{ string:\"îøüöéí\" }", "application/json") + .buildRequest(HttpPost.class); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + request.getEntity().writeTo(os); + + String decoded = os.toString("UTF-8"); + assertThat("{ string:\"îøüöéí\" }", equalTo(decoded)); + } + + @Test + public void shouldBuildARequestWithContentAndPreserveQueryParameters() throws Exception { + HttpPost post = Request + .to("/foo") + .withContent("{}", "application/json") + .with("1", "2").buildRequest(HttpPost.class); + + assertThat(post.getURI().toString(), equalTo("/foo?1=2")); + assertTrue(post.getEntity() instanceof StringEntity); + assertThat(post.getEntity().getContentLength(), equalTo(2l)); + assertThat(EntityUtils.toString(post.getEntity()), equalTo("{}")); + assertThat(post.getFirstHeader("Content-Type").getValue(), equalTo("application/json")); + } + + + @Test public void whenAProgressListenerIsSpecifiedShouldHaveCountingMultipart() throws Exception { HttpPost request = Request.to("/foo") @@ -198,7 +329,7 @@ public void shouldThrowIllegalFormatExceptionWhenInvalidParameters() throws Exce public void toStringShouldWork() throws Exception { assertThat( new Request("/foo").with("1", "2").toString(), - equalTo("Request{params=[1=2], files=null, entity=null, mToken=null, mResource='/foo', listener=null}")); + equalTo("Request{mResource='/foo', params=[1=2], files=null, entity=null, mToken=null, listener=null}")); } @Test @@ -214,6 +345,26 @@ public void itShouldParseExistingQueryParameters() throws Exception { assertThat( new Request("/foo?bar=baz&foo=bar").with("1", "2").toUrl(), equalTo("/foo?bar=baz&foo=bar&1=2")); + + String s3 = "http://ak-media.soundcloud.com/XAGeEabPextR.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319547723&Signature=o53ozj2b%2BrdARFBEZoAziK7mWIY%3D&__gda__=1319547723_e7e8d73cf3af2b003d891ecc01c20143"; + + assertThat(Request.to(s3).toUrl(), equalTo(s3)); + + } + + @Test + public void itShouldParseFullURI() throws Exception { + assertThat( + new Request(URI.create("http://foo.soundcloud.com/foo?bar=baz")).with("1", "2").toUrl(), + equalTo("/foo?bar=baz&1=2")); + + assertThat( + new Request(URI.create("http://foo.soundcloud.com/foo")).with("1", "2").toUrl(), + equalTo("/foo?1=2")); + + assertThat( + new Request(URI.create("http://foo.soundcloud.com/")).toUrl(), + equalTo("/")); } @Test @@ -224,6 +375,12 @@ public void shouldHaveCopyConstructor() { assertThat(copy.getToken(),equalTo(orig.getToken())); } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAcceptNullStringInCtor() throws Exception { + new Request((String) null); + } + @Test public void shouldNotModifyOriginal() { Request orig = new Request("/foo").with("1", 2, "3",4); @@ -237,4 +394,31 @@ public void shouldNotModifyOriginal() { assertThat(copy.getToken(), not(equalTo(orig.getToken()))); assertThat(orig.getListener(),equalTo(copy.getListener())); } + + @Test + public void testFormatRange() throws Exception { + assertThat(Request.formatRange(1, 1000), equalTo("bytes=1-1000")); + assertThat(Request.formatRange(1), equalTo("bytes=1-")); + assertThat(Request.formatRange(), equalTo("bytes=0-")); + } + + @Test(expected = IllegalArgumentException.class) + public void testFormatRangeInvalidArgument() throws Exception { + Request.formatRange(100,200,300); + } + + @Test(expected = IllegalArgumentException.class) + public void testFormatRangeInvalidArgument2() throws Exception { + Request.formatRange(1000, 1); + } + + @Test(expected = IllegalArgumentException.class) + public void testFormatRangeInvalidArgument3() throws Exception { + Request.formatRange(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testFormatRangeInvalidArgument4() throws Exception { + Request.formatRange(-1, 200); + } } diff --git a/src/test/java/com/soundcloud/api/StreamTest.java b/src/test/java/com/soundcloud/api/StreamTest.java new file mode 100644 index 0000000..36f808d --- /dev/null +++ b/src/test/java/com/soundcloud/api/StreamTest.java @@ -0,0 +1,99 @@ +package com.soundcloud.api; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertThat; + +import org.apache.http.HttpException; +import org.apache.http.HttpResponse; +import org.apache.http.impl.DefaultHttpResponseFactory; +import org.apache.http.impl.io.AbstractSessionInputBuffer; +import org.apache.http.impl.io.HttpResponseParser; +import org.apache.http.message.BasicLineParser; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpParams; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class StreamTest { + @Test + public void testParsing() throws Exception { + Stream s = new Stream( + "http://api.soundcloud.com", + "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582", + parse("s3-headers.txt")); + + assertThat("etag", s.eTag, equalTo("\"81c4a04a366ab681ea068b2fa06d10a3\"")); + assertThat("bitrate", s.bitRate, is(128)); + assertThat("duration", s.duration, is(18998)); + assertThat("content-length", s.contentLength, is(303855L)); + assertThat("last-modified", s.lastModified, is(1319536883000L)); + assertThat("expires", s.expires, is(1319537336000L)); + } + + @Test + public void testWithNewStreamUrl() throws Exception { + Stream s1 = new Stream( + "http://api.soundcloud.com", + "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582", + parse("s3-headers.txt")); + + Stream s2 = s1.withNewStreamUrl("http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537337&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582"); + + assertThat(s1.eTag, equalTo(s2.eTag)); + assertThat(s1.bitRate, equalTo(s2.bitRate)); + assertThat(s1.duration, equalTo(s2.duration)); + assertThat(s1.contentLength, equalTo(s2.contentLength)); + assertThat(s1.lastModified, equalTo(s2.lastModified)); + + assertThat(s1.streamUrl, not(equalTo(s2.streamUrl))); + assertThat(s1.expires, not(is(s2.expires))); + } + + @Test + public void shouldBeSerializable() throws Exception { + Stream s1 = new Stream( + "http://api.soundcloud.com", + "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582", + parse("s3-headers.txt")); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + + oos.writeObject(s1); + oos.close(); + + ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); + Stream s2 = (Stream) ois.readObject(); + + assertThat(s1.eTag, equalTo(s2.eTag)); + assertThat(s1.bitRate, equalTo(s2.bitRate)); + assertThat(s1.duration, equalTo(s2.duration)); + assertThat(s1.contentLength, equalTo(s2.contentLength)); + assertThat(s1.lastModified, equalTo(s2.lastModified)); + assertThat(s1.streamUrl, equalTo(s2.streamUrl)); + assertThat(s1.expires, equalTo(s2.expires)); + } + + private HttpResponse parse(final String resource) throws IOException, HttpException { + final HttpParams params = new BasicHttpParams(); + HttpResponseParser parser = new HttpResponseParser(new AbstractSessionInputBuffer() { + { + init(getClass().getResourceAsStream(resource), 8192, params); + } + + @Override + public boolean isDataAvailable(int timeout) throws IOException { + return true; + } + }, new BasicLineParser(), new DefaultHttpResponseFactory(), params); + + return (HttpResponse) parser.parse(); + } +} diff --git a/src/test/java/com/soundcloud/api/TokenTest.java b/src/test/java/com/soundcloud/api/TokenTest.java index 1c2a34e..a053382 100644 --- a/src/test/java/com/soundcloud/api/TokenTest.java +++ b/src/test/java/com/soundcloud/api/TokenTest.java @@ -2,7 +2,12 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; import org.json.JSONObject; import org.junit.Test; @@ -81,4 +86,36 @@ public void shouldParseJsonResponse() throws Exception { assertThat(t.refresh, equalTo("5678")); assertNotNull(t.getExpiresIn()); } + + @Test + public void shouldParseJsonResponseDifferentKeyOrder() throws Exception { + Token t = new Token(new JSONObject("{\n" + + " \"expires_in\": 3600,\n" + + " \"access_token\": \"1234\",\n" + + " \"scope\": \"*\",\n" + + " \"refresh_token\": \"5678\"\n" + + "}")); + + assertThat(t.scoped("*"), is(true)); + assertThat(t.access, equalTo("1234")); + assertThat(t.refresh, equalTo("5678")); + assertNotNull(t.getExpiresIn()); + } + + @Test + public void shouldParseJsonWithCustomParameters() throws Exception { + Token t = new Token(new JSONObject("{\n" + + " \"access_token\": \"1234\",\n" + + " \"refresh_token\": \"5678\",\n" + + " \"expires_in\": 3600,\n" + + " \"scope\": \"*\",\n" + + " \"custom1\": \"foo\",\n" + + " \"soundcloud:user:sign-up\": \"baz\",\n" + + " \"custom2\": 23\n" + + "}")); + + assertThat(t.customParameters.get("custom1"), equalTo("foo")); + assertThat(t.customParameters.get("custom2"), equalTo("23")); + assertThat(t.getSignup(), equalTo("baz")); + } } diff --git a/src/test/resources/com/soundcloud/api/s3-headers.txt b/src/test/resources/com/soundcloud/api/s3-headers.txt new file mode 100644 index 0000000..f5842f9 --- /dev/null +++ b/src/test/resources/com/soundcloud/api/s3-headers.txt @@ -0,0 +1,15 @@ +HTTP/1.1 200 OK +x-amz-id-2: 7WKzm/D/jJXIB52DBYCcs57p44C1W67H+zPP+D/UnETmZfcVtaswkvtyQegBoPY/ +x-amz-request-id: 39B41EBA20F893D8 +x-amz-meta-bitrate: 128 +x-amz-meta-duration: 18998 +x-amz-meta-job: Nbhil06qjDaP +Last-Modified: Tue, 25 Oct 2011 10:01:23 GMT +ETag: "81c4a04a366ab681ea068b2fa06d10a3" +Accept-Ranges: bytes +Content-Type: audio/mpeg +Content-Length: 303855 +Server: AmazonS3 +Cache-Control: max-age=31535935 +Date: Tue, 25 Oct 2011 10:06:45 GMT +Connection: keep-alive \ No newline at end of file