Skip to content

Instantly share code, notes, and snippets.

@rmccue
Last active May 14, 2017 15:01
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rmccue/5022591d312952d1245a to your computer and use it in GitHub Desktop.
Save rmccue/5022591d312952d1245a to your computer and use it in GitHub Desktop.
Preliminary pass at a JSON API for WordPress. Version 0.0.4-soalphaithurts.
<?php
/**
* WordPress JSON API
*
* Contains the WP_JSON_Server class.
*
*******************************************************************************
* DO NOT USE THIS IN PRODUCTION UNDER ANY CIRCUMSTANCES. THIS IS ONLY A *
* DEVELOPMENT SNAPSHOT. *
*******************************************************************************
*
* Changes so far:
*
* 0.0.4:
* - Hyperlinks now available in most constructs under the 'meta' key. At the
* moment, the only thing under this key is 'links', but more will come
* eventually. (Try browsing with a browser tool like JSONView; you should be
* able to view all content just by clicking the links.)
* - Accessing / now gives an index which briefly describes the API and gives
* links to more (also added the HIDDEN_ENDPOINT constant to hide from this).
* - Post collections now contain a summary of the post, with the full post
* available via the single post call. (prepare_post() has fields split into
* post and post-extended)
* - Post entities have dropped post_ prefixes, and custom_fields has changed to
* post_meta.
* - Now supports JSONP callback via the _jsonp argument. This can be disabled
* separately to the API itself, as it's only needed for
* cross-origin requests.
* - Internal: No longer extends the XMLRPC class. All relevant pieces have been
* copied over. Further work still needs to be done on this, but it's a start.
*
* 0.0.3:
* - Now accepts JSON bodies if an endpoint is marked with ACCEPT_JSON
*
* Todo:
* Minor:
* - Possibly send CORS headers when JSONP support is enabled
* - Make endpoints completely output independent (i.e. don't send headers
* inside the callbacks)
*
* Major:
* - Rework parts of the creation/updating of posts (sanitization, field
* names, etc)
* - Implement comment methods
* - Implement user methods
* - Implement taxonomy methods
*
* @package WordPress
* @version 0.0.4-soalphaithurts
*/
/**
* WordPress JSON API server handler
*
* @package WordPress
*/
class WP_JSON_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 raw JSON entities?
*/
const ACCEPT_JSON = 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,
);
/**
* Check the authentication headers if supplied
*
* @return WP_Error|WP_User|null WP_User object indicates successful login, WP_Error indicates unsuccessful login and null indicates no authentication provided
*/
public function check_authentication() {
$user = apply_filters( 'json_check_authentication', null);
if ( is_a( $user, 'WP_User' ) )
return $user;
if ( !isset( $_SERVER['PHP_AUTH_USER'] ) )
return;
$username = $_SERVER['PHP_AUTH_USER'];
$password = $_SERVER['PHP_AUTH_PW'];
$user = wp_authenticate( $username, $password );
if ( is_wp_error( $user ) )
return $user;
wp_set_current_user( $user->ID );
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
*
* @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;
}
/**
* Get an appropriate error representation in JSON
*
* Note: This should only be used in {@see WP_JSON_Server::serve_request()},
* as it cannot handle WP_Error internally. All callbacks and other internal
* methods should instead return a WP_Error with the data set to an array
* that includes a 'status' key, with the value being the HTTP status to
* send.
*
* @param string $code WP_Error-style code
* @param string $message Human-readable message
* @param int $status HTTP status code to send
* @return string JSON representation of the error
*/
protected function json_error( $code, $message, $status = null ) {
if ( $status )
status_header( $status );
$error = compact( 'code', 'message' );
return json_encode(array($error));
}
/**
* 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.
*
* @uses WP_JSON_Server::dispatch()
*/
public function serve_request() {
header('Content-Type: application/json; charset=' . get_option('blog_charset'), true);
// Proper filter for turning off the JSON API. It is on by default.
$enabled = apply_filters( 'json_enabled', true );
$jsonp_enabled = apply_filters( 'json_jsonp_enabled', true );
if ( ! $enabled ) {
echo $this->json_error( 'json_disabled', 'The JSON API is disabled on this site.', 405 );
return false;
}
if ( isset($_GET['_jsonp']) ) {
if ( ! $jsonp_enabled ) {
echo $this->json_error( 'json_callback_disabled', 'JSONP support is disabled on this site.', 405 );
return false;
}
// Check for invalid characters (only alphanumeric allowed)
if ( preg_match( '/\W/', $_GET['_jsonp'] ) ) {
echo $this->json_error( 'json_callback_invalid', 'The JSONP callback function is invalid.', 400 );
return false;
}
}
if ( isset( $_SERVER['PATH_INFO'] ) )
$path = $_SERVER['PATH_INFO'];
else
$path = '/';
$method = $_SERVER['REQUEST_METHOD'];
// Compatibility for clients that can't use PUT/PATCH/DELETE
if ( isset( $_GET['_method'] ) ) {
$method = strtoupper( $_GET['_method'] );
}
$result = $this->check_authentication();
if ( ! is_wp_error($result)) {
$result = $this->dispatch( $path, $method );
}
if ( is_wp_error( $result ) ) {
$data = $result->get_error_data();
if ( is_array( $data ) && isset( $data['status'] ) ) {
status_header( $data['status'] );
}
$result = $this->error_to_array( $result );
}
if ( 'HEAD' === $method )
return;
if ( isset($_GET['_jsonp']) )
echo $_GET['_jsonp'] . '(' . json_encode( $result ) . ')';
else
echo json_encode( $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()
*
* @return array `'/path/regex' => array( $callback, $bitmask )` or `'/path/regex' => array( array( $callback, $bitmask ), ...)`
*/
public function getRoutes() {
$endpoints = array(
// Meta endpoints
'/' => array( array($this, 'getIndex'), self::READABLE),
// Post endpoints
'/posts' => array(
array( array( $this, 'getPosts' ), self::READABLE ),
array( array( $this, 'newPost' ), self::CREATABLE | self::ACCEPT_JSON ),
),
'/posts/(?P<id>\d+)' => array(
array( array( $this, 'getPost' ), self::READABLE ),
array( array( $this, 'editPost' ), self::EDITABLE | self::ACCEPT_JSON ),
array( array( $this, 'deletePost' ), self::DELETABLE ),
),
'/posts/(?P<id>\d+)/revisions' => array( '__return_null', self::READABLE ),
// Comments
'/posts/(?P<id>\d+)/comments' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ),
),
'/posts/(?P<id>\d+)/comments/(?P<comment>\d+)' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::EDITABLE | self::ACCEPT_JSON ),
array( '__return_null', self::DELETABLE ),
),
// Meta-post endpoints
'/posts/types' => array('__return_null', self::READABLE),
'/posts/types/(?P<type>\w+)' => array('__return_null', self::READABLE),
'/posts/statuses' => array('__return_null', self::READABLE),
// Taxonomies
'/taxonomies' => array('__return_null', self::READABLE),
'/taxonomies/(?P<taxonomy>\w+)' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::EDITABLE | self::ACCEPT_JSON ),
array( '__return_null', self::DELETABLE ),
),
'/taxonomies/(?P<taxonomy>\w+)/terms' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ),
),
'/taxonomies/(?P<taxonomy>\w+)/terms/(?P<term>\w+)' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::EDITABLE | self::ACCEPT_JSON ),
array( '__return_null', self::DELETABLE ),
),
// Users
'/users' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ),
),
// /users/me is an alias, and simply redirects to /users/<id>
'/users/me' => array( '__return_null', self::ALLMETHODS ),
'/users/(?P<user>\d+)' => array(
array( '__return_null', self::READABLE ),
array( '__return_null', self::CREATABLE | self::ACCEPT_JSON ),
),
);
$endpoints = apply_filters( 'json_endpoints', $endpoints );
// Normalise the endpoints
foreach ( $endpoints as $route => &$handlers ) {
if ( count($handlers) <= 2 && ! is_array( $handlers[1] ) ) {
$handlers = array( $handlers );
}
}
return $endpoints;
}
/**
* Match the request to a callback and call it
*
* @param string $path Requested route
* @return mixed The value returned by the callback, or a WP_Error instance
*/
public function dispatch( $path, $method = self::METHOD_GET ) {
switch ( $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( 'json_unsupported_method', __( 'Unsupported request method' ), array( 'status' => 400 ) );
}
foreach ( $this->getRoutes() 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', $path, $args);
if ( !$match )
continue;
if ( ! is_callable($callback) )
return new WP_Error( 'json_invalid_handler', __('The handler for the route is invalid'), array( 'status' => 500 ) );
$args = array_merge( $args, $_GET );
if ( $method & self::METHOD_POST ) {
$args = array_merge( $args, $_POST );
}
if ( $supported & self::ACCEPT_JSON ) {
$data = json_decode( $this->get_raw_data(), true );
$args = array_merge( $args, $data );
}
$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( 'json_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
*
* @param callback $callback
* @param array $params
* @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( 'json_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.
*
* @todo Should we generate text documentation too based on PHPDoc?
*
* @return array Index entity
*/
public function getIndex() {
// General site data
$available = array(
'name' => get_option('blogname'),
'description' => get_option('blogdescription'),
'URL' => get_option('siteurl'),
'routes' => array(),
'meta' => array(
'links' => array(
'help' => 'http://codex.wordpress.org/JSON_API',
),
),
);
// Find the available routes
foreach ( $this->getRoutes() 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_JSON )
$data['accepts_json'] = true;
}
}
$available['routes'][$route] = apply_filters( 'json_endpoints_description', $data );
}
return apply_filters( 'json_index', $available );
}
/**
* Retrieve posts.
*
* @since 3.4.0
*
* The optional $filter parameter modifies the query used to retrieve posts.
* Accepted keys are 'post_type', 'post_status', 'number', 'offset',
* 'orderby', and 'order'.
*
* The optional $fields parameter specifies what fields will be included
* in the response array.
*
* @uses wp_get_recent_posts()
* @see WP_JSON_Server::getPost() for more on $fields
* @see get_posts() for more on $filter values
*
* @param array $filter optional
* @param array $fields optional
* @return array contains a collection of Post entities.
*/
public function getPosts( $filter = array(), $fields = array(), $type = 'post' ) {
if ( empty($fields) )
$fields = apply_filters( 'json_default_post_fields', array( 'post', 'meta', 'terms' ), 'getPosts' );
$query = array();
$post_type = get_post_type_object( $type );
if ( ! ( (bool) $post_type ) )
return new WP_Error( 'json_invalid_post_type', __( 'The post type specified is not valid' ), array( 'status' => 403 ) );
$query['post_type'] = $post_type->name;
if ( isset( $filter['post_status'] ) )
$query['post_status'] = $filter['post_status'];
if ( isset( $filter['number'] ) )
$query['numberposts'] = absint( $filter['number'] );
if ( isset( $filter['offset'] ) )
$query['offset'] = absint( $filter['offset'] );
if ( isset( $filter['orderby'] ) ) {
$query['orderby'] = $filter['orderby'];
if ( isset( $filter['order'] ) )
$query['order'] = $filter['order'];
}
if ( isset( $filter['s'] ) ) {
$query['s'] = $filter['s'];
}
$posts_list = wp_get_recent_posts( $query );
if ( ! $posts_list )
return array();
// holds all the posts data
$struct = array();
header('Last-Modified: ' . mysql2date('D, d M Y H:i:s', get_lastpostmodified('GMT'), 0).' GMT');
foreach ( $posts_list as $post ) {
$post_type = get_post_type_object( $post['post_type'] );
if ( 'publish' !== $post['post_status'] && ! current_user_can( $post_type->cap->read_post, $post['ID'] ) )
continue;
$this->link_header( 'item', json_url( '/posts/' . $post['ID'] ), array( 'title' => $post['post_title'] ) );
$struct[] = $this->prepare_post( $post, $fields );
}
return $struct;
}
/**
* Create a new post for any registered post type.
*
* @since 3.4.0
* @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content']
*
* @param array $content Content data. Can contain:
* - post_type (default: 'post')
* - post_status (default: 'draft')
* - post_title
* - post_author
* - post_excerpt
* - post_content
* - post_date_gmt | post_date
* - post_format
* - post_password
* - comment_status - can be 'open' | 'closed'
* - ping_status - can be 'open' | 'closed'
* - sticky
* - post_thumbnail - ID of a media item to use as the post thumbnail/featured image
* - custom_fields - array, with each element containing 'key' and 'value'
* - terms - array, with taxonomy names as keys and arrays of term IDs as values
* - terms_names - array, with taxonomy names as keys and arrays of term names as values
* - enclosure
* - any other fields supported by wp_insert_post()
* @return array Post data (see {@see WP_JSON_Server::getPost})
*/
function newPost( $data ) {
unset( $data['ID'] );
$user = wp_get_current_user();
$result = $this->_insert_post( $user, $data );
if ( is_string( $result ) ) {
status_header(201);
header('Location: ' . json_url('/posts/' . $result));
return $this->getPost( $result );
}
elseif ( $result instanceof IXR_Error ) {
return new WP_Error( 'json_insert_error', $result->message, array( 'status' => $result->code ) );
}
else {
return new WP_Error( 'json_insert_error', __('An unknown error occurred while creating the post'), array( 'status' => 500 ) );
}
}
/**
* Retrieve a post.
*
* @uses get_post()
* @param int $id Post ID
* @param array $fields Post fields to return (optional)
* @return array Post entity
*/
public function getPost( $id, $fields = array() ) {
$id = (int) $id;
$post = get_post( $id, ARRAY_A );
if ( empty( $fields ) )
$fields = apply_filters( 'json_default_post_fields', array( 'post', 'post-extended', 'meta', 'terms', 'custom_fields' ), 'getPost' );
if ( empty( $post['ID'] ) )
return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404) );
$post_type = get_post_type_object( $post['post_type'] );
if ( 'publish' !== $post['post_status'] && ! current_user_can( $post_type->cap->read_post, $id ) )
return new WP_Error( 'json_user_cannot_read_post', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) );
// Link headers (see RFC 5988)
header( 'Last-Modified: ' . mysql2date( 'D, d M Y H:i:s', $post['post_modified_gmt'] ) . 'GMT' );
$post = $this->prepare_post( $post, $fields );
foreach ($post['meta']['links'] as $rel => $url) {
$this->link_header( $rel, $url );
}
$this->link_header( 'alternate', get_permalink($id), array( 'type' => 'text/html' ) );
return $post;
}
/**
* Edit a post for any registered post type.
*
* The $data parameter only needs to contain fields that should be changed.
* All other fields will retain their existing values.
*
* @since 3.4.0
* @internal 'data' is used here rather than 'content', as get_default_post_to_edit uses $_REQUEST['content']
*
* @param int $id Post ID to edit
* @param array $data Data construct, see {@see WP_JSON_Server::newPost}
* @return true on success
*/
function editPost( $id, $data ) {
$post = get_post( $id, ARRAY_A );
if ( empty( $post['ID'] ) )
return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404) );
if ( isset( $data['if_not_modified_since'] ) ) {
// If the post has been modified since the date provided, return an error.
if ( mysql2date( 'U', $post['post_modified_gmt'] ) > $data['if_not_modified_since']->getTimestamp() ) {
return new WP_Error( 'json_old_revision', __( 'There is a revision of this post that is more recent.' ), array( 'status' => 409) );
}
}
// convert the date field back to IXR form
$post['post_date'] = new IXR_Date(mysql2date( 'Ymd\TH:i:s', $post['post_date'], false ));
// ignore the existing GMT date if it is empty or a non-GMT date was supplied in $content_struct,
// since _insert_post will ignore the non-GMT date if the GMT date is set
if ( $post['post_date_gmt'] == '0000-00-00 00:00:00' || isset( $data['post_date'] ) )
unset( $post['post_date_gmt'] );
else
$post['post_date_gmt'] = new IXR_Date(mysql2date( 'Ymd\TH:i:s', $post['post_date_gmt'], false ));
$this->escape( $post );
$merged_content_struct = array_merge( $post, $data );
$user = wp_get_current_user();
$retval = $this->_insert_post( $user, $merged_content_struct );
if ( $retval instanceof IXR_Error ) {
return new WP_Error( 'json_edit_error', $retval->message, array( 'status' => $retval->code ) );
}
return array('message' => __('Updated post'), 'data' => $this->getPost($id));
}
/**
* Delete a post for any registered post type
*
* @uses wp_delete_post()
* @param int $id
* @return true on success
*/
public function deletePost( $id ) {
$id = (int) $id;
$post = get_post( $id, ARRAY_A );
if ( empty( $post['ID'] ) )
return new WP_Error( 'json_post_invalid_id', __( 'Invalid post ID.' ), array( 'status' => 404) );
$post_type = get_post_type_object( $post['post_type'] );
if ( ! current_user_can( $post_type->cap->delete_post, $id ) )
return new WP_Error( 'json_user_cannot_delete_post', __( 'Sorry, you are not allowed to delete this post.' ), array( 'status' => 401 ) );
$result = wp_delete_post( $id );
if ( ! $result )
return new WP_Error( 'json_cannot_delete', __( 'The post cannot be deleted.' ), array( 'status' => 500 ) );
return true;
}
/**
* Send a Link header
*
* @todo Make this safe for <>"';,
* @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
*
* @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
*/
protected 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;
}
header($header, false);
}
/**
* Retrieve the raw request entity (body)
*
* @return string
*/
protected 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;
}
/**
* Prepares post data for return in an XML-RPC object.
*
* @access protected
*
* @param array $post The unprepared post data
* @param array $fields The subset of post type fields to return
* @return array The prepared post data
*/
protected function prepare_post( $post, $fields, $context = 'single' ) {
// holds the data for this post. built up based on $fields
$_post = array(
'ID' => (int) $post['ID'],
);
$post_type = get_post_type_object( $post['post_type'] );
if ( 'publish' !== $post['post_status'] && ! current_user_can( $post_type->cap->read_post, $post['ID'] ) )
return new WP_Error( 'json_user_cannot_read_post', __( 'Sorry, you cannot read this post.' ), array( 'status' => 401 ) );
// prepare common post fields
$post_fields = array(
'title' => $post['post_title'],
'status' => $post['post_status'],
'type' => $post['post_type'],
'author' => (int) $post['post_author'],
'content' => $post['post_content'],
'parent' => (int) $post['post_parent'],
#'post_mime_type' => $post['post_mime_type'],
'link' => get_permalink( $post['ID'] ),
);
$post_fields_extended = array(
'slug' => $post['post_name'],
'guid' => $post['guid'],
'excerpt' => $post['post_excerpt'],
'menu_order' => (int) $post['menu_order'],
'comment_status'=> $post['comment_status'],
'ping_status' => $post['ping_status'],
'sticky' => ( $post['post_type'] === 'post' && is_sticky( $post['ID'] ) ),
);
// Dates
$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+0';
elseif ($current_offset < 0)
$tzstring = 'UTC' . $current_offset;
else
$tzstring = 'UTC+' . $current_offset;
}
$timezone = new DateTimeZone( $tzstring );
$date = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_date'], $timezone );
$post_fields['date'] = $date->format( 'c' );
$post_fields_extended['date_tz'] = $date->format( 'e' );
$post_fields_extended['date_gmt'] = date( 'c', strtotime( $post['post_date_gmt'] ) );
$modified = DateTime::createFromFormat( 'Y-m-d H:i:s', $post['post_modified'], $timezone );
$post_fields['modified'] = $modified->format( 'c' );
$post_fields_extended['modified_tz'] = $modified->format( 'e' );
$post_fields_extended['modified_gmt'] = date( 'c', strtotime( $post['post_modified_gmt'] ) );
// Authorized fields
// TODO: Send `Vary: Authorization` to clarify that the data can be
// changed by the user's auth status
if ( current_user_can( $post_type->cap->edit_post, $post['ID'] ) ) {
$post_fields_extended['password'] = $post['post_password'];
}
// Thumbnail
$post_fields_extended['post_thumbnail'] = array();
$thumbnail_id = get_post_thumbnail_id( $post['ID'] );
if ( $thumbnail_id ) {
$thumbnail_size = current_theme_supports('post-thumbnail') ? 'post-thumbnail' : 'thumbnail';
$post_fields_extended['post_thumbnail'] = $this->_prepare_media_item( get_post( $thumbnail_id ), $thumbnail_size );
}
// Consider future posts as published
if ( $post_fields['status'] === 'future' )
$post_fields['status'] = 'publish';
// Fill in blank post format
$post_fields['format'] = get_post_format( $post['ID'] );
if ( empty( $post_fields['format'] ) )
$post_fields['format'] = 'standard';
$post_fields['author'] = $this->prepare_author( $post['post_author'] );
if ( ( 'single' === $context || 'single-parent' === $context ) && 0 !== $post['post_parent'] ) {
// Avoid nesting too deeply
// This gives post + post-extended + meta for the main post,
// post + meta for the parent and just meta for the grandparent
$parent_fields = array( 'meta' );
if ( $context === 'single' )
$parent_fields[] = 'post';
$parent = get_post( $post['post_parent'], ARRAY_A );
$post_fields['parent'] = $this->prepare_post($parent, $parent_fields, 'single-parent' );
}
// Merge requested $post_fields fields into $_post
if ( in_array( 'post', $fields ) ) {
$_post = array_merge( $_post, $post_fields );
} else {
$requested_fields = array_intersect_key( $post_fields, array_flip( $fields ) );
$_post = array_merge( $_post, $requested_fields );
}
if ( in_array( 'post-extended', $fields ) )
$_post = array_merge( $_post, $post_fields_extended );
// Taxonomies
$all_taxonomy_fields = in_array( 'taxonomies', $fields );
if ( $all_taxonomy_fields || in_array( 'terms', $fields ) ) {
$post_type_taxonomies = get_object_taxonomies( $post['post_type'] );
$terms = wp_get_object_terms( $post['ID'], $post_type_taxonomies );
$_post['terms'] = array();
foreach ( $terms as $term ) {
$_post['terms'][ $term->taxonomy ] = $this->prepare_term( $term );
}
}
if ( in_array( 'custom_fields', $fields ) )
$_post['post_meta'] = $this->prepare_meta( $post['ID'] );
if ( in_array( 'meta', $fields ) ) {
$_post['meta'] = array(
'links' => array(
'self' => json_url( '/posts/' . $post['ID'] ),
'author' => json_url( '/users/' . $post['post_author'] ),
'collection' => json_url( '/posts' ),
'replies' => json_url( '/posts/' . $post['ID'] . '/comments' ),
'version-history' => json_url( '/posts/' . $post['ID'] . '/revisions' ),
),
);
if ( ! empty( $post['post_parent'] ) )
$_post['meta']['links']['up'] = json_url( '/posts/' . (int) $post['post_parent'] );
}
return apply_filters( 'json_prepare_post', $_post, $post, $fields );
}
/**
* Prepares term data for return in an XML-RPC object.
*
* @access protected
*
* @param array|object $term The unprepared term data
* @return array The prepared term data
*/
protected function prepare_term( $term ) {
$_term = $term;
if ( ! is_array( $_term) )
$_term = get_object_vars( $_term );
$_term['id'] = $term->term_id;
$_term['group'] = $term->term_group;
$_term['parent'] = $_term['parent'];
$_term['count'] = $_term['count'];
#unset($_term['term_id'], )
$data = array(
'ID' => (int) $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'group' => (int) $term->term_group,
'parent' => (int) $term->parent,
'count' => (int) $term->count,
'meta' => array(
'links' => array(
'collection' => json_url( '/taxonomy/' . $term->taxonomy ),
'self' => json_url( '/taxonomy/' . $term->taxonomy . '/terms/' . $term->term_id ),
),
),
);
return apply_filters( 'json_prepare_term', $data, $term );
}
/**
* Retrieve custom fields for post.
*
* @since 2.5.0
*
* @param int $post_id Post ID.
* @return array Custom fields, if exist.
*/
protected function prepare_meta( $post_id ) {
$post_id = (int) $post_id;
$custom_fields = array();
foreach ( (array) has_meta($post_id) as $meta ) {
// Don't expose protected fields.
if ( ! current_user_can( 'edit_post_meta', $post_id , $meta['meta_key'] ) )
continue;
$custom_fields[] = array(
'id' => $meta['meta_id'],
'key' => $meta['meta_key'],
'value' => $meta['meta_value']
);
}
return apply_filters( 'json_prepare_meta', $custom_fields );
}
/**
* Convert a WordPress date string to an array.
*
* @access protected
*
* @param string $date
* @return array
*/
protected function _convert_date( $date ) {
if ( $date === '0000-00-00 00:00:00' ) {
return 0;
}
return strtotime($date);
}
/**
* Convert a WordPress GMT date string to an array.
*
* @access protected
*
* @param string $date_gmt
* @param string $date
* @return array
*/
protected function _convert_date_gmt( $date_gmt, $date ) {
return strtotime($date_gmt);
}
protected function prepare_author( $author ) {
$user = get_user_by( 'id', $author );
$author = array(
'ID' => $user->ID,
'name' => $user->display_name,
'slug' => $user->user_nicename,
'URL' => $user->user_url,
'avatar' => $this->get_avatar($user->user_email),
'meta' => array(
'links' => array(
'self' => json_url( '/users/' . $user->ID ),
'archives' => json_url( '/users/' . $user->ID . '/posts' ),
),
),
);
if ( current_user_can('edit_user', $user->ID) ) {
$author['first_name'] = $user->first_name;
$author['last_name'] = $user->last_name;
}
return $author;
}
/**
* Retrieve the avatar for a user who provided a user ID or email address.
*
* {@see get_avatar()} doesn't return just the URL, so we have to
* reimplement this here.
*
* @todo Rework how we do this. Copying it is a hack.
*
* @since 2.5
* @param string $email Email address
* @return string <img> tag for the user's avatar
*/
protected function get_avatar( $email ) {
if ( ! get_option('show_avatars') )
return false;
$email_hash = md5( strtolower( trim( $email ) ) );
if ( is_ssl() ) {
$host = 'https://secure.gravatar.com';
} else {
if ( !empty($email) )
$host = sprintf( "http://%d.gravatar.com", ( hexdec( $email_hash[0] ) % 2 ) );
else
$host = 'http://0.gravatar.com';
}
$avatar = "$host/avatar/$email_hash&d=404";
$rating = get_option('avatar_rating');
if ( !empty( $rating ) )
$avatar .= "&r={$rating}";
return apply_filters('get_avatar', $avatar, $email, '96', '404', '');
}
}
function json_url( $path = '', $scheme = 'json' ) {
return get_json_url( null, $path, $scheme );
}
function get_json_url( $blog_id = null, $path = '', $scheme = 'json' ) {
$url = get_site_url($blog_id, 'wp-json.php', $scheme);
if ( !empty( $path ) && is_string( $path ) && strpos( $path, '..' ) === false )
$url .= '/' . ltrim( $path, '/' );
return apply_filters( 'json_url', $url, $path, $blog_id );
}

