I think the best option is an endpoint. You get all the data as a simple string, so you can decide how it will be parsed, and you don’t have to worry about collisions with other rewrite rules.
One thing I learned about endpoints: keep the main work as abstract as possible, fix the glitches in WordPress’ API in a data agnostic way.
I would separate the logic into three parts: a controller select a model and a view, a model to handle the endpoint and one or more views to return some useful data or error messages.
The controller
Let’s start with the controller. It doesn’t do much, so I use a very simple function here:
add_action( 'plugins_loaded', 't5_cra_init' );
function t5_cra_init()
{
require dirname( __FILE__ ) . '/class.T5_CRA_Model.php';
$options = array (
'callback' => array ( 'T5_CRA_View_Demo', '__construct' ),
'name' => 'api',
'position' => EP_ROOT
);
new T5_CRA_Model( $options );
}
Basically, it loads the model T5_CRA_Model
and hands over some parameters … and all the work. The controller doesn’t know anything about the inner logic of the model or the view. It just sticks both together. This is the only part you cannot reuse; that’s why I kept it separated from the other parts.
Now we need at least two classes: the model that registers the API and the view to create output.
The model
This class will:
- register the endpoint
- catch cases where there endpoint was called without any additional parameters
- fill rewrite rules that are missing due to some bugs in third party code
- fix a WordPress glitch with static front pages and endpoints for
EP_ROOT
- parse the URI into an array (this could be separated too)
- call the callback handler with those values
I hope the code speaks for itself. 🙂
The model doesn’t know anything about the inner structure of the data or about the presentation. So you can use it to register hundreds of APIs without changing one line.
<?php # -*- coding: utf-8 -*-
/**
* Register new REST API as endpoint.
*
* @author toscho http://toscho.de
*
*/
class T5_CRA_Model
{
protected $options;
/**
* Read options and register endpoint actions and filters.
*
* @wp-hook plugins_loaded
* @param array $options
*/
public function __construct( Array $options )
{
$default_options = array (
'callback' => array ( 'T5_CRA_View_Demo', '__construct' ),
'name' => 'api',
'position' => EP_ROOT
);
$this->options = wp_parse_args( $options, $default_options );
add_action( 'init', array ( $this, 'register_api' ), 1000 );
// endpoints work on the front end only
if ( is_admin() )
return;
add_filter( 'request', array ( $this, 'set_query_var' ) );
// Hook in late to allow other plugins to operate earlier.
add_action( 'template_redirect', array ( $this, 'render' ), 100 );
}
/**
* Add endpoint and deal with other code flushing our rules away.
*
* @wp-hook init
* @return void
*/
public function register_api()
{
add_rewrite_endpoint(
$this->options['name'],
$this->options['position']
);
$this->fix_failed_registration(
$this->options['name'],
$this->options['position']
);
}
/**
* Fix rules flushed by other peoples code.
*
* @wp-hook init
* @param string $name
* @param int $position
*/
protected function fix_failed_registration( $name, $position )
{
global $wp_rewrite;
if ( empty ( $wp_rewrite->endpoints ) )
return flush_rewrite_rules( FALSE );
foreach ( $wp_rewrite->endpoints as $endpoint )
if ( $endpoint[0] === $position && $endpoint[1] === $name )
return;
flush_rewrite_rules( FALSE );
}
/**
* Set the endpoint variable to TRUE.
*
* If the endpoint was called without further parameters it does not
* evaluate to TRUE otherwise.
*
* @wp-hook request
* @param array $vars
* @return array
*/
public function set_query_var( Array $vars )
{
if ( ! empty ( $vars[ $this->options['name'] ] ) )
return $vars;
// When a static page was set as front page, the WordPress endpoint API
// does some strange things. Let's fix that.
if ( isset ( $vars[ $this->options['name'] ] )
or ( isset ( $vars['pagename'] ) and $this->options['name'] === $vars['pagename'] )
or ( isset ( $vars['page'] ) and $this->options['name'] === $vars['name'] )
)
{
// In some cases WP misinterprets the request as a page request and
// returns a 404.
$vars['page'] = $vars['pagename'] = $vars['name'] = FALSE;
$vars[ $this->options['name'] ] = TRUE;
}
return $vars;
}
/**
* Prepare API requests and hand them over to the callback.
*
* @wp-hook template_redirect
* @return void
*/
public function render()
{
$api = get_query_var( $this->options['name'] );
$api = trim( $api, "https://wordpress.stackexchange.com/" );
if ( '' === $api )
return;
$parts = explode( "https://wordpress.stackexchange.com/", $api );
$type = array_shift( $parts );
$values = $this->get_api_values( join( "https://wordpress.stackexchange.com/", $parts ) );
$callback = $this->options['callback'];
if ( is_string( $callback ) )
{
call_user_func( $callback, $type, $values );
}
elseif ( is_array( $callback ) )
{
if ( '__construct' === $callback[1] )
new $callback[0]( $type, $values );
elseif ( is_callable( $callback ) )
call_user_func( $callback, $type, $values );
}
else
{
trigger_error(
'Cannot call your callback: ' . var_export( $callback, TRUE ),
E_USER_ERROR
);
}
// Important. WordPress will render the main page if we leave this out.
exit;
}
/**
* Parse request URI into associative array.
*
* @wp-hook template_redirect
* @param string $request
* @return array
*/
protected function get_api_values( $request )
{
$keys = $values = array();
$count = 0;
$request = trim( $request, "https://wordpress.stackexchange.com/" );
$tok = strtok( $request, "https://wordpress.stackexchange.com/" );
while ( $tok !== FALSE )
{
0 === $count++ % 2 ? $keys[] = $tok : $values[] = $tok;
$tok = strtok( "https://wordpress.stackexchange.com/" );
}
// fix odd requests
if ( count( $keys ) !== count( $values ) )
$values[] = '';
return array_combine( $keys, $values );
}
}
The view
Now we have to do something with our data. We can also catch missing data for incomplete requests or delegate the handling to other views or sub-controllers.
Here is a very simple example:
class T5_CRA_View_Demo
{
protected $allowed_types = array (
'plain',
'html',
'xml'
);
protected $default_values = array (
'country' => 'Norway',
'date' => 1700,
'max' => 200
);
public function __construct( $type, $data )
{
if ( ! in_array( $type, $this->allowed_types ) )
die( 'Your request is invalid. Please read our fantastic manual.' );
$data = wp_parse_args( $data, $this->default_values );
header( "Content-Type: text/$type;charset=utf-8" );
$method = "render_$type";
$this->$method( $data );
}
protected function render_plain( $data )
{
foreach ( $data as $key => $value )
print "$key: $value\n";
}
protected function render_html( $data ) {}
protected function render_xml( $data ) {}
}
The important part is: the view doesn’t know anything about the endpoint. You can use it to handle completely different requests, for example AJAX requests in wp-admin
. You can split the view into its own MVC pattern or use just a simple function.