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}