Documents

General

date          = 1*DIGIT
boolean       = "true" | "false"
timezone      = quoted-string

Index

The Index entity is the root endpoint for the API server and describes the contents and abilities of the API server.

Entity

The Index entity is a JSON object of site properties. The following properties are defined for the Index entity object:

Index            = "{" index-field *( "," index-field ) "}"
index-field      = ( ( "name" ":" quoted-string )
                 | ( "description" ":" quoted-string )
                 | ( "URL" ":" URI )
                 | ( "routes" ":" route-map )
                 | ( "meta" ":" meta-map ) )
route-map        = "{" route ":" route-descriptor
                 *( "," route ":" route-descriptor ) "}"
route            = quoted-string
route-descriptor = "{" route-property *( "," route-property ) "}"
route-property   = ( ( "supports" ":" "[" method *( "," method ) "]" )
                 | ( "accepts_json" ":" boolean ) )
method           = "HEAD" | "GET" | "POST" | "PUT" | "PATCH" | "DELETE"

Example

{
	"name":"My WordPress Site",
	"description":"Just another WordPress site",
	"URL":"http:\/\/example.com",
	"routes": {
		"\/": {
			"supports": [ "HEAD", "GET" ]
		},
		"\/posts": {
			"supports": [ "HEAD", "GET", "POST" ],
			"accepts_json": true
		},
		"\/posts\/<id>": {
			"supports": [ "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE" ]
		},
		"\/posts\/<id>\/revisions": {
			"supports": [ "HEAD", "GET" ]
		},
		"\/posts\/<id>\/comments": {
			"supports": [ "HEAD", "GET", "POST" ],
			"accepts_json":true
		}
	},
	"meta": {
		"links": {
			"help":"http:\/\/codex.wordpress.org\/JSON_API"
		}
	}
}

