package com.soundcloud.api; import org.apache.http.ConnectionReuseStrategy; import org.apache.http.Header; 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; 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.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; import org.apache.http.conn.scheme.SchemeRegistry; import org.apache.http.conn.scheme.SocketFactory; import org.apache.http.conn.ssl.SSLSocketFactory; import org.apache.http.impl.client.DefaultHttpClient; 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; import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpProcessor; import org.apache.http.protocol.HttpRequestExecutor; import org.json.JSONException; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.net.URI; import java.util.Arrays; /** * Interface with SoundCloud, using OAuth2. * This API wrapper makes a few assumptions - namely: * <ul> * <li>Server responses are always requested in JSON format</li> * <li>Refresh-token handling is transparent to the client application (you should not need to * call <code>refreshToken()</code> manually) * </li> * <li>You use <a href="http://hc.apache.org/httpcomponents-client-ga/">Apache HttpClient</a></li> * </ul> * Example usage: * <code> * <pre> * ApiWrapper wrapper = new ApiWrapper("client_id", "client_secret", null, null, Env.SANDBOX); * wrapper.login("login", "password"); * HttpResponse response = wrapper.get(Request.to("/tracks")); * </pre> * </code> * @see <a href="http://developers.soundcloud.com/docs">Using the SoundCloud API</a> */ public class ApiWrapper implements CloudAPI, Serializable { public static final String DEFAULT_CONTENT_TYPE = "application/json"; 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; private final URI mRedirectUri; 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; /* spam response code from API */ public static final int STATUS_CODE_SPAM_WARNING = 429; /** debug request details to stderr */ public boolean debugRequests; /** * Constructs a new ApiWrapper instance. * * @param clientId the application client id * @param clientSecret the application client secret * @param redirectUri the registered redirect url, or null * @param token an valid token, or null if not known * @see <a href="http://developers.soundcloud.com/docs#authentication">API authentication documentation</a> */ public ApiWrapper(String clientId, String clientSecret, URI redirectUri, Token token) { mClientId = clientId; mClientSecret = clientSecret; mRedirectUri = redirectUri; mToken = token == null ? EMPTY_TOKEN : token; } @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 = 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, String... scopes) throws IOException { if (code == null) { throw new IllegalArgumentException("code is null"); } 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(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 (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)); return mToken; } @Override public Token invalidateToken() { if (mToken != null) { Token alternative = listener == null ? null : listener.onTokenInvalid(mToken); mToken.invalidate(); if (alternative != null) { mToken = alternative; return mToken; } else { return null; } } else { return null; } } @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 > 1) req.add(SCOPE, options[1]); if (options.length > 2) req.add(DISPLAY, options[2]); if (options.length > 3) req.add(STATE, options[3]); return getURI(req, false, true); } /** * Constructs URI path for a given resource. * @param request the resource to access * @param api api or web * @param secure whether to use SSL or not * @return a valid URI */ public URI getURI(Request request, boolean api, boolean secure) { 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; } /** * Request an OAuth2 token from SoundCloud * @param request the token request * @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 = safeExecute(env.sslResourceHost, request.buildRequest(HttpPost.class)); final int status = response.getStatusLine().getStatusCode(); 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) { error = ignored.getMessage(); } catch (JSONException ignored) { error = ignored.getMessage(); } throw status == HttpStatus.SC_UNAUTHORIZED ? new InvalidTokenException(status, error) : new ApiResponseException(response, error); } /** * @return the default HttpParams * @see <a href="http://developer.android.com/reference/android/net/http/AndroidHttpClient.html#newInstance(java.lang.String, android.content.Context)"> * android.net.http.AndroidHttpClient#newInstance(String, Context)</a> */ protected HttpParams getParams() { 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; } /** * @return SocketFactory used by the underlying HttpClient */ protected SocketFactory getSocketFactory() { return PlainSocketFactory.getSocketFactory(); } /** * @return SSL SocketFactory used by the underlying HttpClient */ protected SSLSocketFactory getSSLSocketFactory() { return SSLSocketFactory.getSocketFactory(); } /** @return The HttpClient instance used to make the calls */ public HttpClient getHttpClient() { if (httpClient == null) { final HttpParams params = getParams(); HttpClientParams.setRedirecting(params, false); HttpProtocolParams.setUserAgent(params, getUserAgent()); final SchemeRegistry registry = new SchemeRegistry(); registry.register(new Scheme("http", getSocketFactory(), 80)); final SSLSocketFactory sslFactory = getSSLSocketFactory(); registry.register(new Scheme("https", sslFactory, 443)); httpClient = new DefaultHttpClient( new ThreadSafeClientConnManager(params, registry), params) { { setKeepAliveStrategy(new ConnectionKeepAliveStrategy() { @Override public long getKeepAliveDuration(HttpResponse httpResponse, HttpContext httpContext) { return KEEPALIVE_TIMEOUT; } }); getCredentialsProvider().setCredentials( new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, CloudAPI.REALM, OAUTH_SCHEME), 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() { HttpContext ctxt = super.createHttpContext(); ctxt.setAttribute(ClientContext.AUTH_SCHEME_PREF, Arrays.asList(CloudAPI.OAUTH_SCHEME, "digest", "basic")); return ctxt; } @Override protected BasicHttpProcessor createHttpProcessor() { BasicHttpProcessor processor = super.createHttpProcessor(); processor.addInterceptor(new OAuth2HttpRequestInterceptor()); return processor; } // for testability only @Override protected RequestDirector createClientRequestDirector(HttpRequestExecutor requestExec, ClientConnectionManager conman, ConnectionReuseStrategy reustrat, ConnectionKeepAliveStrategy kastrat, HttpRoutePlanner rouplan, HttpProcessor httpProcessor, HttpRequestRetryHandler retryHandler, RedirectHandler redirectHandler, AuthenticationHandler targetAuthHandler, AuthenticationHandler proxyAuthHandler, UserTokenHandler stateHandler, HttpParams params) { return getRequestDirector(requestExec, conman, reustrat, kastrat, rouplan, httpProcessor, retryHandler, redirectHandler, targetAuthHandler, proxyAuthHandler, stateHandler, params); } }; } return httpClient; } @Override public long resolve(String url) throws IOException { HttpResponse resp = get(Request.to(Endpoints.RESOLVE).with("url", url)); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_MOVED_TEMPORARILY) { Header location = resp.getFirstHeader("Location"); if (location != null && location.getValue() != null) { final String path = URI.create(location.getValue()).getPath(); if (path != null && path.contains("/")) { try { final String id = path.substring(path.lastIndexOf("/") + 1); return Integer.parseInt(id); } catch (NumberFormatException e) { throw new ResolverException(e, resp); } } else { throw new ResolverException("Invalid string:"+path, 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 = safeExecute(null, addHeaders(Request.to(url).buildRequest(HttpHead.class))); 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 = safeExecute(null, addHeaders(Request.to(url).buildRequest(HttpGet.class))); if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_MOVED_TEMPORARILY) { return stream.withNewStreamUrl(resp.getFirstHeader("Location").getValue()); } else { throw new ResolverException("Unexpected response code", resp); } } else { throw new ResolverException("Unexpected response code", resp); } } else { throw new ResolverException("Location header not set", resp); } } else { throw new ResolverException("Unexpected response code", resp); } } @Override public HttpResponse head(Request request) throws IOException { return execute(request, HttpHead.class); } @Override public HttpResponse get(Request request) throws IOException { return execute(request, HttpGet.class); } @Override public HttpResponse put(Request request) throws IOException { return execute(request, HttpPut.class); } @Override public HttpResponse post(Request request) throws IOException { return execute(request, HttpPost.class); } @Override public HttpResponse delete(Request request) throws IOException { return execute(request, HttpDelete.class); } @Override public Token getToken() { return mToken; } @Override public void setToken(Token newToken) { mToken = newToken == null ? EMPTY_TOKEN : newToken; } @Override public synchronized void setTokenListener(TokenListener listener) { this.listener = listener; } /** * Execute an API request, adds the necessary headers. * @param request the HTTP request * @return the HTTP response * @throws java.io.IOException network error etc. */ 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 { 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 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; } } /** * serialize the wrapper to a File * @param f target * @throws java.io.IOException IO problems */ public void toFile(File f) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(f)); oos.writeObject(this); oos.close(); } public String getDefaultContentType() { return (mDefaultContentType == null) ? DEFAULT_CONTENT_TYPE : mDefaultContentType; } 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<scopes.length; i++) { scope.append(scopes[i]); if (i < scopes.length-1) scope.append(" "); } request.add(SCOPE, scope.toString()); } return request; } /** * Read wrapper from a file * @param f the file * @return the wrapper * @throws IOException IO problems * @throws ClassNotFoundException class not found */ public static ApiWrapper fromFile(File f) throws IOException, ClassNotFoundException { ObjectInputStream ois = new ObjectInputStream(new FileInputStream(f)); try { return (ApiWrapper) ois.readObject(); } finally { ois.close(); } } /** Creates an OAuth2 header for the given token */ public static Header createOAuthHeader(Token token) { return new BasicHeader(AUTH.WWW_AUTH_RESP, "OAuth " + (token == null || !token.valid() ? "invalidated" : token.access)); } /** Adds an OAuth2 header to a given request */ protected HttpUriRequest addAuthHeader(HttpUriRequest request) { if (!request.containsHeader(AUTH.WWW_AUTH_RESP)) { if (mToken != EMPTY_TOKEN) { request.addHeader(createOAuthHeader(mToken)); } } return request; } /** Forces JSON */ protected HttpUriRequest addAcceptHeader(HttpUriRequest request) { if (!request.containsHeader("Accept")) { request.addHeader("Accept", getDefaultContentType()); } return request; } /** Adds all required headers to the request */ protected HttpUriRequest addHeaders(HttpUriRequest req) { return addAcceptHeader(addAuthHeader(addEncodingHeader(req))); } protected HttpUriRequest addEncodingHeader(HttpUriRequest req) { if (getDefaultAcceptEncoding() != null) { req.addHeader("Accept-Encoding", getDefaultAcceptEncoding()); } return req; } /** This method mainly exists to make the wrapper more testable. oh, apache's insanity. */ protected RequestDirector getRequestDirector(HttpRequestExecutor requestExec, ClientConnectionManager conman, ConnectionReuseStrategy reustrat, ConnectionKeepAliveStrategy kastrat, HttpRoutePlanner rouplan, HttpProcessor httpProcessor, HttpRequestRetryHandler retryHandler, RedirectHandler redirectHandler, AuthenticationHandler targetAuthHandler, AuthenticationHandler proxyAuthHandler, UserTokenHandler stateHandler, HttpParams params ) { return new DefaultRequestDirector(requestExec, conman, reustrat, kastrat, rouplan, httpProcessor, retryHandler, redirectHandler, targetAuthHandler, proxyAuthHandler, stateHandler, params); } private static final ThreadLocal<Request> defaultParams = new ThreadLocal<Request>() { @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(); } }