LESSON 6 โฑ๏ธ 17 min read

REST API Endpoints & AJAX Interactions

WordPress REST API Fundamentals

The REST API lets you interact with WordPress data via HTTP requests. We'll create endpoints for our Task Manager.

REST API URL Structure

/wp-json/task-manager/v1/tasks          # All tasks
/wp-json/task-manager/v1/tasks/123      # Single task
/wp-json/task-manager/v1/tasks/stats    # Task statistics

Creating the Tasks Controller

<?php
// src/REST/TasksController.php

namespace TaskManagerREST;

use TaskManagerDatabaseTasksTable;
use TaskManagerModelsTask;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;

class TasksController extends WP_REST_Controller {
    
    protected $namespace = 'task-manager/v1';
    protected $rest_base = 'tasks';
    
    private TasksTable $db;
    
    public function __construct() {
        $this->db = new TasksTable();
    }
    
    /**
     * Register REST routes.
     */
    public function register_routes(): void {
        // GET /tasks - List all tasks
        // POST /tasks - Create new task
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => [ $this, 'get_items' ],
                    'permission_callback' => [ $this, 'get_items_permissions_check' ],
                    'args'                => $this->get_collection_params(),
                ],
                [
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => [ $this, 'create_item' ],
                    'permission_callback' => [ $this, 'create_item_permissions_check' ],
                    'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
                ],
                'schema' => [ $this, 'get_public_item_schema' ],
            ]
        );
        
        // GET /tasks/{id} - Get single task
        // PUT /tasks/{id} - Update task
        // DELETE /tasks/{id} - Delete task
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/(?P<id>[d]+)',
            [
                [
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => [ $this, 'get_item' ],
                    'permission_callback' => [ $this, 'get_item_permissions_check' ],
                    'args'                => [
                        'id' => [
                            'type'        => 'integer',
                            'required'    => true,
                            'description' => __( 'Unique task ID.', 'task-manager' ),
                        ],
                    ],
                ],
                [
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => [ $this, 'update_item' ],
                    'permission_callback' => [ $this, 'update_item_permissions_check' ],
                    'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
                ],
                [
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => [ $this, 'delete_item' ],
                    'permission_callback' => [ $this, 'delete_item_permissions_check' ],
                ],
                'schema' => [ $this, 'get_public_item_schema' ],
            ]
        );
    }
    
    /**
     * Check if user can read tasks.
     */
    public function get_items_permissions_check( WP_REST_Request $request ): bool {
        return current_user_can( 'read' );
    }
    
    /**
     * Check if user can create tasks.
     */
    public function create_item_permissions_check( WP_REST_Request $request ): bool {
        return current_user_can( 'edit_posts' );
    }
    
    /**
     * Check if user can read a specific task.
     */
    public function get_item_permissions_check( WP_REST_Request $request ): bool {
        return current_user_can( 'read' );
    }
    
    /**
     * Check if user can update a task.
     */
    public function update_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
        $task = $this->db->find( $request->get_param( 'id' ) );
        
        if ( ! $task ) {
            return new WP_Error(
                'rest_task_not_found',
                __( 'Task not found.', 'task-manager' ),
                [ 'status' => 404 ]
            );
        }
        
        // Allow admins or task owner
        if ( current_user_can( 'manage_options' ) || $task->created_by === get_current_user_id() ) {
            return true;
        }
        
        return new WP_Error(
            'rest_forbidden',
            __( 'You cannot edit this task.', 'task-manager' ),
            [ 'status' => 403 ]
        );
    }
    
    /**
     * Check if user can delete a task.
     */
    public function delete_item_permissions_check( WP_REST_Request $request ): bool|WP_Error {
        return $this->update_item_permissions_check( $request );
    }
    
    /**
     * GET /tasks - List tasks.
     */
    public function get_items( WP_REST_Request $request ): WP_REST_Response {
        $args = [
            'status'   => $request->get_param( 'status' ),
            'orderby'  => $request->get_param( 'orderby' ) ?: 'created_at',
            'order'    => $request->get_param( 'order' ) ?: 'DESC',
            'limit'    => $request->get_param( 'per_page' ) ?: 20,
            'offset'   => ( ( $request->get_param( 'page' ) ?: 1 ) - 1 ) * ( $request->get_param( 'per_page' ) ?: 20 ),
        ];
        
        $tasks = $this->db->get_all( $args );
        $total = $this->db->count( [ 'status' => $args['status'] ] );
        
        $data = array_map( [ $this, 'prepare_item_for_response' ], $tasks, array_fill( 0, count( $tasks ), $request ) );
        
        $response = new WP_REST_Response( $data );
        $response->header( 'X-WP-Total', $total );
        $response->header( 'X-WP-TotalPages', ceil( $total / $args['limit'] ) );
        
        return $response;
    }
    
    /**
     * GET /tasks/{id} - Get single task.
     */
    public function get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $task = $this->db->find( $request->get_param( 'id' ) );
        
        if ( ! $task ) {
            return new WP_Error(
                'rest_task_not_found',
                __( 'Task not found.', 'task-manager' ),
                [ 'status' => 404 ]
            );
        }
        
        return new WP_REST_Response( $this->prepare_item_for_response( $task, $request ) );
    }
    
    /**
     * POST /tasks - Create new task.
     */
    public function create_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $data = [
            'title'       => $request->get_param( 'title' ),
            'description' => $request->get_param( 'description' ),
            'status'      => $request->get_param( 'status' ) ?: 'pending',
            'priority'    => $request->get_param( 'priority' ) ?: 1,
            'due_date'    => $request->get_param( 'due_date' ),
            'assigned_to' => $request->get_param( 'assigned_to' ),
        ];
        
        $task_id = $this->db->create( $data );
        
        if ( ! $task_id ) {
            return new WP_Error(
                'rest_task_create_failed',
                __( 'Failed to create task.', 'task-manager' ),
                [ 'status' => 500 ]
            );
        }
        
        $task = $this->db->find( $task_id );
        $response = new WP_REST_Response( $this->prepare_item_for_response( $task, $request ) );
        $response->set_status( 201 );
        $response->header( 'Location', rest_url( "{$this->namespace}/{$this->rest_base}/{$task_id}" ) );
        
        return $response;
    }
    
    /**
     * PUT /tasks/{id} - Update task.
     */
    public function update_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id = $request->get_param( 'id' );
        
        $data = [];
        foreach ( [ 'title', 'description', 'status', 'priority', 'due_date', 'assigned_to' ] as $field ) {
            if ( $request->has_param( $field ) ) {
                $data[ $field ] = $request->get_param( $field );
            }
        }
        
        if ( ! $this->db->update( $id, $data ) ) {
            return new WP_Error(
                'rest_task_update_failed',
                __( 'Failed to update task.', 'task-manager' ),
                [ 'status' => 500 ]
            );
        }
        
        $task = $this->db->find( $id );
        return new WP_REST_Response( $this->prepare_item_for_response( $task, $request ) );
    }
    
    /**
     * DELETE /tasks/{id} - Delete task.
     */
    public function delete_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id = $request->get_param( 'id' );
        $task = $this->db->find( $id );
        
        if ( ! $this->db->delete( $id ) ) {
            return new WP_Error(
                'rest_task_delete_failed',
                __( 'Failed to delete task.', 'task-manager' ),
                [ 'status' => 500 ]
            );
        }
        
        return new WP_REST_Response( [
            'deleted'  => true,
            'previous' => $this->prepare_item_for_response( $task, $request ),
        ] );
    }
    
    /**
     * Prepare task for REST response.
     */
    public function prepare_item_for_response( $task, $request ): array {
        return [
            'id'          => $task->id,
            'title'       => $task->title,
            'description' => $task->description,
            'status'      => $task->status,
            'status_label' => $task->get_status_label(),
            'priority'    => $task->priority,
            'due_date'    => $task->due_date,
            'is_overdue'  => $task->is_overdue(),
            'assigned_to' => $task->assigned_to,
            'created_by'  => $task->created_by,
            'created_at'  => $task->created_at,
            'updated_at'  => $task->updated_at,
            '_links'      => [
                'self' => [
                    [ 'href' => rest_url( "{$this->namespace}/{$this->rest_base}/{$task->id}" ) ],
                ],
            ],
        ];
    }
    
    /**
     * Get item schema.
     */
    public function get_item_schema(): array {
        return [
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'task',
            'type'       => 'object',
            'properties' => [
                'id' => [
                    'description' => __( 'Unique identifier.', 'task-manager' ),
                    'type'        => 'integer',
                    'readonly'    => true,
                ],
                'title' => [
                    'description' => __( 'Task title.', 'task-manager' ),
                    'type'        => 'string',
                    'required'    => true,
                ],
                'description' => [
                    'description' => __( 'Task description.', 'task-manager' ),
                    'type'        => 'string',
                ],
                'status' => [
                    'description' => __( 'Task status.', 'task-manager' ),
                    'type'        => 'string',
                    'enum'        => [ 'pending', 'in_progress', 'completed' ],
                ],
                'priority' => [
                    'description' => __( 'Priority level 1-5.', 'task-manager' ),
                    'type'        => 'integer',
                    'minimum'     => 1,
                    'maximum'     => 5,
                ],
            ],
        ];
    }
}

