Skip to main content

Authored on

Thu, 07/18/2019 - 16:21
Image
nyc

From time to time by different reasons I got involved into interviews processes, then technical questions stuff come to my mind, when it's about Drupal back-end integrations and customizations. What I used to ask is something like:

"Are you into back-end integrations?" - `sure thing!` They used to say, then I shoot my first question.

"Have you ever consume a service? RESTful API/ASMX SOAP?"

At least, in my experience, it seems that people are starting to forget about SOAP services, from my perspective isn't too bad, since we have JSON and RESTful APIs out there. However there are a lot of legacy implementations around the web which are still using this kind of SOAP services.

Then I thought to kill two birds with one rock, share a little bit my experience on how to consume a service by using Drupal 8 and also by using a SOAP service available here at Costa Rica from the Banco Central de Costa Rica, (a.k.a., BCCR) what is exposing exchange rate information.

ASMX SOAP Definition

The documentation is your friend, if you didn't know it already, now you know. We need to find the documentation in order to use the service, it is not required you may think but trust me it helps a lot. I found the definition at official Bank's page

After some back and forth I found a public subdomain where the ASMX file is available, 

http://indicadoreseconomicos.bccr.fi.cr/indicadoreseconomicos/WebServices/wsIndicadoresEconomicos.asmx

By click "The following operations are supported. For a formal definition, please review the Service Description." It wil redirect you to XML service definition (it just includes the following query string "?WSDL" to the URL).

Then I read the documentation and I understood that I need to consume their XML method one,

http://indicadoreseconomicos.bccr.fi.cr/indicadoreseconomicos/WebServices/wsIndicadoresEconomicos.asmx?op=ObtenerIndicadoresEconomicosXML

This method requires an index/indicator to retrieve the proper type of value we want to show up, it's a public list of them available here:

https://gee.bccr.fi.cr/Indicadores/Suscripciones/UI/ConsultaIndicadores/ObtenerArchivo

In our case we may use the following codes:

  • Compra Diaria (317) | Buying rate
  • Venta Diaria (318) | Selling rate
Exchange rate module

Keep it simple, as far as you can! For the sake of this tutorial I will create a new module which will contain a service to consume the SOAP web service mentioned above and also a controller to show up the exchange rate information retrieved from BCCR's web service.

exchange_rate.info.yml

name: Exchange Rate
description: Consume ASMX SOAP service to show up exchange rate from BCCR
package: keboca

type: module
core: 8.x

dependencies:
  - drupal:serialization

Next thing, we need to define two new services and setup our dependency injection properly:

exchange_rate.services.yml

services:
  exchange_rate.manager:
    class: \Drupal\exchange_rate\ExchangeRateManager
    arguments: ['@http_client', '@serializer', '@cache.default']
  exchange_rate.controller:
    class: \Drupal\exchange_rate\Controller\ExchangeRateController
    arguments: ['@exchange_rate.manager']

Based on previous service definition we need to create our manager class per se, it means the arguments should be injected into manager's constructor class, it will look like this:

src/ExchangeRateManager.php

<?php

namespace Drupal\exchange_rate;

use Drupal\Component\Datetime\DateTimePlus;
use Drupal\Component\Utility\Tags;
use Drupal\Core\Cache\CacheBackendInterface;
use GuzzleHttp\Client;
use Symfony\Component\Serializer\Serializer;

/**
 * Class ExchangeRateManager
 *
 * @package Drupal\exchange_rate
 */
class ExchangeRateManager {

  /**
   * @var \GuzzleHttp\Client
   */
  protected $httpClient;

  /**
   * @var \Symfony\Component\Serializer\Serializer
   */
  protected $serializer;

  /**
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $cache;

  /**
   * ExchangeRateManager constructor.
   *
   * @param \GuzzleHttp\Client $http_client
   * @param \Symfony\Component\Serializer\Serializer $serializer
   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
   */
  public function __construct(Client $http_client, Serializer $serializer, CacheBackendInterface $cacheBackend) {
    $this->httpClient = $http_client;
    $this->serializer = $serializer;
    $this->cache = $cacheBackend;
  }

  // code...
}

Now we have to create our method in order to consume the SOAP service, it may look like this:

  /**
   * Retrieve exchange rate information from cache or by consuming the API.
   *
   * @param array $dates
   * @param int $type
   *
   * @return array|null
   */
  public function getExchangeRates(array $dates, int $type) {
    // Init resource.
    $resource = NULL;

    // Build cache ID value based on given values.
    $cid = implode('::', [Tags::implode($dates), $type]);

    // Looks up value from cache.
    if ($cache = $this->cache->get($cid)) {
      $resource = $cache->data;
    }
    else {
      // Otherwise consume API resource.
      $resource = $this->consume($dates, $type);

      // When resource was properly defined.
      if (!is_null($resource)) {

        // Then include it into cache.
        $this->cache->set($cid, $resource);
      }
    }

    // Return parsed value.
    return $resource;
  }

