diff --git a/CHANGES.md b/CHANGES.md
index 87e5ba3..b543538 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,21 @@
+## 1.1.1-SNAPSHOT tbd
+
+ * Respect system proxy settings
+
+## 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
diff --git a/README.md b/README.md
index 6c40070..8be212a 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,58 @@
# 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, Env.LIVE);
+```
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 +72,22 @@ the authentication process. If you don't want to use them you can request
non-expiring tokens by specifying the scope "non-expiring" when exchanging the
tokens:
- Token token = wrapper.login("username", "password", Token.SCOPE_NON_EXPIRING);
+```java
+Token token = wrapper.login("username", "password", Token.SCOPE_NON_EXPIRING);
+```
The resulting token will be valid until revoked manually.
For the `authorization_code` grant type you need to request the scope like so:
- URI uri = wrapper.authorizationCodeUrl(Endpoints.CONNECT, Token.SCOPE_NON_EXPIRING);
- // open uri in browser / WebView etc.
+```java
+URI uri = wrapper.authorizationCodeUrl(Endpoints.CONNECT, Token.SCOPE_NON_EXPIRING);
+// open uri in browser / WebView etc.
+```
+
+## Login via Facebook
+
+Please see [FacebookConnect][] for an example of this login flow.
## Requirements
@@ -99,6 +124,7 @@ The wrapper ships with a few examples in `src/examples/java`:
* [PutResource][] performs a PUT request to update a resource and prints the
JSON result
* [UploadFile][] uploads a file to SoundCloud.
+ * [FacebookConnect][] obtain an access token via Facebook login
You can use gradle tasks to compile and run these examples with one command.
If you don't want to use gradle there is also a precompiled jar with all
@@ -228,7 +254,7 @@ See LICENSE for details.
[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.1.0/com/soundcloud/api/package-summary.html
[soundcloudapi-java]: http://code.google.com/p/soundcloudapi-java/
[soundcloudapi-java-annouce]: http://blog.soundcloud.com/2010/01/08/java-wrapper/
[CreateWrapper]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/CreateWrapper.java
@@ -236,11 +262,15 @@ See LICENSE for details.
[PutResource]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/PutResource.java
[PostResource]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/PostResource.java
[UploadFile]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/UploadFile.java
+[FacebookConnect]: https://github.com/soundcloud/java-api-wrapper/blob/master/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java
[SoundCloud Android]: https://market.android.com/details?id=com.soundcloud.android
[register-app]: http://soundcloud.com/you/apps/new
[Apache Maven]: http://maven.apache.org/
-[jar-all]: https://github.com/downloads/soundcloud/java-api-wrapper/java-api-wrapper-1.0.1-all.jar
+[jar-all]: https://github.com/downloads/soundcloud/java-api-wrapper/java-api-wrapper-1.1.0-all.jar
[downloads]: https://github.com/soundcloud/java-api-wrapper/archives/master
[snapshots]: https://oss.sonatype.org/content/repositories/snapshots/com/soundcloud/java-api-wrapper/
[releases]: https://oss.sonatype.org/content/repositories/releases/com/soundcloud/java-api-wrapper/
[maven-central]: http://repo1.maven.org/maven2/com/soundcloud/java-api-wrapper/
+[Android Sharing Kit]: https://github.com/soundcloud/android-intent-sharing/wiki
+[android-token-sharing]: https://github.com/soundcloud/android-token-sharing
+[intent]: http://developer.android.com/reference/android/content/Intent.html
diff --git a/RELEASE.md b/RELEASE.md
index 8955bf9..5621018 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -24,7 +24,7 @@ Regenerate pom.xml
This doesn't work properly at the moment - use `gradle uploadArchive` and copy
`build/poms/pom-default.xml` to `pom.xml`.
-Releasing to Sonatype OSS (staging)
+## Releasing to Sonatype OSS (staging)
(make sure there are no uncommitted changes in the repo)
$ mvn -Dresume=false release:prepare # tag repo, bump pom.xml (needs SNAPSHOT tag in pom)
@@ -39,7 +39,7 @@ staging repository which can be used for testing. Once everything works you
select "Release" to actually release it to the [release repo][]. The release
repo is synced with [Maven Central][].
-Releasing snapshot versions
+## Releasing snapshot versions
This is for releasing developer version of the package and can be done anytime,
just make sure `build.gradle` version contains a `-SNAPSHOT` suffix, then run:
diff --git a/build.gradle b/build.gradle
index 5c425a1..9e4010f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,7 +4,7 @@ apply plugin: 'eclipse'
apply plugin: 'maven'
sourceCompatibility = 1.6
-version = '1.0.2-SNAPSHOT'
+version = '1.1.1-SNAPSHOT'
group = 'com.soundcloud'
repositories { mavenCentral() }
@@ -75,6 +75,7 @@ def example(name, mainClass, arguments) {
}
}
example('createWrapper', 'CreateWrapper', { [client_id, client_secret, login, password, env] })
+example('facebookLogin', 'FacebookLogin', { [] })
example('getResource', 'GetResource', { resource })
example('putResource', 'PutResource', { [resource, content, contentType] })
example('postResource', 'PostResource', { [resource, content, contentType] })
@@ -116,3 +117,7 @@ def getAuth(repo_id) {
}
[:]
}
+
+task printDebug << {
+ println httpDebug.collect { "-D"+it.key+"="+it.value }.join(' ')
+}
diff --git a/pom.xml b/pom.xml
index 308424a..dbfb593 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0
com.soundcloud
java-api-wrapper
- 1.0.2-SNAPSHOT
+ 1.1.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/FacebookConnect.java b/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java
new file mode 100644
index 0000000..5fc4525
--- /dev/null
+++ b/src/examples/java/com/soundcloud/api/examples/FacebookConnect.java
@@ -0,0 +1,147 @@
+package com.soundcloud.api.examples;
+
+import com.soundcloud.api.ApiWrapper;
+import com.soundcloud.api.CloudAPI;
+import com.soundcloud.api.Endpoints;
+import com.soundcloud.api.Env;
+import com.soundcloud.api.Token;
+
+import java.awt.*;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.PrintStream;
+import java.io.UnsupportedEncodingException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.util.HashMap;
+import java.util.Map;
+
+
+/**
+ * This example shows how to get an API token by logging in w/ Facebook.
+ */
+public class FacebookConnect {
+ // http://sandbox-soundcloud.com/you/apps/java-api-wrapper-test-app
+ // user: api-testing
+
+ //https://skitch.com/jberkel/ggb18/edit-java-api-wrapper-test-app-on-soundcloud-create-record-and-share-your-sounds-for-free
+ static final String CLIENT_ID = "yH1Jv2C5fhIbZfGTpKtujQ";
+ static final String CLIENT_SECRET = "C6o8jc517b6PIw0RKtcfQsbOK3BjGpxWFLg977UiguY";
+ static final URI REDIRECT_URI = URI.create("http://localhost:8000");
+
+ public static void main(String[] args) throws IOException {
+ final ApiWrapper wrapper = new ApiWrapper(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ REDIRECT_URI,
+ null /* token */,
+ Env.SANDBOX);
+
+ // generate the URL the user needs to open in the browser
+ URI url = wrapper.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT, Token.SCOPE_NON_EXPIRING);
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ Desktop.getDesktop().browse(url);
+ } else {
+ System.err.println("open \"" + url + "\" in a browser");
+ }
+
+ // start a web server to get the redirect information
+ startServer(wrapper);
+
+ // note: on Android you would use a WebView instead an override 'shouldOverrideUrlLoading':
+
+ /*
+ WebView webView = (WebView) findViewById(R.id.webview);
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(final WebView view, String url) {
+ if (url.startsWith(REDIRECT_URI)) {
+ Uri result = Uri.parse(url);
+ String error = result.getQueryParameter("error");
+ String code = result.getQueryParameter("code");
+ }
+ }
+ });
+
+ webView.loadUrl(wrapper.authorizationCodeUrl(Endpoints.FACEBOOK_CONNECT, ...);
+ */
+ }
+
+ static void startServer(ApiWrapper wrapper) throws IOException {
+ ServerSocket socket = new ServerSocket(8000);
+ for (;;) {
+ final Socket client = socket.accept();
+ try {
+ InputStream is = client.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192);
+ PrintStream out = new PrintStream(client.getOutputStream());
+ String line = reader.readLine();
+ if (line == null) throw new IOException("client closed connection without a request.");
+
+ final String[] request = line.split(" ", 3);
+ if (request.length != 3) throw new IOException("invalid request:" + line);
+ if (!"GET".equals(request[0])) throw new IOException("invalid method:" + line);
+
+ Map params = parseParameters(request[1]);
+
+ if (params.containsKey("error")) {
+ // error logging in, redirect mismatch etc.
+
+ reply(out, "Error: " + params.get("error_description"));
+ } else if (params.containsKey("code")) {
+ // we got a code back, try to exchange it for a token
+ try {
+ Token token = wrapper.authorizationCode(params.get("code"));
+ reply(out, "Got token: " + token);
+ } catch (CloudAPI.InvalidTokenException e) {
+ reply(out, e.getMessage());
+ }
+ } else {
+ // unexpected redirect
+ reply(out, "invalid request:"+request[1]);
+ }
+ break;
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ try {
+ client.close();
+ } catch (IOException ignored) {
+ }
+ }
+ }
+ }
+
+ static void reply(PrintStream out, String text) {
+ System.out.println(text);
+
+ out.println("HTTP/1.1 200 OK");
+ out.println("Content-Type: text/plain");
+ out.println();
+ out.println(text);
+ out.flush();
+ }
+
+ static Map parseParameters(String request) {
+ Map params = new HashMap();
+ if (request.contains("?")) {
+ String query = request.substring(Math.min(request.length(), request.indexOf("?") + 1),
+ request.length());
+ for (String s : query.split("&")) {
+ String[] kv = s.split("=", 2);
+ if (kv != null && kv.length == 2) {
+ try {
+ params.put(URLDecoder.decode(kv[0], "UTF-8"),
+ URLDecoder.decode(kv[1], "UTF-8"));
+ } catch (UnsupportedEncodingException ignored) {
+ }
+ }
+ }
+ }
+ return params;
+ }
+}
diff --git a/src/examples/java/com/soundcloud/api/examples/GetResource.java b/src/examples/java/com/soundcloud/api/examples/GetResource.java
index 353dfc5..056c2f0 100644
--- a/src/examples/java/com/soundcloud/api/examples/GetResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/GetResource.java
@@ -36,7 +36,7 @@ public static void main(String[] args) throws Exception {
try {
HttpResponse resp = wrapper.get(resource);
if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
- System.out.println("\n" + formatJSON(Http.getString(resp)));
+ System.out.println("\n" + Http.formatJSON(Http.getString(resp)));
} else {
System.err.println("Invalid status received: " + resp.getStatusLine());
}
@@ -47,15 +47,5 @@ public static void main(String[] args) throws Exception {
}
}
- static String formatJSON(String s) {
- try {
- return new JSONObject(s).toString(4);
- } catch (JSONException e) {
- try {
- return new JSONArray(s).toString(4);
- } catch (JSONException e2) {
- return s;
- }
- }
- }
+
}
diff --git a/src/examples/java/com/soundcloud/api/examples/PostResource.java b/src/examples/java/com/soundcloud/api/examples/PostResource.java
index d1ccaca..a5bb634 100644
--- a/src/examples/java/com/soundcloud/api/examples/PostResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/PostResource.java
@@ -34,7 +34,7 @@ public static void main(String[] args) throws Exception {
try {
HttpResponse resp = wrapper.post(resource);
if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_CREATED) {
- System.out.println("\n" + formatJSON(Http.getString(resp)));
+ System.out.println("\n" + Http.formatJSON(Http.getString(resp)));
} else {
System.err.println("Invalid status received: " + resp.getStatusLine());
}
@@ -44,17 +44,4 @@ public static void main(String[] args) throws Exception {
}
}
}
-
-
- static String formatJSON(String s) {
- try {
- return new JSONObject(s).toString(4);
- } catch (JSONException e) {
- try {
- return new JSONArray(s).toString(4);
- } catch (JSONException e2) {
- return s;
- }
- }
- }
}
diff --git a/src/examples/java/com/soundcloud/api/examples/PutResource.java b/src/examples/java/com/soundcloud/api/examples/PutResource.java
index 2059536..65a2163 100644
--- a/src/examples/java/com/soundcloud/api/examples/PutResource.java
+++ b/src/examples/java/com/soundcloud/api/examples/PutResource.java
@@ -40,7 +40,7 @@ public static void main(String[] args) throws Exception {
try {
HttpResponse resp = wrapper.put(resource);
if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
- System.out.println("\n" + formatJSON(Http.getString(resp)));
+ System.out.println("\n" + Http.formatJSON(Http.getString(resp)));
} else {
System.err.println("Invalid status received: " + resp.getStatusLine());
}
@@ -50,16 +50,4 @@ public static void main(String[] args) throws Exception {
}
}
}
-
- static String formatJSON(String s) {
- try {
- return new JSONObject(s).toString(4);
- } catch (JSONException e) {
- try {
- return new JSONArray(s).toString(4);
- } catch (JSONException e2) {
- return s;
- }
- }
- }
}
diff --git a/src/main/java/com/soundcloud/api/ApiWrapper.java b/src/main/java/com/soundcloud/api/ApiWrapper.java
index 8eef9c4..0d0e8dc 100644
--- a/src/main/java/com/soundcloud/api/ApiWrapper.java
+++ b/src/main/java/com/soundcloud/api/ApiWrapper.java
@@ -2,6 +2,7 @@
import org.apache.http.ConnectionReuseStrategy;
import org.apache.http.Header;
+import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
@@ -15,6 +16,7 @@
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;
@@ -22,6 +24,12 @@
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 +40,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;
@@ -83,11 +93,18 @@ public class ApiWrapper implements CloudAPI, Serializable {
transient private HttpClient httpClient;
transient private TokenListener listener;
- /** debug request details to stderr */
- public boolean debugRequests;
+ private String mDefaultContentType;
+ public static final int BUFFER_SIZE = 8192;
+ /** Connection timeout */
+ public static final int TIMEOUT = 20 * 1000;
+ /** Keepalive timeout */
+ public static final long KEEPALIVE_TIMEOUT = 20 * 1000;
+ /* maximum number of connections allowed */
+ public static final int MAX_TOTAL_CONNECTIONS = 10;
- private String mDefaultContentType;
+ /** debug request details to stderr */
+ public boolean debugRequests;
/**
* Constructs a new ApiWrapper instance.
@@ -111,63 +128,66 @@ public ApiWrapper(String clientId,
this.env = env;
}
- @Override public Token login(String username, String password) throws IOException {
- return login(username, password, null);
- }
-
- @Override public Token login(String username, String password, String scope) throws IOException {
+ @Override public Token login(String username, String password, String... scopes) throws IOException {
if (username == null || password == null) {
throw new IllegalArgumentException("username or password is null");
}
- final Request request = Request.to(Endpoints.TOKEN).with(
+ final Request request = addScope(Request.to(Endpoints.TOKEN).with(
"grant_type", PASSWORD,
"client_id", mClientId,
"client_secret", mClientSecret,
"username", username,
- "password", password);
- if (scope != null) request.add("scope", scope);
+ "password", password), scopes);
mToken = requestToken(request);
return mToken;
}
- @Override public Token authorizationCode(String code) throws IOException {
- return authorizationCode(code, null);
- }
- @Override public Token authorizationCode(String code, String scope) throws IOException {
+
+ @Override public Token authorizationCode(String code, String... scopes) throws IOException {
if (code == null) {
throw new IllegalArgumentException("username or password is null");
}
- final Request request = Request.to(Endpoints.TOKEN).with(
+ final Request request = addScope(Request.to(Endpoints.TOKEN).with(
"grant_type", AUTHORIZATION_CODE,
"client_id", mClientId,
"client_secret", mClientSecret,
"redirect_uri", mRedirectUri,
- "code", code);
- if (scope != null) request.add("scope", scope);
-
+ "code", code), scopes);
mToken = requestToken(request);
return mToken;
}
- @Override public Token clientCredentials() throws IOException {
- return clientCredentials(Token.SCOPE_SIGNUP);
- }
- @Override public Token clientCredentials(String scope) throws IOException {
- final Request req = Request.to(Endpoints.TOKEN).with(
+ @Override public Token clientCredentials(String... scopes) throws IOException {
+ final Request req = addScope(Request.to(Endpoints.TOKEN).with(
"grant_type", CLIENT_CREDENTIALS,
"client_id", mClientId,
- "client_secret", mClientSecret);
- if (scope != null) req.add("scope", scope);
+ "client_secret", mClientSecret), scopes);
+
final Token token = requestToken(req);
- if (scope != null && !token.scoped(scope)) {
- throw new InvalidTokenException(-1, "Could not obtain requested scope '"+scope+"' (got: '" +
+ if (scopes != null) {
+ for (String scope : scopes) {
+ if (!token.scoped(scope)) {
+ throw new InvalidTokenException(-1, "Could not obtain requested scope '"+scope+"' (got: '" +
token.scope + "')");
+ }
+ }
}
return token;
}
+ @Override
+ public Token extensionGrantType(String grantType, String... scopes) throws IOException {
+ final Request req = addScope(Request.to(Endpoints.TOKEN).with(
+ "grant_type", grantType,
+ "client_id", mClientId,
+ "client_secret", mClientSecret), scopes);
+
+ mToken = requestToken(req);
+ return mToken;
+ }
+
@Override public Token refreshToken() throws IOException {
if (mToken == null || mToken.refresh == null) throw new IllegalStateException("no refresh token available");
mToken = requestToken(Request.to(Endpoints.TOKEN).with(
@@ -223,6 +243,15 @@ public URI getURI(Request request, boolean api, boolean secure) {
return URI.create((api ? env.getResourceHost(secure) : env.getAuthResourceHost(secure)).toURI()).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;
+ }
+
/**
* Request an OAuth2 token from SoundCloud
* @param request the token request
@@ -251,13 +280,61 @@ protected Token requestToken(Request request) throws IOException {
}
}
-
-
/**
- * @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) {
+ getHttpClient().getParams().setParameter(
+ ConnRoutePNames.DEFAULT_PROXY,
+ proxy == null ? null : new HttpHost(proxy.getHost(), proxy.getPort(), proxy.getScheme()));
+ }
+
+
+ public boolean isProxySet() {
+ return getHttpClient().getParams().getParameter(ConnRoutePNames.DEFAULT_PROXY) != null;
}
/**
@@ -274,14 +351,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() {
@@ -305,7 +374,7 @@ public HttpClient getHttpClient() {
setKeepAliveStrategy(new ConnectionKeepAliveStrategy() {
@Override
public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) {
- return 20 * 1000; // milliseconds
+ return KEEPALIVE_TIMEOUT;
}
});
@@ -360,13 +429,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 = getHttpClient().execute(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 {
@@ -433,6 +545,18 @@ public void setDefaultContentType(String contentType) {
mDefaultContentType = contentType;
}
+ /* package */ static Request addScope(Request request, String[] scopes) {
+ if (scopes != null && scopes.length > 0) {
+ StringBuilder scope = new StringBuilder();
+ for (int i=0; i
- * Resource Owner Password Credentials.
- *
- * @param username SoundCloud username
- * @param password SoundCloud password
- * @return a valid token
- * @throws com.soundcloud.api.CloudAPI.InvalidTokenException invalid token
- * @throws IOException In case of network/server errors
- */
- Token login(String username, String password) throws IOException;
/**
* Request a token using
@@ -43,13 +38,13 @@ public interface CloudAPI {
*
* @param username SoundCloud username
* @param password SoundCloud password
- * @param scope the desired scope
+ * @param scopes the desired scope(s), or empty for default scope
* @return a valid token
* @throws com.soundcloud.api.CloudAPI.InvalidTokenException
* invalid token
* @throws IOException In case of network/server errors
*/
- Token login(String username, String password, String scope) throws IOException;
+ Token login(String username, String password, String... scopes) throws IOException;
/**
@@ -57,23 +52,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 +69,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 +116,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 +151,33 @@ public interface CloudAPI {
*/
HttpResponse delete(Request request) throws IOException;
+ /**
+ * @return the used httpclient
+ */
+ HttpClient getHttpClient();
+
/**
* 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 +203,7 @@ public interface CloudAPI {
*
* @param options auth endpoint to use (leave out for default), requested scope (leave out for default)
* @return the URI to open in a browser/WebView etc.
- * @see CloudAPI#authorizationCode(String)
+ * @see CloudAPI#authorizationCode(String, String...)
*/
URI authorizationCodeUrl(String... options);
@@ -241,4 +247,28 @@ public InvalidTokenException(int code, String status) {
super("HTTP error:" + code + " (" + status + ")");
}
}
+
+ class ResolverException extends IOException {
+ private static final long serialVersionUID = -2990651725862868387L;
+
+ public final HttpResponse response;
+ public ResolverException(String s, HttpResponse resp) {
+ super(s);
+ this.response = resp;
+ }
+
+ public ResolverException(Throwable throwable, HttpResponse response) {
+ super(throwable);
+ this.response = response;
+ }
+
+ public int getStatusCode() {
+ return response.getStatusLine().getStatusCode();
+ }
+
+ @Override
+ public String getMessage() {
+ return super.getMessage()+" "+(response != null ? response.getStatusLine() : "");
+ }
+ }
}
diff --git a/src/main/java/com/soundcloud/api/Endpoints.java b/src/main/java/com/soundcloud/api/Endpoints.java
index 2a02ae6..cad051f 100644
--- a/src/main/java/com/soundcloud/api/Endpoints.java
+++ b/src/main/java/com/soundcloud/api/Endpoints.java
@@ -2,7 +2,7 @@
/**
* Various SoundCloud API endpoints.
- * See the API docs for the most
+ * See the API docs for the most
* recent listing.
*/
@SuppressWarnings({"UnusedDeclaration"})
@@ -13,6 +13,8 @@ public interface Endpoints {
String TRACK_DETAILS = "/tracks/%d";
String TRACK_COMMENTS = "/tracks/%d/comments";
String TRACK_FAVORITERS = "/tracks/%d/favoriters";
+ String TRACK_PLAYS = "/tracks/%d/plays";
+ String TRACK_PERMISSIONS = "/tracks/%d/permissions";
String USERS = "/users";
String USER_DETAILS = "/users/%d";
@@ -37,6 +39,7 @@ public interface Endpoints {
String MY_FOLLOWING = "/me/followings/%d";
String MY_CONFIRMATION = "/me/email-confirmations";
String MY_FRIENDS = "/me/connections/friends";
+ String MY_DEVICES = "/me/devices";
String SUGGESTED_USERS = "/users/suggested";
@@ -45,4 +48,6 @@ public interface Endpoints {
String SEND_PASSWORD = "/passwords/reset-instructions";
String CONNECT = "/connect";
String FACEBOOK_CONNECT = "/connect/via/facebook";
+
+
}
diff --git a/src/main/java/com/soundcloud/api/Env.java b/src/main/java/com/soundcloud/api/Env.java
index 4494af1..25d5b37 100644
--- a/src/main/java/com/soundcloud/api/Env.java
+++ b/src/main/java/com/soundcloud/api/Env.java
@@ -22,6 +22,7 @@ public enum Env {
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");
}
@@ -33,4 +34,10 @@ public HttpHost getResourceHost(boolean secure) {
public HttpHost getAuthResourceHost(boolean secure) {
return secure ? sslAuthResourceHost : authResourceHost;
}
+
+ public boolean isApiHost(HttpHost host) {
+ return ("http".equals(host.getSchemeName()) ||
+ "https".equals(host.getSchemeName())) &&
+ resourceHost.getHostName().equals(host.getHostName());
+ }
}
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..69b1c63 100644
--- a/src/main/java/com/soundcloud/api/Request.java
+++ b/src/main/java/com/soundcloud/api/Request.java
@@ -6,12 +6,15 @@
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.james.mime4j.util.CharsetUtil;
import java.io.File;
import java.io.IOException;
@@ -20,6 +23,7 @@
import java.net.URI;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
@@ -43,13 +47,15 @@
*/
public class Request implements Iterable {
private List mParams = new ArrayList(); // XXX should probably be lazy
- private Map mFiles;
- private Map mByteBuffers;
+ private Map mFiles;
+
private HttpEntity mEntity;
private Token mToken;
private String mResource;
private TransferProgressListener listener;
+ private String mIfNoneMatch;
+ private long[] mRange;
/** Empty request */
public Request() {}
@@ -58,7 +64,15 @@ 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("&")) {
@@ -77,6 +91,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 +108,8 @@ public Request(Request request) {
mToken = request.mToken;
listener = request.listener;
mParams = new ArrayList(request.mParams);
- if (request.mFiles != null) mFiles = new HashMap(request.mFiles);
+ mIfNoneMatch = request.mIfNoneMatch;
+ if (request.mFiles != null) mFiles = new HashMap(request.mFiles);
}
/**
@@ -128,6 +152,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
@@ -170,8 +204,19 @@ public String toUrl() {
* @return this
*/
public Request withFile(String name, File file) {
- if (mFiles == null) mFiles = new HashMap();
- if (file != null) mFiles.put(name, file);
+ return file != null ? withFile(name, file, file.getName()) : this;
+ }
+
+ /**
+ * Registers a file to be uploaded with a POST or PUT request.
+ * @param name the name of the parameter
+ * @param file the file to be submitted
+ * @param fileName the name of the uploaded file (over rides file parameter)
+ * @return this
+ */
+ public Request withFile(String name, File file, String fileName) {
+ if (mFiles == null) mFiles = new HashMap();
+ if (file != null) mFiles.put(name, new Attachment(file, fileName));
return this;
}
@@ -179,9 +224,10 @@ public Request withFile(String name, File file) {
* Registers binary data to be uploaded with a POST or PUT request.
* @param name the name of the parameter
* @param data the data to be submitted
+ * @deprecated use {@link #withFile(String, byte[], String)} instead
* @return this
*/
- public Request withFile(String name, byte[] data) {
+ @Deprecated public Request withFile(String name, byte[] data) {
return withFile(name, ByteBuffer.wrap(data));
}
@@ -189,11 +235,34 @@ public Request withFile(String name, byte[] data) {
* Registers binary data to be uploaded with a POST or PUT request.
* @param name the name of the parameter
* @param data the data to be submitted
+ * @param fileName the name of the uploaded file
* @return this
*/
- public Request withFile(String name, ByteBuffer data) {
- if (mByteBuffers == null) mByteBuffers = new HashMap();
- if (data != null) mByteBuffers.put(name, data);
+ public Request withFile(String name, byte[] data, String fileName) {
+ return withFile(name, ByteBuffer.wrap(data), fileName);
+ }
+
+ /**
+ * Registers binary data to be uploaded with a POST or PUT request.
+ * @param name the name of the parameter
+ * @param data the data to be submitted
+ * @return this
+ * @deprecated use {@link #withFile(String, java.nio.ByteBuffer), String} instead
+ */
+ @Deprecated public Request withFile(String name, ByteBuffer data) {
+ return withFile(name, data, "upload");
+ }
+
+ /**
+ * Registers binary data to be uploaded with a POST or PUT request.
+ * @param name the name of the parameter
+ * @param data the data to be submitted
+ * @param fileName the name of the uploaded file
+ * @return this
+ */
+ public Request withFile(String name, ByteBuffer data, String fileName) {
+ if (mFiles == null) mFiles = new HashMap();
+ if (data != null) mFiles.put(name, new Attachment(data, fileName));
return this;
}
@@ -225,6 +294,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 +309,25 @@ public Request setProgressListener(TransferProgressListener listener) {
}
public boolean isMultipart() {
- return (mFiles != null && !mFiles.isEmpty()) ||
- (mByteBuffers != null && !mByteBuffers.isEmpty());
+ return mFiles != null && !mFiles.isEmpty();
+ }
+
+ /**
+ * Conditional GET
+ * @param etag the etag to check for (If-None-Match: etag)
+ * @return this
+ */
+ public Request ifNoneMatch(String etag) {
+ mIfNoneMatch = etag;
+ return this;
+ }
+
+ public Map getParams() {
+ Map params = new HashMap();
+ for (NameValuePair p : mParams) {
+ params.put(p.getName(), p.getValue());
+ }
+ return params;
}
/**
@@ -253,23 +344,21 @@ public T buildRequest(Class method) {
HttpEntityEnclosingRequestBase enclosingRequest =
(HttpEntityEnclosingRequestBase) request;
+ final Charset charSet = CharsetUtil.getCharset("UTF-8");
if (isMultipart()) {
- MultipartEntity multiPart = new MultipartEntity();
+ MultipartEntity multiPart = new MultipartEntity(
+ HttpMultipartMode.BROWSER_COMPATIBLE, // XXX change this to STRICT once rack on server is upgraded
+ null,
+ charSet);
if (mFiles != null) {
- for (Map.Entry e : mFiles.entrySet()) {
- multiPart.addPart(e.getKey(), new FileBody(e.getValue()));
- }
- }
-
- if (mByteBuffers != null) {
- for (Map.Entry e : mByteBuffers.entrySet()) {
- multiPart.addPart(e.getKey(), new ByteBufferBody(e.getValue()));
+ for (Map.Entry e : mFiles.entrySet()) {
+ multiPart.addPart(e.getKey(), e.getValue().toContentBody());
}
}
for (NameValuePair pair : mParams) {
- multiPart.addPart(pair.getName(), new StringBodyNoHeaders(pair.getValue()));
+ multiPart.addPart(pair.getName(), new StringBody(pair.getValue(), "text/plain", charSet));
}
enclosingRequest.setEntity(listener == null ? multiPart :
@@ -284,7 +373,14 @@ public T buildRequest(Class method) {
}
request.setURI(URI.create(mResource));
- } else { // just plain GET/DELETE/...
+ } else { // just plain GET/HEAD/DELETE/...
+ if (mRange != null) {
+ request.addHeader("Range", formatRange(mRange));
+ }
+
+ if (mIfNoneMatch != null) {
+ request.addHeader("If-None-Match", mIfNoneMatch);
+ }
request.setURI(URI.create(toUrl()));
}
@@ -302,6 +398,20 @@ public T buildRequest(Class method) {
}
}
+ 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 +419,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 +436,6 @@ public String toString() {
return listener;
}
-
/**
* Updates about the amount of bytes already transferred.
*/
@@ -338,23 +447,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 +484,56 @@ 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");
+ }
+ }
+ }
}
diff --git a/src/main/java/com/soundcloud/api/Stream.java b/src/main/java/com/soundcloud/api/Stream.java
new file mode 100644
index 0000000..cab576f
--- /dev/null
+++ b/src/main/java/com/soundcloud/api/Stream.java
@@ -0,0 +1,137 @@
+package com.soundcloud.api;
+
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.impl.cookie.DateUtils;
+
+import java.io.Serializable;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Locale;
+
+/**
+ * Class representing a remote audio stream object, including metadata.
+ */
+public class Stream implements Serializable {
+ public static final String AMZ_BITRATE = "x-amz-meta-bitrate";
+ public static final String AMZ_DURATION = "x-amz-meta-duration";
+ static final String EXPIRES = "Expires";
+
+ public static final long DEFAULT_URL_LIFETIME = 60 * 1000; // expire after 1 minute
+ public static final DateFormat DATE_FORMAT = new SimpleDateFormat(DateUtils.PATTERN_RFC1123, Locale.US);
+
+ private static final long serialVersionUID = -2054788615389851590L;
+
+ public final String url;
+ public final String streamUrl;
+ public final String eTag;
+ public final long contentLength;
+ public final long lastModified;
+ public final int duration;
+ public final int bitRate;
+ public final long expires;
+
+ public Stream(String url, String streamUrl, HttpResponse resp) throws CloudAPI.ResolverException {
+ this(url, streamUrl, getHeaderValue(resp, "ETag"),
+ getLongHeader(resp, "Content-Length"),
+ getDateHeader(resp, "Last-Modified"),
+ getIntHeader(resp, AMZ_DURATION),
+ getIntHeader(resp, AMZ_BITRATE),
+ getExpires(streamUrl));
+ }
+
+ public Stream(String url, String streamUrl, String eTag, long contentLength, long lastModified,
+ int duration, int bitRate, long expires) {
+ this.url = url;
+ this.streamUrl = streamUrl;
+ this.eTag = eTag;
+ this.contentLength = contentLength;
+ this.lastModified = lastModified;
+ this.duration = duration;
+ this.bitRate = bitRate;
+ this.expires = expires;
+ }
+
+ public Request streamUrl() {
+ return Request.to(streamUrl);
+ }
+
+ public Request url() {
+ return Request.to(url);
+ }
+
+ public Stream withNewStreamUrl(String newStreamUrl) {
+ return new Stream(url, newStreamUrl, eTag, contentLength, lastModified, duration, bitRate, getExpires(newStreamUrl));
+ }
+
+ public static long getLongHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException {
+ try {
+ return Long.parseLong(getHeaderValue(resp, name));
+ } catch (NumberFormatException e) {
+ throw new CloudAPI.ResolverException(e, resp);
+ }
+ }
+
+ public static int getIntHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException {
+ try {
+ return Integer.parseInt(getHeaderValue(resp, name));
+ } catch (NumberFormatException e) {
+ throw new CloudAPI.ResolverException(e, resp);
+ }
+ }
+
+ public static long getDateHeader(HttpResponse resp, String name) throws CloudAPI.ResolverException {
+ try {
+ return DATE_FORMAT.parse(getHeaderValue(resp, name)).getTime();
+ } catch (ParseException e) {
+ throw new CloudAPI.ResolverException(e, resp);
+ }
+ }
+
+ private static String getHeaderValue(HttpResponse resp, String name) throws CloudAPI.ResolverException {
+ Header h = resp.getFirstHeader(name);
+ if (h != null && h.getValue() != null) {
+ return h.getValue();
+ } else {
+ throw new CloudAPI.ResolverException("header " + name + " not set", resp);
+ }
+ }
+
+ private static long getExpires(String resource) {
+ String query = resource.substring(Math.min(resource.length(), resource.indexOf("?")+1),
+ resource.length());
+ for (String s : query.split("&")) {
+ String[] kv = s.split("=", 2);
+ if (kv != null && kv.length == 2) {
+ try {
+ String name = URLDecoder.decode(kv[0], "UTF-8");
+ if (EXPIRES.equalsIgnoreCase(name)) {
+ String value = URLDecoder.decode(kv[1], "UTF-8");
+ try {
+ return Long.parseLong(value) * 1000L;
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ } catch (UnsupportedEncodingException ignored) {}
+ }
+ }
+ return System.currentTimeMillis() + DEFAULT_URL_LIFETIME;
+ }
+
+ @Override
+ public String toString() {
+ return "Stream{" +
+ "url='" + url + '\'' +
+ ", streamUrl='" + streamUrl + '\'' +
+ ", eTag='" + eTag + '\'' +
+ ", contentLength=" + contentLength +
+ ", lastModified=" + lastModified +
+ ", duration=" + duration +
+ ", bitRate=" + bitRate +
+ ", expires=" + expires +
+ '}';
+ }
+}
diff --git a/src/main/java/com/soundcloud/api/Token.java b/src/main/java/com/soundcloud/api/Token.java
index 83139b6..f61dab8 100644
--- a/src/main/java/com/soundcloud/api/Token.java
+++ b/src/main/java/com/soundcloud/api/Token.java
@@ -6,7 +6,9 @@
import java.io.IOException;
import java.io.Serializable;
import java.util.Date;
-import java.util.Set;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
/**
* Represents an OAuth2 access/refresh token pair.
@@ -32,6 +34,8 @@ public class Token implements Serializable {
public String access, refresh, scope;
public long expiresIn;
+ public final Map customParameters = new HashMap();
+
/**
* Constructs a new token with the given sub-tokens
* @param access A token used by the client to make authenticated requests on behalf of the resource owner.
@@ -55,13 +59,21 @@ 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(); ) {
+ String key = it.next().toString();
+ if (ACCESS_TOKEN.equals(key)) {
+ access = json.getString(ACCESS_TOKEN);
+ } else if (REFRESH_TOKEN.equals(key)) {
+ // refresh token won't be set if we don't expire
+ refresh = json.getString(REFRESH_TOKEN);
+ expiresIn = System.currentTimeMillis() + json.getLong(EXPIRES_IN) * 1000;
+ } else if (SCOPE.equals(key)) {
+ scope = json.getString(SCOPE);
+ } else {
+ // custom parameter
+ customParameters.put(key, json.getString(key));
+ }
}
- scope = json.getString(SCOPE);
} catch (JSONException e) {
throw new IOException(e.getMessage());
}
@@ -101,6 +113,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..f567440 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.1.0, 09/11/11
*/
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..340d9fc 100644
--- a/src/test/java/com/soundcloud/api/ApiWrapperTest.java
+++ b/src/test/java/com/soundcloud/api/ApiWrapperTest.java
@@ -43,6 +43,7 @@ public class ApiWrapperTest {
@Before
public void setup() {
api = new ApiWrapper("invalid", "invalid", URI.create("redirect://me"), null, Env.SANDBOX) {
+ private static final long serialVersionUID = 12345; // silence warnings
@Override
protected RequestDirector getRequestDirector(HttpRequestExecutor requestExec,
ClientConnectionManager conman,
@@ -249,10 +250,10 @@ public boolean matches(HttpRequest request) {
assertThat(api.resolve("http://soundcloud.com/crazybob"), is(1000L));
}
- @Test
+ @Test(expected = CloudAPI.ResolverException.class)
public void resolveShouldReturnNegativeOneWhenInvalid() throws Exception {
layer.addPendingHttpResponse(404, "Not found");
- assertThat(api.resolve("http://soundcloud.com/nonexisto"), equalTo(-1L));
+ api.resolve("http://soundcloud.com/nonexisto");
}
@Test
@@ -417,4 +418,13 @@ 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"));
+ }
}
diff --git a/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java b/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java
index 5c14bb6..d9e9eac 100644
--- a/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java
+++ b/src/test/java/com/soundcloud/api/CloudAPIIntegrationTest.java
@@ -1,12 +1,16 @@
package com.soundcloud.api;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertTrue;
import static org.hamcrest.CoreMatchers.*;
-import static org.hamcrest.CoreMatchers.containsString;
-import static org.junit.Assert.assertThat;
+import static org.junit.Assert.*;
+import org.apache.http.Header;
import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpGet;
import org.json.JSONObject;
import org.junit.Before;
+import org.junit.Ignore;
import org.junit.Test;
import java.io.File;
@@ -14,10 +18,14 @@
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.net.UnknownHostException;
import java.nio.ByteBuffer;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
public class CloudAPIIntegrationTest implements Params.Track, Endpoints {
// http://sandbox-soundcloud.com/you/apps/java-api-wrapper-test-app
+ // user: api-testing
static final String CLIENT_ID = "yH1Jv2C5fhIbZfGTpKtujQ";
static final String CLIENT_SECRET = "C6o8jc517b6PIw0RKtcfQsbOK3BjGpxWFLg977UiguY";
@@ -41,12 +49,8 @@ public void setUp() throws Exception {
Env.SANDBOX);
}
- private Token login() throws IOException {
- return login(null);
- }
-
- private Token login(String scope) throws IOException {
- return api.login("api-testing", "testing", scope);
+ private Token login(String... scopes) throws IOException {
+ return api.login("api-testing", "testing", scopes);
}
@Test
@@ -59,6 +63,9 @@ public void shouldUploadASimpleAudioFile() throws Exception {
int status = resp.getStatusLine().getStatusCode();
assertThat(status, is(201));
+
+ Header location = resp.getFirstHeader("Location");
+ assertNotNull(location);
}
@Test
@@ -73,19 +80,26 @@ public void shouldUploadASimpleAudioFileBytes() throws Exception {
HttpResponse resp = api.post(Request.to(TRACKS).with(
TITLE, "Hello Android",
POST_TO_EMPTY, "")
- .withFile(ASSET_DATA, bb));
+ .withFile(ASSET_DATA, bb, "hello.aiff"));
int status = resp.getStatusLine().getStatusCode();
assertThat(status, is(201));
}
- @Test(expected = IOException.class)
+ @Test(expected = IOException.class) @Ignore
public void shouldNotGetASignupTokenWhenInofficialApp() throws Exception {
login();
api.clientCredentials();
}
+ @Test(expected = CloudAPI.InvalidTokenException.class)
+ public void shouldGetATokenUsingExtensionGrantTypes() throws Exception {
+ // TODO
+ String fbToken = "fbToken";
+ Token token = api.extensionGrantType(CloudAPI.FACEBOOK_GRANT_TYPE +fbToken);
+ }
+
@Test
public void shouldReturn401WithInvalidToken() throws Exception {
login();
@@ -94,6 +108,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();
@@ -117,6 +139,74 @@ public void shouldResolveUrls() throws Exception {
long id = api.resolve("http://sandbox-soundcloud.com/api-testing");
assertThat(id, is(1862213L));
+
+ try {
+ id = api.resolve("http://sandbox-soundcloud.com/i-do-no-exist");
+ fail("expected resolver exception, got: "+id);
+ } catch (CloudAPI.ResolverException e) {
+ // expected
+ assertThat(e.getStatusCode(), is(404));
+ }
+ }
+
+ @Test
+ public void shouldResolveStreamUrls() throws Exception {
+ login();
+ Stream resolved = api.resolveStreamUrl("https://api.sandbox-soundcloud.com/tracks/2112881/stream", false);
+
+ assertThat(resolved.url, equalTo("https://api.sandbox-soundcloud.com/tracks/2112881/stream"));
+ assertThat(resolved.streamUrl, containsString("http://ak-media.soundcloud.com/"));
+
+ assertTrue("expire should be in the future", resolved.expires > System.currentTimeMillis());
+ assertThat(resolved.eTag, equalTo("\"a1782cf9976c2bc26988929e956def26\""));
+ }
+
+ @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));
+
+ int count = Http.getJSON(api.get(Request.to("/tracks/2100832"))).getInt("playback_count");
+ api.resolveStreamUrl("https://api.sandbox-soundcloud.com/tracks/2100832/stream", false);
+ int count2 = Http.getJSON(api.get(Request.to("/tracks/2100832"))).getInt("playback_count");
+
+ assertTrue(String.format("%d !> %d", count2, count), count2 > count);
+
+ // resolve again, this time skipping count
+ api.resolveStreamUrl("https://api.sandbox-soundcloud.com/tracks/2100832/stream", true);
+
+ int count3 = Http.getJSON(api.get(Request.to("/tracks/2100832"))).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.sandbox-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();
+
+ Stream resolved = api.resolveStreamUrl("https://api.sandbox-soundcloud.com/tracks/2112881/stream", false);
+ assertThat(resolved.contentLength, is(19643L));
+
+ 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/19643"));
+ assertThat(resp.getEntity().getContentLength(), is(51L));
}
@Test
@@ -170,8 +260,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 +334,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 {
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..bc47378
--- /dev/null
+++ b/src/test/java/com/soundcloud/api/EnvTest.java
@@ -0,0 +1,17 @@
+package com.soundcloud.api;
+
+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")));
+ }
+}
diff --git a/src/test/java/com/soundcloud/api/RequestTest.java b/src/test/java/com/soundcloud/api/RequestTest.java
index be719ef..9ddc763 100644
--- a/src/test/java/com/soundcloud/api/RequestTest.java
+++ b/src/test/java/com/soundcloud/api/RequestTest.java
@@ -21,6 +21,7 @@
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;
@@ -58,6 +59,15 @@ public void shouldSupportWith() throws Exception {
assertThat(p.queryString(), equalTo("foo=100&baz=22.3&baz=66"));
}
+ @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 +109,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 +137,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 +171,21 @@ public void shouldDetectMultipartRequests() throws Exception {
.withFile("foo", "foo".getBytes()).isMultipart());
}
+ @Test
+ public void shouldUploadByteDataWithFilename() throws Exception {
+ HttpPost request = Request.to("/foo")
+ .with("key", "value")
+ .withFile("testing", "foo".getBytes(), "music.mp3")
+ .buildRequest(HttpPost.class);
+
+ assertTrue(request.getEntity() instanceof MultipartEntity);
+
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ request.getEntity().writeTo(os);
+ String encoded = os.toString();
+ assertThat(encoded, containsString("filename=\"music.mp3\""));
+ }
+
@Test
public void shouldCreateMultipartRequestWhenFilesAreAddedWithByteArray() throws Exception {
HttpPost request = Request.to("/foo")
@@ -198,7 +255,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 +271,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 +301,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 +320,31 @@ public void shouldNotModifyOriginal() {
assertThat(copy.getToken(), not(equalTo(orig.getToken())));
assertThat(orig.getListener(),equalTo(copy.getListener()));
}
+
+ @Test
+ public void testFormatRange() throws Exception {
+ assertThat(Request.formatRange(1, 1000), equalTo("bytes=1-1000"));
+ assertThat(Request.formatRange(1), equalTo("bytes=1-"));
+ assertThat(Request.formatRange(), equalTo("bytes=0-"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testFormatRangeInvalidArgument() throws Exception {
+ Request.formatRange(100,200,300);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testFormatRangeInvalidArgument2() throws Exception {
+ Request.formatRange(1000, 1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testFormatRangeInvalidArgument3() throws Exception {
+ Request.formatRange(-1);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testFormatRangeInvalidArgument4() throws Exception {
+ Request.formatRange(-1, 200);
+ }
}
diff --git a/src/test/java/com/soundcloud/api/StreamTest.java b/src/test/java/com/soundcloud/api/StreamTest.java
new file mode 100644
index 0000000..1650aa1
--- /dev/null
+++ b/src/test/java/com/soundcloud/api/StreamTest.java
@@ -0,0 +1,100 @@
+package com.soundcloud.api;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpResponse;
+import org.apache.http.impl.DefaultHttpResponseFactory;
+import org.apache.http.impl.io.AbstractSessionInputBuffer;
+import org.apache.http.impl.io.HttpResponseParser;
+import org.apache.http.message.BasicLineParser;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.HttpParams;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+public class StreamTest {
+ @Test
+ public void testParsing() throws Exception {
+ Stream s = new Stream(
+ "http://api.soundcloud.com",
+ "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582",
+ parse("s3-headers.txt"));
+
+ assertThat("etag", s.eTag, equalTo("\"81c4a04a366ab681ea068b2fa06d10a3\""));
+ assertThat("bitrate", s.bitRate, is(128));
+ assertThat("duration", s.duration, is(18998));
+ assertThat("content-length", s.contentLength, is(303855L));
+ assertThat("last-modified", s.lastModified, is(1319536883000L));
+ assertThat("expires", s.expires, is(1319537336000L));
+ }
+
+ @Test
+ public void testWithNewStreamUrl() throws Exception {
+ Stream s1 = new Stream(
+ "http://api.soundcloud.com",
+ "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582",
+ parse("s3-headers.txt"));
+
+ Stream s2 = s1.withNewStreamUrl("http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537337&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582");
+
+ assertThat(s1.eTag, equalTo(s2.eTag));
+ assertThat(s1.bitRate, equalTo(s2.bitRate));
+ assertThat(s1.duration, equalTo(s2.duration));
+ assertThat(s1.contentLength, equalTo(s2.contentLength));
+ assertThat(s1.lastModified, equalTo(s2.lastModified));
+
+ assertThat(s1.streamUrl, not(equalTo(s2.streamUrl)));
+ assertThat(s1.expires, not(is(s2.expires)));
+ }
+
+ @Test
+ public void shouldBeSerializable() throws Exception {
+ Stream s1 = new Stream(
+ "http://api.soundcloud.com",
+ "http://ak-media.soundcloud.com/Nbhil06qjDaP.128.mp3?AWSAccessKeyId=AKIAJBHW5FB4ERKUQUOQ&Expires=1319537336&Signature=tzk9EAm3bcjpMJ0cukHPdVx2ml4%3D&__gda__=1319537336_9354e7fea41da4f7a87e78db9a4ed582",
+ parse("s3-headers.txt"));
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(bos);
+
+ oos.writeObject(s1);
+ oos.close();
+
+ ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
+ Stream s2 = (Stream) ois.readObject();
+
+ assertThat(s1.eTag, equalTo(s2.eTag));
+ assertThat(s1.bitRate, equalTo(s2.bitRate));
+ assertThat(s1.duration, equalTo(s2.duration));
+ assertThat(s1.contentLength, equalTo(s2.contentLength));
+ assertThat(s1.lastModified, equalTo(s2.lastModified));
+ assertThat(s1.streamUrl, equalTo(s2.streamUrl));
+ assertThat(s1.expires, equalTo(s2.expires));
+ }
+
+ private HttpResponse parse(final String resource) throws IOException, HttpException {
+ final HttpParams params = new BasicHttpParams();
+ HttpResponseParser parser = new HttpResponseParser(new AbstractSessionInputBuffer() {
+ {
+ init(getClass().getResourceAsStream(resource), 8192, params);
+ }
+
+ @Override
+ public boolean isDataAvailable(int timeout) throws IOException {
+ return true;
+ }
+ }, new BasicLineParser(), new DefaultHttpResponseFactory(), params);
+
+ return (HttpResponse) parser.parse();
+ }
+}
diff --git a/src/test/java/com/soundcloud/api/TokenTest.java b/src/test/java/com/soundcloud/api/TokenTest.java
index 1c2a34e..f31b82f 100644
--- a/src/test/java/com/soundcloud/api/TokenTest.java
+++ b/src/test/java/com/soundcloud/api/TokenTest.java
@@ -81,4 +81,21 @@ public void shouldParseJsonResponse() throws Exception {
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