20.4.5.2. Eigenen Endpunkt-Controller abgeleitet von WP_REST_Controller

Auf die Klasse WP_REST_Controller aufzubauen bietet sich immer dann an, wenn man mit Listen von Elementen arbeitet. Das muss nicht immer ein benutzerdefinierte Artikeltyp sein.

Denn, wie oben bereits erwähnt, integriert die Klasse bereits einige Methoden wie get_items(), get_item() und so fort. Sie ist so out-of-the-box nicht nutzbar. Sie produziert WP_Error-Meldungen, wenn Methoden nicht implementiert werden. Das zwingt den Entwickler, die entsprechenden Methoden zu integrieren.

Nehmen wir als Beispiel unser Link-Tracking Plugin aus den Kapiteln zuvor. Es installiert bei der Aktivierung eine neue Datenbanktabelle in der alle Links gespeichert werden. Das sind die idealen Voraussetzungen zur Verwendung von WP_REST_Controller. Wir haben eine Liste, die wir über die REST-API ansteuern wollen.

In der Datei inc/bootstra.php ergänzen wir folgendes:

<?php
add_action( 'rest_api_init', function () {
	require __DIR__ . '/rest.php';
	new REST();
} );
?>

Und erstellen dann eine neue Datei inc/rest.php:

<?php

namespace f\tel;

if ( ! defined( 'ABSPATH' ) ) {
	die();
}

/**
 * Class REST
 * @package f\tel
 *
 * @since   0.2.0
 */
class REST extends \WP_REST_Controller {

	/**
	 * REST constructor.
	 * @since 0.2.0
	 */
	public function __construct() {
		$this->namespace = 'tel/v1';
		$this->rest_base = 'outgoing-links';
		$this->register_routes();
	}

	/**
	 * Registers all REST routes.
	 * @since 0.2.0
	 */
	public function register_routes() {

	}

	public function get_items_permissions_check( $request ) {
	}


	public function get_items( $request ) {
	}


	public function get_item_permissions_check( $request ) {
	}


	public function get_item( $request ) {
	}


	public function create_item_permissions_check( $request ) {
	}


	public function create_item( $request ) {
	}


	public function update_item_permissions_check( $request ) {
	}


	public function update_item( $request ) {
	}


	public function delete_item_permissions_check( $request ) {
	}


	public function delete_item( $request ) {
	}


	protected function prepare_item_for_database( $request ) {
	}


	public function prepare_item_for_response( $item, $request ) {
	}

}
?>

Damit haben wir das Grundgerüst mit allen Methoden geschaffen, die die Klasse WP_REST_Controller vorgibt. Diese müssen zwingend implementiert werden.

Beginnen wir mit dem Anlegen der Routen, auf die lesend zugegriffen werden können:

<?php
public function register_routes() {
	register_rest_route( $this->namespace, '/' . $this->rest_base, array(
		array(
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => array( $this, 'get_items' ),
			'permission_callback' => array( $this, 'get_items_permissions_check' ),
			'args'                => $this->get_collection_params(),
		),
		'schema' => array( $this, 'get_public_item_schema' ),
	) );

	register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
		array(
			'methods'             => \WP_REST_Server::READABLE,
			'callback'            => array( $this, 'get_item' ),
			'permission_callback' => array( $this, 'get_item_permissions_check' ),
		),
		'schema' => array( $this, 'get_public_item_schema' ),
	) );
}
?>

Zu den Routen benötigen wir das Schema der Felder:

