feat: add rest transport client

This commit is contained in:
Keith Solomon
2026-04-28 12:38:59 -05:00
parent 428c64a46a
commit 3c7ad655c0
3 changed files with 286 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
<?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(
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Basic auth requires base64-encoded username:application-password credentials.
'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.';
}
}
@@ -0,0 +1,99 @@
<?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'] );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Expected Basic auth value for application-password requests.
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 ),
),
)
);
}
}
+96
View File
@@ -5,6 +5,8 @@
* @package WPContentSync
*/
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- WordPress class and function stubs share this test bootstrap.
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! defined( 'ABSPATH' ) ) {
@@ -23,6 +25,20 @@ if ( ! defined( 'WPCS_VERSION' ) ) {
define( 'WPCS_VERSION', '0.1.0' );
}
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( 'sanitize_text_field' ) ) {
/**
* Minimal WordPress-compatible text sanitizer for unit tests.
@@ -370,6 +386,86 @@ if ( ! function_exists( 'wp_safe_redirect' ) ) {
}
}
if ( ! function_exists( 'wp_remote_get' ) ) {
/**
* Minimal HTTP GET helper for unit tests.
*
* @param string $url Request URL.
* @param array<string, mixed> $args Request arguments.
* @return array<string, mixed>|\WP_Error
*/
function wp_remote_get( $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' ) ) {
/**
* Minimal HTTP POST helper for unit tests.
*
* @param string $url Request URL.
* @param array<string, mixed> $args Request arguments.
* @return array<string, mixed>|\WP_Error
*/
function wp_remote_post( $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' ) ) {
/**
* Minimal response code helper for unit tests.
*
* @param array<string, mixed> $response HTTP response.
* @return int
*/
function wp_remote_retrieve_response_code( array $response ) {
return (int) ( $response['response']['code'] ?? 0 );
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
/**
* Minimal response body helper for unit tests.
*
* @param array<string, mixed> $response HTTP response.
* @return string
*/
function wp_remote_retrieve_body( array $response ) {
return (string) ( $response['body'] ?? '' );
}
}
if ( ! function_exists( 'is_wp_error' ) ) {
/**
* Minimal WP_Error checker for unit tests.
*
* @param mixed $value Value to check.
* @return bool
*/
function is_wp_error( $value ) {
return $value instanceof WP_Error;
}
}
if ( ! function_exists( 'admin_url' ) ) {
/**
* Minimal admin URL helper for unit tests.