Adding a custom endpoint to WordPress’ REST API to upload files

This guide provides an overview and examples that demonstrate how to create a custom endpoint for WordPress’ REST API that files can be uploaded to.

The particular examples in this post support a CSV file upload but they are easily modified to accept other types of files.

The ability to upload data files in common formats such as CSV, JSON, XLS, etc. is a common requirement found in many business applications. I hope it is useful to you :).

Authentication

This post assumes the file upload functionality is restricted to administrator-level users that have authenticated by logging into the wp-admin dashboard.

No special auth plugins are required to use the REST API because the user is logged in and assumed to be using their web browser to upload the file.

In order to have the REST API accept file uploads from a remote source such as another website or app, a remote script, or a detached front-end user interface implemented with something like React, consider the various alternative authentication methods available for the WP REST API such as OAuth or Application Passwords that can be supported by installing plugins.

File upload form

For the sake of our example, assume we’ve made a plugin that creates an admin panel that features an HTML form with a file upload input. JavaScript will be used to handle the form submission and send the file to the REST API.

Note the file could be sent from anywhere as long as the server (e.g. CORS) and WordPress is configured correctly (e.g. to support remote authentication using something like OAuth).

The form’s file input element might be something like:

<input id="csv" name="file" type="file" accept=".csv" required /> 

To accept other file formats, add or change the “.csv” value passed to the accept attribute (prop) above.

We also assume our plugin has employed the wp_localize_script() technique to make the following variables available to JavaScript:

  • pluginConfig.restURL — value set to the output of esc_url_raw( rest_url() )
  • pluginConfig.restNonce — value set to the output of wp_create_nonce( 'wp_rest' )

For more information regarding how and why this is done, see the official REST API Handbook.

In short, JavaScript needs to know the URL of where to send the file and the value of a nonce. The nonce is related to a security measure implemented by WordPress that helps discourage CSRF attacks.

The following JavaScript code makes use of the newer FormData API and uses JQuery’s $.ajax() method to POST the CSV to the API endpoint.

The code assumes a standard HTML form with class “import” (<form class="import"...>) and:

  • a file <input /> tag with id="csv" and name="file"
  • a submit <button> (or <input type="submit"... />) with class="csv-submit"

The specific classes and id’s are used to target the form and obtain the values/data from its inputs.

( function($) {

  'use strict';

  $(document).ready( function() {
    $('.import').on('click', '.csv-submit', function(event) {

      event.preventDefault();
      var file = $('input#csv')[0].files[0];
      var formData = new FormData();

      formData.append( 'file', file );

      // append any other formData 
      // e.g. any id fields, etc as necessary here

      $.ajax({
        url: pluginConfig.restURL + '/import/csv',
        data: formData,
        processData: false,
        contentType: false,
        method: 'POST',
        cache: false,
        beforeSend: function ( xhr ) {
          xhr.setRequestHeader( 'X-WP-Nonce', pluginConfig.restNonce );
        }
      })
     .done(function(data) { 
        // handle success 
      })
      .fail(function(jqXHR, textStatus, errorThrown) {
        // handle failure
      });
    });
  });
})(jQuery);

REST API

Moving into the WordPress side of things, we need to add an endpoint to handle the file upload.

There are tons of ways to structure plugins when it comes to WordPress. The following example isolates the REST-related functionality in its own class called MyPluginRestAPI.

It is assumed that our plugin require()‘s the class file, creates an instance of the class, and then calls the instance’s init() method.

It’s up to you to handle errors. For this example, assume that a subclass of PHP’s Exception class named PluginException exists and that it implements a hypothetical restApiErrorResponse() method. Assume the method sends an error response back to the client by calling WordPress’ rest_ensure_response() function with a WP_Error object as its argument.

Note the following uses some PHP7.1+ syntax. Earlier versions of PHP may not support private constants (which are very new at the time of writing) or the square bracket syntax for defining arrays (use array() instead).

class MyPluginRestAPI {

