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