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'] ); } // load response handler $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 array( 'errors' => $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 ); // the API is enabled by default if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) || ( 'no' === get_option( 'woocommerce_api_enabled' ) ) ) { $this->send_status( 404 ); echo $this->handler->generate_response( array( 'errors' => 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', 'woocommerce' ), 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', urldecode( $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', 'woocommerce' ), 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', 'woocommerce' ), array( 'status' => 404 ) ); } /** * urldecode deep. * * @since 2.2 * @param string/array $value Data to decode with urldecode. * @return string/array Decoded data. */ protected function urldecode_deep( $value ) { if ( is_array( $value ) ) { return array_map( array( $this, 'urldecode_deep' ), $value ); } else { return urldecode( $value ); } } /** * 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.2 * @param callable|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 if ( 'data' == $param->getName() ) { $ordered_parameters[] = $provided[ $param->getName() ]; continue; } $ordered_parameters[] = $this->urldecode_deep( $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', 'woocommerce' ), $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( 'store' => array( 'name' => get_option( 'blogname' ), 'description' => get_option( 'blogdescription' ), 'URL' => get_option( 'siteurl' ), 'wc_version' => WC()->version, 'routes' => array(), 'meta' => array( 'timezone' => wc_timezone_string(), 'currency' => get_woocommerce_currency(), 'currency_format' => get_woocommerce_currency_symbol(), 'price_num_decimals' => wc_get_price_decimals(), 'tax_included' => wc_prices_include_tax(), 'weight_unit' => get_option( 'woocommerce_weight_unit' ), 'dimension_unit' => get_option( 'woocommerce_dimension_unit' ), 'ssl_enabled' => ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ), 'permalinks_enabled' => ( '' !== get_option( 'permalink_structure' ) ), 'links' => array( 'help' => 'http://woothemes.github.io/woocommerce-rest-api-docs/', ), ), ) ); // Find the available routes foreach ( $this->get_routes() as $route => $callbacks ) { $data = array(); $route = preg_replace( '#\(\?P(<\w+?>).*?\)#', '$1', $route ); 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['store']['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 associative array */ public function link_header( $rel, $link, $other = array() ) { $header = sprintf( '<%s>; rel="%s"', $link, esc_attr( $rel ) ); foreach ( $other as $key => $value ) { if ( 'title' == $key ) { $value = '"' . $value . '"'; } $header .= '; ' . $key . '=' . $value; } $this->header( 'Link', $header, false ); } /** * Send pagination headers for resources * * @since 2.1 * @param WP_Query|WP_User_Query $query */ public function add_pagination_headers( $query ) { // WP_User_Query if ( is_a( $query, 'WP_User_Query' ) ) { $page = $query->page; $single = count( $query->get_results() ) == 1; $total = $query->get_total(); $total_pages = $query->total_pages; // WP_Query } else { $page = $query->get( 'paged' ); $single = $query->is_single(); $total = $query->found_posts; $total_pages = $query->max_num_pages; } if ( ! $page ) $page = 1; $next_page = absint( $page ) + 1; if ( ! $single ) { // first/prev if ( $page > 1 ) { $this->link_header( 'first', $this->get_paginated_url( 1 ) ); $this->link_header( 'prev', $this->get_paginated_url( $page -1 ) ); } // next if ( $next_page <= $total_pages ) { $this->link_header( 'next', $this->get_paginated_url( $next_page ) ); } // last if ( $page != $total_pages ) $this->link_header( 'last', $this->get_paginated_url( $total_pages ) ); } $this->header( 'X-WC-Total', $total ); $this->header( 'X-WC-TotalPages', $total_pages ); do_action( 'woocommerce_api_pagination_headers', $this, $query ); } /** * Returns the request URL with the page query parameter set to the specified page * * @since 2.1 * @param int $page * @return string */ private function get_paginated_url( $page ) { // remove existing page query param $request = remove_query_arg( 'page' ); // add provided page query param $request = urldecode( add_query_arg( 'page', $page, $request ) ); // get the home host $host = parse_url( get_home_url(), PHP_URL_HOST ); return set_url_scheme( "http://{$host}{$request}" ); } /** * 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 datetime into a MySQl datetime * * Invalid dates default to unix epoch * * @since 2.1 * @param string $datetime RFC3339 datetime * @return string MySQl datetime (YYYY-MM-DD HH:MM:SS) */ public function parse_datetime( $datetime ) { // Strip millisecond precision (a full stop followed by one or more digits) if ( strpos( $datetime, '.' ) !== false ) { $datetime = preg_replace( '/\.\d+/', '', $datetime ); } // default timezone to UTC $datetime = preg_replace( '/[+-]\d+:+\d+$/', '+00:00', $datetime ); try { $datetime = new DateTime( $datetime, new DateTimeZone( 'UTC' ) ); } catch ( Exception $e ) { $datetime = new DateTime( '@0' ); } return $datetime->format( 'Y-m-d H:i:s' ); } /** * Format a unix timestamp or MySQL datetime into an RFC3339 datetime * * @since 2.1 * @param int|string $timestamp unix timestamp or MySQL datetime * @param bool $convert_to_utc * @return string RFC3339 datetime */ public function format_datetime( $timestamp, $convert_to_utc = false ) { if ( $convert_to_utc ) { $timezone = new DateTimeZone( wc_timezone_string() ); } else { $timezone = new DateTimeZone( 'UTC' ); } try { if ( is_numeric( $timestamp ) ) { $date = new DateTime( "@{$timestamp}" ); } else { $date = new DateTime( $timestamp, $timezone ); } // convert to UTC by adjusting the time based on the offset of the site's timezone if ( $convert_to_utc ) { $date->modify( -1 * $date->getOffset() . ' seconds' ); } } catch ( Exception $e ) { $date = new DateTime( '@0' ); } return $date->format( 'Y-m-d\TH:i:s\Z' ); } /** * 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; } }