Q2 Requests

The Caliper SDK has a lot of tools for communication with the Q2 system, but it’s not limited to just that. What if you wanted to query a third-party API or service, or a service you created yourself outside of the Q2 stack? The q2_requests toolset wraps the powerful and widely used Python requests library in some Q2-specific functionality and adds asynchronous code features.

Here’s how it looks:

from q2_sdk.core import q2_requests
response = await q2_requests.get(self.logger, 'https://www.q2ebanking.com/')

It’s that simple. A q2_requests method exists for each commonly used HTTP method: GET, POST, PUT, DELETE, OPTIONS, PATCH, and HEAD.

Our GET request above requires only a logger (we will discuss logging in a later step, but for now this should always be self.logger), and a URL.

Let’s use this, plus some more advanced Python and HTML, to add a more ambitious feature: a foreign currency converter for our transaction history. The Q2 local-dev-api server we use for our websockets has a currency rate related endpoint built for this purpose.

Here’s an example of what we can expect in response when we call it:

{
  "success":true,
  "source":"USD",
  "quotes":{
    "AED":3.672696,
    "AFN":71.150002,
    "ALL":107.949997,
    "AMD":482.720001,
    "ANG":1.790388,
    "AOA":237.675995,
    "ARS":25.420509,
    "AUD":1.315697,
    "AWG":1.78,
    "AZN":1.699503,
    ...
  }
}

First, let’s go ahead and add the third-party request. We can do this inside its own method for better organization.

async def get_currency_rates(self):
    url = "https://local-dev-api.q2developer.com/projects/currencyRates"

    response = await q2_requests.get(
        self.logger,
        url,
        headers={
            'apikey': 'S6TczFkiWjDZy7FElvqKYMogzdcEcoQy'
        }
    )

    response_as_dict = response.json()

    try:
        quotes = response_as_dict['quotes']
        quotes_dictionary = {}

        # Reformat our quotes into a more display friendly format
        for quotes_key, quotes_value in quotes.items():
            quotes_dictionary[quotes_key] = quotes_value

    except KeyError:
        quotes_dictionary = {}

    return quotes_dictionary

There are several new concepts here. First, the CurrencyRates endpoint needs an API key, so we provide it as a header via the headers keyword argument.

The json method converts our response string into a Python dictionary, allowing us to pull out the quotes dictionary and create a sorted list of its keys.

But take a look at our sample response: the quote keys are returned with the source currency symbol as well the the conversion symbol. We want our users to see the conversion only, so we have a data formatting step to do. You will often find responses from 3rd parties to need some massaging before they are ready for use. Python makes this easy.

In this case, we iterate through the quotes dictionary items, creating a new dictionary with only the conversion symbol as a key. If we were to print this new dictionary out, it would appear like this:

{
    "AED":3.672696,
    "AFN":71.150002,
    "ALL":107.949997,
    "AMD":482.720001,
    "ANG":1.790388,
    "AOA":237.675995,
    "ARS":25.420509,
    "AUD":1.315697,
    "AWG":1.78,
    "AZN":1.699503,
    ...
}

Note

Don’t be afraid to add non-route methods on the RequestHandler! You won’t break anything, and it’s a great way to isolate concerns and make code more readable and testable. Methods only serve as routes if referenced explicitly in self.router().

Now, our goal is to create a select field containing these options, which will reload the transaction history page with the amounts converted to the selected currency when changed. This requires some more complicated Python than what we’ve been using, but it isn’t too rough. Here’s our new submit function, which calls the get_currency_rates method we just added :

async def submit(self):
    rates_dictionary = await self.get_currency_rates()

    sorted_rates = sorted(rates_dictionary.keys())
    current_account_id = self.form_fields['account_id']
    selected_currency = self.form_fields.get('currency', 'USD')

    params_obj = GetAccountHistoryById.ParamsObj(
        self.logger,
        current_account_id,
        '',
        hq_credentials=self.hq_credentials
    )

    hq_response = await GetAccountHistoryById.execute(params_obj)

    transaction_models = []
    for transaction in hq_response.result_node.Data.AllHostTransactions.Transactions:
        date_string = str(transaction.PostDate)

        arrow_date_object = arrow.get(date_string)
        display_date = arrow_date_object.format('MMMM DD, YYYY')

        is_credit = (transaction.SignedTxnAmount > 0)

        if selected_currency and selected_currency != 'USD':
            # multiply our USD amount by the retrieved exchange rate
            # return a string representation of the absolute value of the amount
            # in our new category rounded to 2 decimal places
            modified_amount = transaction.TxnAmount * rates_dictionary[selected_currency]
            display_amount = format(modified_amount, '.2f')
        else:
            # if we are displaying USD, just return a string representation of
            # the absolute value of the amounts as before
            display_amount = str(abs(transaction.TxnAmount))

        display_description = str(transaction.Description)

        transaction_models.append({
            'display_date': display_date,
            'display_amount': display_amount,
            'is_credit': is_credit,
            'display_description': display_description
        })

    template = self.get_template(
        'transaction_history.html.jinja2',
        {
            'header': 'Transaction History',
            'transactions': transaction_models,
            'currency_options': sorted_rates,
            'current_account_id': self.form_fields['account_id'],
            'current_selected_currency': selected_currency
        }
    )

    html = self.get_tecton_form(
        "Transaction History",
        custom_template=template,
        hide_submit_button=True
    )

    return html