Post

A Post entity is defined as the representation of a post item, analogous to an Atom item.

Headers

The following headers are sent when a Post is the main entity:

  • Link:
    • rel="alternate"; type=text/html: The permalink for the Post
    • rel="collection": The endpoint of the Post Collection the Post is contained in
    • rel="replies": The endpoint of the associated Comment Collection
    • rel="version-history": The endpoint of the Post Collection containing the revisions of the Post

Entity

The Post entity is a JSON object of post properties. The following properties are defined for the Post entity object:

Post           = "{" post-field *( "," post-field ) "}"
post-field     = ( ( "ID" ":"  [ "-" ] 1*DIGIT )
               | ( "title" ":" quoted-string )
               | ( "date" ":" date )
               | ( "date_tz" ":" timezone )
               | ( "date_gmt" ":" date )
               | ( "modified" ":" date )
               | ( "modified_tz" ":" timezone )
               | ( "modified_gmt" ":" date )
               | ( "status" ":" <"> post-status <"> )
               | ( "type" ":" <"> post-type <"> )
               | ( "name" ":" quoted-string )
               | ( "author" ":" ( 1*DIGIT | User ) )
               | ( "password" ":" quoted-string )
               | ( "excerpt" ":" quoted-string )
               | ( "content" ":" quoted-string )
               | ( "parent" ":" ( 1*DIGIT | Post ) )
               | ( "link" ":" URI )
               | ( "guid" ":" quoted-string )
               | ( "menu_order" ":" 1*DIGIT )
               | ( "comment_status" ":" comment-status )
               | ( "ping_status" ":" ping-status )
               | ( "sticky" ":" boolean )
               | ( "post_thumbnail" ":" post-thumbnail )
               | ( "post_format" ":" post-format )
               | ( "terms" ":" terms )
               | ( "post_meta" ":" custom-fields ) )
