001/* 002 * Copyright (c) 2012, the Last.fm Java Project and Committers 003 * All rights reserved. 004 * 005 * Redistribution and use of this software in source and binary forms, with or without modification, are 006 * permitted provided that the following conditions are met: 007 * 008 * - Redistributions of source code must retain the above 009 * copyright notice, this list of conditions and the 010 * following disclaimer. 011 * 012 * - Redistributions in binary form must reproduce the above 013 * copyright notice, this list of conditions and the 014 * following disclaimer in the documentation and/or other 015 * materials provided with the distribution. 016 * 017 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED 018 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 019 * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 020 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 021 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 022 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 023 * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 024 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 025 */ 026 027package de.umass.lastfm; 028 029import javax.xml.parsers.DocumentBuilder; 030import javax.xml.parsers.DocumentBuilderFactory; 031import javax.xml.parsers.ParserConfigurationException; 032import java.io.*; 033import java.net.HttpURLConnection; 034import java.net.Proxy; 035import java.net.URL; 036import java.util.*; 037import java.util.Map.Entry; 038import java.util.logging.Level; 039import java.util.logging.Logger; 040 041import de.umass.lastfm.Result.Status; 042import de.umass.lastfm.cache.Cache; 043import de.umass.lastfm.cache.FileSystemCache; 044import org.w3c.dom.Document; 045import org.w3c.dom.Element; 046import org.xml.sax.InputSource; 047import org.xml.sax.SAXException; 048 049import static de.umass.util.StringUtilities.*; 050 051/** 052 * The <code>Caller</code> class handles the low-level communication between the client and last.fm.<br/> 053 * Direct usage of this class should be unnecessary since all method calls are available via the methods in 054 * the <code>Artist</code>, <code>Album</code>, <code>User</code>, etc. classes. 055 * If specialized calls which are not covered by the Java API are necessary this class may be used directly.<br/> 056 * Supports the setting of a custom {@link Proxy} and a custom <code>User-Agent</code> HTTP header. 057 * 058 * @author Janni Kovacs 059 */ 060public class Caller { 061 062 public static boolean tomDebug_ = false; 063 private static final String PARAM_API_KEY = "api_key"; 064 private static final String PARAM_METHOD = "method"; 065 066 private static final String DEFAULT_API_ROOT = "https://ws.audioscrobbler.com/2.0/"; 067 private static final Caller instance = new Caller(); 068 069 private final Logger log = Logger.getLogger("de.umass.lastfm.Caller"); 070 071 private String apiRootUrl = DEFAULT_API_ROOT; 072 073 private Proxy proxy; 074 private String userAgent = "tst"; 075 076 private boolean debugMode = false; 077 078 private Cache cache; 079 private Result lastResult; 080 081 private Caller() { 082 cache = new FileSystemCache(); 083 } 084 085 /** 086 * Returns the single instance of the <code>Caller</code> class. 087 * 088 * @return a <code>Caller</code> 089 */ 090 public static Caller getInstance() { 091 return instance; 092 } 093 094 /** 095 * Set api root url. 096 * 097 * @param apiRootUrl new api root url 098 */ 099 public void setApiRootUrl(String apiRootUrl) { 100 this.apiRootUrl = apiRootUrl; 101 } 102 103 /** 104 * Sets a {@link Proxy} instance this Caller will use for all upcoming HTTP requests. May be <code>null</code>. 105 * 106 * @param proxy A <code>Proxy</code> or <code>null</code>. 107 */ 108 public void setProxy(Proxy proxy) { 109 this.proxy = proxy; 110 } 111 112 public Proxy getProxy() { 113 return proxy; 114 } 115 116 /** 117 * Sets a User Agent this Caller will use for all upcoming HTTP requests. For testing purposes use "tst". 118 * If you distribute your application use an identifiable User-Agent. 119 * 120 * @param userAgent a User-Agent string 121 */ 122 public void setUserAgent(String userAgent) { 123 this.userAgent = userAgent; 124 } 125 126 public String getUserAgent() { 127 return userAgent; 128 } 129 130 /** 131 * Returns the current {@link Cache}. 132 * 133 * @return the Cache 134 */ 135 public Cache getCache() { 136 return cache; 137 } 138 139 /** 140 * Sets the active {@link Cache}. May be <code>null</code> to disable caching. 141 * 142 * @param cache the new Cache or <code>null</code> 143 */ 144 public void setCache(Cache cache) { 145 this.cache = cache; 146 } 147 148 /** 149 * Sets the <code>debugMode</code> property. If <code>debugMode</code> is <code>true</code> all call() methods 150 * will print debug information and error messages on failure to stdout and stderr respectively.<br/> 151 * Default is <code>false</code>. Set this to <code>true</code> while in development and for troubleshooting. 152 * 153 * @see de.umass.lastfm.Caller#getLogger() 154 * @param debugMode <code>true</code> to enable debug mode 155 * @deprecated Use the Logger instead 156 */ 157 public void setDebugMode(boolean debugMode) { 158 this.debugMode = debugMode; 159 log.setLevel(debugMode ? Level.ALL : Level.OFF); 160 } 161 162 /** 163 * @see de.umass.lastfm.Caller#getLogger() 164 * @return the debugMode property 165 * @deprecated Use the Logger instead 166 */ 167 public boolean isDebugMode() { 168 return debugMode; 169 } 170 171 public Logger getLogger() { 172 return log; 173 } 174 175 /** 176 * Returns the {@link Result} of the last operation, or <code>null</code> if no call operation has been 177 * performed yet. 178 * 179 * @return the last Result object 180 */ 181 public Result getLastResult() { 182 return lastResult; 183 } 184 185 public Result call(String method, String apiKey, String... params) throws CallException { 186 return call(method, apiKey, map(params)); 187 } 188 189 public Result call(String method, String apiKey, Map<String, String> params) throws CallException { 190 return call(method, apiKey, params, null); 191 } 192 193 public Result call(String method, Session session, String... params) { 194 return call(method, session.getApiKey(), map(params), session); 195 } 196 197 public Result call(String method, Session session, Map<String, String> params) { 198 return call(method, session.getApiKey(), params, session); 199 } 200 201 /** 202 * Performs the web-service call. If the <code>session</code> parameter is <code>non-null</code> then an 203 * authenticated call is made. If it's <code>null</code> then an unauthenticated call is made.<br/> 204 * The <code>apiKey</code> parameter is always required, even when a valid session is passed to this method. 205 * 206 * @param method The method to call 207 * @param apiKey A Last.fm API key 208 * @param params Parameters 209 * @param session A Session instance or <code>null</code> 210 * @return the result of the operation 211 */ 212 private Result call(String method, String apiKey, Map<String, String> params, Session session) { 213 params = new HashMap<String, String>(params); // create new Map in case params is an immutable Map 214 InputStream inputStream = null; 215 216 // try to load from cache 217 String cacheEntryName = Cache.createCacheEntryName(method, params); 218 if (session == null && cache != null) { 219 inputStream = getStreamFromCache(cacheEntryName); 220 } 221 222 // no entry in cache, load from web 223 if (inputStream == null) { 224 // fill parameter map with apiKey and session info 225 params.put(PARAM_API_KEY, apiKey); 226 if (session != null) { 227 params.put("sk", session.getKey()); 228 params.put("api_sig", Authenticator.createSignature(method, params, session.getSecret())); 229 } 230 if (tomDebug_) System.out.println("\n++++++++++++\nde.umass.Lastfm DEBUG Caller.call: params="+params+"\n++++++++++++\n"); 231 try { 232 HttpURLConnection urlConnection = openPostConnection(method, params); 233 inputStream = getInputStreamFromConnection(urlConnection); 234 235 if (inputStream == null) { 236 this.lastResult = Result.createHttpErrorResult(urlConnection.getResponseCode(), urlConnection.getResponseMessage()); 237 return lastResult; 238 } else { 239 if (cache != null) { 240 long expires = urlConnection.getHeaderFieldDate("Expires", -1); 241 if (expires == -1) { 242 expires = cache.findExpirationDate(method, params); 243 } 244 if (expires != -1) { 245 cache.store(cacheEntryName, inputStream, expires); // if data wasn't cached store new result 246 inputStream = cache.load(cacheEntryName); 247 if (inputStream == null) 248 throw new CallException("Caching/Reloading failed"); 249 } 250 } 251 } 252 } catch (IOException e) { 253 throw new CallException(e); 254 } 255 } 256 257 try { 258 Result result = createResultFromInputStream(inputStream); 259 if (!result.isSuccessful()) { 260 log.warning(String.format("API call failed with result: %s%n", result)); 261 if (cache != null) { 262 cache.remove(cacheEntryName); 263 } 264 } 265 this.lastResult = result; 266 return result; 267 } catch (IOException e) { 268 throw new CallException(e); 269 } catch (SAXException e) { 270 throw new CallException(e); 271 } 272 } 273 274 private InputStream getStreamFromCache(String cacheEntryName) { 275 if (cache != null && cache.contains(cacheEntryName) && !cache.isExpired(cacheEntryName)) { 276 return cache.load(cacheEntryName); 277 } 278 return null; 279 } 280 281 /** 282 * Creates a new {@link HttpURLConnection}, sets the proxy, if available, and sets the User-Agent property. 283 * 284 * @param url URL to connect to 285 * @return a new connection. 286 * @throws IOException if an I/O exception occurs. 287 */ 288 public HttpURLConnection openConnection(String url) throws IOException { 289 log.info("Open connection: " + url); 290 URL u = new URL(url); 291 HttpURLConnection urlConnection; 292 if (proxy != null) 293 urlConnection = (HttpURLConnection) u.openConnection(proxy); 294 else 295 urlConnection = (HttpURLConnection) u.openConnection(); 296 urlConnection.setRequestProperty("User-Agent", userAgent); 297 return urlConnection; 298 } 299 300 private HttpURLConnection openPostConnection(String method, Map<String, String> params) throws IOException { 301 HttpURLConnection urlConnection = openConnection(apiRootUrl); 302 urlConnection.setRequestMethod("POST"); 303 urlConnection.setDoOutput(true); 304 OutputStream outputStream = urlConnection.getOutputStream(); 305 BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream)); 306 String post = buildPostBody(method, params); 307 log.info("Post body: " + post); 308 writer.write(post); 309 writer.close(); 310 return urlConnection; 311 } 312 313 private InputStream getInputStreamFromConnection(HttpURLConnection connection) throws IOException { 314 int responseCode = connection.getResponseCode(); 315 316 if (responseCode == HttpURLConnection.HTTP_FORBIDDEN || responseCode == HttpURLConnection.HTTP_BAD_REQUEST) { 317 return connection.getErrorStream(); 318 } else if (responseCode == HttpURLConnection.HTTP_OK) { 319 return connection.getInputStream(); 320 } 321 322 return null; 323 } 324 325 private Result createResultFromInputStream(InputStream inputStream) throws SAXException, IOException { 326 Document document = newDocumentBuilder().parse(new InputSource(new InputStreamReader(inputStream, "UTF-8"))); 327 Element root = document.getDocumentElement(); // lfm element 328 String statusString = root.getAttribute("status"); 329 Status status = "ok".equals(statusString) ? Status.OK : Status.FAILED; 330 if (status == Status.FAILED) { 331 Element errorElement = (Element) root.getElementsByTagName("error").item(0); 332 int errorCode = Integer.parseInt(errorElement.getAttribute("code")); 333 String message = errorElement.getTextContent(); 334 return Result.createRestErrorResult(errorCode, message); 335 } else { 336 return Result.createOkResult(document); 337 } 338 } 339 340 private DocumentBuilder newDocumentBuilder() { 341 try { 342 DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); 343 return builderFactory.newDocumentBuilder(); 344 } catch (ParserConfigurationException e) { 345 // better never happens 346 throw new RuntimeException(e); 347 } 348 } 349 350 private String buildPostBody(String method, Map<String, String> params, String... strings) { 351 StringBuilder builder = new StringBuilder(100); 352 builder.append("method="); 353 builder.append(method); 354 builder.append('&'); 355 for (Iterator<Entry<String, String>> it = params.entrySet().iterator(); it.hasNext();) { 356 Entry<String, String> entry = it.next(); 357 builder.append(entry.getKey()); 358 builder.append('='); 359 builder.append(encode(entry.getValue())); 360 if (it.hasNext() || strings.length > 0) 361 builder.append('&'); 362 } 363 int count = 0; 364 for (String string : strings) { 365 builder.append(count % 2 == 0 ? string : encode(string)); 366 count++; 367 if (count != strings.length) { 368 if (count % 2 == 0) { 369 builder.append('&'); 370 } else { 371 builder.append('='); 372 } 373 } 374 } 375 return builder.toString(); 376 } 377 378 private String createSignature(Map<String, String> params, String secret) { 379 Set<String> sorted = new TreeSet<String>(params.keySet()); 380 StringBuilder builder = new StringBuilder(50); 381 for (String s : sorted) { 382 builder.append(s); 383 builder.append(encode(params.get(s))); 384 } 385 builder.append(secret); 386 return md5(builder.toString()); 387 } 388}