<?php
public function get_item_schema() {

	return array(
		'$schema'    => 'http://json-schema.org/draft-04/schema#',
		'title'      => $this->rest_base,
		'type'       => 'object',
		'properties' => array(
			'id' => array(
				'description' => __( 'Unique identifier for the object.', 'track-external-links' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),

			'title' => array(
				'description' => __( 'The title for the object.', 'track-external-links' ),
				'type'        => 'object',
				'context'     => array( 'view', 'edit' ),
				'arg_options' => array(
					'sanitize_callback' => '\sanitize_text_field',
					'validate_callback' => function ( $param ) {
						$title = sanitize_text_field( $param );

						return ! empty( $title );
					},
				),
			),

			'slug' => array(
				'description' => __( 'An alphanumeric identifier for the object unique to its type.', 'track-external-links' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'arg_options' => array(
					'sanitize_callback' => array( $this, 'sanitize_slug' ),
				),
				'readonly'    => true,
			),

			'link' => array(
				'description' => __( 'URL to the object.', 'track-external-links' ),
				'type'        => 'string',
				'format'      => 'uri',
				'context'     => array( 'view', 'edit' ),
			),

			'count' => array(
				'description' => __( 'How often the Link was clicked.', 'track-external-links' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),

		),
	);
}
?>

Und alle möglichen Felder, die für eine Abfrage genutzt werden können damit diese ebenfalls im Schema auftauchen und der Endbenutzer der API schließlich erkennen kann, was er mit dem Endpunkt alles anstellen kann.

<?php
public function get_collection_params() {
	$query_params = parent::get_collection_params();

	$query_params['context']['default'] = 'view';

	return $query_params;
}
?>

Bevor wir mit den Datenbank-Abfragen beginnen, legen wir noch die Rechte fest:

<?php
public function get_items_permissions_check( $request ) {
	return true;
}


public function get_item_permissions_check( $request ) {
	return true;
}


public function create_item_permissions_check( $request ) {
	return current_user_can( 'edit_posts' );
}


public function update_item_permissions_check( $request ) {
	return current_user_can( 'edit_posts' );
}


public function delete_item_permissions_check( $request ) {
	return current_user_can( 'edit_posts' );
}
?>

You’re not allowed to see this content. Please log in first.

Ein REST-Aufruf an:

GET http://test.local/wp-json/tel/v1

gibt folgendes zurück:

{
    "namespace": "tel/v1",
    "routes": {
        "/tel/v1": {
            "namespace": "tel/v1",
            "methods": [
                "GET"
            ],
            "endpoints": [
                {
                    "methods": [
                        "GET"
                    ],
                    "args": {
                        "namespace": {
                            "required": false,
                            "default": "tel/v1"
                        },
                        "context": {
                            "required": false,
                            "default": "view"
                        }
                    }
                }
            ],
            "_links": {
                "self": "http://test.local/wp-json/tel/v1"
            }
        },
        "/tel/v1/outgoing-links": {
            "namespace": "tel/v1",
            "methods": [
                "GET"
            ],
            "endpoints": [
                {
                    "methods": [
                        "GET"
                    ],
                    "args": {
                        "context": {
                            "required": false,
                            "default": "view",
                            "enum": [
                                "view",
                                "edit"
                            ],
                            "description": "Scope under which the request is made; determines fields present in response.",
                            "type": "string"
                        },
                        "page": {
                            "required": false,
                            "default": 1,
                            "description": "Current page of the collection.",
                            "type": "integer"
                        },
                        "per_page": {
                            "required": false,
                            "default": 10,
                            "description": "Maximum number of items to be returned in result set.",
                            "type": "integer"
                        },
                        "search": {
                            "required": false,
                            "description": "Limit results to those matching a string.",
                            "type": "string"
                        }
                    }
                }
            ],
            "_links": {
                "self": "http://test.local/wp-json/tel/v1/outgoing-links"
            }
        },
        "/tel/v1/outgoing-links/(?P<id>[\\d]+)": {
            "namespace": "tel/v1",
            "methods": [
                "GET"
            ],
            "endpoints": [
                {
                    "methods": [
                        "GET"
                    ],
                    "args": []
                }
            ]
        }
    },
    "_links": {
        "up": [
            {
                "href": "http://test.local/wp-json/"
            }
        ]
    }
}

Wie man sieht gibt es neben den von uns gewollten Parametern auch noch search, den wir in diesem Beispiel nicht integrieren.

Widmen wir uns der eigentlichen Paginierung:

<?php
public function get_items( $request ) {
	global $wpdb;

	$page_number = $request->get_param( 'page' );
	$per_page    = $request->get_param( 'per_page' );
	$offset      = $request->get_param( 'offset' );

	$sql = "SELECT * FROM {$wpdb->prefix}outgoing_links LIMIT {$per_page}";

	if ( $page_number > 1 ) {
		$sql .= ' OFFSET ' . ( $page_number - 1 ) * $per_page;
	} elseif ( $offset > 0 ) {
		$sql .= ' OFFSET ' . $offset;
	}

	// @todo Integrate search functionality

	$query_result = $wpdb->get_results( $sql );

	if ( ! is_array( $query_result ) ) {
		return new \WP_Error(
			'tel/rest/get_items',
			sprintf(
				__( 'Could not fetch any links. Database responded with the following error: %s', 'track-external-links' ),
				$wpdb->last_error
			)
		);
	}

	$links = array();

	foreach ( $query_result as $data ) {
		$link    = $this->prepare_item_for_response( $data, $request );
		$links[] = $this->prepare_response_for_collection( $link );
	}

	$response = rest_ensure_response( $links );

	return $response;
}
?>

Das Validieren und Säubern übernimmt WordPress für uns. Das liegt daran, dass wir die Vordefinierten Werte über die Funktion get_collection_params() nutzen.

Ein Aufruf an

GET http://test.local/wp-json/tel/v1/outgoing-links?per_page=2&page=2

liefert uns beispielhaft:

[
    {
        "id": "3",
        "title": "WP-Typ",
        "slug": "wp-typ",
        "link": "https://wp-typ.de",
        "count": "0"
    },
    {
        "id": "4",
        "title": "WP-Plugin-Erstellen",
        "slug": "wp-plugin-erstellen",
        "link": "https://wp-plugin-erstellen.de",
        "count": "0"
    }
]

Als kleines Schmankerl integrieren wir noch – wie von WordPress verwendet – die Page-Navigation-Headers wie folgt:

<?php
public function get_items( $request ) {
	global $wpdb;

	...

	$response = rest_ensure_response( $links );

	$total_links = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}outgoing_links" );
	$max_pages   = ceil( $total_links / $per_page );

	$response->header( 'X-WP-Total', (int) $total_links );
	$response->header( 'X-WP-TotalPages', (int) $max_pages );

	$request_params = $request->get_query_params();
	$base           = add_query_arg( $request_params, rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );

	if ( $page_number > 1 ) {
		$prev_page = $page_number - 1;

		if ( $prev_page > $max_pages ) {
			$prev_page = $max_pages;
		}

		$prev_link = add_query_arg( 'page', $prev_page, $base );
		$response->link_header( 'prev', $prev_link );
	}
	if ( $max_pages > $page_number ) {
		$next_page = $page_number + 1;
		$next_link = add_query_arg( 'page', $next_page, $base );

		$response->link_header( 'next', $next_link );
	}

	return $response;
}
?>

Nun integrieren wir die Möglichkeit, einen einzelnen Link abzurufen. Die Route dafür haben wir bereits angelegt. Allerdings wollen wir sie noch einmal erweitern. Wir müssen sicherstellen, dass die ID auch existiert.

<?php
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
	array(
		'methods'             => \WP_REST_Server::READABLE,
		'callback'            => array( $this, 'get_item' ),
		'permission_callback' => array( $this, 'get_item_permissions_check' ),
	),
	'schema' => array( $this, 'get_public_item_schema' ),
	'args'   => array(
		'id' => array(
			'sanitize_callback' => function ( $id, $request, $parameter ) {
				return absint( $id );
			},
			'validate_callback' => function ( $id, $request, $parameter ) {
				return is_object( $this->get_link( absint( $id ) ) );
			},
		),
	),
) );
?>