post-status    = "draft" | "pending" | "private" | "publish" | "trash"
post-type      = "post" | "page" | token
comment-status = "open" | "closed"
ping-status    = "open" | "closed"
post-thumbnail = "[" *( post-thumb ) "]"
post-format    = "standard" | "aside" | "gallery" | "image" | "link" | "status"
custom-fields  = "[" *( "{"
               ( "id" ":" 1*DIGIT
               | "key" ":" quoted-string
               | "value" ":" quoted-string
               ) "}" ) "]"

Example

HTTP/1.1 200 OK
Date: Mon, 07 Jan 2013 03:35:14 GMT
Last-Modified: Mon, 07 Jan 2013 03:35:14 GMT
Link: <http://localhost/wptrunk/?p=1>; rel="alternate"; type=text/html
Link: <http://localhost/wptrunk/wp-json.php/users/1>; rel="author"
Link: <http://localhost/wptrunk/wp-json.php/posts>; rel="collection"
Link: <http://localhost/wptrunk/wp-json.php/posts/158/comments>; rel="replies"
Link: <http://localhost/wptrunk/wp-json.php/posts/158/revisions>; rel="version-history"
Content-Type: application/json; charset=UTF-8

{
	"ID":158,
	"title":"This is a test!",
	"status":"publish",
	"type":"post",
	"author":{
		"ID":1,
		"name":"admin",
		"slug":"admin",
		"URL":"",
		"avatar":"http:\/\/0.gravatar.com\/avatar\/c57c8945079831fa3c19caef02e44614&d=404&r=G",
		"meta":{
			"links":{
				"self":"http:\/\/localhost\/wptrunk\/wp-json.php\/users\/1",
				"archives":"http:\/\/localhost\/wptrunk\/wp-json.php\/users\/1\/posts"
			}
		}
	},
	"content":"Hello.\r\n\r\nHah.",
	"parent":0,
	"link":"http:\/\/localhost\/wptrunk\/158\/this-is-a-test\/",
	"date":"2013-01-07T13:35:14+10:00",
	"modified":"2013-01-07T13:49:40+10:00",
	"format":"standard",
	"slug":"this-is-a-test",
	"guid":"http:\/\/localhost\/wptrunk\/?p=158",
	"excerpt":"",
	"menu_order":0,
	"comment_status":"open",
	"ping_status":"open",
	"sticky":false,
	"date_tz":"Australia\/Brisbane",
	"date_gmt":"2013-01-07T03:35:14+00:00",
	"modified_tz":"Australia\/Brisbane",
	"modified_gmt":"2013-01-07T03:49:40+00:00",
	"post_thumbnail":[],
	"terms":{
		"category":{
			"ID":1,
			"name":"Uncategorized",
			"slug":"uncategorized",
			"group":0,
			"parent":0,
			"count":4,
			"meta":{
				"links":{
					"collection":"http:\/\/localhost\/wptrunk\/wp-json.php\/taxonomy\/category",
					"self":"http:\/\/localhost\/wptrunk\/wp-json.php\/taxonomy\/category\/terms\/1"
				}
			}
		}
	},
	"post_meta":[],
	"meta":{
		"links":{
			"self":"http:\/\/localhost\/wptrunk\/wp-json.php\/posts\/158",
			"author":"http:\/\/localhost\/wptrunk\/wp-json.php\/users\/1",
			"collection":"http:\/\/localhost\/wptrunk\/wp-json.php\/posts",
			"replies":"http:\/\/localhost\/wptrunk\/wp-json.php\/posts\/158\/comments",
			"version-history":"http:\/\/localhost\/wptrunk\/wp-json.php\/posts\/158\/revisions"
		}
	}
}

