From 0807d75af940700bc7dfed8b67817af8e8deb6d2 Mon Sep 17 00:00:00 2001
From: Christian Wach <needle@haystack.co.uk>
Date: Mon, 24 Jun 2019 12:19:04 +0100
Subject: [PATCH] First pass at adding REST API compatibility

Signed-off-by: Kevin Cristiano <kcristiano@kcristiano.com>
---
 civicrm.php                             |  20 +
 wp-rest/Autoloader.php                  | 115 ++++++
 wp-rest/Civi/Mailing-Hooks.php          | 136 ++++++
 wp-rest/Controller/AuthorizeIPN.php     | 123 ++++++
 wp-rest/Controller/Base.php             | 101 +++++
 wp-rest/Controller/Cxn.php              | 125 ++++++
 wp-rest/Controller/Open.php             | 129 ++++++
 wp-rest/Controller/PayPalIPN.php        | 134 ++++++
 wp-rest/Controller/PxIPN.php            | 139 +++++++
 wp-rest/Controller/Rest.php             | 522 ++++++++++++++++++++++++
 wp-rest/Controller/Soap.php             |  98 +++++
 wp-rest/Controller/Url.php              | 214 ++++++++++
 wp-rest/Controller/Widget.php           | 214 ++++++++++
 wp-rest/Endpoint/Endpoint-Interface.php |  35 ++
 wp-rest/Plugin.php                      | 125 ++++++
 wp-rest/README.md                       |  59 +++
 16 files changed, 2289 insertions(+)
 create mode 100644 wp-rest/Autoloader.php
 create mode 100644 wp-rest/Civi/Mailing-Hooks.php
 create mode 100644 wp-rest/Controller/AuthorizeIPN.php
 create mode 100644 wp-rest/Controller/Base.php
 create mode 100644 wp-rest/Controller/Cxn.php
 create mode 100644 wp-rest/Controller/Open.php
 create mode 100644 wp-rest/Controller/PayPalIPN.php
 create mode 100644 wp-rest/Controller/PxIPN.php
 create mode 100644 wp-rest/Controller/Rest.php
 create mode 100644 wp-rest/Controller/Soap.php
 create mode 100644 wp-rest/Controller/Url.php
 create mode 100644 wp-rest/Controller/Widget.php
 create mode 100644 wp-rest/Endpoint/Endpoint-Interface.php
 create mode 100644 wp-rest/Plugin.php
 create mode 100644 wp-rest/README.md

