1 <?php
  2 /**
  3  * WooCommerce API
  4  *
  5  * Handles REST API requests
  6  *
  7  * This class and related code (JSON response handler, resource classes) are based on WP-API v0.6 (https://github.com/WP-API/WP-API)
  8  * Many thanks to Ryan McCue and any other contributors!
  9  *
 10  * @author      WooThemes
 11  * @category    API
 12  * @package     WooCommerce/API
 13  * @since       2.1
 14  */
 15 
 16 if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
 17 
 18 require_once ABSPATH . 'wp-admin/includes/admin.php';
 19 
 20 class WC_API_Server {
 21 
 22     const METHOD_GET    = 1;
 23     const METHOD_POST   = 2;
 24     const METHOD_PUT    = 4;
 25     const METHOD_PATCH  = 8;
 26     const METHOD_DELETE = 16;
 27 
 28     const READABLE   = 1;  // GET
 29     const CREATABLE  = 2;  // POST
 30     const EDITABLE   = 14; // POST | PUT | PATCH
 31     const DELETABLE  = 16; // DELETE
 32     const ALLMETHODS = 31; // GET | POST | PUT | PATCH | DELETE
 33 
 34     /**
 35      * Does the endpoint accept a raw request body?
 36      */
 37     const ACCEPT_RAW_DATA = 64;
 38 
 39     /** Does the endpoint accept a request body? (either JSON or XML) */
 40     const ACCEPT_DATA = 128;
 41 
 42     /**
 43      * Should we hide this endpoint from the index?
 44      */
 45     const HIDDEN_ENDPOINT = 256;
 46 
 47     /**
 48      * Map of HTTP verbs to constants
 49      * @var array
 50      */
 51     public static $method_map = array(
 52         'HEAD'   => self::METHOD_GET,
 53         'GET'    => self::METHOD_GET,
 54         'POST'   => self::METHOD_POST,
 55         'PUT'    => self::METHOD_PUT,
 56         'PATCH'  => self::METHOD_PATCH,
 57         'DELETE' => self::METHOD_DELETE,
 58     );
 59 
 60     /**
 61      * Requested path (relative to the API root, wp-json.php)
 62      *
 63      * @var string
 64      */
 65     public $path = '';
 66 
 67     /**
 68      * Requested method (GET/HEAD/POST/PUT/PATCH/DELETE)
 69      *
 70      * @var string
 71      */
 72     public $method = 'HEAD';
 73 
 74     /**
 75      * Request parameters
 76      *
 77      * This acts as an abstraction of the superglobals
 78      * (GET => $_GET, POST => $_POST)
 79      *
 80      * @var array
 81      */
 82     public $params = array( 'GET' => array(), 'POST' => array() );
 83 
 84     /**
 85      * Request headers
 86      *
 87      * @var array
 88      */
 89     public $headers = array();
 90 
 91     /**
 92      * Request files (matches $_FILES)
 93      *
 94      * @var array
 95      */
 96     public $files = array();
 97 
 98     /**
 99      * Request/Response handler, either JSON by default
100      * or XML if requested by client
101      *
102      * @var WC_API_Handler
103      */
104     public $handler;
105 
106 
107     /**
108      * Setup class and set request/response handler
109      *
110      * @since 2.1
111      * @param $path
112      * @return WC_API_Server
113      */
114     public function __construct( $path ) {
115 
116         if ( empty( $path ) ) {
117             if ( isset( $_SERVER['PATH_INFO'] ) )
118                 $path = $_SERVER['PATH_INFO'];
119             else
120                 $path = '/';
121         }
122 
123         $this->path           = $path;
124         $this->method         = $_SERVER['REQUEST_METHOD'];
125         $this->params['GET']  = $_GET;
126         $this->params['POST'] = $_POST;
127         $this->headers        = $this->get_headers( $_SERVER );
128         $this->files          = $_FILES;
129 
130         // Compatibility for clients that can't use PUT/PATCH/DELETE
131         if ( isset( $_GET['_method'] ) ) {
132             $this->method = strtoupper( $_GET['_method'] );
133         }
134 
135         // determine type of request/response and load handler, JSON by default
136         if ( $this->is_json_request() )
137             $handler_class = 'WC_API_JSON_Handler';
138 
139         elseif ( $this->is_xml_request() )
140             $handler_class = 'WC_API_XML_Handler';
141 
142         else
143             $handler_class = apply_filters( 'woocommerce_api_default_response_handler', 'WC_API_JSON_Handler', $this->path, $this );
144 
145         $this->handler = new $handler_class();
146     }
147 
148     /**
149      * Check authentication for the request
150      *
151      * @since 2.1
152      * @return WP_User|WP_Error WP_User object indicates successful login, WP_Error indicates unsuccessful login
153      */
154     public function check_authentication() {
155 
156         // allow plugins to remove default authentication or add their own authentication
157         $user = apply_filters( 'woocommerce_api_check_authentication', null, $this );
158 
159         // API requests run under the context of the authenticated user
160         if ( is_a( $user, 'WP_User' ) )
161             wp_set_current_user( $user->ID );
162 
163         // WP_Errors are handled in serve_request()
164         elseif ( ! is_wp_error( $user ) )
165             $user = new WP_Error( 'woocommerce_api_authentication_error', __( 'Invalid authentication method', 'woocommerce' ), array( 'code' => 500 ) );
166 
167         return $user;
168     }
169 
170     /**
171      * Convert an error to an array
172      *
173      * This iterates over all error codes and messages to change it into a flat
174      * array. This enables simpler client behaviour, as it is represented as a
175      * list in JSON rather than an object/map
176      *
177      * @since 2.1
178      * @param WP_Error $error
179      * @return array List of associative arrays with code and message keys
180      */
181     protected function error_to_array( $error ) {
182         $errors = array();
183         foreach ( (array) $error->errors as $code => $messages ) {
184             foreach ( (array) $messages as $message ) {
185                 $errors[] = array( 'code' => $code, 'message' => $message );
186             }
187         }
188         return array( 'errors' => $errors );
189     }
190 
191     /**
192      * Handle serving an API request
193      *
194      * Matches the current server URI to a route and runs the first matching
195      * callback then outputs a JSON representation of the returned value.
196      *
197      * @since 2.1
198      * @uses WC_API_Server::dispatch()
199      */
200     public function serve_request() {
201 
202         do_action( 'woocommerce_api_server_before_serve', $this );
203 
204         $this->header( 'Content-Type', $this->handler->get_content_type(), true );
205 
206         // the API is enabled by default
207         if ( ! apply_filters( 'woocommerce_api_enabled', true, $this ) || ( 'no' === get_option( 'woocommerce_api_enabled' ) ) ) {
208 
209             $this->send_status( 404 );
210 
211             echo $this->handler->generate_response( array( 'errors' => array( 'code' => 'woocommerce_api_disabled', 'message' => 'The WooCommerce API is disabled on this site' ) ) );
212 
213             return;
214         }
215 
216         $result = $this->check_authentication();
217 
218         // if authorization check was successful, dispatch the request
219         if ( ! is_wp_error( $result ) ) {
220             $result = $this->dispatch();
221         }
222 
223         // handle any dispatch errors
224         if ( is_wp_error( $result ) ) {
225             $data = $result->get_error_data();
226             if ( is_array( $data ) && isset( $data['status'] ) ) {
227                 $this->send_status( $data['status'] );
228             }
229 
230             $result = $this->error_to_array( $result );
231         }
232 
233         // This is a filter rather than an action, since this is designed to be
234         // re-entrant if needed
235         $served = apply_filters( 'woocommerce_api_serve_request', false, $result, $this );
236 
237         if ( ! $served ) {
238 
239             if ( 'HEAD' === $this->method )
240                 return;
241 
242             echo $this->handler->generate_response( $result );
243         }
244     }
245 
246     /**
247      * Retrieve the route map
248      *
249      * The route map is an associative array with path regexes as the keys. The
250      * value is an indexed array with the callback function/method as the first
251      * item, and a bitmask of HTTP methods as the second item (see the class
252      * constants).
253      *
254      * Each route can be mapped to more than one callback by using an array of
255      * the indexed arrays. This allows mapping e.g. GET requests to one callback
256      * and POST requests to another.
257      *
258      * Note that the path regexes (array keys) must have @ escaped, as this is
259      * used as the delimiter with preg_match()
260      *
261      * @since 2.1
262      * @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)`
263      */
264     public function get_routes() {
265 
266         // index added by default
267         $endpoints = array(
268 
269             '/' => array( array( $this, 'get_index' ), self::READABLE ),
270         );
271 
272         $endpoints = apply_filters( 'woocommerce_api_endpoints', $endpoints );
273 
274         // Normalise the endpoints
275         foreach ( $endpoints as $route => &$handlers ) {
276             if ( count( $handlers ) <= 2 && isset( $handlers[1] ) && ! is_array( $handlers[1] ) ) {
277                 $handlers = array( $handlers );
278             }
279         }
280 
281         return $endpoints;
282     }
283 
284     /**
285      * Match the request to a callback and call it
286      *
287      * @since 2.1
288      * @return mixed The value returned by the callback, or a WP_Error instance
289      */
290     public function dispatch() {
291 
292         switch ( $this->method ) {
293 
294             case 'HEAD':
295             case 'GET':
296                 $method = self::METHOD_GET;
297                 break;
298 
299             case 'POST':
300                 $method = self::METHOD_POST;
301                 break;
302 
303             case 'PUT':
304                 $method = self::METHOD_PUT;
305                 break;
306 
307             case 'PATCH':
308                 $method = self::METHOD_PATCH;
309                 break;
310 
311             case 'DELETE':
312                 $method = self::METHOD_DELETE;
313                 break;
314 
315             default:
316                 return new WP_Error( 'woocommerce_api_unsupported_method', __( 'Unsupported request method', 'woocommerce' ), array( 'status' => 400 ) );
317         }
318 
319         foreach ( $this->get_routes() as $route => $handlers ) {
320             foreach ( $handlers as $handler ) {
321                 $callback = $handler[0];
322                 $supported = isset( $handler[1] ) ? $handler[1] : self::METHOD_GET;
323 
324                 if ( !( $supported & $method ) )
325                     continue;
326 
327                 $match = preg_match( '@^' . $route . '$@i', urldecode( $this->path ), $args );
328 
329                 if ( !$match )
330                     continue;
331 
332                 if ( ! is_callable( $callback ) )
333                     return new WP_Error( 'woocommerce_api_invalid_handler', __( 'The handler for the route is invalid', 'woocommerce' ), array( 'status' => 500 ) );
334 
335                 $args = array_merge( $args, $this->params['GET'] );
336                 if ( $method & self::METHOD_POST ) {
337                     $args = array_merge( $args, $this->params['POST'] );
338                 }
339                 if ( $supported & self::ACCEPT_DATA ) {
340                     $data = $this->handler->parse_body( $this->get_raw_data() );
341                     $args = array_merge( $args, array( 'data' => $data ) );
342                 }
343                 elseif ( $supported & self::ACCEPT_RAW_DATA ) {
344                     $data = $this->get_raw_data();
345                     $args = array_merge( $args, array( 'data' => $data ) );
346                 }
347 
348                 $args['_method']  = $method;
349                 $args['_route']   = $route;
350                 $args['_path']    = $this->path;
351                 $args['_headers'] = $this->headers;
352                 $args['_files']   = $this->files;
353 
354                 $args = apply_filters( 'woocommerce_api_dispatch_args', $args, $callback );
355 
356                 // Allow plugins to halt the request via this filter
357                 if ( is_wp_error( $args ) ) {
358                     return $args;
359                 }
360 
361                 $params = $this->sort_callback_params( $callback, $args );
362                 if ( is_wp_error( $params ) )
363                     return $params;
364 
365                 return call_user_func_array( $callback, $params );
366             }
367         }
368 
369         return new WP_Error( 'woocommerce_api_no_route', __( 'No route was found matching the URL and request method', 'woocommerce' ), array( 'status' => 404 ) );
370     }
371 
372     /**
373      * Sort parameters by order specified in method declaration
374      *
375      * Takes a callback and a list of available params, then filters and sorts
376      * by the parameters the method actually needs, using the Reflection API
377      *
378      * @since 2.1
379      * @param callable|array $callback the endpoint callback
380      * @param array $provided the provided request parameters
381      * @return array
382      */
383     protected function sort_callback_params( $callback, $provided ) {
384         if ( is_array( $callback ) )
385             $ref_func = new ReflectionMethod( $callback[0], $callback[1] );
386         else
387             $ref_func = new ReflectionFunction( $callback );
388 
389         $wanted = $ref_func->getParameters();
390         $ordered_parameters = array();
391 
392         foreach ( $wanted as $param ) {
393             if ( isset( $provided[ $param->getName() ] ) ) {
394                 // We have this parameters in the list to choose from
395 
396                 $ordered_parameters[] = is_array( $provided[ $param->getName() ] ) ? array_map( 'urldecode', $provided[ $param->getName() ] ) : urldecode( $provided[ $param->getName() ] );
397             }
398             elseif ( $param->isDefaultValueAvailable() ) {
399                 // We don't have this parameter, but it's optional
400                 $ordered_parameters[] = $param->getDefaultValue();
401             }
402             else {
403                 // We don't have this parameter and it wasn't optional, abort!
404                 return new WP_Error( 'woocommerce_api_missing_callback_param', sprintf( __( 'Missing parameter %s', 'woocommerce' ), $param->getName() ), array( 'status' => 400 ) );
405             }
406         }
407         return $ordered_parameters;
408     }
409 
410     /**
411      * Get the site index.
412      *
413      * This endpoint describes the capabilities of the site.
414      *
415      * @since 2.1
416      * @return array Index entity
417      */
418     public function get_index() {
419 
420         // General site data
421         $available = array( 'store' => array(
422             'name'        => get_option( 'blogname' ),
423             'description' => get_option( 'blogdescription' ),
424             'URL'         => get_option( 'siteurl' ),
425             'wc_version'  => WC()->version,
426             'routes'      => array(),
427             'meta'        => array(
428                 'timezone'           => wc_timezone_string(),
429                 'currency'           => get_woocommerce_currency(),
430                 'currency_format'    => get_woocommerce_currency_symbol(),
431                 'tax_included'       => ( 'yes' === get_option( 'woocommerce_prices_include_tax' ) ),
432                 'weight_unit'        => get_option( 'woocommerce_weight_unit' ),
433                 'dimension_unit'     => get_option( 'woocommerce_dimension_unit' ),
434                 'ssl_enabled'        => ( 'yes' === get_option( 'woocommerce_force_ssl_checkout' ) ),
435                 'permalinks_enabled' => ( '' !== get_option( 'permalink_structure' ) ),
436                 'links'              => array(
437                     'help' => 'http://woothemes.github.io/woocommerce/rest-api/',
438                 ),
439             ),
440         ) );
441 
442         // Find the available routes
443         foreach ( $this->get_routes() as $route => $callbacks ) {
444             $data = array();
445 
446             $route = preg_replace( '#\(\?P(<\w+?>).*?\)#', '$1', $route );
447             $methods = array();
448             foreach ( self::$method_map as $name => $bitmask ) {
449                 foreach ( $callbacks as $callback ) {
450                     // Skip to the next route if any callback is hidden
451                     if ( $callback[1] & self::HIDDEN_ENDPOINT )
452                         continue 3;
453 
454                     if ( $callback[1] & $bitmask )
455                         $data['supports'][] = $name;
456 
457                     if ( $callback[1] & self::ACCEPT_DATA )
458                         $data['accepts_data'] = true;
459 
460                     // For non-variable routes, generate links
461                     if ( strpos( $route, '<' ) === false ) {
462                         $data['meta'] = array(
463                             'self' => get_woocommerce_api_url( $route ),
464                         );
465                     }
466                 }
467             }
468             $available['store']['routes'][ $route ] = apply_filters( 'woocommerce_api_endpoints_description', $data );
469         }
470         return apply_filters( 'woocommerce_api_index', $available );
471     }
472 
473     /**
474      * Send a HTTP status code
475      *
476      * @since 2.1
477      * @param int $code HTTP status
478      */
479     public function send_status( $code ) {
480         status_header( $code );
481     }
482 
483     /**
484      * Send a HTTP header
485      *
486      * @since 2.1
487      * @param string $key Header key
488      * @param string $value Header value
489      * @param boolean $replace Should we replace the existing header?
490      */
491     public function header( $key, $value, $replace = true ) {
492         header( sprintf( '%s: %s', $key, $value ), $replace );
493     }
494 
495     /**
496      * Send a Link header
497      *
498      * @internal The $rel parameter is first, as this looks nicer when sending multiple
499      *
500      * @link http://tools.ietf.org/html/rfc5988
501      * @link http://www.iana.org/assignments/link-relations/link-relations.xml
502      *
503      * @since 2.1
504      * @param string $rel Link relation. Either a registered type, or an absolute URL
505      * @param string $link Target IRI for the link
506      * @param array $other Other parameters to send, as an associative array
507      */
508     public function link_header( $rel, $link, $other = array() ) {
509 
510         $header = sprintf( '<%s>; rel="%s"', $link, esc_attr( $rel ) );
511 
512         foreach ( $other as $key => $value ) {
513 
514             if ( 'title' == $key ) {
515 
516                 $value = '"' . $value . '"';
517             }
518 
519             $header .= '; ' . $key . '=' . $value;
520         }
521 
522         $this->header( 'Link', $header, false );
523     }
524 
525     /**
526      * Send pagination headers for resources
527      *
528      * @since 2.1
529      * @param WP_Query|WP_User_Query $query
530      */
531     public function add_pagination_headers( $query ) {
532 
533         // WP_User_Query
534         if ( is_a( $query, 'WP_User_Query' ) ) {
535 
536             $page        = $query->page;
537             $single      = count( $query->get_results() ) > 1;
538             $total       = $query->get_total();
539             $total_pages = $query->total_pages;
540 
541         // WP_Query
542         } else {
543 
544             $page        = $query->get( 'paged' );
545             $single      = $query->is_single();
546             $total       = $query->found_posts;
547             $total_pages = $query->max_num_pages;
548         }
549 
550         if ( ! $page )
551             $page = 1;
552 
553         $next_page = absint( $page ) + 1;
554 
555         if ( ! $single ) {
556 
557             // first/prev
558             if ( $page > 1 ) {
559                 $this->link_header( 'first', $this->get_paginated_url( 1 ) );
560                 $this->link_header( 'prev', $this->get_paginated_url( $page -1 ) );
561             }
562 
563             // next
564             if ( $next_page <= $total_pages ) {
565                 $this->link_header( 'next', $this->get_paginated_url( $next_page ) );
566             }
567 
568             // last
569             if ( $page != $total_pages )
570                 $this->link_header( 'last', $this->get_paginated_url( $total_pages ) );
571         }
572 
573         $this->header( 'X-WC-Total', $total );
574         $this->header( 'X-WC-TotalPages', $total_pages );
575 
576         do_action( 'woocommerce_api_pagination_headers', $this, $query );
577     }
578 
579     /**
580      * Returns the request URL with the page query parameter set to the specified page
581      *
582      * @since 2.1
583      * @param int $page
584      * @return string
585      */
586     private function get_paginated_url( $page ) {
587 
588         // remove existing page query param
589         $request = remove_query_arg( 'page' );
590 
591         // add provided page query param
592         $request = urldecode( add_query_arg( 'page', $page, $request ) );
593 
594         // get the home host
595         $host = parse_url( get_home_url(), PHP_URL_HOST );
596 
597         return set_url_scheme( "http://{$host}{$request}" );
598     }
599 
600     /**
601      * Retrieve the raw request entity (body)
602      *
603      * @since 2.1
604      * @return string
605      */
606     public function get_raw_data() {
607         global $HTTP_RAW_POST_DATA;
608 
609         // A bug in PHP < 5.2.2 makes $HTTP_RAW_POST_DATA not set by default,
610         // but we can do it ourself.
611         if ( !isset( $HTTP_RAW_POST_DATA ) ) {
612             $HTTP_RAW_POST_DATA = file_get_contents( 'php://input' );
613         }
614 
615         return $HTTP_RAW_POST_DATA;
616     }
617 
618     /**
619      * Parse an RFC3339 datetime into a MySQl datetime
620      *
621      * Invalid dates default to unix epoch
622      *
623      * @since 2.1
624      * @param string $datetime RFC3339 datetime
625      * @return string MySQl datetime (YYYY-MM-DD HH:MM:SS)
626      */
627     public function parse_datetime( $datetime ) {
628 
629         // Strip millisecond precision (a full stop followed by one or more digits)
630         if ( strpos( $datetime, '.' ) !== false ) {
631             $datetime = preg_replace( '/\.\d+/', '', $datetime );
632         }
633 
634         // default timezone to UTC
635         $datetime = preg_replace( '/[+-]\d+:+\d+$/', '+00:00', $datetime );
636 
637         try {
638 
639             $datetime = new DateTime( $datetime, new DateTimeZone( 'UTC' ) );
640 
641         } catch ( Exception $e ) {
642 
643             $datetime = new DateTime( '@0' );
644 
645         }
646 
647         return $datetime->format( 'Y-m-d H:i:s' );
648     }
649 
650     /**
651      * Format a unix timestamp or MySQL datetime into an RFC3339 datetime
652      *
653      * @since 2.1
654      * @param int|string $timestamp unix timestamp or MySQL datetime
655      * @param bool $convert_to_utc
656      * @return string RFC3339 datetime
657      */
658     public function format_datetime( $timestamp, $convert_to_utc = false ) {
659 
660         if ( $convert_to_utc ) {
661             $timezone = new DateTimeZone( wc_timezone_string() );
662         } else {
663             $timezone = new DateTimeZone( 'UTC' );
664         }
665 
666         try {
667 
668             if ( is_numeric( $timestamp ) ) {
669                 $date = new DateTime( "@{$timestamp}" );
670             } else {
671                 $date = new DateTime( $timestamp, $timezone );
672             }
673 
674             // convert to UTC by adjusting the time based on the offset of the site's timezone
675             if ( $convert_to_utc ) {
676                 $date->modify( -1 * $date->getOffset() . ' seconds' );
677             }
678 
679         } catch ( Exception $e ) {
680 
681             $date = new DateTime( '@0' );
682         }
683 
684         return $date->format( 'Y-m-d\TH:i:s\Z' );
685     }
686 
687     /**
688      * Extract headers from a PHP-style $_SERVER array
689      *
690      * @since 2.1
691      * @param array $server Associative array similar to $_SERVER
692      * @return array Headers extracted from the input
693      */
694     public function get_headers($server) {
695         $headers = array();
696         // CONTENT_* headers are not prefixed with HTTP_
697         $additional = array('CONTENT_LENGTH' => true, 'CONTENT_MD5' => true, 'CONTENT_TYPE' => true);
698 
699         foreach ($server as $key => $value) {
700             if ( strpos( $key, 'HTTP_' ) === 0) {
701                 $headers[ substr( $key, 5 ) ] = $value;
702             }
703             elseif ( isset( $additional[ $key ] ) ) {
704                 $headers[ $key ] = $value;
705             }
706         }
707 
708         return $headers;
709     }
710 
711     /**
712      * Check if the current request accepts a JSON response by checking the endpoint suffix (.json) or
713      * the HTTP ACCEPT header
714      *
715      * @since 2.1
716      * @return bool
717      */
718     private function is_json_request() {
719 
720         // check path
721         if ( false !== stripos( $this->path, '.json' ) )
722             return true;
723 
724         // check ACCEPT header, only 'application/json' is acceptable, see RFC 4627
725         if ( isset( $this->headers['ACCEPT'] ) && 'application/json' == $this->headers['ACCEPT'] )
726             return true;
727 
728         return false;
729     }
730 
731     /**
732      * Check if the current request accepts an XML response by checking the endpoint suffix (.xml) or
733      * the HTTP ACCEPT header
734      *
735      * @since 2.1
736      * @return bool
737      */
738     private function is_xml_request() {
739 
740         // check path
741         if ( false !== stripos( $this->path, '.xml' ) )
742             return true;
743 
744         // check headers, 'application/xml' or 'text/xml' are acceptable, see RFC 2376
745         if ( isset( $this->headers['ACCEPT'] ) && ( 'application/xml' == $this->headers['ACCEPT'] || 'text/xml' == $this->headers['ACCEPT'] ) )
746             return true;
747 
748         return false;
749     }
750 }
751 
WooCommerce API documentation generated by ApiGen 2.8.0