26 KiB
WordPress Content Sync REST Transport Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add authenticated REST package receive/status endpoints and a REST client that can test connections and send validated content packages using WordPress application passwords.
Architecture: This phase adds a REST controller under src/Rest/ and a REST client under src/Transport/. The REST controller validates request shape, permissions, and package schema, then returns a typed response without mutating WordPress content. The REST client serializes the existing ContentPackage, sends it with Basic application-password authentication, and converts network/API failures into typed transport errors so later orchestration can fall back to file transport.
Tech Stack: PHP 7.4, WordPress REST API, WordPress HTTP API, application passwords, PHPUnit, PHPStan, PHPCS/WPCS.
File Structure
- Create:
src/Transport/RestTransportException.phpfor typed REST failure details. - Create:
src/Transport/RestTransportClient.phpfor connection tests and package sends. - Create:
src/Rest/RestPackageController.phpfor/wp-content-sync/v1/statusand/wp-content-sync/v1/packageendpoints. - Modify:
src/Plugin.phpto register REST services. - Modify:
tests/bootstrap.phpto add REST and HTTP API stubs. - Test:
tests/Unit/Transport/RestTransportExceptionTest.php - Test:
tests/Unit/Transport/RestTransportClientTest.php - Test:
tests/Unit/Rest/RestPackageControllerTest.php - Test:
tests/Unit/PluginTest.php
REST Contract
Status Endpoint
Route: GET /wp-json/wp-content-sync/v1/status
Successful response:
{
"ok": true,
"plugin": "wp-content-sync",
"version": "0.1.0"
}
Package Receive Endpoint
Route: POST /wp-json/wp-content-sync/v1/package
Request JSON:
{
"package": {
"schema_version": "1.0",
"generated_at": "2026-04-28T12:00:00+00:00",
"source": {
"site_url": "https://example.test",
"name": "Example Production"
},
"destination": {
"site_url": "https://staging.example.test",
"name": "Example Staging"
},
"manifest": {
"posts": 0,
"terms": 0,
"media": 0,
"custom_post_types": 0
},
"records": {
"posts": [],
"terms": [],
"media": [],
"custom_post_types": []
},
"checksums": {
"records": "sha256:..."
}
}
}
Successful response:
{
"accepted": true,
"schema_version": "1.0",
"manifest": {
"posts": 0,
"terms": 0,
"media": 0,
"custom_post_types": 0
}
}
Error response for invalid package:
{
"accepted": false,
"errors": [
"records is required."
]
}
Task 1: REST Transport Exception
Files:
-
Create:
tests/Unit/Transport/RestTransportExceptionTest.php -
Create:
src/Transport/RestTransportException.php -
Step 1: Write the failing exception test
Create tests/Unit/Transport/RestTransportExceptionTest.php:
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Transport\RestTransportException;
class RestTransportExceptionTest extends TestCase {
public function test_it_exposes_transport_failure_context(): void {
$exception = RestTransportException::connectionFailed(
'Connection timed out.',
array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' )
);
self::assertSame( 'connection_failed', $exception->failureCode() );
self::assertSame( 'Connection timed out.', $exception->getMessage() );
self::assertSame( array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' ), $exception->context() );
}
public function test_it_exposes_authentication_failures(): void {
$exception = RestTransportException::authenticationFailed( 'REST authentication failed.' );
self::assertSame( 'authentication_failed', $exception->failureCode() );
self::assertSame( 'REST authentication failed.', $exception->getMessage() );
self::assertSame( array(), $exception->context() );
}
}
- Step 2: Run the test to verify it fails
Run: composer test -- --filter RestTransportExceptionTest
Expected: FAIL with class WPContentSync\Transport\RestTransportException not found.
- Step 3: Implement the exception
Create src/Transport/RestTransportException.php:
<?php
/**
* Typed REST transport failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
final class RestTransportException extends \RuntimeException {
/** @var array<string, mixed> */
private array $context;
private string $failure_code;
/**
* @param array<string, mixed> $context Failure context.
*/
private function __construct( string $failure_code, string $message, array $context = array() ) {
parent::__construct( $message );
$this->failure_code = $failure_code;
$this->context = $context;
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function connectionFailed( string $message, array $context = array() ): self {
return new self( 'connection_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function authenticationFailed( string $message, array $context = array() ): self {
return new self( 'authentication_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function remoteRejected( string $message, array $context = array() ): self {
return new self( 'remote_rejected', $message, $context );
}
public function failureCode(): string {
return $this->failure_code;
}
/**
* @return array<string, mixed>
*/
public function context(): array {
return $this->context;
}
}
- Step 4: Run the test to verify it passes
Run: composer test -- --filter RestTransportExceptionTest
Expected: PASS.
- Step 5: Commit
git add src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportExceptionTest.php
git commit -m "feat: add rest transport exception"
Task 2: REST Transport Client
Files:
-
Create:
tests/Unit/Transport/RestTransportClientTest.php -
Create:
src/Transport/RestTransportClient.php -
Modify:
tests/bootstrap.php -
Step 1: Add HTTP API test stubs
Add these stubs to tests/bootstrap.php if they do not already exist:
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private string $message;
public function __construct( string $code, string $message ) {
$this->message = $message;
}
public function get_error_message(): string {
return $this->message;
}
}
}
if ( ! function_exists( 'wp_remote_get' ) ) {
function wp_remote_get( string $url, array $args = array() ) {
$GLOBALS['wpcs_last_http_request'] = array( 'method' => 'GET', 'url' => $url, 'args' => $args );
return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"ok":true}' );
}
}
if ( ! function_exists( 'wp_remote_post' ) ) {
function wp_remote_post( string $url, array $args = array() ) {
$GLOBALS['wpcs_last_http_request'] = array( 'method' => 'POST', 'url' => $url, 'args' => $args );
return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"accepted":true}' );
}
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( array $response ): int {
return (int) ( $response['response']['code'] ?? 0 );
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
function wp_remote_retrieve_body( array $response ): string {
return (string) ( $response['body'] ?? '' );
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $value ): bool {
return $value instanceof WP_Error;
}
}
- Step 2: Write failing client tests
Create tests/Unit/Transport/RestTransportClientTest.php:
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Transport\RestTransportException;
class RestTransportClientTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_http_response'], $GLOBALS['wpcs_last_http_request'] );
parent::tearDown();
}
public function test_it_tests_connections_with_application_password_auth(): void {
$client = new RestTransportClient();
self::assertTrue( $client->testConnection( 'https://destination.test', 'codex', 'app-pass' ) );
self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] );
self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] );
self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] );
}
public function test_it_sends_packages_to_receive_endpoint(): void {
$client = new RestTransportClient();
self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) );
self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] );
self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] );
self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] );
self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] );
}
public function test_it_throws_authentication_failures_for_unauthorized_status(): void {
$GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 401 ), 'body' => '{"message":"Unauthorized"}' );
$client = new RestTransportClient();
$this->expectException( RestTransportException::class );
$this->expectExceptionMessage( 'REST authentication failed.' );
$client->testConnection( 'https://destination.test', 'codex', 'bad-pass' );
}
public function test_it_throws_remote_rejected_for_invalid_package_response(): void {
$GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 400 ), 'body' => '{"message":"Invalid package"}' );
$client = new RestTransportClient();
$this->expectException( RestTransportException::class );
$this->expectExceptionMessage( 'Invalid package' );
$client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() );
}
private function package(): ContentPackage {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-28T12:00:00+00:00',
'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ),
'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ),
'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ),
'records' => $records,
'checksums' => array( 'records' => PackageChecksum::records( $records ) ),
)
);
}
}
- Step 3: Run tests to verify they fail
Run: composer test -- --filter RestTransportClientTest
Expected: FAIL with class WPContentSync\Transport\RestTransportClient not found.
- Step 4: Implement REST client
Create src/Transport/RestTransportClient.php:
<?php
/**
* REST package transport client.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
final class RestTransportClient {
public function testConnection( string $base_url, string $username, string $application_password ): bool {
$response = wp_remote_get(
$this->endpoint( $base_url, 'status' ),
$this->requestArgs( $username, $application_password )
);
$this->assertSuccessfulResponse( $response, 200 );
return true;
}
public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool {
$body = wp_json_encode( array( 'package' => $package->toArray() ) );
if ( false === $body ) {
throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' );
}
$args = $this->requestArgs( $username, $application_password );
$args['body'] = $body;
$args['headers']['Content-Type'] = 'application/json';
$response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args );
$this->assertSuccessfulResponse( $response, 200 );
return true;
}
private function endpoint( string $base_url, string $route ): string {
return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' );
}
/**
* @return array<string, mixed>
*/
private function requestArgs( string $username, string $application_password ): array {
return array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ),
),
);
}
/**
* @param mixed $response HTTP response.
*/
private function assertSuccessfulResponse( $response, int $expected_code ): void {
if ( is_wp_error( $response ) ) {
throw RestTransportException::connectionFailed( $response->get_error_message() );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 401 === $status_code || 403 === $status_code ) {
throw RestTransportException::authenticationFailed( 'REST authentication failed.' );
}
if ( $expected_code !== $status_code ) {
throw RestTransportException::remoteRejected( $this->responseMessage( $response ) );
}
}
/**
* @param array<string, mixed> $response HTTP response.
*/
private function responseMessage( array $response ): string {
$body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $body, true );
if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) {
return $decoded['message'];
}
return 'REST transport request failed.';
}
}
- Step 5: Run tests to verify they pass
Run: composer test -- --filter RestTransportClientTest
Expected: PASS.
- Step 6: Commit
git add src/Transport/RestTransportClient.php src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportClientTest.php tests/bootstrap.php
git commit -m "feat: add rest transport client"
Task 3: REST Package Controller
Files:
-
Create:
tests/Unit/Rest/RestPackageControllerTest.php -
Create:
src/Rest/RestPackageController.php -
Modify:
tests/bootstrap.php -
Step 1: Add REST API test stubs
Add these stubs to tests/bootstrap.php if they do not already exist:
if ( ! function_exists( 'register_rest_route' ) ) {
function register_rest_route( string $namespace, string $route, array $args ): bool {
$GLOBALS['wpcs_rest_routes'][ $namespace . $route ] = $args;
return true;
}
}
if ( ! function_exists( 'rest_ensure_response' ) ) {
function rest_ensure_response( $response ) {
return $response;
}
}
- Step 2: Write failing controller tests
Create tests/Unit/Rest/RestPackageControllerTest.php:
<?php
namespace WPContentSync\Tests\Unit\Rest;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Rest\RestPackageController;
class RestPackageControllerTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'] );
parent::tearDown();
}
public function test_it_registers_status_and_package_routes(): void {
$controller = new RestPackageController( new PackageValidator() );
$controller->register();
self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] );
self::assertArrayHasKey( 'wp-content-sync/v1/package', $GLOBALS['wpcs_rest_routes'] );
}
public function test_it_requires_manage_options_permission(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = false;
$controller = new RestPackageController( new PackageValidator() );
self::assertFalse( $controller->canReceivePackage() );
}
public function test_it_returns_status_payload(): void {
$controller = new RestPackageController( new PackageValidator() );
self::assertSame(
array(
'ok' => true,
'plugin' => 'wp-content-sync',
'version' => WPCS_VERSION,
),
$controller->status()
);
}
public function test_it_accepts_valid_packages(): void {
$controller = new RestPackageController( new PackageValidator() );
self::assertSame(
array(
'accepted' => true,
'schema_version' => '1.0',
'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ),
),
$controller->receivePackage( array( 'package' => $this->validPackage() ) )
);
}
public function test_it_rejects_invalid_package_shapes(): void {
$controller = new RestPackageController( new PackageValidator() );
self::assertSame(
array(
'accepted' => false,
'errors' => array( 'package is required and must be an object.' ),
),
$controller->receivePackage( array() )
);
}
/**
* @return array<string, mixed>
*/
private function validPackage(): array {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return array(
'schema_version' => '1.0',
'generated_at' => '2026-04-28T12:00:00+00:00',
'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ),
'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ),
'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ),
'records' => $records,
'checksums' => array( 'records' => PackageChecksum::records( $records ) ),
);
}
}
- Step 3: Run tests to verify they fail
Run: composer test -- --filter RestPackageControllerTest
Expected: FAIL with class WPContentSync\Rest\RestPackageController not found.
- Step 4: Implement REST controller
Create src/Rest/RestPackageController.php:
<?php
/**
* REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Rest;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class RestPackageController {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->validator = $validator;
}
public function register(): void {
register_rest_route(
'wp-content-sync/v1',
'/status',
array(
'methods' => 'GET',
'callback' => array( $this, 'status' ),
'permission_callback' => array( $this, 'canReceivePackage' ),
)
);
register_rest_route(
'wp-content-sync/v1',
'/package',
array(
'methods' => 'POST',
'callback' => array( $this, 'receivePackage' ),
'permission_callback' => array( $this, 'canReceivePackage' ),
)
);
}
public function canReceivePackage(): bool {
return current_user_can( 'manage_options' );
}
/**
* @return array<string, mixed>
*/
public function status(): array {
return array(
'ok' => true,
'plugin' => 'wp-content-sync',
'version' => WPCS_VERSION,
);
}
/**
* @param array<string, mixed> $request Request data.
*
* @return array<string, mixed>
*/
public function receivePackage( $request ): array {
$data = $this->requestData( $request );
if ( ! isset( $data['package'] ) || ! is_array( $data['package'] ) ) {
return array(
'accepted' => false,
'errors' => array( 'package is required and must be an object.' ),
);
}
$result = $this->validator->validate( $data['package'] );
if ( ! $result->isValid() ) {
return array(
'accepted' => false,
'errors' => $result->errors(),
);
}
$package = ContentPackage::fromArray( $data['package'] );
return array(
'accepted' => true,
'schema_version' => $package->schemaVersion(),
'manifest' => $package->manifest(),
);
}
/**
* @param mixed $request REST request or test payload.
*
* @return array<string, mixed>
*/
private function requestData( $request ): array {
if ( is_array( $request ) ) {
return $request;
}
if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) {
$params = $request->get_json_params();
return is_array( $params ) ? $params : array();
}
return array();
}
}
- Step 5: Run tests to verify they pass
Run: composer test -- --filter RestPackageControllerTest
Expected: PASS.
- Step 6: Commit
git add src/Rest/RestPackageController.php tests/Unit/Rest/RestPackageControllerTest.php tests/bootstrap.php
git commit -m "feat: add rest package endpoints"
Task 4: Service Wiring
Files:
-
Modify:
src/Plugin.php -
Modify:
tests/Unit/PluginTest.php -
Step 1: Extend plugin wiring tests
Add this test to tests/Unit/PluginTest.php:
public function test_it_registers_rest_transport_services(): void {
$container = $this->getPluginContainer( Plugin::create() );
self::assertInstanceOf(
\WPContentSync\Transport\RestTransportClient::class,
$container->get( \WPContentSync\Transport\RestTransportClient::class )
);
self::assertInstanceOf(
\WPContentSync\Rest\RestPackageController::class,
$container->get( \WPContentSync\Rest\RestPackageController::class )
);
}
- Step 2: Run the test to verify it fails
Run: composer test -- --filter PluginTest
Expected: FAIL with service WPContentSync\Transport\RestTransportClient not registered.
- Step 3: Wire services in
src/Plugin.php
Add imports:
use WPContentSync\Rest\RestPackageController;
use WPContentSync\Transport\RestTransportClient;
Register factories before AdminPage::class:
$container->factory(
RestTransportClient::class,
static function (): RestTransportClient {
return new RestTransportClient();
}
);
$container->factory(
RestPackageController::class,
static function () use ( $container ): RestPackageController {
return new RestPackageController(
$container->get( PackageValidator::class )
);
}
);
Register the REST controller in register():
/** @var RestPackageController $rest_package_controller */
$rest_package_controller = $this->container->get( RestPackageController::class );
$rest_package_controller->register();
- Step 4: Run tests to verify they pass
Run: composer test -- --filter PluginTest
Expected: PASS.
- Step 5: Commit
git add src/Plugin.php tests/Unit/PluginTest.php
git commit -m "feat: wire rest transport services"
Task 5: Full REST Transport Verification
Files:
-
Verify all files created or modified in Tasks 1-4.
-
Step 1: Run Composer validation
Run: composer validate --strict
Expected: PASS with ./composer.json is valid.
- Step 2: Run PHPCS
Run: composer lint
Expected: PASS with no PHPCS errors.
- Step 3: Run PHPStan
Run: composer stan
Expected: PASS with [OK] No errors.
- Step 4: Run PHPUnit
Run: composer test
Expected: PASS with existing foundation, URL, file transport, and REST transport tests.
- Step 5: Run REST client smoke test
Run:
php -r "require 'tests/bootstrap.php'; `$client=new WPContentSync\Transport\RestTransportClient(); var_export(`$client->testConnection('https://destination.test','codex','app-pass')); echo PHP_EOL;"
Expected output:
true
- Step 6: Manual WordPress smoke test
In http://basic-wp.test/wp-admin, verify:
- The plugin still activates and the WP Content Sync admin page still loads.
GET http://basic-wp.test/wp-json/wp-content-sync/v1/statusrequires authentication.- Authenticated status requests return
ok: true, plugin name, and version. - Invalid package POST requests return
accepted: falseand validation errors. - Valid package POST requests return
accepted: truewithout creating posts, terms, media, or custom post type records.
Spec Coverage
- REST API transport is covered by
RestTransportClient. - Application password authentication is covered by Basic auth header tests.
- Destination receive/status endpoints are covered by
RestPackageController. - Permission validation is covered by
canReceivePackage()tests and route registration. - Request shape and package schema validation are covered by receive endpoint tests.
- Typed REST failures for connection, authentication, and remote rejection are covered by
RestTransportExceptionand client tests. - File fallback decision remains available to later sync orchestration through
RestTransportException::failureCode().
Deferred Work
- Automatic retry/backoff remains in Phase 5 sync orchestration.
- Choosing REST versus file transport remains in Phase 5 sync orchestration.
- Storing and rotating application passwords remains in Phase 6 admin hardening.
- Applying accepted package records to WordPress content remains in Phase 5 content handlers.
Placeholder Scan
- No unspecified implementation markers are intentionally included.
- Every code-creating step names exact files and includes concrete code.
- Every verification step includes exact commands and expected outcomes.