Wir benötigen noch die entsprechende Methode get_link():

<?php
private function get_link( $id ) {
	global $wpdb;

	return $wpdb->get_row( $wpdb->prepare(
		"SELECT * FROM {$wpdb->prefix}outgoing_links WHERE id = %d",
		$id
	) );
}
?>

Nun machen wir uns an get_item(). Das ist an dieser Stelle relativ einfach. Wir können uns das meiste von get_items() abschauen:

<?php
public function get_item( $request ) {

	$link = $this->get_link( $request->get_param( 'id' ) );

	$link = $this->prepare_item_for_response( $link, $request );

	return rest_ensure_response( $link );
}
?>

Nun aktualisieren wir unsere Route noch einmal. Wir ergänzen einen zweiten Endpunkt:

<?php
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
	array(
		'methods'             => \WP_REST_Server::READABLE,
		'callback'            => array( $this, 'get_item' ),
		'permission_callback' => array( $this, 'get_item_permissions_check' ),
	),
	array(
		'methods'             => \WP_REST_Server::DELETABLE,
		'callback'            => array( $this, 'delete_item' ),
		'permission_callback' => array( $this, 'delete_item_permissions_check' ),

	),
	'schema' => array( $this, 'get_public_item_schema' ),
	'args'   => array(
		'id' => array(
			'sanitize_callback' => function ( $id, $request, $parameter ) {
				return absint( $id );
			},
			'validate_callback' => function ( $id, $request, $parameter ) {
				return is_object( $this->get_link( absint( $id ) ) );
			},
		),
	),
) );
?>

