/*
* Copyright (c) 2010, the Last.fm Java Project and Committers
* All rights reserved.
*
* Redistribution and use of this software in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above
* copyright notice, this list of conditions and the
* following disclaimer.
*
* - Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the
* following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
* PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package de.umass.lastfm;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeSet;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import de.umass.lastfm.Result.Status;
import de.umass.lastfm.cache.Cache;
import de.umass.lastfm.cache.FileSystemCache;
import static de.umass.util.StringUtilities.encode;
import static de.umass.util.StringUtilities.map;
import static de.umass.util.StringUtilities.md5;
/**
* The <code>Caller</code> class handles the low-level communication between the client and last.fm.<br/>
* Direct usage of this class should be unnecessary since all method calls are available via the methods in
* the <code>Artist</code>, <code>Album</code>, <code>User</code>, etc. classes.
* If specialized calls which are not covered by the Java API are necessary this class may be used directly.<br/>
* Supports the setting of a custom {@link Proxy} and a custom <code>User-Agent</code> HTTP header.
*
* @author Janni Kovacs
*/
public class Caller {
private static final String PARAM_API_KEY = "api_key";
private static final String PARAM_METHOD = "method";
private static final String DEFAULT_API_ROOT = "http://ws.audioscrobbler.com/2.0/";
private static final Caller instance = new Caller();
private String apiRootUrl = DEFAULT_API_ROOT;
private Proxy proxy;
private String userAgent = "tst";
private boolean debugMode = false;
private Cache cache;
private Result lastResult;
private Caller() {
cache = new FileSystemCache();
}
/**
* Returns the single instance of the <code>Caller</code> class.
*
* @return a <code>Caller</code>
*/
public static Caller getInstance() {
return instance;
}
/**
* Set api root url.
*
* @param apiRootUrl new api root url
*/
public void setApiRootUrl(String apiRootUrl) {
this.apiRootUrl = apiRootUrl;
}
/**
* Sets a {@link Proxy} instance this Caller will use for all upcoming HTTP requests. May be <code>null</code>.
*
* @param proxy A <code>Proxy</code> or <code>null</code>.
*/
public void setProxy(Proxy proxy) {
this.proxy = proxy;
}
/**
* Sets a User Agent this Caller will use for all upcoming HTTP requests. For testing purposes use "tst".
* If you distribute your application use an identifiable User-Agent.
*
* @param userAgent a User-Agent string
*/
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
/**
* Sets the <code>debugMode</code> property. If <code>debugMode</code> is <code>true</code> all call() methods
* will print debug information and error messages on failure to stdout and stderr respectively.<br/>
* Default is <code>false</code>. Set this to <code>true</code> while in development and for troubleshooting.
*
* @param debugMode <code>true</code> to enable debug mode
*/
public void setDebugMode(boolean debugMode) {
this.debugMode = debugMode;
}
/**
* Returns the current {@link Cache}.
*
* @return the Cache
*/
public Cache getCache() {
return cache;
}
/**
* Sets the active {@link Cache}. May be <code>null</code> to disable caching.
*
* @param cache the new Cache or <code>null</code>
*/
public void setCache(Cache cache) {
this.cache = cache;
}
public Result call(String method, String apiKey, String... params) throws CallException {
return call(method, apiKey, map(params));
}
public Result call(String method, String apiKey, Map<String, String> params) throws CallException {
return call(method, apiKey, params, null);
}
public Result call(String method, Session session, String... params) {
return call(method, session.getApiKey(), map(params), session);
}
public Result call(String method, Session session, Map<String, String> params) {
return call(method, session.getApiKey(), params, session);
}
/**
* Performs the web-service call. If the <code>session</code> parameter is <code>non-null</code> then an
* authenticated call is made. If it's <code>null</code> then an unauthenticated call is made.<br/>
* The <code>apiKey</code> parameter is always required, even when a valid session is passed to this method.
*
* @param method The method to call
* @param apiKey A Last.fm API key
* @param params Parameters
* @param session A Session instance or <code>null</code>
* @return the result of the operation
*/
private Result call(String method, String apiKey, Map<String, String> params, Session session) {
params = new HashMap<String, String>(params); // create new Map in case params is an immutable Map
InputStream inputStream = null;
// try to load from cache
String cacheEntryName = Cache.createCacheEntryName(method, params);
if (session == null && cache != null) {
if (cache.contains(cacheEntryName) && !cache.isExpired(cacheEntryName)) {
inputStream = cache.load(cacheEntryName);
}
}
if (inputStream == null) {
params.put(PARAM_API_KEY, apiKey);
if (session != null) {
params.put("sk", session.getKey());
String sig = Authenticator.createSignature(method, params, session.getSecret());
params.put("api_sig", sig);
}
try {
HttpURLConnection urlConnection = openConnection(apiRootUrl);
urlConnection.setRequestMethod("POST");
urlConnection.setDoOutput(true);
OutputStream outputStream = urlConnection.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
String post = buildParameterQueue(method, params);
if (debugMode) {
System.out.println("body: " + post);
}
writer.write(post);
writer.close();
int responseCode = urlConnection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_FORBIDDEN || responseCode == HttpURLConnection.HTTP_BAD_REQUEST) {
inputStream = urlConnection.getErrorStream();
} else if (responseCode != HttpURLConnection.HTTP_OK) {
this.lastResult = Result.createHttpErrorResult(responseCode, urlConnection.getResponseMessage());
return lastResult;
} else {
inputStream = urlConnection.getInputStream();
if (cache != null) {
long expires = urlConnection.getHeaderFieldDate("Expires", -1);
if (expires == -1) {
long expirationTime = cache.getExpirationPolicy().getExpirationTime(method, params);
if (expirationTime > 0) {
if (expirationTime == Long.MAX_VALUE) {
expires = Long.MAX_VALUE;
} else {
expires = System.currentTimeMillis() + expirationTime;
}
}
}
if (expires != -1) {
cache.store(cacheEntryName, inputStream, expires); // if data wasn't cached store new result
inputStream = cache.load(cacheEntryName);
if (inputStream == null)
throw new CallException("caching failed.");
}
}
}
} catch (IOException e) {
throw new CallException(e);
}
}
try {
Document document = newDocumentBuilder()
.parse(new InputSource(new InputStreamReader(inputStream, "UTF-8")));
Element root = document.getDocumentElement(); // lfm element
String statusString = root.getAttribute("status");
Status status = "ok".equals(statusString) ? Status.OK : Status.FAILED;
if (status == Status.FAILED) {
if (cache != null)
cache.remove(cacheEntryName); // if request was failed remove from cache
Element error = (Element) root.getElementsByTagName("error").item(0);
int errorCode = Integer.parseInt(error.getAttribute("code"));
String message = error.getTextContent();
if (debugMode)
System.err.printf("Failed. Code: %d, Error: %s%n", errorCode, message);
this.lastResult = Result.createRestErrorResult(errorCode, message);
} else {
this.lastResult = Result.createOkResult(document);
}
return this.lastResult;
} catch (IOException e) {
throw new CallException(e);
} catch (SAXException e) {
throw new CallException(e);
}
}
/**
* Returns the {@link Result} of the last operation, or <code>null</code> if no call operation has been
* performed yet.
*
* @return the last Result object
*/
public Result getLastResult() {
return lastResult;
}
private DocumentBuilder newDocumentBuilder() {
try {
DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
return builderFactory.newDocumentBuilder();
} catch (ParserConfigurationException e) {
// better never happens
throw new RuntimeException(e);
}
}
/**
* Creates a new {@link HttpURLConnection}, sets the proxy, if available, and sets the User-Agent property.
*
* @param url URL to connect to
* @return a new connection.
* @throws IOException if an I/O exception occurs.
*/
public HttpURLConnection openConnection(String url) throws IOException {
if (isDebugMode())
System.out.println("open: " + url);
URL u = new URL(url);
HttpURLConnection urlConnection;
if (proxy != null)
urlConnection = (HttpURLConnection) u.openConnection(proxy);
else
urlConnection = (HttpURLConnection) u.openConnection();
urlConnection.setRequestProperty("User-Agent", userAgent);
return urlConnection;
}
private String buildParameterQueue(String method, Map<String, String> params, String... strings) {
StringBuilder builder = new StringBuilder(100);
builder.append("method=");
builder.append(method);
builder.append('&');
for (Iterator<Entry<String, String>> it = params.entrySet().iterator(); it.hasNext();) {
Entry<String, String> entry = it.next();
builder.append(entry.getKey());
builder.append('=');
builder.append(encode(entry.getValue()));
if (it.hasNext() || strings.length > 0)
builder.append('&');
}
int count = 0;
for (String string : strings) {
builder.append(count % 2 == 0 ? string : encode(string));
count++;
if (count != strings.length) {
if (count % 2 == 0) {
builder.append('&');
} else {
builder.append('=');
}
}
}
return builder.toString();
}
private String createSignature(Map<String, String> params, String secret) {
Set<String> sorted = new TreeSet<String>(params.keySet());
StringBuilder builder = new StringBuilder(50);
for (String s : sorted) {
builder.append(s);
builder.append(encode(params.get(s)));
}
builder.append(secret);
return md5(builder.toString());
}
public Proxy getProxy() {
return proxy;
}
public String getUserAgent() {
return userAgent;
}
public boolean isDebugMode() {
return debugMode;
}
}