  private const BASE = 'myplugin/v1';

  public function init() {
    add_action( 'rest_api_init', [ $this, 'initRoutes'] );
  }

  public function initRoutes() {
    register_rest_route( self::BASE, '/import/csv', [
            'methods' => [ 'POST' ],
            'callback' => [ $this, 'importCSVPostRequestHandler' ],
            'permission_callback' => [ $this, 'enforceAdminPermissions' ],
            'args' => [
              // ... 
            ]
    ] );
  } 

  public function enforceAdminPermissions() {
    if ( ! ( current_user_can( 'manage_options' ) || current_user_can( 'administrator' ) ) ) {
      return new WP_Error( 'rest_forbidden', esc_html__( 'Private', 'myplugin' ), array( 'status' => 401 ) );
    }
    return true;
  }

  public function importCSVPostRequestHandler( WP_REST_Request $request ) {

    // if you sent any parameters along with the request, you can access them like so:
    // $myParam = $request->get_param('my_param');

    $permittedExtension = 'csv';
    $permittedTypes = ['text/csv', 'text/plain'];

    $files = $request->get_file_params();
    $headers = $request->get_headers();

    if ( !empty( $files ) && !empty( $files['file'] ) ) {
      $file = $files['file'];
    }

    try {
      // smoke/sanity check
      if (! $file ) {
        throw new PluginException( 'Error' );
      }
      // confirm file uploaded via POST
      if (! is_uploaded_file( $file['tmp_name'] ) ) {
        throw new PluginException( 'File upload check failed ');
      }
      // confirm no file errors
      if (! $file['error'] === UPLOAD_ERR_OK ) {
        throw new PluginException( 'Upload error: ' . $file['error'] );
      }
      // confirm extension meets requirements
      $ext = pathinfo( $file['name'], PATHINFO_EXTENSION );
      if ( $ext !== $permittedExtension ) {
        throw new PluginException( 'Invalid extension. ');
      }
      // check type
      $mimeType = mime_content_type($file['tmp_name']);
      if ( !in_array( $file['type'], $permittedTypes )
          || !in_array( $mimeType, $permittedTypes ) ) {
            throw new PluginException( 'Invalid mime type' );
      }
    } catch ( PluginException $pe ) {
      return $pe->restApiErrorResponse( '...' );
    }

    // we've passed our checks, now read and process the file
    $handle = fopen( $file['tmp_name'], 'r' );
    $headerFlag = true;
    while ( ( $data = fgetcsv( $handle, 1000, ',' ) ) !== FALSE ) { // next arg is field delim e.g. "'"
      // skip csv's header row / first iteration of loop
      if ( $headerFlag ) {
        $headerFlag = false;
        continue;
      }
      // process rows in csv body
      if ( $data[0] ) {
        $field1  = sanitize_text_field( $data[0] );
        $field2  = sanitize_text_field( $data[1] );
        // ... 
        // your code here to do something with the data
        // such as put it in the database, write it to a file, send it somewhere, etc. 
        // ...
      }
    }
    fclose( $handle );
    // return any necessary data in the response here
    return rest_ensure_response( ['success' => true] );
  }

}

Note the series of checks to ensure the uploaded file has the right MIME type, correct extension, was uploaded via POST, and was received with no errors. These are important for both security and file integrity.

The last thing you want to do is enable a possible attacker to upload an executable script (such as a PHP file) and then be able to trigger it (e.g. by visiting the direct URL for the file or finding a way to get a user to access the file).

In terms of methods implemented by the class:

  • initRoutes() calls register_rest_route() to register the custom route
  • enforceAdminPermissions() implements a permission callback to ensure the user is an administrator
  • importCSVPostRequestHandler() handles the POST request

Note that the CSV is assumed to have a header row with column labels. The code is easily revised to accommodate cases where there is no header row.

I hope this helps! If you get stuck, please ask your questions in the comments, and remember that I’m available as a consultant to assist with your project 🙂 .