<?php
/*
 * Copyright 2010 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Curl based implementation of apiIO.
 * This class implements http spec compliant request caching using the apiCache class
 *
 * @author Chris Chabot <chabotc@google.com>
 * @author Chirag Shah <chirags@google.com>
 */
class apiCurlIO implements apiIO {
  private static $DEFAULT_CURL_PARAMS = array (
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_FOLLOWLOCATION => 0,
      CURLOPT_FAILONERROR => false,
      CURLOPT_SSL_VERIFYPEER => true,
      CURLOPT_HEADER => true,
  );

  /**
   * Perform an authenticated / signed apiHttpRequest.
   * This function takes the apiHttpRequest, calls apiAuth->sign on it
   * (which can modify the request in what ever way fits the auth mechanism)
   * and then calls apiCurlIO::makeRequest on the signed request
   *
   * @param apiHttpRequest $request
   * @return apiHttpRequest The resulting HTTP response including the
   * responseHttpCode, responseHeaders and responseBody.
   */
  public function authenticatedRequest(apiHttpRequest $request) {
    $request = apiClient::$auth->sign($request);
    return $this->makeRequest($request);
  }

  /**
   * Execute a apiHttpRequest
   *
   * @param apiHttpRequest $request the http request to be executed
   * @return apiHttpRequest http request with the response http code, response
   * headers and response body filled in
   * @throws apiIOException on curl or IO error
   */
  public function makeRequest(apiHttpRequest $request) {
    // If it's a GET request, check to see if we have a valid cached version
    if ($request->getMethod() == 'GET') {
      // check to see if this is signed, and if so use the original url + oauth
      // access token to get a (per user context(!)) unique key to match against
      if (($cachedRequest = $this->getCachedRequest($request)) !== false) {
        if ($this->mustRevalidate($cachedRequest)) {
          $addHeaders = array();
          $headers = $this->getNormalizedHeaders($cachedRequest);
          if (isset($headers['etag'])) {
            $addHeaders[] = 'If-None-Match: ' . $headers['etag'];
          } elseif (isset($headers['Date'])) {
            $addHeaders[] = 'If-Modified-Since: ' . $headers['Date'];
          }
          if (is_array($request->getHeaders())) {
            $request->setHeaders(array_merge($addHeaders, $request->getHeaders()));
          } else {
            $request->setHeaders($addHeaders);
          }
        } else {
          // No need to revalidate the request, return it directly
          return $cachedRequest;
        }
      }
    }
    // Couldn't use a cached version, so perform the actual request

    if ($request->getMethod() == 'POST' || $request->getMethod() == 'PUT') {
      // make sure a Content-length header is set
      $postBody = $request->getPostBody();
      if (is_array($postBody)) {
        $postBody = http_build_query($postBody);
        $request->setPostBody($postBody);
      }
      if (! is_array($postBody)) {
        $postContentLength = strlen($postBody) != 0 ? strlen($postBody) : '0';
        $addHeaders = array('Content-Length: ' . $postContentLength);
        if (is_array($request->getHeaders())) {
          $request->setHeaders(array_merge($addHeaders, $request->getHeaders()));
        } else {
          $request->setHeaders($addHeaders);
        }
      }
    }

    $ch = curl_init();
    curl_setopt_array($ch, self::$DEFAULT_CURL_PARAMS);
    curl_setopt($ch, CURLOPT_URL, $request->getUrl());
    if ($request->getPostBody()) {
      curl_setopt($ch, CURLOPT_POSTFIELDS, $request->getPostBody());
    }
    if ($request->getHeaders() && is_array($request->getHeaders())) {
      curl_setopt($ch, CURLOPT_HTTPHEADER, array_unique($request->getHeaders()));
    }
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $request->getMethod());
    curl_setopt($ch, CURLOPT_USERAGENT, $request->getUserAgent());
    $respData = curl_exec($ch);
    $respHeaderSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $respHttpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlErrorNum = curl_errno($ch);
    $curlError = curl_error($ch);
    curl_close($ch);
    if ($curlErrorNum != CURLE_OK) {
      throw new apiIOException('HTTP Error: (' . $respHttpCode . ') ' . $curlError);
    }
    if ($respHttpCode == 304 && $cachedRequest) {
      // If the server responded NOT_MODIFIED, return the cached request
      return $cachedRequest;
    }
    // Parse out the raw response into usable bits
    $rawResponseHeaders = substr($respData, 0, $respHeaderSize);
    $responseBody = substr($respData, $respHeaderSize);
    $responseHeaderLines = explode("\r\n", $rawResponseHeaders);
    $responseHeaders = array();
    foreach ($responseHeaderLines as $headerLine) {
      if ($headerLine && strpos($headerLine, ':') !== false) {
        list($header, $value) = explode(': ', $headerLine, 2);
        if (isset($responseHeaders[$header])) {
          $responseHeaders[$header] .= "\n" . $value;
        } else {
          $responseHeaders[$header] = $value;
        }
      }
    }
    // Fill in the apiHttpRequest with the response values
    $request->setResponseHttpCode($respHttpCode);
    $request->setResponseHeaders($responseHeaders);
    $request->setResponseBody($responseBody);
    // Store the request in cache (the function checks to see if the request can actually be cached)
    $this->setCachedRequest($request);
    // And finally return it
    return $request;
  }

  private function setCachedRequest(apiHttpRequest $request) {
    // Only cache GET requests
    if ($request->getMethod() != 'GET') {
      return false;
    }
    // Analyze the request headers to see if there is a valid caching strategy.
    $headers = $this->getNormalizedHeaders($request);
    // And parse all the bits that are required for the can-cache evaluation
    $etag = isset($headers['etag']) ? $headers['etag'] : false;
    $expires = isset($headers['expires']) ? strtotime($headers['expires']) : false;
    $date = isset($headers['date']) ? strtotime($headers['date']) : time();
    $cacheControl = array();
    if (isset($headers['cache-control'])) {
      $cacheControl = explode(', ', $headers['cache-control']);
      foreach ($cacheControl as $key => $val) {
        $cacheControl[$key] = strtolower($val);
      }
    }
    $pragmaNoCache = isset($headers['pragma']) ? strtolower($headers['pragma']) == 'no-cache' : false;
    // evaluate if the request can be cached
    $canCache = ! in_array('no-store', $cacheControl) &&                                            // If no Cache-Control: no-store is present, we can cache
              (($etag || $expires > $date) ||                                                       // if the response has an etag, or if it has an expiration date that is greater then the current date, we can check for a 304 NOT MODIFIED, so cache
              (! $etag && ! $expires && ! $pragmaNoCache && ! in_array('no-cache', $cacheControl))); // or if there is no etag, and no expiration set, but also no pragma: no-cache and no cache-control: no-cache, we can cache (but we'll set our own expires header to make sure it's refreshed frequently)
    if ($canCache) {
      // Set an 1 hour expiration header if non exists, and no do-not-cache directives exist
      if (! $etag && ! $expires && ! $pragmaNoCache && ! in_array('no-cache', $cacheControl)) {
        // Add Expires and Date headers to simplify the cache retrieval code path
        $request->setResponseHeaders(array_merge(array(
            'Expires' => date('r', time() + 60 * 60),
            'Date' => date('r', time())), $request->getHeaders()));
      }
      apiClient::$cache->set($this->getRequestKey($request), $request);
    }
  }

  private function getCachedRequest(apiHttpRequest $request) {
    if (($cachedRequest = apiClient::$cache->get($this->getRequestKey($request))) !== false) {
      // There is a cached version of this request, validate if it can actually be used
      $headers = $this->getNormalizedHeaders($request);
      $etag = isset($headers['etag']) ? $headers['etag'] : false;
      $expires = isset($headers['expires']) ? strtotime($headers['expires']) : false;
      $date = isset($headers['date']) ? strtotime($headers['date']) : time();
      $cacheControl = array();
      if (isset($headers['cache-control'])) {
        $cacheControl = explode(', ', $headers['cache-control']);
        foreach ($cacheControl as $key => $val) {
          $cacheControl[$key] = strtolower($val);
        }
      }
      // Only use the cached request if it has an etag or expiration date that's lower then the current time
      if ($etag || ($expires < $date)) {
        // There is either an ETag set, or the expiration time is less then the current time, return it
        return $cachedRequest;
      } else {
        // Clean out the stale cache entry before returning
        apiClient::$cache->delete($this->getRequestKey($request));
      }
    }
    // Either the request was not cached, or it has expired, return false
    return false;
  }

  /**
   * Returns true if the request has specified must-revalidate in it's Cache-Control header, or if it doesn't have an Expires header but does have an ETag or has expired
   * @param apiHttpRequest $request
   * @return boolean
   */
  private function mustRevalidate(apiHttpRequest $request) {
    // check to see if we need to go the If-Modified-Since or Etag route (in which case we make the request, but accept a 304 NOT MODIFIED)
    $headers = $this->getNormalizedHeaders($request);
    $etag = isset($headers['etag']) ? $headers['etag'] : false;
    $expires = isset($headers['expires']) ? strtotime($headers['expires']) : false;
    $date = isset($headers['date']) ? strtotime($headers['date']) : time();
    $cacheControl = array();
    if (isset($headers['cache-control'])) {
      $cacheControl = explode(', ', $headers['cache-control']);
      foreach ($cacheControl as $key => $val) {
        $cacheControl[$key] = strtolower($val);
      }
    }
    return (in_array('must-revalidate', $cacheControl) || ($etag && ! $expires) || $expires > $date);
  }

  /**
   * Returns a cache key depending on if this was an OAuth signed request in which case it will use the non-signed url and access key to make this caching key unique
   * per authenticated user, else use the plain request url
   * @param apiHttpRequest $request
   * @return a md5 sum of the request url
   */
  private function getRequestKey(apiHttpRequest $request) {
    $cacheUrl = $request->getUrl();
    if (isset($request->accessKey)) {
      $cacheUrl .= $request->accessKey;
    }

    $headers = $request->getHeaders();
    $token = apiClient::$auth->accessToken;
    if (isset($token['id_token']) && $token['id_token']) {
      $cacheUrl .= $token['id_token'];
    }

    return md5($cacheUrl);
  }

  /**
   * Normalize all HTTP headers.
   * @param apiHttpRequest $request
   * @return array
   */
  private function getNormalizedHeaders(apiHttpRequest $request) {
    if (!is_array($request->getResponseHeaders())) {
      return array();
    }
    $headers = $request->getResponseHeaders();
    $newHeaders = array();
    foreach ($headers as $key => $val) {
      $newHeaders[strtolower($key)] = $val;
    }
    return $newHeaders;
  }
}