diff --git a/civicrm.php b/civicrm.php
index e67b079675..cc72ce2df6 100644
--- a/civicrm.php
+++ b/civicrm.php
@@ -121,6 +121,17 @@ if ( file_exists( CIVICRM_SETTINGS_PATH )  ) {
 // Prevent CiviCRM from rendering its own header
 define( 'CIVICRM_UF_HEAD', TRUE );
 
+/**
+ * Setting this to 'true' will replace all mailing URLs calls to 'extern/url.php'
+ * and 'extern/open.php' with their REST counterpart 'civicrm/v3/url' and 'civicrm/v3/open'.
+ *
+ * Use for test purposes, may affect mailing
+ * performance, see Plugin->replace_tracking_urls() method.
+ */
+if ( ! defined( 'CIVICRM_WP_REST_REPLACE_MAILING_TRACKING' ) ) {
+  define( 'CIVICRM_WP_REST_REPLACE_MAILING_TRACKING', false );
+}
+
 
 /**
  * Define CiviCRM_For_WordPress Class.
@@ -512,6 +523,9 @@ class CiviCRM_For_WordPress {
     include_once CIVICRM_PLUGIN_DIR . 'includes/civicrm.basepage.php';
     $this->basepage = new CiviCRM_For_WordPress_Basepage;
 
+    // Include REST API autoloader class
+    require_once( CIVICRM_PLUGIN_DIR . 'wp-rest/Autoloader.php' );
+
   }
 
 
@@ -636,6 +650,12 @@ class CiviCRM_For_WordPress {
     // Register hooks for clean URLs.
     $this->register_hooks_clean_urls();
 
+    // Set up REST API.
+    CiviCRM_WP_REST\Autoloader::add_source( $source_path = trailingslashit( CIVICRM_PLUGIN_DIR . 'wp-rest' ) );
+
+    // Init REST API.
+    new CiviCRM_WP_REST\Plugin;
+
   }
 
 
diff --git a/wp-rest/Autoloader.php b/wp-rest/Autoloader.php
new file mode 100644
index 0000000000..a5d6757c21
--- /dev/null
+++ b/wp-rest/Autoloader.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Autoloader class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST;
+
+class Autoloader {
+
+	/**
+	 * Instance.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	private static $instance = null;
+
+	/**
+	 * Namespace.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	private $namespace = 'CiviCRM_WP_REST';
+
+	/**
+	 * Autoloader directory sources.
+	 *
+	 * @since 0.1
+	 * @var array
+	 */
+	private static $source_directories = [];
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 0.1
+	 */
+	private function __construct() {
+
+		$this->register_autoloader();
+
+	}
+
+	/**
+	 * Creates an instance of this class.
+	 *
+	 * @since 0.1
+	 */
+	private static function instance() {
+
+		if ( ! self::$instance ) self::$instance = new self;
+
+	}
+
+	/**
+	 * Adds a directory source.
+	 *
+	 * @since 0.1
+	 * @param string $source The source path
+	 */
+	public static function add_source( string $source_path ) {
+
+		// make sure we have an instance
+		self::instance();
+
+		if ( ! is_readable( trailingslashit( $source_path ) ) )
+			return \WP_Error( 'civicrm_wp_rest_error', __( 'The source ' . $source . ' is not readable.' ) );
+
+		self::$source_directories[] = $source_path;
+
+	}
+
+	/**
+	 * Registers the autoloader.
+	 *
+	 * @since 0.1
+	 * @return bool Wehather the autoloader has been registered or not
+	 */
+	private function register_autoloader() {
+
+		return spl_autoload_register( [ $this, 'autoload' ] );
+
+	}
+
+	/**
+	 * Loads the classes.
+	 *
+	 * @since 0.1
+	 * @param string $class_name The class name to load
+	 */
+	private function autoload( $class_name ) {
+
+		if ( false === strpos( $class_name, $this->namespace ) ) return;
+
+		$parts = explode( '\\', $class_name );
+
+		// remove namespace and join class path
+		$class_path = str_replace( '_', '-', implode( DIRECTORY_SEPARATOR, array_slice( $parts, 1 ) ) );
+
+		array_map( function( $source_path ) use ( $class_path ) {
+
+			$path = $source_path . $class_path . '.php';
+
+			if ( ! file_exists( $path ) ) return;
+
+			require $path;
+
+		}, static::$source_directories );
+
+	}
+
+}
diff --git a/wp-rest/Civi/Mailing-Hooks.php b/wp-rest/Civi/Mailing-Hooks.php
new file mode 100644
index 0000000000..7113088b3b
--- /dev/null
+++ b/wp-rest/Civi/Mailing-Hooks.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * CiviCRM Mailing_Hooks class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Civi;
+
+class Mailing_Hooks {
+
+	/**
+	 * Mailing Url endpoint.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	public $url_endpoint;
+
+	/**
+	 * Mailing Open endpoint.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	public $open_endpoint;
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 0.1
+	 */
+	public function __construct() {
+
+		$this->url_endpoint = rest_url( 'civicrm/v3/url' );
+
+		$this->open_endpoint = rest_url( 'civicrm/v3/open' );
+
+	}
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 0.1
+	 */
+	public function register_hooks() {
+
+		add_filter( 'civicrm_alterMailParams', [ $this, 'do_mailing_urls' ], 10, 2 );
+
+	}
+
+	/**
+	 * Filters the mailing html and replaces calls to 'extern/url.php' and
+	 * 'extern/open.php' with their REST counterparts 'civicrm/v3/url' and 'civicrm/v3/open'.
+	 *
+	 * @uses 'civicrm_alterMailParams'
+	 *
+	 * @since 0.1
+	 * @param array &$params Mail params
+	 * @param string $context The Context
+	 * @return array $params The filtered Mail params
+	 */
+	public function do_mailing_urls( &$params, $context ) {
+
+		if ( $context == 'civimail' ) {
+
+			$params['html'] = $this->replace_html_mailing_tracking_urls( $params['html'] );
+
+			$params['text'] = $this->replace_text_mailing_tracking_urls( $params['text'] );
+
+		}
+
+		return $params;
+
+	}
+
+	/**
+	 * Replace html mailing tracking urls.
+	 *
+	 * @since 0.1
+	 * @param string $contnet The mailing content
+	 * @return string $content The mailing content
+	 */
+	public function replace_html_mailing_tracking_urls( string $content ) {
+
+		$doc = \phpQuery::newDocument( $content );
+
+		foreach ( $doc[ '[href*="civicrm/extern/url.php"], [src*="civicrm/extern/open.php"]' ] as $element ) {
+
+			$href = pq( $element )->attr( 'href' );
+			$src = pq( $element )->attr( 'src' );
+
+			// replace extern/url
+			if ( strpos( $href, 'civicrm/extern/url.php' ) )	{
+
+				$query_string = strstr( $href, '?' );
+				pq( $element )->attr( 'href', $this->url_endpoint . $query_string );
+
+			}
+
+			// replace extern/open
+			if ( strpos( $src, 'civicrm/extern/open.php' ) ) {
+
+				$query_string = strstr( $src, '?' );
+				pq( $element )->attr( 'src', $this->open_endpoint . $query_string );
+
+			}
+
+			unset( $href, $src, $query_string );
+
+		}
+
+		return $doc->html();
+
+	}
+
+	/**
+	 * Replace text mailing tracking urls.
+	 *
+	 * @since 0.1
+	 * @param string $contnet The mailing content
+	 * @return string $content The mailing content
+	 */
+	public function replace_text_mailing_tracking_urls( string $content ) {
+
+		// replace extern url
+		$content = preg_replace( '/http.*civicrm\/extern\/url\.php/i', $this->url_endpoint, $content );
+
+		// replace open url
+		$content = preg_replace( '/http.*civicrm\/extern\/open\.php/i', $this->open_endpoint, $content );
+
+		return $content;
+
+	}
+
+}
diff --git a/wp-rest/Controller/AuthorizeIPN.php b/wp-rest/Controller/AuthorizeIPN.php
new file mode 100644
index 0000000000..3f8cf64c9c
--- /dev/null
+++ b/wp-rest/Controller/AuthorizeIPN.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * AuthorizeIPN controller class.
+ *
+ * Replacement for CiviCRM's 'extern/authorizeIPN.php'.
+ *
+ * @see https://docs.civicrm.org/sysadmin/en/latest/setup/payment-processors/authorize-net/#shell-script-testing-method
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class AuthorizeIPN extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'authorizeIPN';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_item' ]
+			]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter request params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/authorizeIPN/params', $request->get_params(), $request );
+
+		$authorize_IPN = new \CRM_Core_Payment_AuthorizeNetIPN( $params );
+
+		// log notification
+		\Civi::log()->alert( 'payment_notification processor_name=AuthNet', $params );
+
+		/**
+		 * Filter AuthorizeIPN object.
+		 *
+		 * @param CRM_Core_Payment_AuthorizeNetIPN $authorize_IPN
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$authorize_IPN = apply_filters( 'civi_wp_rest/controller/authorizeIPN/instance', $authorize_IPN, $params, $request );
+
+		try {
+
+			if ( ! method_exists( $authorize_IPN, 'main' ) || ! $this->instance_of_crm_base_ipn( $authorize_IPN ) )
+				return $this->civi_rest_error( get_class( $authorize_IPN ) . ' must implement a "main" method.' );
+
+			$result = $authorize_IPN->main();
+
+		} catch ( \CRM_Core_Exception $e ) {
+
+			\Civi::log()->error( $e->getMessage() );
+			\Civi::log()->error( 'error data ', [ 'data' => $e->getErrorData() ] );
+			\Civi::log()->error( 'REQUEST ', [ 'params' => $params ] );
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		return rest_ensure_response( $result );
+
+	}
+
+	/**
+	 * Checks whether object is an instance of CRM_Core_Payment_AuthorizeNetIPN or CRM_Core_Payment_BaseIPN.
+	 *
+	 * Needed because the instance is being filtered through 'civi_wp_rest/controller/authorizeIPN/instance'.
+	 *
+	 * @since 0.1
+	 * @param CRM_Core_Payment_AuthorizeNetIPN|CRM_Core_Payment_BaseIPN $object
+	 * @return bool
+	 */
+	public function instance_of_crm_base_ipn( $object ) {
+
+		return $object instanceof \CRM_Core_Payment_BaseIPN || $object instanceof \CRM_Core_Payment_AuthorizeNetIPN;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {}
+
+}
diff --git a/wp-rest/Controller/Base.php b/wp-rest/Controller/Base.php
new file mode 100644
index 0000000000..5115c490e7
--- /dev/null
+++ b/wp-rest/Controller/Base.php
@@ -0,0 +1,101 @@
+<?php
+/**
+ * Base controller class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+use CiviCRM_WP_REST\Endpoint\Endpoint_Interface;
+
+abstract class Base extends \WP_REST_Controller implements Endpoint_Interface {
+
+	/**
+	 * Route namespace.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $namespace = 'civicrm/v3';
+
+	/**
+	 * Gets the endpoint namespace.
+	 *
+	 * @since 0.1
+	 * @return string $namespace
+	 */
+	public function get_namespace() {
+
+		return $this->namespace;
+
+	}
+
+	/**
+	 * Gets the rest base route.
+	 *
+	 * @since 0.1
+	 * @return string $rest_base
+	 */
+	public function get_rest_base() {
+
+		return '/' . $this->rest_base;
+
+	}
+
+	/**
+	 * Retrieves the endpoint ie. '/civicrm/v3/rest'.
+	 *
+	 * @since 0.1
+	 * @return string $rest_base
+	 */
+	public function get_endpoint() {
+
+		return '/' . $this->get_namespace() . $this->get_rest_base();
+
+	}
+
+	/**
+	 * Checks whether the requested route is equal to this endpoint.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 * @return bool $is_current_endpoint True if it's equal, false otherwise
+	 */
+	public function is_current_endpoint( $request ) {
+
+		return $this->get_endpoint() == $request->get_route();
+
+	}
+
+	/**
+	 * Authorization status code.
+	 *
+	 * @since 0.1
+	 * @return int $status
+	 */
+	protected function authorization_status_code() {
+
+		$status = 401;
+
+		if ( is_user_logged_in() ) $status = 403;
+
+		return $status;
+
+	}
+
+	/**
+	 * Wrapper for WP_Error.
+	 *
+	 * @since 0.1
+	 * @param string $message
+	 * @param mixed $data Error data
+	 * @return WP_Error $error
+	 */
+	protected function civi_rest_error( $message, $data = [] ) {
+
+		return new \WP_Error( 'civicrm_rest_api_error', $message, empty( $data ) ? [ 'status' => $this->authorization_status_code() ] : $data );
+
+	}
+
+}
diff --git a/wp-rest/Controller/Cxn.php b/wp-rest/Controller/Cxn.php
new file mode 100644
index 0000000000..7f7cca5c56
--- /dev/null
+++ b/wp-rest/Controller/Cxn.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * Cxn controller class.
+ *
+ * CiviConnect endpoint, replacement for CiviCRM's 'extern/cxn.php'.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Cxn extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'cxn';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_item' ]
+			]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter request params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/cxn/params', $request->get_params(), $request );
+
+		// init connection server
+		$cxn = \CRM_Cxn_BAO_Cxn::createApiServer();
+
+		/**
+		 * Filter connection server object.
+		 *
+		 * @param Civi\Cxn\Rpc\ApiServer $cxn
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$cxn = apply_filters( 'civi_wp_rest/controller/cxn/instance', $cxn, $params, $request );
+
+		try {
+
+			$result = $cxn->handle( $request->get_body() );
+
+		} catch ( Civi\Cxn\Rpc\Exception\CxnException $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		} catch ( Civi\Cxn\Rpc\Exception\ExpiredCertException $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		} catch ( Civi\Cxn\Rpc\Exception\InvalidCertException $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		} catch ( Civi\Cxn\Rpc\Exception\InvalidMessageException $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		} catch ( Civi\Cxn\Rpc\Exception\GarbledMessageException $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		/**
+		 * Bypass WP and send request from Cxn.
+		 */
+		add_filter( 'rest_pre_serve_request', function( $served, $response, $request, $server ) use ( $result ) {
+
+			// Civi\Cxn\Rpc\Message->send()
+			$result->send();
+
+			return true;
+
+		}, 10, 4 );
+
+		return rest_ensure_response( $result );
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {}
+
+}
diff --git a/wp-rest/Controller/Open.php b/wp-rest/Controller/Open.php
new file mode 100644
index 0000000000..db70e1070e
--- /dev/null
+++ b/wp-rest/Controller/Open.php
@@ -0,0 +1,129 @@
+<?php
+/**
+ * Open controller class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Open extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'open';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::READABLE,
+				'callback' => [ $this, 'get_item' ],
+				'args' => $this->get_item_args()
+			],
+			'schema' => [ $this, 'get_item_schema' ]
+		] );
+
+	}
+
+	/**
+	 * Get item.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		$queue_id = $request->get_param( 'q' );
+
+		// track open
+		\CRM_Mailing_Event_BAO_Opened::open( $queue_id );
+
+		// serve tracker file
+		add_filter( 'rest_pre_serve_request', [ $this, 'serve_tracker_file' ], 10, 4 );
+
+	}
+
+	/**
+	 * Serves the tracker gif file.
+	 *
+	 * @since 0.1
+	 * @param bool $served Whether the request has been served
+	 * @param WP_REST_Response $result
+	 * @param WP_REST_Request $request
+	 * @param WP_REST_Server $server
+	 * @return bool $served Whether the request has been served
+	 */
+	public function serve_tracker_file( $served, $result, $request, $server ) {
+
+		// tracker file path
+		$file = CIVICRM_PLUGIN_DIR . 'civicrm/i/tracker.gif';
+
+		// set headers
+		$server->send_header( 'Content-type', 'image/gif' );
+		$server->send_header( 'Cache-Control', 'must-revalidate, post-check=0, pre-check=0' );
+		$server->send_header( 'Content-Description', 'File Transfer' );
+		$server->send_header( 'Content-Disposition', 'inline; filename=tracker.gif' );
+		$server->send_header( 'Content-Length', filesize( $file ) );
+
+		$buffer = readfile( $file );
+
+		return true;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {
+
+		return [
+			'$schema' => 'http://json-schema.org/draft-04/schema#',
+			'title' => 'civicrm/v3/open',
+			'description' => 'CiviCRM Open endpoint',
+			'type' => 'object',
+			'required' => [ 'q' ],
+			'properties' => [
+				'q' => [
+					'type' => 'integer'
+				]
+			]
+		];
+
+	}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {
+
+		return [
+			'q' => [
+				'type' => 'integer',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			]
+		];
+
+	}
+
+}
diff --git a/wp-rest/Controller/PayPalIPN.php b/wp-rest/Controller/PayPalIPN.php
new file mode 100644
index 0000000000..0aaac0513b
--- /dev/null
+++ b/wp-rest/Controller/PayPalIPN.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * PayPalIPN controller class.
+ *
+ * PayPal IPN endpoint, replacement for CiviCRM's 'extern/ipn.php'.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class PayPalIPN extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'ipn';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_item' ]
+			]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter request params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/ipn/params', $request->get_params(), $request );
+
+		if ( $request->get_method() == 'GET' ) {
+
+			// paypal standard
+			$paypal_IPN = new \CRM_Core_Payment_PayPalIPN( $params );
+
+			// log notification
+			\Civi::log()->alert( 'payment_notification processor_name=PayPal_Standard', $params );
+
+		} else {
+
+			// paypal pro
+			$paypal_IPN = new \CRM_Core_Payment_PayPalProIPN( $params );
+
+			// log notification
+			\Civi::log()->alert( 'payment_notification processor_name=PayPal', $params );
+
+		}
+
+		/**
+		 * Filter PayPalIPN object.
+		 *
+		 * @param CRM_Core_Payment_PayPalIPN|CRM_Core_Payment_PayPalProIPN $paypal_IPN
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$paypal_IPN = apply_filters( 'civi_wp_rest/controller/ipn/instance', $paypal_IPN, $params, $request );
+
+		try {
+
+			if ( ! method_exists( $paypal_IPN, 'main' ) || ! $this->instance_of_crm_base_ipn( $paypal_IPN ) )
+				return $this->civi_rest_error( get_class( $paypal_IPN ) . ' must implement a "main" method.' );
+
+			$result = $paypal_IPN->main();
+
+		} catch ( \CRM_Core_Exception $e ) {
+
+			\Civi::log()->error( $e->getMessage() );
+			\Civi::log()->error( 'error data ', [ 'data' => $e->getErrorData() ] );
+			\Civi::log()->error( 'REQUEST ', [ 'params' => $params ] );
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		return rest_ensure_response( $result );
+
+	}
+
+	/**
+	 * Checks whether object is an instance of CRM_Core_Payment_BaseIPN|CRM_Core_Payment_PayPalProIPN|CRM_Core_Payment_PayPalIPN.
+	 *
+	 * Needed because the instance is being filtered through 'civi_wp_rest/controller/ipn/instance'.
+	 *
+	 * @since 0.1
+	 * @param CRM_Core_Payment_BaseIPN|CRM_Core_Payment_PayPalProIPN|CRM_Core_Payment_PayPalIPN $object
+	 * @return bool
+	 */
+	public function instance_of_crm_base_ipn( $object ) {
+
+		return $object instanceof \CRM_Core_Payment_BaseIPN || $object instanceof \CRM_Core_Payment_PayPalProIPN || $object instanceof \CRM_Core_Payment_PayPalIPN;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {}
+
+}
diff --git a/wp-rest/Controller/PxIPN.php b/wp-rest/Controller/PxIPN.php
new file mode 100644
index 0000000000..d68fc8d787
--- /dev/null
+++ b/wp-rest/Controller/PxIPN.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * PxIPN controller class.
+ *
+ * PxPay IPN endpoint, replacement for CiviCRM's 'extern/pxIPN.php'.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class PxIPN extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'pxIPN';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_item' ]
+			]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter payment processor params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters(
+			'civi_wp_rest/controller/pxIPN/params',
+			$this->get_payment_processor_args( $request ),
+			$request
+		);
+
+		// log notification
+		\Civi::log()->alert( 'payment_notification processor_name=Payment_Express', $params );
+
+		try {
+
+			$result = \CRM_Core_Payment_PaymentExpressIPN::main( ...$params );
+
+		} catch ( \CRM_Core_Exception $e ) {
+
+			\Civi::log()->error( $e->getMessage() );
+			\Civi::log()->error( 'error data ', [ 'data' => $e->getErrorData() ] );
+			\Civi::log()->error( 'REQUEST ', [ 'params' => $params ] );
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		return rest_ensure_response( $result );
+
+	}
+
+	/**
+	 * Get payment processor necessary params.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Resquest $request
+	 * @return array $args
+	 */
+	public function get_payment_processor_args( $request ) {
+
+		// get payment processor types
+		$payment_processor_types = civicrm_api3( 'PaymentProcessor', 'getoptions', [
+			'field' => 'payment_processor_type_id'
+		] );
+
+		// payment processor params
+		$params = apply_filters( 'civi_wp_rest/controller/pxIPN/payment_processor_params', [
+			'user_name' => $request->get_param( 'userid' ),
+			'payment_processor_type_id' => array_search(
+				'DPS Payment Express',
+				$payment_processor_types['values']
+			),
+			'is_active' => 1,
+			'is_test' => 0
+		] );
+
+		// get payment processor
+		$payment_processor = civicrm_api3( 'PaymentProcessor', 'get', $params );
+
+		$args = $payment_processor['values'][$payment_processor['id']];
+
+		$method = empty( $args['signature'] ) ? 'pxpay' : 'pxaccess';
+
+		return [
+			$method,
+			$request->get_param( 'result' ),
+			$args['url_site'],
+			$args['user_name'],
+			$args['password'],
+			$args['signature']
+		];
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {}
+
+}
diff --git a/wp-rest/Controller/Rest.php b/wp-rest/Controller/Rest.php
new file mode 100644
index 0000000000..b3b97b205c
--- /dev/null
+++ b/wp-rest/Controller/Rest.php
@@ -0,0 +1,522 @@
+<?php
+/**
+ * Rest controller class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Rest extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'rest';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_items' ],
+				'permission_callback' => [ $this, 'permissions_check' ],
+				'args' => $this->get_item_args()
+			],
+			'schema' => [ $this, 'get_item_schema' ]
+		] );
+
+	}
+
+	/**
+	 * Check get permission.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 * @return bool
+	 */
+	public function permissions_check( $request ) {
+
+		if ( ! $this->is_valid_api_key( $request ) )
+			return $this->civi_rest_error( __( 'Param api_key is not valid.' ) );
+
+		if ( ! $this->is_valid_site_key() )
+			return $this->civi_rest_error( __( 'Param key is not valid.' ) );
+
+		return true;
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_items( $request ) {
+
+		/**
+		 * Filter formatted api params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/rest/api_params', $this->get_formatted_api_params( $request ), $request );
+
+		try {
+
+			$items = civicrm_api3( ...$params );
+
+		} catch ( \CiviCRM_API3_Exception $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		if ( ! isset( $items ) || empty( $items ) )
+			return rest_ensure_response( [] );
+
+		/**
+		 * Filter civi api result.
+		 *
+		 * @since 0.1
+		 * @param array $items
+		 * @param WP_REST_Request $request
+		 */
+		$data = apply_filters( 'civi_wp_rest/controller/rest/api_result', $items, $params, $request );
+
+		// only collections of items, ie any action but 'getsingle'
+		if ( isset( $data['values'] ) ) {
+
+			$data['values'] = array_reduce( $items['values'] ?? $items, function( $items, $item ) use ( $request ) {
+
+				$response = $this->prepare_item_for_response( $item, $request );
+
+				$items[] = $this->prepare_response_for_collection( $response );
+
+				return $items;
+
+			}, [] );
+
+		}
+
+		$response = rest_ensure_response( $data );
+
+		// check wheather we need to serve xml or json
+		if ( ! in_array( 'json', array_keys( $request->get_params() ) ) ) {
+
+			/**
+			 * Adds our response holding Civi data before dispatching.
+			 *
+			 * @since 0.1
+			 * @param WP_HTTP_Response $result Result to send to client
+			 * @param WP_REST_Server $server The REST server
+			 * @param WP_REST_Request $request The request
+			 * @return WP_HTTP_Response $result Result to send to client
+			 */
+			add_filter( 'rest_post_dispatch', function( $result, $server, $request ) use ( $response ) {
+
+				return $response;
+
+			}, 10, 3 );
+
+			// serve xml
+			add_filter( 'rest_pre_serve_request', [ $this, 'serve_xml_response' ], 10, 4 );
+
+		} else {
+
+			// return json
+			return $response;
+
+		}
+
+	}
+
+	/**
+	 * Get formatted api params.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Resquest $request
+	 * @return array $params
+	 */
+	public function get_formatted_api_params( $request ) {
+
+		$args = $request->get_params();
+
+		// destructure entity and action
+		[ 'entity' => $entity, 'action' => $action ] = $args;
+
+		// unset unnecessary args
+		unset( $args['entity'], $args['action'], $args['key'], $args['api_key'] );
+
+		if ( ! isset( $args['json'] ) || is_numeric( $args['json'] ) ) {
+
+			$params = $args;
+
+		} else {
+
+			$params = is_string( $args['json'] ) ? json_decode( $args['json'], true ) : [];
+
+		}
+
+		// ensure check permissions is enabled
+		$params['check_permissions'] = true;
+
+		return [ $entity, $action, $params ];
+
+	}
+
+	/**
+	 * Matches the item data to the schema.
+	 *
+	 * @since 0.1
+	 * @param object $item
+	 * @param WP_REST_Request $request
+	 */
+	public function prepare_item_for_response( $item, $request ) {
+
+		return rest_ensure_response( $item );
+
+	}
+
+	/**
+	 * Serves XML response.
+	 *
+	 * @since 0.1
+	 * @param bool $served Whether the request has already been served
+	 * @param WP_REST_Response $result
+	 * @param WP_REST_Request $request
+	 * @param WP_REST_Server $server
+	 */
+	public function serve_xml_response( $served, $result, $request, $server ) {
+
+		// get xml from response
+		$xml = $this->get_xml_formatted_data( $result->get_data() );
+
+		// set content type header
+		$server->send_header( 'Content-Type', 'text/xml' );
+
+		echo $xml;
+
+		return true;
+
+	}
+
+	/**
+	 * Formats CiviCRM API result to XML.
+	 *
+	 * @since 0.1
+	 * @param array $data The CiviCRM api result
+	 * @return string $xml The formatted xml
+	 */
+	protected function get_xml_formatted_data( array $data ) {
+
+		// xml document
+		$xml = new \DOMDocument();
+
+		// result set element <ResultSet>
+		$result_set = $xml->createElement( 'ResultSet' );
+
+		// xmlns:xsi attribute
+		$result_set->setAttribute( 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance' );
+
+		// count attribute
+		if ( isset( $data['count'] ) ) $result_set->setAttribute( 'count', $data['count'] );
+
+		// build result from result => values
+		if ( isset( $data['values'] ) ) {
+
+			array_map( function( $item ) use ( $result_set, $xml ) {
+
+				// result element <Result>
+				$result = $xml->createElement( 'Result' );
+
+				// format item
+				$result = $this->get_xml_formatted_item( $item, $result, $xml );
+
+				// append result to result set
+				$result_set->appendChild( $result );
+
+			}, $data['values'] );
+
+		} else {
+
+			// result element <Result>
+			$result = $xml->createElement( 'Result' );
+
+			// format item
+			$result = $this->get_xml_formatted_item( $data, $result, $xml );
+
+			// append result to result set
+			$result_set->appendChild( $result );
+
+		}
+
+		// append result set
+		$xml->appendChild( $result_set );
+
+		return $xml->saveXML();
+
+	}
+
+	/**
+	 * Formats a single api result to xml.
+	 *
+	 * @since 0.1
+	 * @param array $item The single api result
+	 * @param DOMElement $parent The parent element to append to
+	 * @param DOMDocument $doc The document
+	 * @return DOMElement $parent The parent element
+	 */
+	public function get_xml_formatted_item( array $item, \DOMElement $parent, \DOMDocument $doc ) {
+
+		// build field => values
+		array_map( function( $field, $value ) use ( $parent, $doc ) {
+
+			// entity field element
+			$element = $doc->createElement( $field );
+
+			// handle array values
+			if ( is_array( $value ) ) {
+
+				array_map( function( $key, $val ) use ( $element, $doc ) {
+
+					// child element, append underscore '_' otherwise createElement
+					// will throw an Invalid character exception as elements cannot start with a number
+					$child = $doc->createElement( '_' . $key, $val );
+
+					// append child
+					$element->appendChild( $child );
+
+				}, array_keys( $value ), $value );
+
+			} else {
+
+				// assign value
+				$element->nodeValue = $value;
+
+			}
+
+			// append element
+			$parent->appendChild( $element );
+
+		}, array_keys( $item ), $item );
+
+		return $parent;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {
+
+		return [
+			'$schema' => 'http://json-schema.org/draft-04/schema#',
+			'title' => 'civicrm/v3/rest',
+			'description' => 'CiviCRM API3 WP rest endpoint wrapper',
+			'type' => 'object',
+			'required' => [ 'entity', 'action', 'params' ],
+			'properties' => [
+				'is_error' => [
+					'type' => 'integer'
+				],
+				'version' => [
+					'type' => 'integer'
+				],
+				'count' => [
+					'type' => 'integer'
+				],
+				'values' => [
+					'type' => 'array'
+				]
+			]
+		];
+
+	}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {
+
+		return [
+			'key' => [
+				'type' => 'string',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return $this->is_valid_site_key();
+
+				}
+			],
+			'api_key' => [
+				'type' => 'string',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return $this->is_valid_api_key( $request );
+
+				}
+			],
+			'entity' => [
+				'type' => 'string',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_string( $value );
+
+				}
+			],
+			'action' => [
+				'type' => 'string',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_string( $value );
+
+				}
+			],
+			'json' => [
+				'type' => ['integer', 'string', 'array'],
+				'required' => false,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value ) || is_array( $value ) || $this->is_valid_json( $value );
+
+				}
+			]
+		];
+
+	}
+
+	/**
+	 * Checks if string is a valid json.
+	 *
+	 * @since 0.1
+	 * @param string $param
+	 * @return bool
+	 */
+	protected function is_valid_json( $param ) {
+
+		$param = json_decode( $param, true );
+
+		if ( ! is_array( $param ) ) return false;
+
+ 		return ( json_last_error() == JSON_ERROR_NONE );
+
+	}
+
+	/**
+	 * Validates the site key.
+	 *
+	 * @since 0.1
+	 * @return bool $is_valid_site_key
+	 */
+	private function is_valid_site_key() {
+
+		return \CRM_Utils_System::authenticateKey( false );
+
+	}
+
+	/**
+	 * Validates the api key.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Resquest $request
+	 * @return bool $is_valid_api_key
+	 */
+	private function is_valid_api_key( $request ) {
+
+		$api_key = $request->get_param( 'api_key' );
+
+		if ( ! $api_key ) return false;
+
+		$contact_id = \CRM_Core_DAO::getFieldValue( 'CRM_Contact_DAO_Contact', $api_key, 'id', 'api_key' );
+
+		// validate contact and login
+		if ( $contact_id ) {
+
+			$wp_user = $this->get_wp_user( $contact_id );
+
+			$this->do_user_login( $wp_user );
+
+			return true;
+
+		}
+
+		return false;
+
+	}
+
+	/**
+	 * Get WordPress user data.
+	 *
+	 * @since 0.1
+	 * @param int $contact_id The contact id
+	 * @return bool|WP_User $user The WordPress user data
+	 */
+	protected function get_wp_user( int $contact_id ) {
+
+		try {
+
+			// Get CiviCRM domain group ID from constant, if set.
+			$domain_id = defined( 'CIVICRM_DOMAIN_ID' ) ? CIVICRM_DOMAIN_ID : 0;
+
+			// If this fails, get it from config.
+			if ( $domain_id === 0 ) {
+				$domain_id = CRM_Core_Config::domainID();
+			}
+
+			// Call API.
+			$uf_match = civicrm_api3( 'UFMatch', 'getsingle', [
+				'contact_id' => $contact_id,
+				'domain_id' => $domain_id,
+			] );
+
+		} catch ( \CiviCRM_API3_Exception $e ) {
+
+			return $this->civi_rest_error( $e->getMessage() );
+
+		}
+
+		$wp_user = get_userdata( $uf_match['uf_id'] );
+
+		return $wp_user;
+
+	}
+
+	/**
+	 * Logs in the WordPress user, needed to respect CiviCRM ACL and permissions.
+	 *
+	 * @since 0.1
+	 * @param  WP_User $user
+	 */
+	protected function do_user_login( \WP_User $user ) {
+
+		if ( is_user_logged_in() ) return;
+
+		wp_set_current_user( $user->ID, $user->user_login );
+
+		wp_set_auth_cookie( $user->ID );
+
+		do_action( 'wp_login', $user->user_login, $user );
+
+	}
+
+}
diff --git a/wp-rest/Controller/Soap.php b/wp-rest/Controller/Soap.php
new file mode 100644
index 0000000000..17402cc579
--- /dev/null
+++ b/wp-rest/Controller/Soap.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Soap controller class.
+ *
+ * Soap endpoint, replacement for CiviCRM's 'extern/soap.php'.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Soap extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'soap';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::ALLMETHODS,
+				'callback' => [ $this, 'get_item' ]
+			]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter request params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/soap/params', $request->get_params(), $request );
+
+		// init soap server
+		$soap_server = new \SoapServer(
+			NULL,
+			[
+				'uri' => 'urn:civicrm',
+				'soap_version' => SOAP_1_2,
+			]
+		);
+
+		$crm_soap_server = new \CRM_Utils_SoapServer();
+
+		$soap_server->setClass( 'CRM_Utils_SoapServer', \CRM_Core_Config::singleton()->userFrameworkClass );
+		$soap_server->setPersistence( SOAP_PERSISTENCE_SESSION );
+
+		/**
+		 * Bypass WP and send request from Soap server.
+		 */
+		add_filter( 'rest_pre_serve_request', function( $served, $response, $request, $server ) use ( $soap_server ) {
+
+			$soap_server->handle();
+
+			return true;
+
+		}, 10, 4 );
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {}
+
+}
diff --git a/wp-rest/Controller/Url.php b/wp-rest/Controller/Url.php
new file mode 100644
index 0000000000..263a5410ab
--- /dev/null
+++ b/wp-rest/Controller/Url.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Url controller class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Url extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'url';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::READABLE,
+				'callback' => [ $this, 'get_item' ],
+				'args' => $this->get_item_args()
+			],
+			'schema' => [ $this, 'get_item_schema' ]
+		] );
+
+	}
+
+	/**
+	 * Get items.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter formatted api params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters( 'civi_wp_rest/controller/url/params', $this->get_formatted_params( $request ), $request );
+
+		// track url
+		$url = \CRM_Mailing_Event_BAO_TrackableURLOpen::track( $params['queue_id'], $params['url_id'] );
+
+		/**
+		 * Filter url.
+		 *
+		 * @param string $url
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$url = apply_filters( 'civi_wp_rest/controller/url/before_parse_url', $url, $params, $request );
+
+		// parse url
+		$url = $this->parse_url( $url, $params );
+
+		$this->do_redirect( $url );
+
+	}
+
+	/**
+	 * Get formatted api params.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Resquest $request
+	 * @return array $params
+	 */
+	protected function get_formatted_params( $request ) {
+
+		$args = $request->get_params();
+
+		$params = [
+			'queue_id' => isset( $args['qid'] ) ? $args['qid'] ?? '' : $args['q'] ?? '',
+			'url_id' => $args['u']
+		];
+
+		// unset unnecessary args
+		unset( $args['qid'], $args['u'], $args['q'] );
+
+		if ( ! empty( $args ) ) {
+
+			$params['query'] = http_build_query( $args );
+
+		}
+
+		return $params;
+
+	}
+
+	/**
+	 * Parses the url.
+	 *
+	 * @since 0.1
+	 * @param string $url
+	 * @param array $params
+	 * @return string $url
+	 */
+	protected function parse_url( $url, $params ) {
+
+		// CRM-18320 - Fix encoded ampersands
+		$url = str_replace( '&amp;', '&', $url );
+
+		// CRM-7103 - Look for additional query variables and append them
+		if ( isset( $params['query'] ) && strpos( $url, '?' ) ) {
+
+			$url .= '&' . $params['query'];
+
+		} elseif ( isset( $params['query'] ) ) {
+
+			$url .= '?' . $params['query'];
+
+		}
+
+		return apply_filters( 'civi_wp_rest/controller/url/parsed_url', $url, $params );
+
+	}
+
+	/**
+	 * Do redirect.
+	 *
+	 * @since 0.1
+	 * @param string $url
+	 */
+	protected function do_redirect( $url ) {
+
+		wp_redirect( $url );
+
+		exit;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {
+
+		return [
+			'$schema' => 'http://json-schema.org/draft-04/schema#',
+			'title' => 'civicrm_api3/v3/url',
+			'description' => 'CiviCRM API3 wrapper',
+			'type' => 'object',
+			'required' => [ 'qid', 'u' ],
+			'properties' => [
+				'qid' => [
+					'type' => 'integer'
+				],
+				'q' => [
+					'type' => 'integer'
+				],
+				'u' => [
+					'type' => 'integer'
+				]
+			]
+		];
+
+	}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {
+
+		return [
+			'qid' => [
+				'type' => 'integer',
+				'required' => false,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			],
+			'q' => [
+				'type' => 'integer',
+				'required' => false,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			],
+			'u' => [
+				'type' => 'integer',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			]
+		];
+
+	}
+
+}
diff --git a/wp-rest/Controller/Widget.php b/wp-rest/Controller/Widget.php
new file mode 100644
index 0000000000..fd4ee14bc6
--- /dev/null
+++ b/wp-rest/Controller/Widget.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Widget controller class.
+ *
+ * Widget endpoint, replacement for CiviCRM's 'extern/widget.php'
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Controller;
+
+class Widget extends Base {
+
+	/**
+	 * The base route.
+	 *
+	 * @since 0.1
+	 * @var string
+	 */
+	protected $rest_base = 'widget';
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes() {
+
+		register_rest_route( $this->get_namespace(), $this->get_rest_base(), [
+			[
+				'methods' => \WP_REST_Server::READABLE,
+				'callback' => [ $this, 'get_item' ],
+				'args' => $this->get_item_args()
+			],
+			'schema' => [ $this, 'get_item_schema' ]
+		] );
+
+	}
+
+	/**
+	 * Get item.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Request $request
+	 */
+	public function get_item( $request ) {
+
+		/**
+		 * Filter mandatory params.
+		 *
+		 * @since 0.1
+		 * @param array $params
+		 * @param WP_REST_Request $request
+		 */
+		$params = apply_filters(
+			'civi_wp_rest/controller/widget/params',
+			$this->get_mandatory_params( $request ),
+			$request
+		);
+
+		$jsonvar = 'jsondata';
+
+		if ( ! empty( $request->get_param( 'format' ) ) ) $jsonvar .= $request->get_param( 'cpageId' );
+
+		$data = \CRM_Contribute_BAO_Widget::getContributionPageData( ...$params );
+
+		$response = 'var ' . $jsonvar . ' = ' . json_encode( $data ) . ';';
+
+		/**
+		 * Adds our response data before dispatching.
+		 *
+		 * @since 0.1
+		 * @param WP_HTTP_Response $result Result to send to client
+		 * @param WP_REST_Server $server The REST server
+		 * @param WP_REST_Request $request The request
+		 * @return WP_HTTP_Response $result Result to send to client
+		 */
+		add_filter( 'rest_post_dispatch', function( $result, $server, $request ) use ( $response ) {
+
+			return rest_ensure_response( $response );
+
+		}, 10, 3 );
+
+		// serve javascript
+		add_filter( 'rest_pre_serve_request', [ $this, 'serve_javascript' ], 10, 4 );
+
+	}
+
+	/**
+	 * Get mandatory params from request.
+	 *
+	 * @since 0.1
+	 * @param WP_REST_Resquest $request
+	 * @return array $params The widget params
+	 */
+	protected function get_mandatory_params( $request ) {
+
+		$args = $request->get_params();
+
+		return [
+			$args['cpageId'],
+			$args['widgetId'],
+			$args['includePending'] ?? false
+		];
+
+	}
+
+	/**
+	 * Serve jsondata response.
+	 *
+	 * @since 0.1
+	 * @param bool $served Whether the request has already been served
+	 * @param WP_REST_Response $result
+	 * @param WP_REST_Request $request
+	 * @param WP_REST_Server $server
+	 * @return bool $served
+	 */
+	public function serve_javascript( $served, $result, $request, $server ) {
+
+		// set content type header
+		$server->send_header( 'Expires', gmdate( 'D, d M Y H:i:s \G\M\T', time() + 60 ) );
+		$server->send_header( 'Content-Type', 'application/javascript' );
+		$server->send_header( 'Cache-Control', 'max-age=60, public' );
+
+		echo $result->get_data();
+
+		return true;
+
+	}
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema() {
+
+		return [
+			'$schema' => 'http://json-schema.org/draft-04/schema#',
+			'title' => 'civicrm_api3/v3/widget',
+			'description' => 'CiviCRM API3 wrapper',
+			'type' => 'object',
+			'required' => [ 'cpageId', 'widgetId' ],
+			'properties' => [
+				'cpageId' => [
+					'type' => 'integer',
+					'minimum' => 1
+				],
+				'widgetId' => [
+					'type' => 'integer',
+					'minimum' => 1
+				],
+				'format' => [
+					'type' => 'integer'
+				],
+				'includePending' => [
+					'type' => 'boolean'
+				]
+			]
+		];
+
+	}
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args() {
+
+		return [
+			'cpageId' => [
+				'type' => 'integer',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			],
+			'widgetId' => [
+				'type' => 'integer',
+				'required' => true,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			],
+			'format' => [
+				'type' => 'integer',
+				'required' => false,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_numeric( $value );
+
+				}
+			],
+			'includePending' => [
+				'type' => 'boolean',
+				'required' => false,
+				'validate_callback' => function( $value, $request, $key ) {
+
+					return is_string( $value );
+
+				}
+			]
+		];
+
+	}
+
+}
diff --git a/wp-rest/Endpoint/Endpoint-Interface.php b/wp-rest/Endpoint/Endpoint-Interface.php
new file mode 100644
index 0000000000..9497cde509
--- /dev/null
+++ b/wp-rest/Endpoint/Endpoint-Interface.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Endpoint Interface class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST\Endpoint;
+
+interface Endpoint_Interface {
+
+	/**
+	 * Registers routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_routes();
+
+	/**
+	 * Item schema.
+	 *
+	 * @since 0.1
+	 * @return array $schema
+	 */
+	public function get_item_schema();
+
+	/**
+	 * Item arguments.
+	 *
+	 * @since 0.1
+	 * @return array $arguments
+	 */
+	public function get_item_args();
+
+}
diff --git a/wp-rest/Plugin.php b/wp-rest/Plugin.php
new file mode 100644
index 0000000000..7cf3e9f92f
--- /dev/null
+++ b/wp-rest/Plugin.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * Main plugin class.
+ *
+ * @since 0.1
+ */
+
+namespace CiviCRM_WP_REST;
+
+use CiviCRM_WP_REST\Civi\Mailing_Hooks;
+
+class Plugin {
+
+	/**
+	 * Constructor.
+	 *
+	 * @since 0.1
+	 */
+	public function __construct() {
+
+		$this->register_hooks();
+
+		$this->setup_objects();
+
+	}
+
+	/**
+	 * Register hooks.
+	 *
+	 * @since 1.0
+	 */
+	protected function register_hooks() {
+
+		add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
+
+		add_filter( 'rest_pre_dispatch', [ $this, 'bootstrap_civi' ], 10, 3 );
+
+	}
+
+	/**
+	 * Bootstrap CiviCRM when hitting a the 'civicrm' namespace.
+	 *
+	 * @since 0.1
+	 * @param mixed $result
+	 * @param WP_REST_Server $server REST server instance
+	 * @param WP_REST_Request $request The request
+	 * @return mixed $result
+	 */
+	public function bootstrap_civi( $result, $server, $request ) {
+
+		if ( false !== strpos( $request->get_route(), 'civicrm' ) ) civi_wp()->initialize();
+
+		return $result;
+
+	}
+
+	/**
+	 * Setup objects.
+	 *
+	 * @since 0.1
+	 */
+	private function setup_objects() {
+
+		if ( CIVICRM_WP_REST_REPLACE_MAILING_TRACKING ) {
+
+			// register mailing hooks
+			$mailing_hooks = ( new Mailing_Hooks )->register_hooks();
+
+		}
+
+	}
+
+	/**
+	 * Registers Rest API routes.
+	 *
+	 * @since 0.1
+	 */
+	public function register_rest_routes() {
+
+		// rest endpoint
+		$rest_controller = new Controller\Rest;
+		$rest_controller->register_routes();
+
+		// url controller
+		$url_controller = new Controller\Url;
+		$url_controller->register_routes();
+
+		// open controller
+		$open_controller = new Controller\Open;
+		$open_controller->register_routes();
+
+		// authorizenet controller
+		$authorizeIPN_controller = new Controller\AuthorizeIPN;
+		$authorizeIPN_controller->register_routes();
+
+		// paypal controller
+		$paypalIPN_controller = new Controller\PayPalIPN;
+		$paypalIPN_controller->register_routes();
+
+		// pxpay controller
+		$paypalIPN_controller = new Controller\PxIPN;
+		$paypalIPN_controller->register_routes();
+
+		// civiconnect controller
+		$cxn_controller = new Controller\Cxn;
+		$cxn_controller->register_routes();
+
+		// widget controller
+		$widget_controller = new Controller\Widget;
+		$widget_controller->register_routes();
+
+		// soap controller
+		$soap_controller = new Controller\Soap;
+		$soap_controller->register_routes();
+
+		/**
+		 * Opportunity to add more rest routes.
+		 *
+		 * @since 0.1
+		 */
+		do_action( 'civi_wp_rest/plugin/rest_routes_registered' );
+
+	}
+
+}
diff --git a/wp-rest/README.md b/wp-rest/README.md
new file mode 100644
index 0000000000..77234de84a
--- /dev/null
+++ b/wp-rest/README.md
@@ -0,0 +1,59 @@
+# CiviCRM WP REST API Wrapper
+
+This is a WordPress plugin that aims to expose CiviCRM's [extern](https://github.com/civicrm/civicrm-core/tree/master/extern) scripts as WordPress REST endpoints.
+
+This plugin requires:
+
+-   PHP 7.1+
+-   WordPress 4.7+
+-   CiviCRM to be installed and activated.
+
+### Endpoints
+
+1. `civicrm/v3/rest` - a wrapper around `civicrm_api3()`
+
+    **Parameters**:
+
+    - `key` - **required**, the site key
+    - `api_key` - **required**, the contact api key
+    - `entity` - **required**, the API entity
+    - `action` - **required**, the API action
+    - `json` - **optional**, json formatted string with the API parameters/argumets, or `1` as in `json=1`
+
+    By default all calls to `civicrm/v3/rest` return XML formatted results, to get `json` formatted result pass `json=1` or a json formatted string with the API parameters, like in the example 2 below.
+
+    **Examples**:
+
+    1. `https://example.com/wp-json/civicrm/v3/rest?entity=Contact&action=get&key=<site_key>&api_key=<api_key>&group=Administrators`
+
+    2. `https://example.com/wp-json/civicrm/v3/rest?entity=Contact&action=get&key=<site_key>&api_key=<api_key>&json={"group": "Administrators"}`
+
+2. `civicrm/v3/url` - a substition for `civicrm/extern/url.php` mailing tracking
+
+3. `civicrm/v3/open` - a substition for `civicrm/extern/open.php` mailing tracking
+
+4. `civicrm/v3/authorizeIPN` - a substition for `civicrm/extern/authorizeIPN.php` (for testing Authorize.net as per [docs](https://docs.civicrm.org/sysadmin/en/latest/setup/payment-processors/authorize-net/#shell-script-testing-method))
+
+    **_Note_**: this endpoint has **not been tested**
+
+5. `civicrm/v3/ipn` - a substition for `civicrm/extern/ipn.php` (for PayPal Standard and Pro live transactions)
+
+    **_Note_**: this endpoint has **not been tested**
+
+6. `civicrm/v3/cxn` - a substition for `civicrm/extern/cxn.php`
+
+7. `civicrm/v3/pxIPN` - a substition for `civicrm/extern/pxIPN.php`
+
+    **_Note_**: this endpoint has **not been tested**
+
+8. `civicrm/v3/widget` - a substition for `civicrm/extern/widget.php`
+
+9. `civicrm/v3/soap` - a substition for `civicrm/extern/soap.php`
+
+    **_Note_**: this endpoint has **not been tested**
+
+### Settings
+
+Set the `CIVICRM_WP_REST_REPLACE_MAILING_TRACKING` constant to `true` to replace mailing url and open tracking calls with their counterpart REST endpoints, `civicrm/v3/url` and `civicrm/v3/open`.
+
+_Note: use this setting with caution, it may affect performance on large mailings, see `CiviCRM_WP_REST\Civi\Mailing_Hooks` class._
-- 
GitLab