001/*
002 * flattr4j - A Java library for Flattr
003 *
004 * Copyright (C) 2011 Richard "Shred" Körber
005 *   http://flattr4j.shredzone.org
006 *
007 * This program is free software: you can redistribute it and/or modify
008 * it under the terms of the GNU General Public License / GNU Lesser
009 * General Public License as published by the Free Software Foundation,
010 * either version 3 of the License, or (at your option) any later version.
011 *
012 * Licensed under the Apache License, Version 2.0 (the "License");
013 * you may not use this file except in compliance with the License.
014 *
015 * This program is distributed in the hope that it will be useful,
016 * but WITHOUT ANY WARRANTY; without even the implied warranty of
017 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
018 */
019package org.shredzone.flattr4j.connector.impl;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.InputStreamReader;
024import java.io.OutputStream;
025import java.io.Reader;
026import java.io.UnsupportedEncodingException;
027import java.net.HttpRetryException;
028import java.net.HttpURLConnection;
029import java.net.URI;
030import java.net.URISyntaxException;
031import java.net.URL;
032import java.net.URLEncoder;
033import java.nio.charset.Charset;
034import java.nio.charset.UnsupportedCharsetException;
035import java.util.ArrayList;
036import java.util.Collection;
037import java.util.Collections;
038import java.util.Date;
039import java.util.List;
040import java.util.Properties;
041import java.util.regex.Matcher;
042import java.util.regex.Pattern;
043import java.util.zip.GZIPInputStream;
044
045import org.json.JSONArray;
046import org.json.JSONException;
047import org.json.JSONObject;
048import org.json.JSONTokener;
049import org.shredzone.flattr4j.connector.Connection;
050import org.shredzone.flattr4j.connector.FlattrObject;
051import org.shredzone.flattr4j.connector.RateLimit;
052import org.shredzone.flattr4j.connector.RequestType;
053import org.shredzone.flattr4j.exception.FlattrException;
054import org.shredzone.flattr4j.exception.FlattrServiceException;
055import org.shredzone.flattr4j.exception.ForbiddenException;
056import org.shredzone.flattr4j.exception.MarshalException;
057import org.shredzone.flattr4j.exception.NoMoneyException;
058import org.shredzone.flattr4j.exception.NotFoundException;
059import org.shredzone.flattr4j.exception.RateLimitExceededException;
060import org.shredzone.flattr4j.exception.ValidationException;
061import org.shredzone.flattr4j.oauth.AccessToken;
062import org.shredzone.flattr4j.oauth.ConsumerKey;
063
064import android.os.Build;
065
066/**
067 * Default implementation of {@link Connection}.
068 *
069 * @author Richard "Shred" Körber
070 */
071public class FlattrConnection implements Connection {
072    private static final Logger LOG = new Logger("flattr4j", FlattrConnection.class.getName());
073    private static final String ENCODING = "utf-8";
074    private static final int TIMEOUT = 10000;
075    private static final Pattern CHARSET = Pattern.compile(".*?charset=\"?(.*?)\"?\\s*(;.*)?", Pattern.CASE_INSENSITIVE);
076    private static final String BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
077    private static final String USER_AGENT;
078
079    private String baseUrl;
080    private String call;
081    private RequestType type;
082    private ConsumerKey key;
083    private AccessToken token;
084    private FlattrObject data;
085    private StringBuilder queryParams;
086    private StringBuilder formParams;
087    private RateLimit limit;
088
089    static {
090        StringBuilder agent = new StringBuilder("flattr4j");
091        try {
092            Properties prop = new Properties();
093            prop.load(FlattrConnection.class.getResourceAsStream("/org/shredzone/flattr4j/version.properties"));
094            agent.append('/').append(prop.getProperty("version"));
095        } catch (IOException ex) {
096            // Ignore, just don't use a version
097            LOG.verbose("Failed to read version number", ex);
098        }
099
100        try {
101            String release = Build.VERSION.RELEASE;
102            agent.append(" Android/").append(release);
103        } catch (Throwable t) { //NOSONAR: catch an Error and ignore it
104            // We're not running on Android...
105            agent.append(" Java/").append(System.getProperty("java.version"));
106        }
107
108        USER_AGENT = agent.toString();
109    }
110
111    /**
112     * Creates a new {@link FlattrConnection} for the given {@link RequestType}.
113     *
114     * @param type
115     *            {@link RequestType} to be used
116     */
117    public FlattrConnection(RequestType type) {
118        this.type = type;
119    }
120
121    @Override
122    public Connection url(String url) {
123        this.baseUrl = url;
124        LOG.verbose("-> baseUrl {0}", url);
125        return this;
126    }
127
128    @Override
129    public Connection call(String call) {
130        this.call = call;
131        LOG.verbose("-> call {0}", call);
132        return this;
133    }
134
135    @Override
136    public Connection token(AccessToken token) {
137        this.token = token;
138        return this;
139    }
140
141    @Override
142    public Connection key(ConsumerKey key) {
143        this.key = key;
144        return this;
145    }
146
147    @Override
148    public Connection parameter(String name, String value) {
149        try {
150            call = call.replace(":" + name, URLEncoder.encode(value, ENCODING));
151            LOG.verbose("-> param {0} = {1}", name, value);
152            return this;
153        } catch (UnsupportedEncodingException ex) {
154            // should never be thrown, as "utf-8" encoding is available on any VM
155            throw new IllegalStateException(ex);
156        }
157    }
158
159    @Override
160    public Connection parameterArray(String name, String[] value) {
161        try {
162            StringBuilder sb = new StringBuilder();
163            for (int ix = 0; ix < value.length; ix++) {
164                if (ix > 0) {
165                    // Is it genius or madness, but the Flattr server does not accept
166                    // URL encoded ','!
167                    sb.append(',');
168                }
169                sb.append(URLEncoder.encode(value[ix], ENCODING));
170            }
171            call = call.replace(":" + name, sb.toString());
172            LOG.verbose("-> param {0} = [{1}]", name, sb.toString());
173            return this;
174        } catch (UnsupportedEncodingException ex) {
175            // should never be thrown, as "utf-8" encoding is available on any VM
176            throw new IllegalStateException(ex);
177        }
178    }
179
180    @Override
181    public Connection query(String name, String value) {
182        if (queryParams == null) {
183            queryParams = new StringBuilder();
184        }
185        appendParam(queryParams, name, value);
186        LOG.verbose("-> query {0} = {1}", name, value);
187        return this;
188    }
189
190    @Override
191    public Connection data(FlattrObject data) {
192        if (formParams != null) {
193            throw new IllegalArgumentException("no data permitted when form is used");
194        }
195        this.data = data;
196        LOG.verbose("-> JSON body: {0}", data);
197        return this;
198    }
199
200    @Override
201    public Connection form(String name, String value) {
202        if (data != null) {
203            throw new IllegalArgumentException("no form permitted when data is used");
204        }
205        if (formParams == null) {
206            formParams = new StringBuilder();
207        }
208        appendParam(formParams, name, value);
209        LOG.verbose("-> form {0} = {1}", name, value);
210        return this;
211    }
212
213    @Override
214    public Connection rateLimit(RateLimit limit) {
215        this.limit = limit;
216        return this;
217    }
218
219    @Override
220    public Collection<FlattrObject> result() throws FlattrException {
221        try {
222            String queryString = (queryParams != null ? "?" + queryParams : "");
223
224            URL url;
225            if (call != null) {
226                url = new URI(baseUrl).resolve(call + queryString).toURL();
227            } else {
228                url = new URI(baseUrl + queryString).toURL();
229            }
230
231            HttpURLConnection conn = createConnection(url);
232            conn.setRequestMethod(type.name());
233            conn.setRequestProperty("Accept", "application/json");
234            conn.setRequestProperty("Accept-Charset", ENCODING);
235            conn.setRequestProperty("Accept-Encoding", "gzip");
236
237            if (token != null) {
238                conn.setRequestProperty("Authorization", "Bearer " + token.getToken());
239            } else if (key != null) {
240                conn.setRequestProperty("Authorization", "Basic " +
241                                base64(key.getKey() + ':' +  key.getSecret()));
242            }
243
244            byte[] outputData = null;
245            if (data != null) {
246                outputData = data.toString().getBytes(ENCODING);
247                conn.setDoOutput(true);
248                conn.setRequestProperty("Content-Type", "application/json");
249                conn.setFixedLengthStreamingMode(outputData.length);
250            } else if (formParams != null) {
251                outputData = formParams.toString().getBytes(ENCODING);
252                conn.setDoOutput(true);
253                conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
254                conn.setFixedLengthStreamingMode(outputData.length);
255            }
256
257            LOG.info("Sending Flattr request: {0}", call);
258            conn.connect();
259
260            if (outputData != null) {
261                OutputStream out = conn.getOutputStream();
262                try {
263                    out.write(outputData);
264                } finally {
265                    out.close();
266                }
267            }
268
269            if (limit != null) {
270                String remainingHeader = conn.getHeaderField("X-RateLimit-Remaining");
271                if (remainingHeader != null) {
272                    limit.setRemaining(Long.parseLong(remainingHeader));
273                } else {
274                    limit.setRemaining(null);
275                }
276
277                String limitHeader = conn.getHeaderField("X-RateLimit-Limit");
278                if (limitHeader != null) {
279                    limit.setLimit(Long.parseLong(limitHeader));
280                } else {
281                    limit.setLimit(null);
282                }
283
284                String currentHeader = conn.getHeaderField("X-RateLimit-Current");
285                if (currentHeader != null) {
286                    limit.setCurrent(Long.parseLong(currentHeader));
287                } else {
288                    limit.setCurrent(null);
289                }
290
291                String resetHeader = conn.getHeaderField("X-RateLimit-Reset");
292                if (resetHeader != null) {
293                    limit.setReset(new Date(Long.parseLong(resetHeader) * 1000L));
294                } else {
295                    limit.setReset(null);
296                }
297            }
298
299            List<FlattrObject> result;
300
301            if (assertStatusOk(conn)) {
302                // Status is OK and there is content
303                Object resultData = new JSONTokener(readResponse(conn)).nextValue();
304                if (resultData instanceof JSONArray) {
305                    JSONArray array = (JSONArray) resultData;
306                    result = new ArrayList<FlattrObject>(array.length());
307                    for (int ix = 0; ix < array.length(); ix++) {
308                        FlattrObject fo = new FlattrObject(array.getJSONObject(ix));
309                        result.add(fo);
310                        LOG.verbose("<- JSON result: {0}", fo);
311                    }
312                    LOG.verbose("<-   {0} rows", array.length());
313                } else if (resultData instanceof JSONObject) {
314                    FlattrObject fo = new FlattrObject((JSONObject) resultData);
315                    result = Collections.singletonList(fo);
316                    LOG.verbose("<- JSON result: {0}", fo);
317                } else {
318                    throw new MarshalException("unexpected result type " + resultData.getClass().getName());
319                }
320            } else {
321                // Status was OK, but there is no content
322                result = Collections.emptyList();
323            }
324
325            return result;
326        } catch (URISyntaxException ex) {
327            throw new IllegalStateException("bad baseUrl", ex);
328        } catch (IOException ex) {
329            throw new FlattrException("API access failed: " + call, ex);
330        } catch (JSONException ex) {
331            throw new MarshalException(ex);
332        } catch (ClassCastException ex) {
333            throw new FlattrException("Unexpected result type", ex);
334        }
335    }
336
337    @Override
338    public FlattrObject singleResult() throws FlattrException {
339        Collection<FlattrObject> result = result();
340        if (result.size() == 1) {
341            return result.iterator().next();
342        } else {
343            throw new MarshalException("Expected 1, but got " + result.size() + " result rows");
344        }
345    }
346
347    /**
348     * Reads the returned HTTP response as string.
349     *
350     * @param conn
351     *            {@link HttpURLConnection} to read from
352     * @return Response read
353     */
354    private String readResponse(HttpURLConnection conn) throws IOException {
355        InputStream in = null;
356
357        try {
358            in = conn.getErrorStream();
359            if (in == null) {
360                in = conn.getInputStream();
361            }
362
363            if ("gzip".equals(conn.getContentEncoding())) {
364                in = new GZIPInputStream(in);
365            }
366
367            Charset charset = getCharset(conn.getContentType());
368            Reader reader = new InputStreamReader(in, charset);
369
370            // Sadly, the Android API does not offer a JSONTokener for a Reader.
371            char[] buffer = new char[1024];
372            StringBuilder sb = new StringBuilder();
373
374            int len;
375            while ((len = reader.read(buffer)) >= 0) {
376                sb.append(buffer, 0, len);
377            }
378
379            return sb.toString();
380        } finally {
381            if (in != null) {
382                in.close();
383            }
384        }
385    }
386
387    /**
388     * Assert that the HTTP result is OK, otherwise generate and throw an appropriate
389     * {@link FlattrException}.
390     *
391     * @param conn
392     *            {@link HttpURLConnection} to assert
393     * @return {@code true} if the status is OK and there is a content, {@code false} if
394     *         the status is OK but there is no content. (If the status is not OK, an
395     *         exception is thrown.)
396     */
397    private boolean assertStatusOk(HttpURLConnection conn) throws FlattrException {
398        String error = null, desc = null, httpStatus = null;
399
400        try {
401            int statusCode = conn.getResponseCode();
402
403            if (statusCode == HttpURLConnection.HTTP_OK || statusCode == HttpURLConnection.HTTP_CREATED) {
404                return true;
405            }
406            if (statusCode == HttpURLConnection.HTTP_NO_CONTENT) {
407                return false;
408            }
409
410            httpStatus = "HTTP " + statusCode + ": " + conn.getResponseMessage();
411
412            JSONObject errorData = (JSONObject) new JSONTokener(readResponse(conn)).nextValue();
413            LOG.verbose("<- ERROR {0}: {1}", statusCode, errorData);
414
415            error = errorData.optString("error");
416            desc = errorData.optString("error_description");
417            LOG.error("Flattr ERROR {0}: {1}", error, desc);
418        } catch (HttpRetryException ex) {
419            LOG.debug("Could not read error response", ex);
420        } catch (IOException ex) {
421            throw new FlattrException("Could not read response", ex);
422        } catch (ClassCastException ex) {
423            LOG.debug("Unexpected JSON type was returned", ex);
424        } catch (JSONException ex) {
425            LOG.debug("No valid error message was returned", ex);
426        }
427
428        if (error != null && desc != null) {
429            if (   "flattr_once".equals(error)
430                || "flattr_owner".equals(error)
431                || "thing_owner".equals(error)
432                || "forbidden".equals(error)
433                || "insufficient_scope".equals(error)
434                || "unauthorized".equals(error)
435                || "subscribed".equals(error)) {
436                throw new ForbiddenException(error, desc);
437
438            } else if ("no_means".equals(error)
439                || "no_money".equals(error)) {
440                throw new NoMoneyException(error, desc);
441
442            } else  if ("not_found".equals(error)) {
443                throw new NotFoundException(error, desc);
444
445            } else if ("rate_limit_exceeded".equals(error)) {
446                throw new RateLimitExceededException(error, desc);
447
448            } else if ("invalid_parameters".equals(error)
449                || "invalid_scope".equals(error)
450                || "validation".equals(error)) {
451                throw new ValidationException(error, desc);
452            }
453
454            // "not_acceptable", "server_error", "invalid_request", everything else...
455            throw new FlattrServiceException(error, desc);
456        }
457
458        LOG.error("Flattr {0}", httpStatus);
459        throw new FlattrException(httpStatus);
460    }
461
462    /**
463     * Creates a {@link HttpURLConnection} to the given url. Override to configure the
464     * connection.
465     *
466     * @param url
467     *            {@link URL} to connect to
468     * @return {@link HttpURLConnection} that is connected to the url and is
469     *         preconfigured.
470     */
471    protected HttpURLConnection createConnection(URL url) throws IOException {
472        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
473        conn.setConnectTimeout(TIMEOUT);
474        conn.setReadTimeout(TIMEOUT);
475        conn.setUseCaches(false);
476        conn.setRequestProperty("User-Agent", USER_AGENT);
477        return conn;
478    }
479
480    /**
481     * Appends a HTTP parameter to a string builder.
482     *
483     * @param builder
484     *            {@link StringBuffer} to append to
485     * @param key
486     *            parameter key
487     * @param value
488     *            parameter value
489     */
490    private void appendParam(StringBuilder builder, String key, String value) {
491        try {
492            if (builder.length() > 0) {
493                builder.append('&');
494            }
495            builder.append(URLEncoder.encode(key, ENCODING));
496            builder.append('=');
497            builder.append(URLEncoder.encode(value, ENCODING));
498        } catch (UnsupportedEncodingException ex) {
499            throw new IllegalStateException(ex);
500        }
501    }
502
503    /**
504     * Gets the {@link Charset} from the content-type header. If there is no charset, the
505     * default charset is returned instead.
506     *
507     * @param contentType
508     *            content-type header, may be {@code null}
509     * @return {@link Charset}
510     */
511    protected Charset getCharset(String contentType) {
512        Charset charset = Charset.forName(ENCODING);
513        if (contentType != null) {
514            Matcher m = CHARSET.matcher(contentType);
515            if (m.matches()) {
516                try {
517                    charset = Charset.forName(m.group(1));
518                } catch (UnsupportedCharsetException ex) {
519                    // ignore and return default charset
520                    LOG.debug(m.group(1), ex);
521                }
522            }
523        }
524        return charset;
525    }
526
527    /**
528     * Base64 encodes a string.
529     *
530     * @param str
531     *            String to encode
532     * @return Encoded string
533     */
534    protected String base64(String str) {
535        // There is no common Base64 encoder in Java and Android. Sometimes I hate Java.
536        try {
537            byte[] data = str.getBytes(ENCODING);
538
539            StringBuilder sb = new StringBuilder();
540            for (int ix = 0; ix < data.length; ix += 3) {
541                int triplet = (data[ix] & 0xFF) << 16;
542                if (ix + 1 < data.length) {
543                    triplet |= (data[ix+1] & 0xFF) << 8;
544                }
545                if (ix + 2 < data.length) {
546                    triplet |= (data[ix+2] & 0xFF);
547                }
548
549                for (int iy = 0; iy < 4; iy++) {
550                    if (ix + iy <= data.length) {
551                        int ch = (triplet & 0xFC0000) >> 18;
552                        sb.append(BASE64.charAt(ch));
553                        triplet <<= 6;
554                    } else {
555                        sb.append('=');
556                    }
557                }
558            }
559
560            return sb.toString();
561        } catch (UnsupportedEncodingException ex) {
562            throw new IllegalArgumentException(ENCODING, ex);
563        }
564    }
565
566}