AJAX for Admin Interfaces

For interactive admin pages, use WordPress AJAX:

<?php
// In Plugin.php or separate AJAX handler class

public function __construct() {
    add_action( 'wp_ajax_tm_quick_edit', [ $this, 'ajax_quick_edit' ] );
    add_action( 'wp_ajax_tm_delete_task', [ $this, 'ajax_delete_task' ] );
    add_action( 'wp_ajax_tm_update_status', [ $this, 'ajax_update_status' ] );
}

public function ajax_quick_edit(): void {
    // Verify nonce
    check_ajax_referer( 'tm_admin_nonce', 'nonce' );
    
    // Check permissions
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 );
    }
    
    $task_id = absint( $_POST['task_id'] ?? 0 );
    $title = sanitize_text_field( $_POST['title'] ?? '' );
    
    if ( ! $task_id || ! $title ) {
        wp_send_json_error( [ 'message' => 'Invalid data.' ], 400 );
    }
    
    $db = new TasksTable();
    $result = $db->update( $task_id, [ 'title' => $title ] );
    
    if ( $result ) {
        wp_send_json_success( [ 'message' => 'Task updated!' ] );
    } else {
        wp_send_json_error( [ 'message' => 'Update failed.' ], 500 );
    }
}

public function ajax_update_status(): void {
    check_ajax_referer( 'tm_admin_nonce', 'nonce' );
    
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 );
    }
    
    $task_id = absint( $_POST['task_id'] ?? 0 );
    $status = sanitize_key( $_POST['status'] ?? '' );
    
    $valid_statuses = [ 'pending', 'in_progress', 'completed' ];
    if ( ! in_array( $status, $valid_statuses, true ) ) {
        wp_send_json_error( [ 'message' => 'Invalid status.' ], 400 );
    }
    
    $db = new TasksTable();
    $result = $db->update( $task_id, [ 'status' => $status ] );
    
    wp_send_json_success( [
        'message' => 'Status updated!',
        'status'  => $status,
    ] );
}

