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}