You are here

FormAssemblyRequest.php in FormAssembly 7

Authorizes the current site and handles API requests to FormAssembly.

File

includes/FormAssemblyRequest.php
View source
<?php

/**
 * @file
 * Authorizes the current site and handles API requests to FormAssembly.
 */
class FormAssemblyRequest {
  private $apiHost;
  private $clientId;
  private $clientSecret;
  private $authEndpoint = 'oauth/login';
  private $tokenEndpoint = 'oauth/access_token';
  private $authCallback = 'admin/config/services/formassembly/authorize';
  private $returnUrl;
  private $token;
  private $forms = array();
  private $page = 1;
  private $last_hash = NULL;
  private $current_hash = NULL;
  private $responses = array();

  /**
   * Initialize a new FormAssemblyRequest object.
   *
   * @param string $client_id
   *   The client ID required for authentication.
   *
   * @param string $client_secret
   *   The client secret required for authentication.
   *
   * @param string $endpoint
   *   The url of the api endpoint.
   */
  public function __construct($client_id, $client_secret = '', $endpoint = '') {
    global $base_url;
    $api_mode = variable_get('formassembly_endpoint', FALSE);
    if ($api_mode) {
      switch ($api_mode) {
        case 'dev':
          $this->apiHost = 'https://developer.formassembly.com';
          break;
        case 'pro':
          $this->apiHost = 'https://app.formassembly.com';
          break;
        case 'ent':
          $this->apiHost = 'https://' . variable_get('formassembly_instance') . '.tfaforms.net';
          break;
      }
      $this->clientId = $client_id;
      $this->clientSecret = $client_secret;
      $this->authCallback = 'admin/config/services/formassembly/authorize';
      $this->returnUrl = $base_url . '/' . $this->authCallback;
    }
    else {
      watchdog('FormAssembly', 'Connection Failure: API Endpoint must be configured', WATCHDOG_ERROR);
    }
  }

  /**
   * Redirect the user so to authorize the application using OAuth2.0.
   *
   * @return bool|mixed
   *   Returns an access token if the user has already authorized.
   */
  public function authorize() {
    $token = $this
      ->getToken();
    if ($token) {
      return $token;
    }
    $params = array(
      'type' => 'web',
      'client_id' => $this->clientId,
      'redirect_uri' => $this->returnUrl,
      'response_type' => 'code',
    );
    $auth_uri = $this->apiHost . '/' . $this->authEndpoint . '?' . drupal_http_build_query($params);
    drupal_goto($auth_uri);
    return NULL;
  }