Danach bauen wir die delete_item()-Methode auf:

<?php
public function delete_item( $request ) {
	global $wpdb;

	$id = $request->get_param( 'id' );

	$link = $this->get_link( $request->get_param( 'id' ) );

	$deleted = $wpdb->delete(
		"{$wpdb->prefix}outgoing_links",
		array( 'id' => $id ),
		array( '%d' )
	);

	if ( false === $deleted ) {
		return new \WP_Error(
			'tel/rest/delete',
			sprintf(
				__( 'Could not delete link. Database responded with the following error: %s', 'track-external-links' ),
				$wpdb->last_error
			)
		);
	}

	return rest_ensure_response( array(
		'deleted'  => $deleted >= 1,
		'previous' => $link
	) );
}
?>

Nun können wir einen Link über folgenden REST-Request löschen:

DELETE http://test.local/wp-json/tel/v1/outgoing-links/1

Beachten Sie, dass Sie – wie oben festgelegt – die entsprechenden Rechte haben müssen. Außerdem sollten Sie sich authentifizieren, damit es klappt. Das heißt, der Header X-WP-Nonce muss gesetzt sein.

Perfekt! Nun sind wir fast am Ziel. Als nächstes integrieren wir den Endpunkt zum Erstellen eines Links. Wir ergänzen die Route wie folgt:

<?php
register_rest_route( $this->namespace, '/' . $this->rest_base, array(
	array(
		'methods'             => \WP_REST_Server::READABLE,
		'callback'            => array( $this, 'get_items' ),
		'permission_callback' => array( $this, 'get_items_permissions_check' ),
		'args'                => $this->get_collection_params(),
	),
	array(
		'methods'             => \WP_REST_Server::CREATABLE,
		'callback'            => array( $this, 'create_item' ),
		'permission_callback' => array( $this, 'create_item_permissions_check' ),
		'args'                => $this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ),
	),
	'schema' => array( $this, 'get_public_item_schema' ),
) );
?>

Die Methode get_endpoint_args_for_item_schema() nimmt uns Arbeit ab. Da wir – über den schema-Parameter bereits festgelegt haben, wie unsere Datenbank-Felder aussehen, können daraus auch gleich die Eingangsfelder für den Endpunkt generiert werden. Die Methode filtert dabei anhand des readonly-Parameters.

Dementsprechend müssen wir an dieser Stelle nur noch die Methode create_item() mit Inhalt füllen:

<?php
public function create_item( $request ) {
	global $wpdb;

	$prepared_link = $this->prepare_item_for_database( $request );

	if ( isset( $prepared_link['id'] ) ) {
		return new \WP_Error(
			'tel/rest/insert',
			__( 'Please do not set the ID parameter.', 'track-external-links' )
		);
	}

	$inserted = $wpdb->insert(
		$wpdb->prefix . 'outgoing_links',
		$prepared_link,
		array( '%s', '%s', '%s' )
	);

	if ( false === $inserted ) {
		return new \WP_Error(
			'tel/rest/insert',
			sprintf(
				__( 'Could not insert link. Database responded with the following error: %s', 'track-external-links' ),
				$wpdb->last_error
			)
		);
	}

	$link = $this->get_link( $wpdb->insert_id );

	$request->set_param( 'context', 'edit' );

	$response = $this->prepare_item_for_response( $link, $request );
	$response = rest_ensure_response( $response );

	$response->set_status( 201 );

	$response->header(
		'Location',
		rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $link->id ) )
	);

	return $response;
}
?>

