Add new WC_API_Server class

Based on WP_JSON_Server, this class add response handling based on
endpoint suffixes (.json or .xml) or the ACCEPT header. It also removes
some unneeded functionality and renames filters so as to not conflict
with users who may have WP-API installed already. Finally, instead of
no authentication required by default, authentication is always
required unless specifically disabled via filters.
This commit is contained in:
Max Rice 2013-11-06 01:53:07 -05:00
parent 8637da63c0
commit ed6de29bfc
1 changed files with 700 additions and 0 deletions

View File

@ -0,0 +1,700 @@
<?php
/**
* WooCommerce API
*
* Handles REST API requests
*
* This class and related code (JSON response handler, resource classes) are based on WP-API v0.6 (https://github.com/WP-API/WP-API)
* Many thanks to Ryan McCue and any other contributors!
*
* @author WooThemes
* @category API
* @package WooCommerce/API
* @since 2.1
*/
if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
require_once ABSPATH . 'wp-admin/includes/admin.php';
class WC_API_Server {
const METHOD_GET = 1;
const METHOD_POST = 2;
const METHOD_PUT = 4;
const METHOD_PATCH = 8;
const METHOD_DELETE = 16;
const READABLE = 1; // GET
const CREATABLE = 2; // POST
const EDITABLE = 14; // POST | PUT | PATCH
const DELETABLE = 16; // DELETE
const ALLMETHODS = 31; // GET | POST | PUT | PATCH | DELETE
/**
* Does the endpoint accept a raw request body?
*/
const ACCEPT_RAW_DATA = 64;
/** Does the endpoint accept a request body? (either JSON or XML) */
const ACCEPT_DATA = 128;
/**
* Should we hide this endpoint from the index?
*/
const HIDDEN_ENDPOINT = 256;
/**
* Map of HTTP verbs to constants
* @var array
*/
public static $method_map = array(
'HEAD' => self::METHOD_GET,
'GET' => self::METHOD_GET,
'POST' => self::METHOD_POST,
'PUT' => self::METHOD_PUT,
'PATCH' => self::METHOD_PATCH,
'DELETE' => self::METHOD_DELETE,
);
/**
* Requested path (relative to the API root, wp-json.php)
*
* @var string
*/
public $path = '';
/**
* Requested method (GET/HEAD/POST/PUT/PATCH/DELETE)
*
* @var string
*/
public $method = 'HEAD';
/**
* Request parameters
*
* This acts as an abstraction of the superglobals
* (GET => $_GET, POST => $_POST)
*
* @var array
*/
public $params = array( 'GET' => array(), 'POST' => array() );
/**
* Request headers
*
* @var array
*/
public $headers = array();
/**
* Request files (matches $_FILES)
*
* @var array
*/
public $files = array();
/**
* Request/Response handler, either JSON by default
* or XML if requested by client
*
* @var WC_API_Handler
*/
public $handler;
/**
* Setup class and set request/response handler
*
* @since 2.1
* @param $path
* @return WC_API_Server
*/
public function __construct( $path ) {
if ( empty( $path ) ) {
if ( isset( $_SERVER['PATH_INFO'] ) )
$path = $_SERVER['PATH_INFO'];
else
$path = '/';
}
$this->path = $path;
$this->method = $_SERVER['REQUEST_METHOD'];
$this->params['GET'] = $_GET;
$this->params['POST'] = $_POST;
$this->headers = $this->get_headers( $_SERVER );
$this->files = $_FILES;
// Compatibility for clients that can't use PUT/PATCH/DELETE
if ( isset( $_GET['_method'] ) ) {
$this->method = strtoupper( $_GET['_method'] );
}
// determine type of request/response and load handler, JSON by default
if ( $this->is_json_request() )
$handler_class = 'WC_API_JSON_Handler';
elseif ( $this->is_xml_request() )
$handler_class = 'WC_API_XML_Handler';
else
$handler_class = apply_filters( 'woocommerce_api_default_response_handler', 'WC_API_JSON_Handler', $this->path, $this );
$this->handler = new $handler_class();
}
/**
* Check authentication for the request
*
* @since 2.1
* @return WP_User|WP_Error WP_User object indicates successful login, WP_Error indicates unsuccessful login
*/
public function check_authentication() {
// allow plugins to remove default authentication or add their own authentication
$user = apply_filters( 'woocommerce_api_check_authentication', null, $this );
// API requests run under the context of the authenticated user
if ( is_a( $user, 'WP_User' ) )
wp_set_current_user( $user->ID );
// WP_Errors are handled in serve_request()
elseif ( ! is_wp_error( $user ) )
$user = new WP_Error( 'woocommerce_api_authentication_error', __( 'Invalid authentication method', 'woocommerce' ), array( 'code' => '500' ) );
return $user;
}
/**
* Convert an error to an array
*
* This iterates over all error codes and messages to change it into a flat
* array. This enables simpler client behaviour, as it is represented as a
* list in JSON rather than an object/map
*
* @since 2.1
* @param WP_Error $error
* @return array List of associative arrays with code and message keys
*/
protected function error_to_array( $error ) {
$errors = array();
foreach ( (array) $error->errors as $code => $messages ) {
foreach ( (array) $messages as $message ) {
$errors[] = array( 'code' => $code, 'message' => $message );
}
}
return $errors;
}
/**
* Handle serving an API request
*
* Matches the current server URI to a route and runs the first matching
* callback then outputs a JSON representation of the returned value.
*
* @since 2.1
* @uses WC_API_Server::dispatch()
*/
public function serve_request() {
do_action( 'woocommerce_api_server_before_serve', $this );
$this->header( 'Content-Type', $this->handler->get_content_type(), true );
// TODO: can we prevent wc_cookie from being sent for API requests?
// the API is enabled by default TODO: implement check for enabled setting here
if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) ) {
$this->send_status( 404 );
echo $this->handler->generate_response( array( array( 'code' => 'woocommerce_api_disabled', 'message' => 'The WooCommerce API is disabled on this site' ) ) );
return;
}
$result = $this->check_authentication();
// if authorization check was successful, dispatch the request
if ( ! is_wp_error( $result ) ) {
$result = $this->dispatch();
}
// handle any dispatch errors
if ( is_wp_error( $result ) ) {
$data = $result->get_error_data();
if ( is_array( $data ) && isset( $data['status'] ) ) {
$this->send_status( $data['status'] );
}
$result = $this->error_to_array( $result );
}
// This is a filter rather than an action, since this is designed to be
// re-entrant if needed
$served = apply_filters( 'woocommerce_api_serve_request', false, $result, $this );
if ( ! $served ) {
if ( 'HEAD' === $this->method )
return;
echo $this->handler->generate_response( $result );
}
}
/**
* Retrieve the route map
*
* The route map is an associative array with path regexes as the keys. The
* value is an indexed array with the callback function/method as the first
* item, and a bitmask of HTTP methods as the second item (see the class
* constants).
*
* Each route can be mapped to more than one callback by using an array of
* the indexed arrays. This allows mapping e.g. GET requests to one callback
* and POST requests to another.
*
* Note that the path regexes (array keys) must have @ escaped, as this is
* used as the delimiter with preg_match()
*
* @since 2.1
* @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)`
*/
public function get_routes() {
// index added by default
$endpoints = array(
'/' => array( array( $this, 'get_index' ), self::READABLE ),
);
$endpoints = apply_filters( 'woocommerce_api_endpoints', $endpoints );
// Normalise the endpoints
foreach ( $endpoints as $route => &$handlers ) {
if ( count( $handlers ) <= 2 && isset( $handlers[1] ) && ! is_array( $handlers[1] ) ) {
$handlers = array( $handlers );
}
}
return $endpoints;
}
/**
* Match the request to a callback and call it
*
* @since 2.1
* @return mixed The value returned by the callback, or a WP_Error instance
*/
public function dispatch() {
switch ( $this->method ) {
case 'HEAD':
case 'GET':
$method = self::METHOD_GET;
break;
case 'POST':
$method = self::METHOD_POST;
break;
case 'PUT':
$method = self::METHOD_PUT;
break;
case 'PATCH':
$method = self::METHOD_PATCH;
break;
case 'DELETE':
$method = self::METHOD_DELETE;
break;
default:
return new WP_Error( 'woocommerce_api_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) );
}
foreach ( $this->get_routes() as $route => $handlers ) {
foreach ( $handlers as $handler ) {
$callback = $handler[0];
$supported = isset( $handler[1] ) ? $handler[1] : self::METHOD_GET;
if ( !( $supported & $method ) )
continue;
$match = preg_match( '@^' . $route . '$@i', $this->path, $args );
if ( !$match )
continue;
if ( ! is_callable( $callback ) )
return new WP_Error( 'woocommerce_api_invalid_handler', __( 'The handler for the route is invalid' ), array( 'status' => 500 ) );
$args = array_merge( $args, $this->params['GET'] );
if ( $method & self::METHOD_POST ) {
$args = array_merge( $args, $this->params['POST'] );
}
if ( $supported & self::ACCEPT_DATA ) {
$data = $this->handler->parse_body( $this->get_raw_data() );
$args = array_merge( $args, array( 'data' => $data ) );
}
elseif ( $supported & self::ACCEPT_RAW_DATA ) {
$data = $this->get_raw_data();
$args = array_merge( $args, array( 'data' => $data ) );
}
$args['_method'] = $method;
$args['_route'] = $route;
$args['_path'] = $this->path;
$args['_headers'] = $this->headers;
$args['_files'] = $this->files;
$args = apply_filters( 'woocommerce_api_dispatch_args', $args, $callback );
// Allow plugins to halt the request via this filter
if ( is_wp_error( $args ) ) {
return $args;
}
$params = $this->sort_callback_params( $callback, $args );
if ( is_wp_error( $params ) )
return $params;
return call_user_func_array( $callback, $params );
}
}
return new WP_Error( 'woocommerce_api_no_route', __( 'No route was found matching the URL and request method' ), array( 'status' => 404 ) );
}
/**
* Sort parameters by order specified in method declaration
*
* Takes a callback and a list of available params, then filters and sorts
* by the parameters the method actually needs, using the Reflection API
*
* @since 2.1
* @param array $callback the endpoint callback
* @param array $provided the provided request parameters
* @return array
*/
protected function sort_callback_params( $callback, $provided ) {
if ( is_array( $callback ) )
$ref_func = new ReflectionMethod( $callback[0], $callback[1] );
else
$ref_func = new ReflectionFunction( $callback );
$wanted = $ref_func->getParameters();
$ordered_parameters = array();
foreach ( $wanted as $param ) {
if ( isset( $provided[ $param->getName() ] ) ) {
// We have this parameters in the list to choose from
$ordered_parameters[] = $provided[ $param->getName() ];
}
elseif ( $param->isDefaultValueAvailable() ) {
// We don't have this parameter, but it's optional
$ordered_parameters[] = $param->getDefaultValue();
}
else {
// We don't have this parameter and it wasn't optional, abort!
return new WP_Error( 'woocommerce_api_missing_callback_param', sprintf( __( 'Missing parameter %s' ), $param->getName() ), array( 'status' => 400 ) );
}
}
return $ordered_parameters;
}
/**
* Get the site index.
*
* This endpoint describes the capabilities of the site.
*
* @since 2.1
* @return array Index entity
*/
public function get_index() {
// General site data
$available = array(
'name' => get_option( 'blogname' ),
'description' => get_option( 'blogdescription' ),
'URL' => get_option( 'siteurl' ),
'routes' => array(),
'meta' => array(
'currency' => get_woocommerce_currency(),
'weight_unit' => get_option( 'woocommerce_weight_unit' ),
'dimension_unit' => get_option( 'woocommerce_dimension_unit' ),
'supports_ssl' => ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ),
'links' => array(
'help' => 'http://docs.woothemes.com/document/woocommerce-rest-api/',
'profile' => 'https://raw.github.com/rmccue/WP-API/master/docs/schema.json', // TODO: update this
),
),
);
// Find the available routes
foreach ( $this->get_routes() as $route => $callbacks ) {
$data = array();
$route = preg_replace( '#\(\?P(<\w+?>).*?\)#', '$1', $route );
$methods = array();
foreach ( self::$method_map as $name => $bitmask ) {
foreach ( $callbacks as $callback ) {
// Skip to the next route if any callback is hidden
if ( $callback[1] & self::HIDDEN_ENDPOINT )
continue 3;
if ( $callback[1] & $bitmask )
$data['supports'][] = $name;
if ( $callback[1] & self::ACCEPT_DATA )
$data['accepts_data'] = true;
// For non-variable routes, generate links
if ( strpos( $route, '<' ) === false ) {
$data['meta'] = array(
'self' => get_woocommerce_api_url( $route ),
);
}
}
}
$available['routes'][$route] = apply_filters( 'woocommerce_api_endpoints_description', $data );
}
return apply_filters( 'woocommerce_api_index', $available );
}
/**
* Send a HTTP status code
*
* @since 2.1
* @param int $code HTTP status
*/
public function send_status( $code ) {
status_header( $code );
}
/**
* Send a HTTP header
*
* @since 2.1
* @param string $key Header key
* @param string $value Header value
* @param boolean $replace Should we replace the existing header?
*/
public function header( $key, $value, $replace = true ) {
header( sprintf( '%s: %s', $key, $value ), $replace );
}
/**
* Send a Link header
*
* @internal The $rel parameter is first, as this looks nicer when sending multiple
*
* @link http://tools.ietf.org/html/rfc5988
* @link http://www.iana.org/assignments/link-relations/link-relations.xml
*
* @since 2.1
* @param string $rel Link relation. Either a registered type, or an absolute URL
* @param string $link Target IRI for the link
* @param array $other Other parameters to send, as an assocative array
*/
public function link_header( $rel, $link, $other = array() ) {
$header = 'Link: <' . $link . '>; rel="' . $rel . '"';
foreach ( $other as $key => $value ) {
if ( 'title' == $key )
$value = '"' . $value . '"';
$header .= '; ' . $key . '=' . $value;
}
$this->header( 'Link', $header, false );
}
/**
* Send navigation-related headers for post collections
*
* @since 2.1
* @param WP_Query $query
*/
public function query_navigation_headers( $query ) {
$max_page = $query->max_num_pages;
$paged = $query->get('paged');
if ( !$paged )
$paged = 1;
$nextpage = intval($paged) + 1;
if ( ! $query->is_single() ) {
if ( $paged > 1 ) {
$request = remove_query_arg( 'page' );
$request = add_query_arg( 'page', $paged - 1, $request );
$this->link_header( 'prev', $request );
}
if ( $nextpage <= $max_page ) {
$request = remove_query_arg( 'page' );
$request = add_query_arg( 'page', $nextpage, $request );
$this->link_header( 'next', $request );
}
}
$this->header( 'X-WP-Total', $query->found_posts );
$this->header( 'X-WP-TotalPages', $max_page );
do_action('woocommerce_api_query_navigation_headers', $this, $query);
}
/**
* Retrieve the raw request entity (body)
*
* @since 2.1
* @return string
*/
public function get_raw_data() {
global $HTTP_RAW_POST_DATA;
// A bug in PHP < 5.2.2 makes $HTTP_RAW_POST_DATA not set by default,
// but we can do it ourself.
if ( !isset( $HTTP_RAW_POST_DATA ) ) {
$HTTP_RAW_POST_DATA = file_get_contents( 'php://input' );
}
return $HTTP_RAW_POST_DATA;
}
/**
* Parse an RFC3339 timestamp into a DateTime
*
* @param string $date RFC3339 timestamp
* @param boolean $force_utc Force UTC timezone instead of using the timestamp's TZ?
* @return DateTime
*/
public function parse_date( $date, $force_utc = false ) {
// Default timezone to the server's current one
$timezone = self::get_timezone();
if ( $force_utc ) {
$date = preg_replace( '/[+-]\d+:?\d+$/', '+00:00', $date );
$timezone = new DateTimeZone( 'UTC' );
}
// Strip millisecond precision (a full stop followed by one or more digits)
if ( strpos( $date, '.' ) !== false ) {
$date = preg_replace( '/\.\d+/', '', $date );
}
$datetime = DateTime::createFromFormat( DateTime::RFC3339, $date );
return $datetime;
}
/**
* Get a local date with its GMT equivalent, in MySQL datetime format
*
* @param string $date RFC3339 timestamp
* @param boolean $force_utc Should we force UTC timestamp?
* @return array Local and UTC datetime strings, in MySQL datetime format (Y-m-d H:i:s)
*/
public function get_date_with_gmt( $date, $force_utc = false ) {
$datetime = $this->parse_date( $date, $force_utc );
$datetime->setTimezone( self::get_timezone() );
$local = $datetime->format( 'Y-m-d H:i:s' );
$datetime->setTimezone( new DateTimeZone( 'UTC' ) );
$utc = $datetime->format('Y-m-d H:i:s');
return array( $local, $utc );
}
/**
* Get the timezone object for the site
*
* @return DateTimeZone
*/
public function get_timezone() {
static $zone = null;
if ($zone !== null)
return $zone;
$tzstring = get_option( 'timezone_string' );
if ( ! $tzstring ) {
// Create a UTC+- zone if no timezone string exists
$current_offset = get_option( 'gmt_offset' );
if ( 0 == $current_offset )
$tzstring = 'UTC';
elseif ($current_offset < 0)
$tzstring = 'Etc/GMT' . $current_offset;
else
$tzstring = 'Etc/GMT+' . $current_offset;
}
$zone = new DateTimeZone( $tzstring );
return $zone;
}
/**
* Extract headers from a PHP-style $_SERVER array
*
* @since 2.1
* @param array $server Associative array similar to $_SERVER
* @return array Headers extracted from the input
*/
public function get_headers($server) {
$headers = array();
// CONTENT_* headers are not prefixed with HTTP_
$additional = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true);
foreach ($server as $key => $value) {
if ( strpos( $key, 'HTTP_' ) === 0) {
$headers[ substr( $key, 5 ) ] = $value;
}
elseif ( isset( $additional[ $key ] ) ) {
$headers[ $key ] = $value;
}
}
return $headers;
}
/**
* Check if the current request accepts a JSON response by checking the endpoint suffix (.json) or
* the HTTP ACCEPT header
*
* @since 2.1
* @return bool
*/
private function is_json_request() {
// check path
if ( false !== stripos( $this->path, '.json' ) )
return true;
// check ACCEPT header, only 'application/json' is acceptable, see RFC 4627
if ( isset( $this->headers['ACCEPT'] ) && 'application/json' == $this->headers['ACCEPT'] )
return true;
return false;
}
/**
* Check if the current request accepts an XML response by checking the endpoint suffix (.xml) or
* the HTTP ACCEPT header
*
* @since 2.1
* @return bool
*/
private function is_xml_request() {
// check path
if ( false !== stripos( $this->path, '.xml' ) )
return true;
// check headers, 'application/xml' or 'text/xml' are acceptable, see RFC 2376
if ( isset( $this->headers['ACCEPT'] ) && ( 'application/xml' == $this->headers['ACCEPT'] || 'text/xml' == $this->headers['ACCEPT'] ) )
return true;
return false;
}
}