  /**
   * Retrieve an active access_token from the database or request a new one.
   *
   * @param string $code
   *   A hash passed into $_GET['code'] after the user has been redirected back
   *   from the auth portal. Needed for new token requests.
   *
   * @return bool|null
   *   An access token if available or FALSE if something has gone wrong.
   */
  public function getToken($code = '') {

    // Retrieve a previously generated token.
    $token = variable_get('formassembly_oauth_access_token', '');
    if (!empty($token)) {
      return $token;
    }

    // Something went wrong - maybe they ended up at this page by accident?
    if (empty($code)) {
      return FALSE;
    }

    // Prepare to request a new access_token.
    $data = array(
      "grant_type" => "authorization_code",
      "type" => "web_server",
      "client_id" => $this->clientId,
      "client_secret" => $this->clientSecret,
      "redirect_uri" => $this->returnUrl,
      "code" => $code,
    );
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $this->apiHost . '/' . $this->tokenEndpoint);
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    $response = curl_exec($ch);
    unset($ch);
    $response = json_decode($response);
    if (isset($response->access_token)) {
      $this
        ->setToken($response->access_token);
      variable_set('formassembly_oauth_access_token', $this->token);
      return $this->token;
    }
    watchdog('formassembly', 'Could not retrieve access token.', array(), WATCHDOG_ERROR);
    return FALSE;
  }

  /**
   * Request all form data from FormAssembly and perform CRUD ops as needed.
   */
  public function syncForms() {
    $forms_by_faid = array();
    $fa_form_controller = entity_get_controller('fa_form');
    foreach ($this->forms as $form_item) {
      $forms_by_faid[$form_item['Form']['id']] = array(
        'faid' => $form_item['Form']['id'],
        'name' => filter_xss(decode_entities($form_item['Form']['name'])),
        'modified' => date('U', strtotime($form_item['Form']['modified'])),
      );
    }

    // Update forms that have changed since the last sync.
    foreach ($forms_by_faid as $form_data) {

      // Load an existing fa_id entity if one matches $form_data->id or
      // create a new entity otherwise.
      $fa_form_search = $fa_form_controller
        ->loadByProperties(array(
        'faid' => $form_data['faid'],
      ));

      // The search returns data as an array keyed by eid or an empty array if
      // no match. There should only be one item - faid is a unique key so pop
      // the first item off the array or we get NULL if the array was empty...
      $found_form = array_shift($fa_form_search);
      if ($found_form != NULL) {

        // Update forms that have changed since the last sync.
        if ($found_form
          ->getModified() < $form_data['modified']) {

          // Update the title and modified date stored.
          $found_form
            ->updateData($form_data);
          $fa_form_controller
            ->save($found_form);
        }
      }
      else {
        $new_fa_form = $fa_form_controller
          ->create($form_data);
        $fa_form_controller
          ->save($new_fa_form);
      }

      // Now check for fa_forms that are no longer in $forms_by_faid.
      // First pull the faids currently stored...
      $stored = $fa_form_controller
        ->loadPropertySet('faid');

      // Now check those against the faids from the rest request...
      foreach ($stored as $record) {
        if (!array_key_exists($record->faid, $forms_by_faid)) {

          // The faid of the current record is not a key of $forms_by_faid,
          // so the form is no longer in FormAssembly.
          $eids_to_delete[] = $record->eid;
        }
      }
      if (isset($eids_to_delete)) {
        formassembly_delete_multiple($eids_to_delete);
      }
    }
    watchdog('formassembly', 'Form sync complete.', array(), WATCHDOG_NOTICE);
  }

  /**
   * Set an access token to use in the current request.
   *
   * @param string $token
   *   A valid access_token as returned by FA's API.
   */
  public function setToken($token) {
    $this->token = $token;
  }

  /**
   * Get data about available forms.  If admin index is in use, get a single page of data.
   *
   * @param string $endpoint
   *   The api endpoint to make the request against.
   * @param boolean $is_admin
   *   Flag indicating if $endpoint is an admin index
   *
   * @return array
   *   An array of objects representing FormAssembly forms.
   */
  public function getForms($endpoint = 'api_v1/forms/index.json', $is_admin = FALSE) {

    //prepare a processing array for forms
    $finished = false;
    if ($is_admin) {
      $request_uri = $this->apiHost . '/' . $endpoint . '?access_token=' . urlencode($this->token) . '&show=50&page=' . $this->page;
      $response = drupal_http_request($request_uri);

      // If there's an error message...
      if (isset($response->error)) {
        throw new Exception($response->error);
      }

      // If the response was not an HTTP 200 Success...
      if ($response->code != 200) {
        throw new Exception($response->status_message);
      }

      // If we don't get JSON on the first try there is an error
      // on subsequent pages FormAssembly indicates that the last page could be empty
      if ($this->page == 1 && !$this
        ->isJson($response->data)) {
        throw new Exception($response->data);
      }

      //increment flags
      if (!empty($this->current_hash)) {
        $this->last_hash = $this->current_hash;
      }

      // hash the request so we can easily see if it has changed
      $this->current_hash = md5($response->data);

      //We store the hashes in the object to keep all the properties of the request together

      // but the boolean expression below is easier to read without also checking for empty object properties.
      $last_hash = $this->last_hash;
      $current_hash = $this->current_hash;
      if (!(empty($response->data) || $last_hash === $current_hash)) {

        // observed behavior from formassembly is if n pages are needed to iterate the admin index
        // page n+1 returns the same response as page n
        // but formassembly advises this could change to page n+1 returns empty. Neither of these responses should
        // be processed and we are finished.
        // Otherwise, process response
        $this->responses[] = drupal_json_decode($response->data);

        // increment page
        ++$this->page;
      }
      else {
        $finished = TRUE;
      }
    }
    else {
      $request_uri = $this->apiHost . '/' . $endpoint . '?access_token=' . urlencode($this->token);
      $response = drupal_http_request($request_uri);

      // If there's an error message...
      if (isset($response->error)) {
        throw new Exception($response->error);
      }

      // If the response was not an HTTP 200 Success...
      if ($response->code != 200) {
        throw new Exception($response->status_message);
      }
      if (!$this
        ->isJson($response->data)) {
        throw new Exception($response->data);
      }
      $this->responses[] = drupal_json_decode($response->data);
      $finished = TRUE;
    }
    return $finished;
  }

  /**
   * process responses property into forms array
   */
  public function processResponses() {
    if (!empty($this->responses)) {
      $forms = array();
      foreach ($this->responses as $response_array) {
        if (!empty($response_array['Forms'])) {
          $forms = array_merge($forms, $response_array['Forms']);
        }
        if (!empty($response_array['Category'])) {
          $this
            ->extractCategories($forms, $response_array['Category']);
        }
      }
      $this->forms = $forms;
    }
  }

  /**
   * @param array $forms
   *   the processing array for forms
   * @param array $Category
   *    a Category array - may contain additional Category arrays
   */
  public function extractCategories(array &$forms, array $Category) {
    foreach ($Category as $formset) {
      if (!empty($formset['Category'])) {
        $this
          ->extractCategories($forms, $formset['Category']);
      }
      if (!empty($formset['Forms'])) {
        $forms = array_merge($forms, $formset['Forms']);
      }
    }
  }

  /**
   * Retrieve the HTML for a FormAssembly form.
   *
   * The FA API recognizes query parameters passed on the rest URL and will
   * use them to pre-fill fields in the returned form markup.  Here we fold
   * in parameters configured via formassembly_form() and expose the hook
   * hook_formassembly_form_params_alter(&$params) to allow modules to modify
   * the passed parameter list.
   *
   * @param $entity
   *   Entity form object.
   *
   * @return string
   *   HTML representation of the form.
   */
  public function getFormMarkup($entity) {

    // Add configured query params passed to FA API.
    $entity_wrapped = entity_metadata_wrapper('fa_form', $entity);
    $query_params = $entity_wrapped->fa_query_params
      ->value();
    $params = array();
    if (!empty($query_params)) {
      $params = unserialize($query_params);
    }

    // Expose hook_formassembly_form_params_alter().
    drupal_alter('formassembly_form_params', $params);

    // Replace any tokens found in the parameter pair values.
    foreach ($params as $key => $value) {
      $params[$key] = token_replace($value);
    }

    // Make FA rest call and return form markup.
    $request_uri = url($this->apiHost . '/rest/forms/view/' . $entity->faid, array(
      'query' => $params,
    ));
    $response = drupal_http_request($request_uri);
    return $response->data;
  }

  /**
   * Retrieve the HTML for a FormAssembly next path using a returned tfa_next value.
   *
   * @param string $tfa_next
   *   The urlencoded parameter from of FormAssembly.
   *
   * @return string
   *   HTML returned by the query
   */
  public function getNextForm($tfa_next) {
    $query_path = urldecode($tfa_next);
    $request_uri = $this->apiHost . '/rest/' . $query_path;
    $response = drupal_http_request($request_uri);
    return $response->data;
  }

  /**
   * Retrieve the current request page value.
   *
   * @return int
   */
  public function getPage() {
    return $this->page;
  }

  /**
   * Helper function check if a string is JSON format.
   *
   * @param string $string
   *   The string to check.
   *
   * @return bool
   *   True if $string is JSON.
   */
  protected function isJson($string) {
    json_decode($string);
    return json_last_error() == JSON_ERROR_NONE;
  }

  /**
   * Split FormAssembly form HTML into HEAD and BODY markup.
   *
   * @param string $markup
   *   HTML markup.
   *
   * @return string[]
   *   Array with two entries: Body HTML and Head HTML for the form.
   */
  public function splitHTML($markup) {
    $dom = new DOMDocument();
    libxml_use_internal_errors(TRUE);
    if (!$dom
      ->loadHTML($markup)) {
      foreach (libxml_get_errors() as $error) {
        watchdog('FormAssembly', 'Form HTML failed to load to split markup. LibXML error message is: ' . $error->message, WATCHDOG_ERROR, NULL);
      }
      libxml_clear_errors();
    }

    /** @var DOMElement|bool $body_wrapper_div */
    $body_wrapper_div_markup = FALSE;
    $div_elements = $dom
      ->getElementsByTagName('div');

    // Now, per FormAssembly support, we'll try to find a <div> element with the class name "wFormContainer".

    /** @var DOMElement $div */
    foreach ($div_elements as $div) {
      $class = $div
        ->getAttribute('class');

      // If this <div> element's class contains "wFormContainer"
      if (stripos($class, 'wFormContainer') !== FALSE) {
        $body_wrapper_div_markup = $div->ownerDocument
          ->saveHTML($div);
        break;
      }
    }

    // If we found the body wrapper div
    if ($body_wrapper_div_markup) {

      // Split the markup into its Body and Head components.
      // Just in case there's another wrapper, we get the HTML before the first
      // <div>, which is probably the same <div> we just found.
      $head_markup = substr($markup, 0, stripos($markup, '<div'));

      // We clean up the Head HTML by removing known useless comments.
      $head_markup = str_ireplace('<!-- FORM: HEAD SECTION -->', '', $head_markup);
      $head_markup = str_ireplace('<!-- FORM: BODY SECTION -->', '', $head_markup);
      $head_markup = trim($head_markup);
      $body_markup = $body_wrapper_div_markup;
    }
    else {

      // Fall back to using the entire markup in the body.
      $head_markup = '';
      $body_markup = trim($markup);
    }
    return array(
      $head_markup,
      $body_markup,
    );
  }

}

Classes

Namesort descending Description
FormAssemblyRequest @file Authorizes the current site and handles API requests to FormAssembly.