Dazu benötigen wir die Methode prepare_item_for_database, die unseren Link entsprechend für die Datenbank vorbereitet. Diese fällt etwas umfangreicher aus, weil wir sie gleich noch für die update_item()-Methode brauchen werden.

<?php
protected function prepare_item_for_database( $request ) {

	$id = $request->get_param( 'id' );

	# check if we do an update and load initial values
	if ( ! empty( $id ) ) {
		$args = (array) $this->get_link( $id );
	} else {
		$args = array();
	}

	$title = $request->get_param( 'title' );

	if ( ! empty( $title ) ) {
		$args['title'] = $title;
	}

	$link = $request->get_param( 'title' );

	if ( ! empty( $link ) ) {
		$args['link'] = $link;
	}

	if ( ! empty( $args['title'] ) ) {
		$args['slug'] = sanitize_key( $args['title'] );
	}


	if ( empty( $args['title'] ) ) {
		return new \WP_Error(
			'tel/rest/item',
			__( 'Please enter a title.', 'track-external-links' )
		);
	}

	if ( empty( $args['link'] ) ) {
		return new \WP_Error(
			'tel/rest/item',
			__( 'Please enter an URL.', 'track-external-links' )
		);
	}

	return $args;
}
?>

Zu beachten sind hier zwei Dinge:

You’re not allowed to see this content. Please log in first.

Fast am Ende! Was fehlt noch? Richtig. Der Endpunkt zum Aktualisieren eines Links. Wir erweitern die Route:

<?php
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
	array(
		'methods'             => \WP_REST_Server::READABLE,
		'callback'            => array( $this, 'get_item' ),
		'permission_callback' => array( $this, 'get_item_permissions_check' ),
	),
	array(
		'methods'             => \WP_REST_Server::DELETABLE,
		'callback'            => array( $this, 'delete_item' ),
		'permission_callback' => array( $this, 'delete_item_permissions_check' ),

	),
	array(
		'methods'             => \WP_REST_Server::EDITABLE,
		'callback'            => array( $this, 'update_item' ),
		'permission_callback' => array( $this, 'update_item_permissions_check' ),
		'args'                => $this->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ),
	),
	array(
		'methods'             => \WP_REST_Server::EDITABLE,
		'callback'            => array( $this, 'update_item' ),
		'permission_callback' => array( $this, 'update_item_permissions_check' ),
		'args'                => $this->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ),
	),
	'schema' => array( $this, 'get_public_item_schema' ),
	'args'   => array(
		'id' => array(
			'sanitize_callback' => function ( $id, $request, $parameter ) {
				return absint( $id );
			},
			'validate_callback' => function ( $id, $request, $parameter ) {
				return is_object( $this->get_link( absint( $id ) ) );
			},
		),
	),
) );
?>

Und ergänzen dann noch die eigentliche Funktionalität:

<?php
public function update_item( $request ) {
	global $wpdb;

	$id = $request->get_param( 'id' );

	$prepared_link = $this->prepare_item_for_database( $request );

	if ( is_wp_error( $prepared_link ) ) {
		return $prepared_link;
	}

	natsort( $prepared_link );

	# do not update the id and count
	unset( $prepared_link['id'], $prepared_link['count'] );

	$updated = $wpdb->update(
		$wpdb->prefix . 'outgoing_links',
		$prepared_link,
		array( 'id' => $id ),
		array( '%s', '%s', '%s' ),
		array( '%d' )
	);

	if ( false === $updated ) {
		return new \WP_Error(
			'tel/rest/update',
			sprintf(
				__( 'Could not update link. Database responded with the following error: %s', 'track-external-links' ),
				$wpdb->last_error
			)
		);
	}

	$link = $this->get_link( $id );

	$request->set_param( 'context', 'edit' );

	$response = $this->prepare_item_for_response( $link, $request );
	$response = rest_ensure_response( $response );

	return $response;
}
?>

Gesamtes Beispiel bei Github laden

Das war’s! Trotzdem uns WordPress einiges an Arbeit abnimmt steht am Ende doch noch ein Script mit mehr als 500 Zeilen.

Im nächsten Kapitel sehen wir uns an, wie wir die REST-API im Frontend ansteuern können.