First, we had to grab the selected currency code from our submitted form. Next, we needed to loop through the transactions, changing the amount to our new currency. We have some new values to pass into our HTML, as well: the currency options, our account id, and the currently selected currency.

Why do we need the last two? Our Caliper SDK server is stateless: for it to correctly remember which account we’re looking at and what currency we requested, we need to make that sure information is stored on our form for resubmission.

Let’s make our UI for this feature. Replace your HTML template file (templates/transaction_history.html.jinja2) with the following:

<div>
    <a href="#default">Back to Accounts</a>
</div>

<q2-select label="Currency" name="currency">
    {% for currency in currency_options %}
        {% if currency == current_selected_currency %}
            <q2-option value="{{currency}}" display="{{currency}}" selected>{{currency}}</q2-option>
        {% else %}
            <q2-option value="{{currency}}" display="{{currency}}">{{currency}}</q2-option>
        {% endif %}
    {% endfor %}
</q2-select>

<input id="hidden_account_id_storage" name="account_id" value="{{current_account_id}}" type="hidden">

<div>
    <a href="#submit">Convert Currency</a>
</div>
<div id="placeholder-converted-values">

</div>
<div class="table-wrapper">
    <table>
    <thead>
        <tr>
        <th width="100">
            Date
        </th>
        <th>
            Description
        </th>
        <th class="text-right">
            Amount
        </th>
        </tr>
    </thead>
    <tbody>
        {% for transaction in transactions %}
            <tr>
                <td>{{transaction.display_date}}</td>
                <td>{{transaction.display_description}}</td>
                {% if transaction.is_credit %}
                    <td class="text-right clr(const-stoplight-success)">+{{transaction.display_amount}} {{current_selected_currency}}</td>
                {% else %}
                    <td class="text-right">-{{transaction.display_amount}} {{current_selected_currency}}</td>
                {% endif %}
            </tr>
        {% endfor %}
    </tbody>
    </table>
    <script>
        window.tecton.connected.then(function() {
            document.querySelector('q2-select').value = '{{current_selected_currency}}';
        });
    </script>
</div>

First, to create our currency selection field, we iterate through our list of currency options, adding each one to a select field while ensuring the currently active currency appears as selected.

Next, that hidden input will store our current account id for us, ensuring the server reloads the right account when we submit. This is a simple way to persist values across multiple form loads.

The “Convert Currency” link will reload the form with our new currency active: this form links to its own routing key, which is perfectly fine and the intended way to reload a route with new data.

Finally, for clarity, we display the current selected currency code after our amounts.

Note

You will need to set a name attribute on an input field for its value to appear in self.form_fields after POST. This name will be used as the key, not id as you might expect. In this case, self.submit will have access to two form keys, “currency” and “account_id”.

Important

If you are using SDK 2.0 there is an extra step here.

If you run this now, you will quickly run into an error. The logs will point you in the right direction:

...
Traceback (most recent call last):
...
   raise BlacklistedUrlError(error_msg)
q2_sdk.core.exceptions.BlacklistedUrlError:
local-dev-api.q2developer.com not in OUTBOUND_WHITELIST.
Outbound calls are allowed at development time,
but will be blocked in the Q2 datacenter.
Please create a Q2 Support ticket at
https://q2developer.com/support/create?ticketType=Whitelist
to whitelist this url if you are planning
to use it in staging/production,
then add it to the OUTBOUND_WHITELIST variable in your
settings file to suppress this message.

Pretty clear path forward. Let’s add local-dev-api.q2developer.com into the OUTBOUND_WHITELIST variable in configuration/settings:

OUTBOUND_WHITELIST.extend(['local-dev-api.q2developer.com'])

When you change the value of the currency select field, your form should reload with the same transactions, but their amounts have been converted to your selected currency, using the daily exchange rates we got from a third-party. Now it’s getting interesting!