Post Collection

A Post Collection entity is defined as a collection of Post entities.

Headers

The following headers are sent when a Post Collection is the main entity:

  • Link:
    • rel="item" - Each item in the collection has a corresponding Link header containing the location of the endpoint for that resource.

Entity

The Post Collection entity is a JSON list of Post entities.

Post-Collection = "[" Post *( "," Post ) "]"

Endpoints

The following endpoints return the given document with associated headers.

/: Index
/posts: Post Collection
/posts/<id>: Post
/posts/<id>/revisions: Post Collection
/posts/<id>/comments: Comment Collection
/posts/<id>/comments/<comment>: Comment

/taxonomies: Taxonomy Collection
/taxonomies/<tax>: Taxonomy
/taxonomies/<tax>/terms: Term Collection
/taxonomies/<tax>/terms/<term>: Term

/users: User Collection
/users/me: User
/users/<user>: User
<?php
/**
* JSON API support for WordPress
*
* @package WordPress
*/
/**
* Whether this is a XMLRPC Request
*
* @var bool
* @todo Remove me in favour of JSON_REQUEST
*/
define('XMLRPC_REQUEST', true);
/**
* Whether this is a JSON Request
*
* @var bool
*/
define('JSON_REQUEST', true);
/** Include the bootstrap for setting up WordPress environment */
include('./wp-load.php');
include_once(ABSPATH . 'wp-admin/includes/admin.php');
include_once(ABSPATH . WPINC . '/class-IXR.php');
include_once(ABSPATH . WPINC . '/class-wp-xmlrpc-server.php');
include_once(ABSPATH . WPINC . '/class-wp-json-server.php');
// Allow for a plugin to insert a different class to handle requests.
$wp_json_server_class = apply_filters('wp_json_server_class', 'WP_JSON_Server');
$wp_json_server = new $wp_json_server_class;
// Fire off the request
$wp_json_server->serve_request();
exit;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment