Using the Rewrite API to Construct a RESTful URL

I’m trying to generate rewrite rules for a RESTful API. I just want to see if there is a better way to make this work than having to write out every possible rewrite combination.

Ok so I have 4 query variables to account for in the URL

Read More
  • Indicator
  • Country
  • Response
  • Survey

The base url will be www.example.com/some-page/ The order of the 4 variables will be consistent but some query variables are optional.

So I could have…

/indicator/{indicator value}/country/{country value}/response/{response value}/survey/{survey value}/

or…(no /response/)

/indicator/{indicator value}/country/{country value}/survey/{survey value}/

or…

/indicator/{indicator value}/country/{country value}/

Is there a better way to accomplish this than filtering the rewrite_rules_array and adding an array of my rewrite rules manually created? Would add_rewrite_endpoint() rewrite_endpoint or add_rewrite_tag() be any use to me?

Related posts

Leave a Reply

1 comment

  1. 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, '/' );
    
            if ( '' === $api )
                return;
    
            $parts  = explode( '/', $api );
            $type   = array_shift( $parts );
            $values = $this->get_api_values( join( '/', $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, '/' );
            $tok     = strtok( $request, '/' );
    
            while ( $tok !== FALSE )
            {
                0 === $count++ % 2 ? $keys[] = $tok : $values[] = $tok;
                $tok = strtok( '/' );
            }
    
            // 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: $valuen";
        }
        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.