Two important things about method above, I am using utility class from Drupal core: "Tags" that's the reason why I am including the use statement in order to import it properly: "Drupal\Component\Utility\Tags". Also I am using an internal method named "consume", it looks like this:


  /**
   * Consume API service by using given arguments.
   *
   * @param array $dates
   * @param int $type
   *
   * @return null|array
   */
  protected function consume(array $dates, int $type) {
    // Extract date range.
    list($start_date, $end_date) = $dates;

    // Build query string.
    $params = [
      'tcIndicador' => $type,
      'tcFechaInicio' => $start_date,
      'tcFechaFinal' => $end_date,
      'tcNombre' => time(),
      'tnSubNiveles' => 'N',
    ];

    // Define endpoint URL.
    $query_string = http_build_query($params);
    $url = sprintf(
    //@TODO: I highly recommend to use configuration instead of URL hardcoded.
      'http://indicadoreseconomicos.bccr.fi.cr/indicadoreseconomicos/WebServices/wsIndicadoresEconomicos.asmx/ObtenerIndicadoresEconomicosXML?%s',
      $query_string
    );

    // Init resource variable.
    $resource = NULL;

    try {
      // Consume service.
      $response = $this->httpClient->get($url);

      // Verify HTTP response code.
      if ($response->getStatusCode() == 200) {
        // Retrieve XML response content.
        $xml = $response->getBody()->getContents();

        // Extract content as an array.
        $resource = $this->parseXMLContent($xml);
      }
      else {
        // Throw a runtime exception.
        throw New \RuntimeException(
          'Error while consuming API. Error code: ' . $response->getStatusCode()
        );
      }
    } catch (\RuntimeException $e) {
      // Show error message and log error message.
      watchdog_exception(__METHOD__, $e);
    }

    return $resource;
  }

Here I am using another internal method named "parseXMLContent" in order to parse XML content to an array, it looks as shown below:


  /**
   * Parse XML value from resource API.
   *
   * @param string $xml
   *
   * @return array
   */
  protected function parseXMLContent(string $xml) {
    // Decode given XML content by using serializer.
    $resource = $this->serializer->decode($xml, 'xml');

    // Then parse it again to retrieve array value to iterate.
    $resource = $this->serializer->decode($resource['0'], 'xml');

    // Parse exchange rate information.
    $resource = array_map(function ($item) {
      /** @var DateTimePlus $date */
      $date = DateTimePlus::createFromFormat(DATE_ATOM, $item['DES_FECHA']);

      return [
        'date' => $date->format('d/m/Y'),
        'rate' => '₡' . number_format($item['NUM_VALOR'], 2),
      ];
    }, $resource['INGC011_CAT_INDICADORECONOMIC']);

    // Return value as an array.
    return $resource;
  }

Here I am using "DateTimePlus" utility class from Drupal core, then I am importing it by including use statement at the top of the class: "Drupal\Component\Datetime\DateTimePlus" as well.

It should be good enough to consume our API, then let's go and see how a custom controller will be coded to expose retrieved exchange rate information. Believe it or not, we now may setup controller as service reason why I defined a service named "exchange_rate.controller" then we go to use it into our routing file as shown here:

exchange_rate.routing.yml

exchange_rate.content:
  path: '/exchange-rate/bccr'
  defaults:
    _controller: 'exchange_rate.controller:content'
  requirements:
    _permission: 'access content'

Now we go to create our controller as a service, like this:

src/Controller/ExchangeRateController.php

<?php

namespace Drupal\exchange_rate\Controller;

use Drupal\Component\Datetime\DateTimePlus;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Controller\ControllerBase;
use Drupal\exchange_rate\ExchangeRateManager;

/**
 * Class ExchangeRateController
 *
 * @package Drupal\exchange_rate\Controller
 */
class ExchangeRateController extends ControllerBase {

  /**
   * @var \Drupal\exchange_rate\ExchangeRateManager
   */
  protected $manager;

  /**
   * ExchangeRateController constructor.
   *
   * @param \Drupal\exchange_rate\ExchangeRateManager $exchangeRateManager
   */
  public function __construct(ExchangeRateManager $exchangeRateManager) {
    $this->manager = $exchangeRateManager;
  }

  public function content() {
    // Init content variable and extract current timestamp.
    $content = [];
    $timestamp = time();

    /** @var DateTimePlus $startDate */
    $startDate = DateTimePlus::createFromTimestamp($timestamp)
      ->sub(\DateInterval::createFromDateString('10 day'));

    /** @var DateTimePlus $endDate */
    $endDate = DateTimePlus::createFromTimestamp($timestamp);

    // Setup resources types to consume.
    $resources = [
      317 => 'buying',
      318 => 'selling',
    ];
    foreach ($resources as $key => $value) {
      /** @var array $resource */
      $resource = $this->manager->getExchangeRates([
        $startDate->format('d/m/Y'),
        $endDate->format('d/m/Y'),
      ], $key);

      // Build up table type for each resource type.
      $content[$value] = [
        '#type' => 'table',
        '#attributes' => [
          'class' => [
            'exchange-rate-table',
          ],
        ],
        '#caption' => $this->t(
          Unicode::ucfirst($value) . ' rate'
        ),
        '#header' => [
          $this->t('Date'),
          $this->t('Rate'),
        ],
        '#rows' => array_map(function ($item) {
          return [
            $item['date'],
            $item['rate'],
          ];
        }, $resource),
      ];
    }

    return $content;
  }
}

If you browse to the new controller's URL ("/exchange-rate/bccr") then you should be able to see it in action.  Yay!!!

Conclusion

Last but not least I am using Drupal 8.7 version, it should be similar if you're using previous version but it is worth to let you know about it. I know, it seems pretty straight-forward by using Drupal 8, what I can say, it is what it is! Of course, if you're more like old really old school, then you still can use "SoapClient" php class, take a look here.

Hope it helps you at some point somehow!

PD: A demo is available here also I uploaded whole custom module to Github.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.