JavaScript for AJAX Calls

// assets/js/admin.js

(function($) {
    'use strict';
    
    const TaskManager = {
        
        init() {
            this.bindEvents();
        },
        
        bindEvents() {
            // Status dropdown change
            $(document).on('change', '.task-status-select', this.updateStatus.bind(this));
            
            // Delete button click
            $(document).on('click', '.delete-task', this.deleteTask.bind(this));
            
            // Quick edit
            $(document).on('click', '.quick-edit-save', this.saveQuickEdit.bind(this));
        },
        
        updateStatus(e) {
            const $select = $(e.target);
            const taskId = $select.data('task-id');
            const newStatus = $select.val();
            
            $select.prop('disabled', true);
            
            $.ajax({
                url: tmAdmin.ajaxUrl,
                type: 'POST',
                data: {
                    action: 'tm_update_status',
                    nonce: tmAdmin.nonce,
                    task_id: taskId,
                    status: newStatus
                },
                success: (response) => {
                    if (response.success) {
                        this.showNotice('Status updated!', 'success');
                        $select.closest('tr').find('.status-badge')
                            .removeClass()
                            .addClass(`status-badge status-${newStatus}`)
                            .text(response.data.status);
                    } else {
                        this.showNotice(response.data.message, 'error');
                    }
                },
                error: () => {
                    this.showNotice('Request failed.', 'error');
                },
                complete: () => {
                    $select.prop('disabled', false);
                }
            });
        },
        
        deleteTask(e) {
            e.preventDefault();
            
            if (!confirm('Are you sure you want to delete this task?')) {
                return;
            }
            
            const $button = $(e.target);
            const taskId = $button.data('task-id');
            const $row = $button.closest('tr');
            
            $button.prop('disabled', true);
            
            $.ajax({
                url: tmAdmin.ajaxUrl,
                type: 'POST',
                data: {
                    action: 'tm_delete_task',
                    nonce: tmAdmin.nonce,
                    task_id: taskId
                },
                success: (response) => {
                    if (response.success) {
                        $row.fadeOut(300, () => $row.remove());
                        this.showNotice('Task deleted!', 'success');
                    } else {
                        this.showNotice(response.data.message, 'error');
                        $button.prop('disabled', false);
                    }
                },
                error: () => {
                    this.showNotice('Delete failed.', 'error');
                    $button.prop('disabled', false);
                }
            });
        },
        
        showNotice(message, type) {
            const $notice = $(`
                <div class="notice notice-${type} is-dismissible">
                    <p>${message}</p>
                    <button type="button" class="notice-dismiss"></button>
                </div>
            `);
            
            $('.wrap h1').after($notice);
            
            setTimeout(() => $notice.fadeOut(300, () => $notice.remove()), 3000);
        }
    };
    
    $(document).ready(() => TaskManager.init());
    
})(jQuery);

