001/* 002 * Copyright 2010-2013 Ning, Inc. 003 * 004 * Ning licenses this file to you under the Apache License, version 2.0 005 * (the "License"); you may not use this file except in compliance with the 006 * License. You may obtain a copy of the License at: 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 012 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 013 * License for the specific language governing permissions and limitations 014 * under the License. 015 */ 016 017package com.ning.billing.recurly; 018 019import java.io.IOException; 020import java.io.InputStream; 021import java.math.BigDecimal; 022import java.util.NoSuchElementException; 023import java.util.Scanner; 024import java.util.concurrent.ExecutionException; 025 026import javax.annotation.Nullable; 027import javax.xml.bind.DatatypeConverter; 028 029import org.slf4j.Logger; 030import org.slf4j.LoggerFactory; 031 032import com.ning.billing.recurly.model.Account; 033import com.ning.billing.recurly.model.Accounts; 034import com.ning.billing.recurly.model.AddOn; 035import com.ning.billing.recurly.model.AddOns; 036import com.ning.billing.recurly.model.Adjustment; 037import com.ning.billing.recurly.model.Adjustments; 038import com.ning.billing.recurly.model.BillingInfo; 039import com.ning.billing.recurly.model.Coupon; 040import com.ning.billing.recurly.model.Coupons; 041import com.ning.billing.recurly.model.Errors; 042import com.ning.billing.recurly.model.Invoice; 043import com.ning.billing.recurly.model.Invoices; 044import com.ning.billing.recurly.model.Plan; 045import com.ning.billing.recurly.model.Plans; 046import com.ning.billing.recurly.model.RecurlyAPIError; 047import com.ning.billing.recurly.model.RecurlyObject; 048import com.ning.billing.recurly.model.RecurlyObjects; 049import com.ning.billing.recurly.model.Redemption; 050import com.ning.billing.recurly.model.RefundOption; 051import com.ning.billing.recurly.model.Subscription; 052import com.ning.billing.recurly.model.SubscriptionUpdate; 053import com.ning.billing.recurly.model.Subscriptions; 054import com.ning.billing.recurly.model.Transaction; 055import com.ning.billing.recurly.model.Transactions; 056import com.ning.http.client.AsyncHttpClient; 057import com.ning.http.client.AsyncHttpClientConfig; 058import com.ning.http.client.Response; 059 060import com.fasterxml.jackson.dataformat.xml.XmlMapper; 061 062public class RecurlyClient { 063 064 private static final Logger log = LoggerFactory.getLogger(RecurlyClient.class); 065 066 public static final String RECURLY_DEBUG_KEY = "recurly.debug"; 067 public static final String RECURLY_PAGE_SIZE_KEY = "recurly.page.size"; 068 069 private static final Integer DEFAULT_PAGE_SIZE = 20; 070 private static final String PER_PAGE = "per_page="; 071 072 private static final String X_RECORDS_HEADER_NAME = "X-Records"; 073 private static final String LINK_HEADER_NAME = "Link"; 074 075 public static final String FETCH_RESOURCE = "/recurly_js/result"; 076 077 /** 078 * Checks a system property to see if debugging output is 079 * required. Used internally by the client to decide whether to 080 * generate debug output 081 */ 082 private static boolean debug() { 083 return Boolean.getBoolean(RECURLY_DEBUG_KEY); 084 } 085 086 /** 087 * Returns the page Size to use when querying. The page size 088 * is set as System.property: recurly.page.size 089 */ 090 public static Integer getPageSize() { 091 Integer pageSize; 092 try { 093 pageSize = new Integer(System.getProperty(RECURLY_PAGE_SIZE_KEY)); 094 } catch (NumberFormatException nfex) { 095 pageSize = DEFAULT_PAGE_SIZE; 096 } 097 return pageSize; 098 } 099 100 public static String getPageSizeGetParam() { 101 return PER_PAGE + getPageSize().toString(); 102 } 103 104 // TODO: should we make it static? 105 private final XmlMapper xmlMapper; 106 107 private final String key; 108 private final String baseUrl; 109 private AsyncHttpClient client; 110 111 public RecurlyClient(final String apiKey) { 112 this(apiKey, "api.recurly.com", 443, "v2"); 113 } 114 115 public RecurlyClient(final String apiKey, final String host, final int port, final String version) { 116 this.key = DatatypeConverter.printBase64Binary(apiKey.getBytes()); 117 this.baseUrl = String.format("https://%s:%d/%s", host, port, version); 118 this.xmlMapper = RecurlyObject.newXmlMapper(); 119 } 120 121 /** 122 * Open the underlying http client 123 */ 124 public synchronized void open() { 125 client = createHttpClient(); 126 } 127 128 /** 129 * Close the underlying http client 130 */ 131 public synchronized void close() { 132 if (client != null) { 133 client.close(); 134 } 135 } 136 137 /** 138 * Create Account 139 * <p/> 140 * Creates a new account. You may optionally include billing information. 141 * 142 * @param account account object 143 * @return the newly created account object on success, null otherwise 144 */ 145 public Account createAccount(final Account account) { 146 return doPOST(Account.ACCOUNT_RESOURCE, account, Account.class); 147 } 148 149 /** 150 * Get Accounts 151 * <p/> 152 * Returns information about all accounts. 153 * 154 * @return account object on success, null otherwise 155 */ 156 public Accounts getAccounts() { 157 return doGET(Accounts.ACCOUNTS_RESOURCE, Accounts.class); 158 } 159 160 public Coupons getCoupons() { 161 return doGET(Coupons.COUPONS_RESOURCE, Coupons.class); 162 } 163 164 /** 165 * Get Account 166 * <p/> 167 * Returns information about a single account. 168 * 169 * @param accountCode recurly account id 170 * @return account object on success, null otherwise 171 */ 172 public Account getAccount(final String accountCode) { 173 return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode, Account.class); 174 } 175 176 /** 177 * Update Account 178 * <p/> 179 * Updates an existing account. 180 * 181 * @param accountCode recurly account id 182 * @param account account object 183 * @return the updated account object on success, null otherwise 184 */ 185 public Account updateAccount(final String accountCode, final Account account) { 186 return doPUT(Account.ACCOUNT_RESOURCE + "/" + accountCode, account, Account.class); 187 } 188 189 /** 190 * Close Account 191 * <p/> 192 * Marks an account as closed and cancels any active subscriptions. Any saved billing information will also be 193 * permanently removed from the account. 194 * 195 * @param accountCode recurly account id 196 */ 197 public void closeAccount(final String accountCode) { 198 doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode); 199 } 200 201 //////////////////////////////////////////////////////////////////////////////////////// 202 // Account adjustments 203 204 public Adjustments getAccountAdjustments(final String accountCode, final Adjustments.AdjustmentType type) { 205 return getAccountAdjustments(accountCode, type, null); 206 } 207 208 public Adjustments getAccountAdjustments(final String accountCode, final Adjustments.AdjustmentType type, final Adjustments.AdjustmentState state) { 209 String url = Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE; 210 if (type != null || state != null) { 211 url += "?"; 212 } 213 214 if (type != null) { 215 url += "type=" + type.getType(); 216 if (state != null) { 217 url += "&"; 218 } 219 } 220 221 if (state != null) { 222 url += "state=" + state.getState(); 223 } 224 225 return doGET(url, Adjustments.class); 226 } 227 228 public Adjustment createAccountAdjustment(final String accountCode, final Adjustment adjustment) { 229 return doPOST(Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE, 230 adjustment, 231 Adjustment.class); 232 } 233 234 public void deleteAccountAdjustment(final String accountCode) { 235 doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE); 236 } 237 238 //////////////////////////////////////////////////////////////////////////////////////// 239 240 /** 241 * Create a subscription 242 * <p/> 243 * Creates a subscription for an account. 244 * 245 * @param subscription Subscription object 246 * @return the newly created Subscription object on success, null otherwise 247 */ 248 public Subscription createSubscription(final Subscription subscription) { 249 return doPOST(Subscription.SUBSCRIPTION_RESOURCE, 250 subscription, Subscription.class); 251 } 252 253 /** 254 * Get a particular {@link Subscription} by it's UUID 255 * <p/> 256 * Returns information about a single account. 257 * 258 * @param uuid UUID of the subscription to lookup 259 * @return Subscriptions for the specified user 260 */ 261 public Subscription getSubscription(final String uuid) { 262 return doGET(Subscriptions.SUBSCRIPTIONS_RESOURCE 263 + "/" + uuid, 264 Subscription.class); 265 } 266 267 /** 268 * Cancel a subscription 269 * <p/> 270 * Cancel a subscription so it remains active and then expires at the end of the current bill cycle. 271 * 272 * @param subscription Subscription object 273 * @return -?- 274 */ 275 public Subscription cancelSubscription(final Subscription subscription) { 276 return doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/cancel", 277 subscription, Subscription.class); 278 } 279 280 /** 281 * Terminate a particular {@link Subscription} by it's UUID 282 * 283 * @param subscription Subscription to terminate 284 */ 285 public void terminateSubscription(final Subscription subscription, final RefundOption refund) { 286 doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/terminate?refund=" + refund, 287 subscription, Subscription.class); 288 } 289 290 /** 291 * Reactivating a canceled subscription 292 * <p/> 293 * Reactivate a canceled subscription so it renews at the end of the current bill cycle. 294 * 295 * @param subscription Subscription object 296 * @return -?- 297 */ 298 public Subscription reactivateSubscription(final Subscription subscription) { 299 return doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/reactivate", 300 subscription, Subscription.class); 301 } 302 303 /** 304 * Update a particular {@link Subscription} by it's UUID 305 * <p/> 306 * Returns information about a single account. 307 * 308 * @param uuid UUID of the subscription to update 309 * @return Subscription the updated subscription 310 */ 311 public Subscription updateSubscription(final String uuid, final SubscriptionUpdate subscriptionUpdate) { 312 return doPUT(Subscriptions.SUBSCRIPTIONS_RESOURCE 313 + "/" + uuid, 314 subscriptionUpdate, 315 Subscription.class); 316 } 317 318 /** 319 * Get the subscriptions for an {@link Account}. 320 * <p/> 321 * Returns information about a single {@link Account}. 322 * 323 * @param accountCode recurly account id 324 * @return Subscriptions for the specified user 325 */ 326 public Subscriptions getAccountSubscriptions(final String accountCode) { 327 return doGET(Account.ACCOUNT_RESOURCE 328 + "/" + accountCode 329 + Subscriptions.SUBSCRIPTIONS_RESOURCE, 330 Subscriptions.class); 331 } 332 333 /** 334 * Get the subscriptions for an account. 335 * <p/> 336 * Returns information about a single account. 337 * 338 * @param accountCode recurly account id 339 * @param status Only accounts in this status will be returned 340 * @return Subscriptions for the specified user 341 */ 342 public Subscriptions getAccountSubscriptions(final String accountCode, final String status) { 343 return doGET(Account.ACCOUNT_RESOURCE 344 + "/" + accountCode 345 + Subscriptions.SUBSCRIPTIONS_RESOURCE 346 + "?state=" 347 + status, 348 Subscriptions.class); 349 } 350 351 //////////////////////////////////////////////////////////////////////////////////////// 352 353 /** 354 * Update an account's billing info 355 * <p/> 356 * When new or updated credit card information is updated, the billing information is only saved if the credit card 357 * is valid. If the account has a past due invoice, the outstanding balance will be collected to validate the 358 * billing information. 359 * <p/> 360 * If the account does not exist before the API request, the account will be created if the billing information 361 * is valid. 362 * <p/> 363 * Please note: this API end-point may be used to import billing information without security codes (CVV). 364 * Recurly recommends requiring CVV from your customers when collecting new or updated billing information. 365 * 366 * @param billingInfo billing info object to create or update 367 * @return the newly created or update billing info object on success, null otherwise 368 */ 369 public BillingInfo createOrUpdateBillingInfo(final BillingInfo billingInfo) { 370 final String accountCode = billingInfo.getAccount().getAccountCode(); 371 // Unset it to avoid confusing Recurly 372 billingInfo.setAccount(null); 373 return doPUT(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE, 374 billingInfo, BillingInfo.class); 375 } 376 377 /** 378 * Lookup an account's billing info 379 * <p/> 380 * Returns only the account's current billing information. 381 * 382 * @param accountCode recurly account id 383 * @return the current billing info object associated with this account on success, null otherwise 384 */ 385 public BillingInfo getBillingInfo(final String accountCode) { 386 return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE, 387 BillingInfo.class); 388 } 389 390 /** 391 * Clear an account's billing info 392 * <p/> 393 * You may remove any stored billing information for an account. If the account has a subscription, the renewal will 394 * go into past due unless you update the billing info before the renewal occurs 395 * 396 * @param accountCode recurly account id 397 */ 398 public void clearBillingInfo(final String accountCode) { 399 doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE); 400 } 401 402 /////////////////////////////////////////////////////////////////////////// 403 // User transactions 404 405 /** 406 * Lookup an account's transactions history 407 * <p/> 408 * Returns the account's transaction history 409 * 410 * @param accountCode recurly account id 411 * @return the transaction history associated with this account on success, null otherwise 412 */ 413 public Transactions getAccountTransactions(final String accountCode) { 414 return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Transactions.TRANSACTIONS_RESOURCE, 415 Transactions.class); 416 } 417 418 /** 419 * Lookup a transaction 420 * 421 * @param transactionId recurly transaction id 422 * @return the transaction if found, null otherwise 423 */ 424 public Transaction getTransaction(final String transactionId) { 425 return doGET(Transactions.TRANSACTIONS_RESOURCE + "/" + transactionId, 426 Transaction.class); 427 } 428 429 /** 430 * Creates a {@link Transaction} through the Recurly API. 431 * 432 * @param trans The {@link Transaction} to create 433 * @return The created {@link Transaction} object 434 */ 435 public Transaction createTransaction(final Transaction trans) { 436 return doPOST(Transactions.TRANSACTIONS_RESOURCE, trans, Transaction.class); 437 } 438 439 /** 440 * Refund a transaction 441 * 442 * @param transactionId recurly transaction id 443 * @param amount amount to refund, null for full refund 444 */ 445 public void refundTransaction(final String transactionId, @Nullable final BigDecimal amount) { 446 String url = Transactions.TRANSACTIONS_RESOURCE + "/" + transactionId; 447 if (amount != null) { 448 url = url + "?amount_in_cents=" + (amount.intValue() * 100); 449 } 450 doDELETE(url); 451 } 452 453 /////////////////////////////////////////////////////////////////////////// 454 // User invoices 455 456 /** 457 * Lookup an account's invoices 458 * <p/> 459 * Returns the account's invoices 460 * 461 * @param accountCode recurly account id 462 * @return the invoices associated with this account on success, null otherwise 463 */ 464 public Invoices getAccountInvoices(final String accountCode) { 465 return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Invoices.INVOICES_RESOURCE, 466 Invoices.class); 467 } 468 469 /////////////////////////////////////////////////////////////////////////// 470 471 /** 472 * Create a Plan's info 473 * <p/> 474 * 475 * @param plan The plan to create on recurly 476 * @return the plan object as identified by the passed in ID 477 */ 478 public Plan createPlan(final Plan plan) { 479 return doPOST(Plan.PLANS_RESOURCE, plan, Plan.class); 480 } 481 482 /** 483 * Get a Plan's details 484 * <p/> 485 * 486 * @param planCode recurly id of plan 487 * @return the plan object as identified by the passed in ID 488 */ 489 public Plan getPlan(final String planCode) { 490 return doGET(Plan.PLANS_RESOURCE + "/" + planCode, Plan.class); 491 } 492 493 /** 494 * Return all the plans 495 * <p/> 496 * 497 * @return the plan object as identified by the passed in ID 498 */ 499 public Plans getPlans() { 500 return doGET(Plans.PLANS_RESOURCE, Plans.class); 501 } 502 503 /** 504 * Deletes a {@link Plan} 505 * <p/> 506 * 507 * @param planCode The {@link Plan} object to delete. 508 */ 509 public void deletePlan(final String planCode) { 510 doDELETE(Plan.PLANS_RESOURCE + 511 "/" + 512 planCode); 513 } 514 515 /////////////////////////////////////////////////////////////////////////// 516 517 /** 518 * Create an AddOn to a Plan 519 * <p/> 520 * 521 * @param planCode The planCode of the {@link Plan } to create within recurly 522 * @param addOn The {@link AddOn} to create within recurly 523 * @return the {@link AddOn} object as identified by the passed in object 524 */ 525 public AddOn createPlanAddOn(final String planCode, final AddOn addOn) { 526 return doPOST(Plan.PLANS_RESOURCE + 527 "/" + 528 planCode + 529 AddOn.ADDONS_RESOURCE, 530 addOn, AddOn.class); 531 } 532 533 /** 534 * Get an AddOn's details 535 * <p/> 536 * 537 * @param addOnCode recurly id of {@link AddOn} 538 * @param planCode recurly id of {@link Plan} 539 * @return the {@link AddOn} object as identified by the passed in plan and add-on IDs 540 */ 541 public AddOn getAddOn(final String planCode, final String addOnCode) { 542 return doGET(Plan.PLANS_RESOURCE + 543 "/" + 544 planCode + 545 AddOn.ADDONS_RESOURCE + 546 "/" + 547 addOnCode, AddOn.class); 548 } 549 550 /** 551 * Return all the {@link AddOn} for a {@link Plan} 552 * <p/> 553 * 554 * @return the {@link AddOn} objects as identified by the passed plan ID 555 */ 556 public AddOns getAddOns(final String planCode) { 557 return doGET(Plan.PLANS_RESOURCE + 558 "/" + 559 planCode + 560 AddOn.ADDONS_RESOURCE, AddOns.class); 561 } 562 563 /** 564 * Deletes a {@link AddOn} for a Plan 565 * <p/> 566 * 567 * @param planCode The {@link Plan} object. 568 * @param addOnCode The {@link AddOn} object to delete. 569 */ 570 public void deleteAddOn(final String planCode, final String addOnCode) { 571 doDELETE(Plan.PLANS_RESOURCE + 572 "/" + 573 planCode + 574 AddOn.ADDONS_RESOURCE + 575 "/" + 576 addOnCode); 577 } 578 579 /////////////////////////////////////////////////////////////////////////// 580 581 /** 582 * Create a {@link Coupon} 583 * <p/> 584 * 585 * @param coupon The coupon to create on recurly 586 * @return the {@link Coupon} object 587 */ 588 public Coupon createCoupon(final Coupon coupon) { 589 return doPOST(Coupon.COUPON_RESOURCE, coupon, Coupon.class); 590 } 591 592 /** 593 * Get a Coupon 594 * <p/> 595 * 596 * @param couponCode The code for the {@link Coupon} 597 * @return The {@link Coupon} object as identified by the passed in code 598 */ 599 public Coupon getCoupon(final String couponCode) { 600 return doGET(Coupon.COUPON_RESOURCE + "/" + couponCode, Coupon.class); 601 } 602 603 /** 604 * Delete a {@link Coupon} 605 * <p/> 606 * 607 * @param couponCode The code for the {@link Coupon} 608 */ 609 public void deleteCoupon(final String couponCode) { 610 doDELETE(Coupon.COUPON_RESOURCE + "/" + couponCode); 611 } 612 613 /////////////////////////////////////////////////////////////////////////// 614 615 /** 616 * Redeem a {@link Coupon} on an account. 617 * 618 * @param couponCode redeemed coupon id 619 * @return the {@link Coupon} object 620 */ 621 public Redemption redeemCoupon(final String couponCode, final Redemption redemption) { 622 return doPOST(Coupon.COUPON_RESOURCE + "/" + couponCode + Redemption.REDEEM_RESOURCE, 623 redemption, Redemption.class); 624 } 625 626 /** 627 * Lookup a coupon redemption on an invoice. 628 * 629 * @param accountCode recurly account id 630 * @return the coupon redemption for this account on success, null otherwise 631 */ 632 public Redemption getCouponRedemptionByAccount(final String accountCode) { 633 return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTION_RESOURCE, 634 Redemption.class); 635 } 636 637 /** 638 * Lookup a coupon redemption on an invoice. 639 * 640 * @param invoiceNumber invoice number 641 * @return the coupon redemption for this invoice on success, null otherwise 642 */ 643 public Redemption getCouponRedemptionByInvoice(final Integer invoiceNumber) { 644 return doGET(Invoices.INVOICES_RESOURCE + "/" + invoiceNumber + Redemption.REDEMPTION_RESOURCE, 645 Redemption.class); 646 } 647 648 /** 649 * Deletes a coupon from an account. 650 * 651 * @param accountCode recurly account id 652 */ 653 public void deleteCouponRedemption(final String accountCode) { 654 doDELETE(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTION_RESOURCE); 655 } 656 657 /////////////////////////////////////////////////////////////////////////// 658 // 659 // Recurly.js API 660 // 661 /////////////////////////////////////////////////////////////////////////// 662 663 /** 664 * Fetch Subscription 665 * <p/> 666 * Returns subscription from a recurly.js token. 667 * 668 * @param recurlyToken token given by recurly.js 669 * @return subscription object on success, null otherwise 670 */ 671 public Subscription fetchSubscription(final String recurlyToken) { 672 return fetch(recurlyToken, Subscription.class); 673 } 674 675 /** 676 * Fetch BillingInfo 677 * <p/> 678 * Returns billing info from a recurly.js token. 679 * 680 * @param recurlyToken token given by recurly.js 681 * @return billing info object on success, null otherwise 682 */ 683 public BillingInfo fetchBillingInfo(final String recurlyToken) { 684 return fetch(recurlyToken, BillingInfo.class); 685 } 686 687 /** 688 * Fetch Invoice 689 * <p/> 690 * Returns invoice from a recurly.js token. 691 * 692 * @param recurlyToken token given by recurly.js 693 * @return invoice object on success, null otherwise 694 */ 695 public Invoice fetchInvoice(final String recurlyToken) { 696 return fetch(recurlyToken, Invoice.class); 697 } 698 699 private <T> T fetch(final String recurlyToken, final Class<T> clazz) { 700 return doGET(FETCH_RESOURCE + "/" + recurlyToken, clazz); 701 } 702 703 /////////////////////////////////////////////////////////////////////////// 704 705 private <T> T doGET(final String resource, final Class<T> clazz) { 706 final StringBuffer url = new StringBuffer(baseUrl); 707 url.append(resource); 708 if (resource != null && !resource.contains("?")) { 709 url.append("?"); 710 } else { 711 url.append("&"); 712 url.append("&"); 713 } 714 url.append(getPageSizeGetParam()); 715 716 return doGETWithFullURL(clazz, url.toString()); 717 } 718 719 public <T> T doGETWithFullURL(final Class<T> clazz, final String url) { 720 if (debug()) { 721 log.info("Msg to Recurly API [GET] :: URL : {}", url); 722 } 723 return callRecurlySafe(client.prepareGet(url), clazz); 724 } 725 726 private <T> T doPOST(final String resource, final RecurlyObject payload, final Class<T> clazz) { 727 final String xmlPayload; 728 try { 729 xmlPayload = xmlMapper.writeValueAsString(payload); 730 if (debug()) { 731 log.info("Msg to Recurly API [POST]:: URL : {}", baseUrl + resource); 732 log.info("Payload for [POST]:: {}", xmlPayload); 733 } 734 } catch (IOException e) { 735 log.warn("Unable to serialize {} object as XML: {}", clazz.getName(), payload.toString()); 736 return null; 737 } 738 739 return callRecurlySafe(client.preparePost(baseUrl + resource).setBody(xmlPayload), clazz); 740 } 741 742 private <T> T doPUT(final String resource, final RecurlyObject payload, final Class<T> clazz) { 743 final String xmlPayload; 744 try { 745 xmlPayload = xmlMapper.writeValueAsString(payload); 746 if (debug()) { 747 log.info("Msg to Recurly API [PUT]:: URL : {}", baseUrl + resource); 748 log.info("Payload for [PUT]:: {}", xmlPayload); 749 } 750 } catch (IOException e) { 751 log.warn("Unable to serialize {} object as XML: {}", clazz.getName(), payload.toString()); 752 return null; 753 } 754 755 return callRecurlySafe(client.preparePut(baseUrl + resource).setBody(xmlPayload), clazz); 756 } 757 758 private void doDELETE(final String resource) { 759 callRecurlySafe(client.prepareDelete(baseUrl + resource), null); 760 } 761 762 private <T> T callRecurlySafe(final AsyncHttpClient.BoundRequestBuilder builder, @Nullable final Class<T> clazz) { 763 try { 764 return callRecurly(builder, clazz); 765 } catch (IOException e) { 766 log.warn("Error while calling Recurly", e); 767 return null; 768 } catch (ExecutionException e) { 769 // Extract the errors exception, if any 770 if (e.getCause() != null && 771 e.getCause().getCause() != null && 772 e.getCause().getCause() instanceof TransactionErrorException) { 773 throw (TransactionErrorException) e.getCause().getCause(); 774 } else if (e.getCause() != null && 775 e.getCause() instanceof TransactionErrorException) { 776 // See https://github.com/killbilling/recurly-java-library/issues/16 777 throw (TransactionErrorException) e.getCause(); 778 } 779 log.error("Execution error", e); 780 return null; 781 } catch (InterruptedException e) { 782 log.error("Interrupted while calling Recurly", e); 783 return null; 784 } 785 } 786 787 private <T> T callRecurly(final AsyncHttpClient.BoundRequestBuilder builder, @Nullable final Class<T> clazz) 788 throws IOException, ExecutionException, InterruptedException { 789 final Response response = builder.addHeader("Authorization", "Basic " + key) 790 .addHeader("Accept", "application/xml") 791 .addHeader("Content-Type", "application/xml; charset=utf-8") 792 .setBodyEncoding("UTF-8") 793 .execute() 794 .get(); 795 796 final InputStream in = response.getResponseBodyAsStream(); 797 try { 798 final String payload = convertStreamToString(in); 799 if (debug()) { 800 log.info("Msg from Recurly API :: {}", payload); 801 } 802 803 // Handle errors payload 804 if (response.getStatusCode() >= 300) { 805 log.warn("Recurly error whilst calling: {}\n{}", response.getUri(), payload); 806 807 if (response.getStatusCode() == 422) { 808 final Errors errors; 809 try { 810 errors = xmlMapper.readValue(payload, Errors.class); 811 } catch (Exception e) { 812 // 422 is returned for transaction errors (see http://docs.recurly.com/api/transactions/error-codes) 813 // as well as bad input payloads 814 log.debug("Unable to extract error", e); 815 return null; 816 } 817 throw new TransactionErrorException(errors); 818 } else { 819 RecurlyAPIError recurlyError = null; 820 try { 821 recurlyError = xmlMapper.readValue(payload, RecurlyAPIError.class); 822 } catch (Exception e) { 823 log.debug("Unable to extract error", e); 824 } 825 throw new RecurlyAPIException(recurlyError); 826 } 827 } 828 829 if (clazz == null) { 830 return null; 831 } 832 833 final T obj = xmlMapper.readValue(payload, clazz); 834 if (obj instanceof RecurlyObject) { 835 ((RecurlyObject) obj).setRecurlyClient(this); 836 } else if (obj instanceof RecurlyObjects) { 837 final RecurlyObjects recurlyObjects = (RecurlyObjects) obj; 838 recurlyObjects.setRecurlyClient(this); 839 840 // Set the RecurlyClient on all objects for later use 841 for (final Object object : recurlyObjects) { 842 ((RecurlyObject) object).setRecurlyClient(this); 843 } 844 845 // Set the total number of records 846 final String xRecords = response.getHeader(X_RECORDS_HEADER_NAME); 847 if (xRecords != null) { 848 recurlyObjects.setNbRecords(Integer.valueOf(xRecords)); 849 } 850 851 // Set links for pagination 852 final String linkHeader = response.getHeader(LINK_HEADER_NAME); 853 if (linkHeader != null) { 854 final String[] links = PaginationUtils.getLinks(linkHeader); 855 recurlyObjects.setStartUrl(links[0]); 856 recurlyObjects.setPrevUrl(links[1]); 857 recurlyObjects.setNextUrl(links[2]); 858 } 859 } 860 return obj; 861 } finally { 862 closeStream(in); 863 } 864 } 865 866 private String convertStreamToString(final java.io.InputStream is) { 867 try { 868 return new Scanner(is).useDelimiter("\\A").next(); 869 } catch (final NoSuchElementException e) { 870 return ""; 871 } 872 } 873 874 private void closeStream(final InputStream in) { 875 if (in != null) { 876 try { 877 in.close(); 878 } catch (IOException e) { 879 log.warn("Failed to close http-client - provided InputStream: {}", e.getLocalizedMessage()); 880 } 881 } 882 } 883 884 private AsyncHttpClient createHttpClient() { 885 // Don't limit the number of connections per host 886 // See https://github.com/ning/async-http-client/issues/issue/28 887 final AsyncHttpClientConfig.Builder builder = new AsyncHttpClientConfig.Builder(); 888 builder.setMaximumConnectionsPerHost(-1); 889 return new AsyncHttpClient(builder.build()); 890 } 891}