#55 - PHP API with Versioning

Date: 2019-04-06 12:00 - PHP

PHP class to define an API with versioning.

<?php
class API {
  const removedEndpoint = -1;

  private static $_endpoints = array();

  private static $_currentEndpointVersions;
  private static $_input;
  private static $_lastVersion = 0;

  public static function getEndpoints() {
    return self::$_endpoints;
  }

  public static function endpoint($name, $version, $function) {
    if (!array_key_exists($name, self::$_endpoints))
      self::$_endpoints[$name] = array();
    if (array_key_exists($version, self::$_endpoints[$name]))
      trigger_error("Endpoint '$name' has multiple '$version' versions", E_USER_ERROR);
    self::$_endpoints[$name][$version] = $function;
    self::$_lastVersion = max(self::$_lastVersion, $version);
  }

  private static function bestVersion() {
    $match = preg_match('/^application\/vnd\.' . API_CONTENT_TYPE_NAME . '\.([\d]+)\+json$/', $_SERVER['HTTP_ACCEPT'], $matches);
    if ($match !== 0 && intval($matches[1]) == $matches[1])
      return min(self::$_lastVersion, $matches[1]);
    return self::$_lastVersion;
  }

  public static function process() {
    $uri = substr($_SERVER['REQUEST_URI'], strlen(API_BASE_URL));
    if (($query_start = strpos($uri, '?')) !== false)
        $uri = substr($uri, 0, $query_start);
    $cleanURI = str_replace(chr(0), '', trim($uri, '/'));
    $split = preg_split('/\//', $cleanURI, NULL, PREG_SPLIT_NO_EMPTY);

    try {
      if (empty($split))
        throw new MethodMissingException();

      if (count($split) > 1)
        $input = array_slice($split, 1);
      else {
        $rawInput = file_get_contents("php://input");
        $input = @json_decode($rawInput, true);

        if ($rawInput !== '' && $input === NULL && json_last_error() !== JSON_ERROR_NONE)
          throw new MalformedRawInputException();
      }

      $version = self::bestVersion();

      $endpoint = $split[0];
      $result = API::handle($endpoint, $version, $input);
      header('Content-Type: application/vnd.' . API_CONTENT_TYPE_NAME . '.' . $version . '+json');
      echo json_encode($result);
      exit();
    } catch (APIException $e) {
      http_response_code($e->getHTTPCode());
      echo json_encode(['error' => $e->getMessage(), 'code' => $e->getCode()]);
      exit();
    }

    http_response_code(500);
    echo json_encode(['error' => 'unknown server error', 'code' => 0]);
    exit();
  }

  public static function handle($name, $version, $input) {
    if (!array_key_exists($name, self::$_endpoints)) {
      throw new UnknownMethodException();
    }

    self::$_currentEndpointVersions = &self::$_endpoints[$name];
    ksort(self::$_currentEndpointVersions);
    self::$_currentEndpointVersions = array_reverse(self::$_currentEndpointVersions, true);
    reset(self::$_currentEndpointVersions);

    while (key(self::$_currentEndpointVersions) > $version) {
      $next = next(self::$_currentEndpointVersions);
      if ($next === FALSE)
        throw new UnknownMethodException();
    }

    self::$_input = $input;

    $function = current(self::$_currentEndpointVersions);
    if ($function === self::removedEndpoint)
      throw new UnknownMethodException();
    return $function();
  }

  private static function totalKey($inputKey, $key) {
    return $inputKey === '' ? $key : ($inputKey . '.' . $key);
  }

  private static function verifyAgainst($structure, $input, $inputKey) {
    if (!is_array($input)) {
      if ($inputKey === '')
        $input = array();
      else
        throw new ParameterMalformedException($inputKey);
    }

    foreach ($structure as $key => $value) {
      if (!array_key_exists($key, $input))
        throw new ParameterMissingException(self::totalKey($inputKey, $key));

      $inputValue = $input[$key];

      if (is_array($value))
        self::verifyAgainst($value, $inputValue, self::totalKey($inputKey, $key));
      else if (!is_string($inputValue) && !is_numeric($inputValue))
        throw new MalformedInputException();
      else if (preg_match('%^'. $value . '$%', $inputValue) === 0)
        throw new ParameterMalformedException(self::totalKey($inputKey, $key));
    }
  }

  public static function verify($structure) {
    self::verifyAgainst($structure, self::$_input, '');
  }

  public static function input($key = NULL) {
    if ($key !== NULL) {
      if (!array_key_exists($key, self::$_input))
        trigger_error("'$key' does not exist in input. missing verification?", E_USER_ERROR);
      return self::$_input[$key];
    }
    return self::$_input;
  }

  public static function older() {
    $function = next(self::$_currentEndpointVersions);
    if ($function === FALSE)
      trigger_error("Endpoint does not have any older version.", E_USER_ERROR);

    $foundRemoved = false;
    if ($function === self::removedEndpoint) {
      $foundRemoved = true;
      $function = next(self::$_currentEndpointVersions);
      if ($function === FALSE)
        trigger_error('Endpoint has removed method as oldest version.', E_USER_ERROR);
      if ($function === self::removedEndpoint)
        trigger_error('Endpoint has multiple removed methods in a row.', E_USER_ERROR);
    }

    $result = $function();

    prev(self::$_currentEndpointVersions);
    if ($foundRemoved)
        prev(self::$_currentEndpointVersions);
    return $result;
  }
}

Previous snippet | Next snippet