Localizing Scripts

Pass PHP data to JavaScript:

public function enqueue_admin_assets( string $hook ): void {
    if ( ! str_contains( $hook, 'task-manager' ) ) {
        return;
    }
    
    wp_enqueue_script(
        'task-manager-admin',
        TM_PLUGIN_URL . 'assets/js/admin.js',
        [ 'jquery' ],
        self::VERSION,
        true
    );
    
    wp_localize_script( 'task-manager-admin', 'tmAdmin', [
        'ajaxUrl'   => admin_url( 'admin-ajax.php' ),
        'restUrl'   => rest_url( 'task-manager/v1/' ),
        'nonce'     => wp_create_nonce( 'tm_admin_nonce' ),
        'restNonce' => wp_create_nonce( 'wp_rest' ),
        'strings'   => [
            'confirmDelete' => __( 'Are you sure?', 'task-manager' ),
            'saving'        => __( 'Saving...', 'task-manager' ),
        ],
    ] );
}

Using Fetch API with REST

// Modern approach using fetch and REST API

async function updateTask(taskId, data) {
    const response = await fetch(`${tmAdmin.restUrl}tasks/${taskId}`, {
        method: 'PUT',
        headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': tmAdmin.restNonce,
        },
        body: JSON.stringify(data),
    });
    
    if (!response.ok) {
        throw new Error('Update failed');
    }
    
    return response.json();
}

// Usage
updateTask(123, { status: 'completed' })
    .then(task => console.log('Updated:', task))
    .catch(err => console.error(err));

Next Steps

In Lesson 7, we'll focus on security โ€“ implementing nonces, capability checks, and proper sanitization throughout the plugin.

๐ŸŽฏ Lesson Complete! You can now build REST APIs and interactive AJAX interfaces.