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..20224b9 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,41 @@
+## 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 +49,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..485c085 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,6 +123,7 @@ 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
@@ -111,8 +136,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 +247,14 @@ Includes portions of code (c) 2010 Xtreme Labs and Pivotal Labs and (c) 2009 urb
See LICENSE for details.
+[](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.2.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 +262,15 @@ 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
+[jar-all]: https://github.com/downloads/soundcloud/java-api-wrapper/java-api-wrapper-1.2.0-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..5621018 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -24,7 +24,7 @@ Regenerate pom.xml
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)
@@ -39,7 +39,7 @@ 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
+## 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..3e8987a 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.2.1'
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..29c6fd9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.soundcloud
java-api-wrapper
- 1.0.2-SNAPSHOT
+ 1.2.2-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..5b5f3a1
--- /dev/null
+++ b/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java
@@ -0,0 +1,147 @@
+package com.soundcloud.api.examples;
+
+import com.soundcloud.api.ApiWrapper;
+import com.soundcloud.api.CloudAPI;
+import com.soundcloud.api.Endpoints;
+import com.soundcloud.api.Env;
+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..056c2f0 100644
--- a/src/examples/java/com/soundcloud/api/examples/GetResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/GetResource.java
@@ -36,7 +36,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 +47,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..a5bb634 100644
--- a/src/examples/java/com/soundcloud/api/examples/PostResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/PostResource.java
@@ -34,7 +34,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 +44,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..65a2163 100644
--- a/src/examples/java/com/soundcloud/api/examples/PutResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/PutResource.java
@@ -40,7 +40,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 +50,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..93ba560 100644
--- a/src/main/java/com/soundcloud/api/ApiWrapper.java
+++ b/src/main/java/com/soundcloud/api/ApiWrapper.java
@@ -2,9 +2,10 @@
import org.apache.http.ConnectionReuseStrategy;
import org.apache.http.Header;
-import org.apache.http.HttpRequest;
+import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
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 +16,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 +41,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 +79,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 +96,20 @@ public class ApiWrapper implements CloudAPI, Serializable {
transient private HttpClient httpClient;
transient private TokenListener listener;
+ private String mDefaultContentType;
+
+ 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,78 +117,78 @@ 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(
+ final Request request = addScope(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);
+ "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(
+ final Request request = addScope(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);
-
+ "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(
+ @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);
- if (scope != null) req.add("scope", scope);
+ "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(
@@ -220,7 +241,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 +260,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 = null;
+ 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 +374,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 +385,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 +393,7 @@ public HttpClient getHttpClient() {
setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
@Override
public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) {
- return 20 * 1000; // milliseconds
+ return KEEPALIVE_TIMEOUT;
}
});
@@ -360,13 +448,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);
}
- return -1;
+ }
+
+ @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);
+ }
+ }
+
+ @Override
+ public HttpResponse head(Request request) throws IOException {
+ return execute(request, HttpHead.class);
}
@Override public HttpResponse get(Request request) throws IOException {
@@ -390,7 +521,7 @@ public long resolve(String url) throws IOException {
}
@Override public void setToken(Token newToken) {
- mToken = newToken;
+ mToken = newToken == null ? EMPTY_TOKEN : newToken;
}
@Override
@@ -400,19 +531,80 @@ 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 extends HttpRequestBase> reqType) throws IOException {
- if (debugRequests) System.err.println(reqType.getSimpleName()+" "+req);
+ 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(req.buildRequest(reqType));
}
+
+ protected void logRequest( Class extends HttpRequestBase> 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;
+ }
+ }
+
/**
* serialize the wrapper to a File
* @param f target
@@ -433,6 +625,18 @@ public void setDefaultContentType(String contentType) {
mDefaultContentType = contentType;
}
+ /* 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..4778791 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,30 +15,24 @@
* @see ApiWrapper
*/
public interface CloudAPI {
- // grant types
+ // 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 soundcloud
+ String OAUTH1_TOKEN = "oauth1_token";
+
+ // oauth2 extension grant types
+ String FACEBOOK_GRANT_TYPE = "urn:soundcloud:oauth2:grant-type:facebook&access_token=";
+
// other constants
String REALM = "SoundCloud";
String OAUTH_SCHEME = "oauth";
- String VERSION = "1.0.1";
+ String VERSION = "1.2.1";
String USER_AGENT = "SoundCloud Java Wrapper ("+VERSION+")";
- /**
- * Request a token using
- * Resource Owner Password Credentials.
- *
- * @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
@@ -43,13 +40,13 @@ public interface CloudAPI {
*
* @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 +54,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 +71,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 +118,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 +153,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 +216,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);
@@ -241,4 +260,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/Endpoints.java b/src/main/java/com/soundcloud/api/Endpoints.java
index 2a02ae6..cad051f 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,8 @@ 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 USERS = "/users";
String USER_DETAILS = "/users/%d";
@@ -37,6 +39,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 +48,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/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..7d80bbd 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,7 +21,9 @@
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;
@@ -43,13 +47,15 @@
*/
public class Request implements Iterable {
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 +64,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 +96,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 +113,8 @@ 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;
+ if (request.mFiles != null) mFiles = new HashMap(request.mFiles);
}
/**
@@ -114,6 +143,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 +183,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 +213,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 +235,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,9 +255,10 @@ 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));
}
@@ -189,11 +266,34 @@ public Request withFile(String name, byte[] 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
+ */
+ @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) {
- if (mByteBuffers == null) mByteBuffers = new HashMap();
- if (data != null) mByteBuffers.put(name, data);
+ 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;
}
@@ -225,6 +325,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 +340,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,23 +375,21 @@ 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 :
@@ -284,7 +404,14 @@ public T buildRequest(Class method) {
}
request.setURI(URI.create(mResource));
- } else { // just plain GET/DELETE/...
+ } else { // just plain GET/HEAD/DELETE/...
+ if (mRange != null) {
+ request.addHeader("Range", formatRange(mRange));
+ }
+
+ if (mIfNoneMatch != null) {
+ request.addHeader("If-None-Match", mIfNoneMatch);
+ }
request.setURI(URI.create(toUrl()));
}
@@ -302,6 +429,20 @@ public T buildRequest(Class method) {
}
}
+ 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 +450,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 +467,6 @@ public String toString() {
return listener;
}
-
/**
* Updates about the amount of bytes already transferred.
*/
@@ -338,23 +478,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 +515,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 extends NameValuePair> 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..cab576f
--- /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], "UTF-8");
+ if (EXPIRES.equalsIgnoreCase(name)) {
+ String value = URLDecoder.decode(kv[1], "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..92a5882 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.
@@ -32,6 +34,8 @@ public class Token implements Serializable {
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..0db32d4 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.2.1, 10/01/12
*/
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..e4d3dae 100644
--- a/src/test/java/com/soundcloud/api/ApiWrapperTest.java
+++ b/src/test/java/com/soundcloud/api/ApiWrapperTest.java
@@ -1,12 +1,16 @@
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.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.*;
+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.verify;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
import com.soundcloud.api.fakehttp.FakeHttpLayer;
import com.soundcloud.api.fakehttp.RequestMatcher;
@@ -18,10 +22,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;
@@ -42,7 +49,8 @@ public class ApiWrapperTest {
final FakeHttpLayer layer = new FakeHttpLayer();
@Before
public void setup() {
- api = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null, Env.SANDBOX) {
+ api = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null) {
+ private static final long serialVersionUID = 12345; // silence warnings
@Override
protected RequestDirector getRequestDirector(HttpRequestExecutor requestExec,
ClientConnectionManager conman,
@@ -183,8 +191,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 +200,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 +212,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,10 +257,10 @@ 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
@@ -303,7 +311,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 +319,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 +327,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 +335,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,7 +344,7 @@ public void shouldGenerateUrlForWebHost() throws Exception {
public void shouldGenerateURIForLoginAuthCode() throws Exception {
assertThat(
api.authorizationCodeUrl().toString(),
- equalTo("https://sandbox-soundcloud.com/connect"+
+ equalTo("https://soundcloud.com/connect"+
"?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code")
);
}
@@ -346,7 +354,7 @@ 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"+
+ equalTo("https://soundcloud.com/connect/via/facebook"+
"?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code")
);
}
@@ -355,7 +363,7 @@ 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"+
+ equalTo("https://soundcloud.com/connect/via/facebook"+
"?redirect_uri=redirect%3A%2F%2Fme&client_id=invalid&response_type=code&scope=non-expiring")
);
}
@@ -402,7 +410,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 +425,149 @@ 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 testAddDefaultParameters() throws Exception {
+ layer.addHttpResponseRule("/foo", "Hi");
+ layer.addHttpResponseRule("/foo?t=1", "Hi t1");
+ layer.addHttpResponseRule("/foo?t=2", "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..b120869 100644
--- a/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java
+++ b/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java
@@ -1,12 +1,17 @@
package com.soundcloud.api;
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertTrue;
import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.CoreMatchers.containsString;
-import static org.junit.Assert.assertThat;
+import static org.junit.Assert.*;
+import org.apache.http.Header;
import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
import org.json.JSONObject;
import org.junit.Before;
+import org.junit.Ignore;
import org.junit.Test;
import java.io.File;
@@ -14,15 +19,26 @@
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
+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 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 +53,17 @@ 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("client_id", CLIENT_ID, "order", "hotness"));
+ assertEquals(200, response.getStatusLine().getStatusCode());
}
@Test
@@ -59,6 +76,9 @@ public void shouldUploadASimpleAudioFile() throws Exception {
int status = resp.getStatusLine().getStatusCode();
assertThat(status, is(201));
+
+ Header location = resp.getFirstHeader("Location");
+ assertNotNull(location);
}
@Test
@@ -73,19 +93,26 @@ 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
+ String fbToken = "fbToken";
+ api.extensionGrantType(CloudAPI.FACEBOOK_GRANT_TYPE +fbToken);
+ }
+
@Test
public void shouldReturn401WithInvalidToken() throws Exception {
login();
@@ -94,6 +121,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 +150,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("http://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,7 +241,7 @@ 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");
}
@@ -170,8 +279,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 +353,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 +365,9 @@ 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;
+ }
}
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/RequestTest.java b/src/test/java/com/soundcloud/api/RequestTest.java
index be719ef..06e1275 100644
--- a/src/test/java/com/soundcloud/api/RequestTest.java
+++ b/src/test/java/com/soundcloud/api/RequestTest.java
@@ -16,11 +16,13 @@
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.mime.MultipartEntity;
import org.hamcrest.CoreMatchers;
+import org.junit.Ignore;
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 +46,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 +72,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 +137,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 +165,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 +199,21 @@ 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 shouldCreateMultipartRequestWhenFilesAreAddedWithByteArray() throws Exception {
HttpPost request = Request.to("/foo")
@@ -198,7 +283,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 +299,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 +329,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 +348,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..1650aa1
--- /dev/null
+++ b/src/test/java/com/soundcloud/api/StreamTest.java
@@ -0,0 +1,100 @@
+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 static org.junit.Assert.assertTrue;
+
+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..49a9b07 100644
--- a/src/test/java/com/soundcloud/api/TokenTest.java
+++ b/src/test/java/com/soundcloud/api/TokenTest.java
@@ -81,4 +81,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