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}