From ea2f728cd8030e7663110c3c91fdce2e4d5e9adb Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 22 Sep 2021 17:13:38 +0300 Subject: [PATCH] Add webhook simulation --- modules/ppcp-api-client/services.php | 5 + .../src/Endpoint/class-webhookendpoint.php | 73 +++++++- .../src/Entity/class-webhookevent.php | 170 ++++++++++++++++++ .../src/Factory/class-webhookeventfactory.php | 74 ++++++++ modules/ppcp-webhooks/extensions.php | 21 +++ .../resources/css/status-page.scss | 13 +- .../ppcp-webhooks/resources/js/status-page.js | 106 +++++++++++ modules/ppcp-webhooks/services.php | 33 ++++ .../src/Endpoint/class-simulateendpoint.php | 77 ++++++++ .../class-simulationstateendpoint.php | 68 +++++++ .../Assets/class-webhooksstatuspageassets.php | 18 ++ .../src/Status/class-webhooksimulation.php | 160 +++++++++++++++++ .../src/class-incomingwebhookendpoint.php | 77 ++++++-- .../ppcp-webhooks/src/class-webhookmodule.php | 21 +++ 14 files changed, 896 insertions(+), 20 deletions(-) create mode 100644 modules/ppcp-api-client/src/Entity/class-webhookevent.php create mode 100644 modules/ppcp-api-client/src/Factory/class-webhookeventfactory.php create mode 100644 modules/ppcp-webhooks/src/Endpoint/class-simulateendpoint.php create mode 100644 modules/ppcp-webhooks/src/Endpoint/class-simulationstateendpoint.php create mode 100644 modules/ppcp-webhooks/src/Status/class-webhooksimulation.php diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index 588a89228..ad71c5936 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -35,6 +35,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerStatusFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingFactory; +use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory; use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache; use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies; @@ -114,6 +115,7 @@ return array( $container->get( 'api.host' ), $container->get( 'api.bearer' ), $container->get( 'api.factory.webhook' ), + $container->get( 'api.factory.webhook-event' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, @@ -214,6 +216,9 @@ return array( 'api.factory.webhook' => static function ( $container ): WebhookFactory { return new WebhookFactory(); }, + 'api.factory.webhook-event' => static function ( $container ): WebhookEventFactory { + return new WebhookEventFactory(); + }, 'api.factory.capture' => static function ( $container ): CaptureFactory { $amount_factory = $container->get( 'api.factory.amount' ); diff --git a/modules/ppcp-api-client/src/Endpoint/class-webhookendpoint.php b/modules/ppcp-api-client/src/Endpoint/class-webhookendpoint.php index 7e5f02315..05466d254 100644 --- a/modules/ppcp-api-client/src/Endpoint/class-webhookendpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/class-webhookendpoint.php @@ -11,8 +11,10 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook; +use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory; use Psr\Log\LoggerInterface; @@ -44,6 +46,13 @@ class WebhookEndpoint { */ private $webhook_factory; + /** + * The webhook event factory. + * + * @var WebhookEventFactory + */ + private $webhook_event_factory; + /** * The logger. * @@ -54,22 +63,25 @@ class WebhookEndpoint { /** * WebhookEndpoint constructor. * - * @param string $host The host. - * @param Bearer $bearer The bearer. - * @param WebhookFactory $webhook_factory The webhook factory. - * @param LoggerInterface $logger The logger. + * @param string $host The host. + * @param Bearer $bearer The bearer. + * @param WebhookFactory $webhook_factory The webhook factory. + * @param WebhookEventFactory $webhook_event_factory The webhook event factory. + * @param LoggerInterface $logger The logger. */ public function __construct( string $host, Bearer $bearer, WebhookFactory $webhook_factory, + WebhookEventFactory $webhook_event_factory, LoggerInterface $logger ) { - $this->host = $host; - $this->bearer = $bearer; - $this->webhook_factory = $webhook_factory; - $this->logger = $logger; + $this->host = $host; + $this->bearer = $bearer; + $this->webhook_factory = $webhook_factory; + $this->webhook_event_factory = $webhook_event_factory; + $this->logger = $logger; } /** @@ -189,6 +201,51 @@ class WebhookEndpoint { return wp_remote_retrieve_response_code( $response ) === 204; } + /** + * Request a simulated webhook to be sent. + * + * @param Webhook $hook The webhook subscription to use. + * @param string $event_type The event type, such as CHECKOUT.ORDER.APPROVED. + * + * @return WebhookEvent + * @throws RuntimeException If the request fails. + * @throws PayPalApiException If the request fails. + */ + public function simulate( Webhook $hook, string $event_type ): WebhookEvent { + $bearer = $this->bearer->bearer(); + $url = trailingslashit( $this->host ) . 'v1/notifications/simulate-event'; + $args = array( + 'method' => 'POST', + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer->token(), + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'webhook_id' => $hook->id(), + 'event_type' => $event_type, + ) + ), + ); + $response = $this->request( $url, $args ); + + if ( is_wp_error( $response ) ) { + throw new RuntimeException( + __( 'Not able to simulate webhook.', 'woocommerce-paypal-payments' ) + ); + } + $json = json_decode( $response['body'] ); + $status_code = (int) wp_remote_retrieve_response_code( $response ); + if ( 202 !== $status_code ) { + throw new PayPalApiException( + $json, + $status_code + ); + } + + return $this->webhook_event_factory->from_paypal_response( $json ); + } + /** * Verifies if a webhook event is legitimate. * diff --git a/modules/ppcp-api-client/src/Entity/class-webhookevent.php b/modules/ppcp-api-client/src/Entity/class-webhookevent.php new file mode 100644 index 000000000..8c2da92f6 --- /dev/null +++ b/modules/ppcp-api-client/src/Entity/class-webhookevent.php @@ -0,0 +1,170 @@ +id = $id; + $this->create_time = $create_time; + $this->resource_type = $resource_type; + $this->event_version = $event_version; + $this->event_type = $event_type; + $this->summary = $summary; + $this->resource_version = $resource_version; + $this->resource = $resource; + } + + /** + * The ID of the event notification. + * + * @return string + */ + public function id(): string { + return $this->id; + } + + /** + * The date and time when the event notification was created. + * + * @return DateTime|null + */ + public function create_time(): ?DateTime { + return $this->create_time; + } + + /** + * The name of the resource related to the webhook notification event, such as 'checkout-order'. + * + * @return string + */ + public function resource_type(): string { + return $this->resource_type; + } + + /** + * The event version in the webhook notification, such as '1.0'. + * + * @return string + */ + public function event_version(): string { + return $this->event_version; + } + + /** + * The event that triggered the webhook event notification, such as 'CHECKOUT.ORDER.APPROVED'. + * + * @return string + */ + public function event_type(): string { + return $this->event_type; + } + + /** + * A summary description for the event notification. + * + * @return string + */ + public function summary(): string { + return $this->summary; + } + + /** + * The resource version in the webhook notification, such as '1.0'. + * + * @return string + */ + public function resource_version(): string { + return $this->resource_version; + } + + /** + * The resource that triggered the webhook event notification. + * + * @return stdClass + */ + public function resource(): stdClass { + return $this->resource; + } +} diff --git a/modules/ppcp-api-client/src/Factory/class-webhookeventfactory.php b/modules/ppcp-api-client/src/Factory/class-webhookeventfactory.php new file mode 100644 index 000000000..989ebf18d --- /dev/null +++ b/modules/ppcp-api-client/src/Factory/class-webhookeventfactory.php @@ -0,0 +1,74 @@ +from_paypal_response( (object) $data ); + } + + /** + * Returns a Webhook based of a PayPal JSON response. + * + * @param stdClass $data The JSON object. + * + * @return WebhookEvent + * @throws RuntimeException When JSON object is malformed. + */ + public function from_paypal_response( stdClass $data ): WebhookEvent { + if ( ! isset( $data->id ) ) { + throw new RuntimeException( + __( 'ID for webhook event not found.', 'woocommerce-paypal-payments' ) + ); + } + if ( ! isset( $data->event_type ) ) { + throw new RuntimeException( + __( 'Event type for webhook event not found.', 'woocommerce-paypal-payments' ) + ); + } + + $create_time = ( isset( $data->create_time ) ) ? + DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $data->create_time ) + : null; + + // Sometimes the time may be in weird format 2018-12-19T22:20:32.000Z (at least in simulation), + // we do not care much about time, so just ignore on failure. + if ( false === $create_time ) { + $create_time = null; + } + + return new WebhookEvent( + (string) $data->id, + $create_time, + (string) $data->resource_type ?? '', + (string) $data->event_version ?? '', + (string) $data->event_type, + (string) $data->summary ?? '', + (string) $data->resource_version ?? '', + (object) $data->resource ?? ( new stdClass() ) + ); + } +} diff --git a/modules/ppcp-webhooks/extensions.php b/modules/ppcp-webhooks/extensions.php index 80a99909f..1f9fdcfdc 100644 --- a/modules/ppcp-webhooks/extensions.php +++ b/modules/ppcp-webhooks/extensions.php @@ -43,6 +43,27 @@ return array( ), ); + $is_registered = $container->get( 'webhook.is-registered' ); + if ( $is_registered ) { + $status_page_fields = array_merge( + $status_page_fields, + array( + 'webhooks_simulate' => array( + 'title' => __( 'Webhook simulation', 'woocommerce-paypal-payments' ), + 'type' => 'ppcp-text', + 'text' => '', + 'screens' => array( + State::STATE_PROGRESSIVE, + State::STATE_ONBOARDED, + ), + 'requirements' => array(), + 'gateway' => WebhooksStatusPage::ID, + 'description' => __( 'Click to request a sample webhook payload from PayPal, allowing to check that your server can successfully receive webhooks.', 'woocommerce-paypal-payments' ), + ), + ) + ); + } + return array_merge( $fields, $status_page_fields ); }, ); diff --git a/modules/ppcp-webhooks/resources/css/status-page.scss b/modules/ppcp-webhooks/resources/css/status-page.scss index f0041e3ef..d4292bbb3 100644 --- a/modules/ppcp-webhooks/resources/css/status-page.scss +++ b/modules/ppcp-webhooks/resources/css/status-page.scss @@ -1,9 +1,16 @@ -.ppcp-webhooks-table { +.ppcp-webhooks-table, .ppcp-webhooks-status-text { .error { color: red; font-weight: bold; } + .success { + color: green; + font-weight: bold; + } +} + +.ppcp-webhooks-table { table { border-collapse: collapse; @@ -21,3 +28,7 @@ } } } + +.ppcp-webhooks-status-text { + padding-top: 4px; +} diff --git a/modules/ppcp-webhooks/resources/js/status-page.js b/modules/ppcp-webhooks/resources/js/status-page.js index e10eec9b3..6c408745b 100644 --- a/modules/ppcp-webhooks/resources/js/status-page.js +++ b/modules/ppcp-webhooks/resources/js/status-page.js @@ -38,5 +38,111 @@ document.addEventListener( window.location.reload(); }); + + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + const simulateBtn = jQuery(PayPalCommerceGatewayWebhooksStatus.simulation.start.button); + simulateBtn.click(async () => { + simulateBtn.prop('disabled', true); + + try { + const response = await fetch( + PayPalCommerceGatewayWebhooksStatus.simulation.start.endpoint, + { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify( + { + nonce: PayPalCommerceGatewayWebhooksStatus.simulation.start.nonce, + } + ) + } + ); + + const reportError = error => { + const msg = PayPalCommerceGatewayWebhooksStatus.simulation.start.failureMessage + ' ' + error; + alert(msg); + }; + + if (!response.ok) { + try { + const result = await response.json(); + reportError(result.data); + } catch (exc) { + console.error(exc); + reportError(response.status); + } + + return; + } + + const showStatus = html => { + let statusBlock = simulateBtn.siblings('.ppcp-webhooks-status-text'); + if (!statusBlock.length) { + statusBlock = jQuery('
').insertAfter(simulateBtn); + } + statusBlock.html(html); + }; + + simulateBtn.siblings('.description').hide(); + + showStatus( + PayPalCommerceGatewayWebhooksStatus.simulation.state.waitingMessage + + '' + ); + + const delay = 2000; + const retriesBeforeErrorMessage = 15; + const maxRetries = 30; + + for (let i = 0; i < maxRetries; i++) { + await sleep(delay); + + const stateResponse = await fetch( + PayPalCommerceGatewayWebhooksStatus.simulation.state.endpoint, + { + method: 'GET', + } + ); + + try { + const result = await stateResponse.json(); + + if (!stateResponse.ok || !result.success) { + console.error('Simulation state query failed: ' + result.data); + continue; + } + + const state = result.data.state; + if (state === PayPalCommerceGatewayWebhooksStatus.simulation.state.successState) { + showStatus( + '' + + '✔️ ' + + PayPalCommerceGatewayWebhooksStatus.simulation.state.successMessage + + '' + ); + return; + } + } catch (exc) { + console.error(exc); + } + + if (i === retriesBeforeErrorMessage) { + showStatus( + '' + + PayPalCommerceGatewayWebhooksStatus.simulation.state.tooLongDelayMessage + + '' + ); + } + } + + } finally { + simulateBtn.prop('disabled', false); + } + }); } ); diff --git a/modules/ppcp-webhooks/services.php b/modules/ppcp-webhooks/services.php index 9ad12a100..1fe09ced5 100644 --- a/modules/ppcp-webhooks/services.php +++ b/modules/ppcp-webhooks/services.php @@ -16,12 +16,15 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook; use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory; use WooCommerce\PayPalCommerce\WcGateway\Assets\WebhooksStatusPageAssets; use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint; use WooCommerce\PayPalCommerce\Webhooks\Handler\CheckoutOrderApproved; use WooCommerce\PayPalCommerce\Webhooks\Handler\CheckoutOrderCompleted; use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureCompleted; use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureRefunded; use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureReversed; use Psr\Container\ContainerInterface; +use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation; return array( @@ -43,12 +46,16 @@ return array( $handler = $container->get( 'webhook.endpoint.handler' ); $logger = $container->get( 'woocommerce.logger.woocommerce' ); $verify_request = ! defined( 'PAYPAL_WEBHOOK_REQUEST_VERIFICATION' ) || PAYPAL_WEBHOOK_REQUEST_VERIFICATION; + $webhook_event_factory = $container->get( 'api.factory.webhook-event' ); + $simulation = $container->get( 'webhook.status.simulation' ); return new IncomingWebhookEndpoint( $webhook_endpoint, $webhook, $logger, $verify_request, + $webhook_event_factory, + $simulation, ... $handler ); }, @@ -132,6 +139,15 @@ return array( ); }, + 'webhook.status.simulation' => function( $container ) : WebhookSimulation { + $webhook_endpoint = $container->get( 'api.endpoint.webhook' ); + $webhook = $container->get( 'webhook.current' ); + return new WebhookSimulation( + $webhook_endpoint, + $webhook + ); + }, + 'webhook.status.assets' => function( $container ) : WebhooksStatusPageAssets { return new WebhooksStatusPageAssets( $container->get( 'webhook.module-url' ) @@ -148,6 +164,23 @@ return array( ); }, + 'webhook.endpoint.simulate' => static function ( $container ) : SimulateEndpoint { + $simulation = $container->get( 'webhook.status.simulation' ); + $request_data = $container->get( 'button.request-data' ); + + return new SimulateEndpoint( + $simulation, + $request_data + ); + }, + 'webhook.endpoint.simulation-state' => static function ( $container ) : SimulationStateEndpoint { + $simulation = $container->get( 'webhook.status.simulation' ); + + return new SimulationStateEndpoint( + $simulation + ); + }, + 'webhook.module-url' => static function ( $container ): string { return plugins_url( '/modules/ppcp-webhooks/', diff --git a/modules/ppcp-webhooks/src/Endpoint/class-simulateendpoint.php b/modules/ppcp-webhooks/src/Endpoint/class-simulateendpoint.php new file mode 100644 index 000000000..f6b2cf17e --- /dev/null +++ b/modules/ppcp-webhooks/src/Endpoint/class-simulateendpoint.php @@ -0,0 +1,77 @@ +simulation = $simulation; + $this->request_data = $request_data; + } + + /** + * Returns the nonce for the endpoint. + * + * @return string + */ + public static function nonce(): string { + return self::ENDPOINT; + } + + /** + * Handles the incoming request. + */ + public function handle_request() { + try { + // Validate nonce. + $this->request_data->read_request( $this->nonce() ); + + $this->simulation->start(); + + wp_send_json_success(); + return true; + } catch ( Exception $error ) { + wp_send_json_error( $error->getMessage(), 500 ); + return false; + } + } +} diff --git a/modules/ppcp-webhooks/src/Endpoint/class-simulationstateendpoint.php b/modules/ppcp-webhooks/src/Endpoint/class-simulationstateendpoint.php new file mode 100644 index 000000000..089722ebc --- /dev/null +++ b/modules/ppcp-webhooks/src/Endpoint/class-simulationstateendpoint.php @@ -0,0 +1,68 @@ +simulation = $simulation; + } + + /** + * Returns the nonce for the endpoint. + * + * @return string + */ + public static function nonce(): string { + return self::ENDPOINT; + } + + /** + * Handles the incoming request. + */ + public function handle_request() { + try { + $state = $this->simulation->get_state(); + + wp_send_json_success( + array( + 'state' => $state, + ) + ); + return true; + } catch ( Exception $error ) { + wp_send_json_error( $error->getMessage(), 500 ); + return false; + } + } +} diff --git a/modules/ppcp-webhooks/src/Status/Assets/class-webhooksstatuspageassets.php b/modules/ppcp-webhooks/src/Status/Assets/class-webhooksstatuspageassets.php index 90fa02086..50c6956a4 100644 --- a/modules/ppcp-webhooks/src/Status/Assets/class-webhooksstatuspageassets.php +++ b/modules/ppcp-webhooks/src/Status/Assets/class-webhooksstatuspageassets.php @@ -12,6 +12,9 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Assets; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation; /** * Class WebhooksStatusPageAssets @@ -77,6 +80,21 @@ class WebhooksStatusPageAssets { 'button' => '.ppcp-webhooks-resubscribe', 'failureMessage' => __( 'Operation failed. Check WooCommerce logs for more details.', 'woocommerce-paypal-payments' ), ), + 'simulation' => array( + 'start' => array( + 'endpoint' => home_url( \WC_AJAX::get_endpoint( SimulateEndpoint::ENDPOINT ) ), + 'nonce' => wp_create_nonce( SimulateEndpoint::nonce() ), + 'button' => '.ppcp-webhooks-simulate', + 'failureMessage' => __( 'Operation failed. Check WooCommerce logs for more details.', 'woocommerce-paypal-payments' ), + ), + 'state' => array( + 'endpoint' => home_url( \WC_AJAX::get_endpoint( SimulationStateEndpoint::ENDPOINT ) ), + 'successState' => WebhookSimulation::STATE_RECEIVED, + 'waitingMessage' => __( 'Waiting for the webhook to arrive...', 'woocommerce-paypal-payments' ), + 'successMessage' => __( 'The webhook was received successfully.', 'woocommerce-paypal-payments' ), + 'tooLongDelayMessage' => __( 'Looks like the webhook cannot be received. Check that your website is accessible from the internet.', 'woocommerce-paypal-payments' ), + ), + ), ); } diff --git a/modules/ppcp-webhooks/src/Status/class-webhooksimulation.php b/modules/ppcp-webhooks/src/Status/class-webhooksimulation.php new file mode 100644 index 000000000..4d5540105 --- /dev/null +++ b/modules/ppcp-webhooks/src/Status/class-webhooksimulation.php @@ -0,0 +1,160 @@ +webhook_endpoint = $webhook_endpoint; + $this->webhook = $webhook; + } + + /** + * Starts the simulation by sending request to PayPal and saving the simulation data with STATE_WAITING. + * + * @throws Exception If failed to start simulation. + */ + public function start() { + if ( ! $this->webhook ) { + throw new Exception( 'Webhooks not registered' ); + } + + $event = $this->webhook_endpoint->simulate( $this->webhook, 'PAYMENT.AUTHORIZATION.CREATED' ); + + $this->save( + array( + 'id' => $event->id(), + 'state' => self::STATE_WAITING, + ) + ); + } + + /** + * Returns true if the given event matches the expected simulation event. + * + * @param WebhookEvent $event The webhook event. + * @return bool + */ + public function is_simulation_event( WebhookEvent $event ): bool { + try { + $data = $this->load(); + + return isset( $data['id'] ) && $event->id() === $data['id']; + } catch ( Exception $exception ) { + return false; + } + } + + /** + * Sets the simulation state to STATE_RECEIVED if the given event matches the expected simulation event. + * + * @param WebhookEvent $event The webhook event. + * + * @return bool + * @throws Exception If failed to save new state. + */ + public function receive( WebhookEvent $event ): bool { + if ( ! $this->is_simulation_event( $event ) ) { + return false; + } + + $this->set_state( self::STATE_RECEIVED ); + + return true; + } + + /** + * Returns the current simulation state, one of the STATE_ constants. + * + * @return string + * @throws Exception If failed to load state. + */ + public function get_state(): string { + $data = $this->load(); + + return $data['state']; + } + + /** + * Saves the new state. + * + * @param string $state One of the STATE_ constants. + * + * @throws Exception If failed to load state. + */ + private function set_state( string $state ): void { + $data = $this->load(); + + $data['state'] = $state; + + $this->save( $data ); + } + + /** + * Saves the simulation data. + * + * @param array $data The simulation data. + */ + private function save( array $data ): void { + update_option( self::OPTION_ID, $data ); + } + + /** + * Returns the current simulation data. + * + * @return array + * @throws UnexpectedValueException If failed to load. + */ + private function load(): array { + $data = get_option( self::OPTION_ID ); + if ( ! $data ) { + throw new UnexpectedValueException( 'Webhook simulation data not found.' ); + } + + return $data; + } +} diff --git a/modules/ppcp-webhooks/src/class-incomingwebhookendpoint.php b/modules/ppcp-webhooks/src/class-incomingwebhookendpoint.php index 97a5b0384..d3431b2d9 100644 --- a/modules/ppcp-webhooks/src/class-incomingwebhookendpoint.php +++ b/modules/ppcp-webhooks/src/class-incomingwebhookendpoint.php @@ -9,11 +9,15 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Webhooks; +use phpDocumentor\Reflection\Types\This; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook; +use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory; use WooCommerce\PayPalCommerce\Webhooks\Handler\RequestHandler; use Psr\Log\LoggerInterface; +use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation; /** * Class IncomingWebhookEndpoint @@ -58,28 +62,48 @@ class IncomingWebhookEndpoint { */ private $verify_request; + /** + * The webhook event factory. + * + * @var WebhookEventFactory + */ + private $webhook_event_factory; + + /** + * The simulation handler. + * + * @var WebhookSimulation + */ + private $simulation; + /** * IncomingWebhookEndpoint constructor. * - * @param WebhookEndpoint $webhook_endpoint The webhook endpoint. - * @param Webhook|null $webhook Our registered webhook. - * @param LoggerInterface $logger The logger. - * @param bool $verify_request Whether requests need to be verified or not. - * @param RequestHandler ...$handlers The handlers, which process a request in the end. + * @param WebhookEndpoint $webhook_endpoint The webhook endpoint. + * @param Webhook|null $webhook Our registered webhook. + * @param LoggerInterface $logger The logger. + * @param bool $verify_request Whether requests need to be verified or not. + * @param WebhookEventFactory $webhook_event_factory The webhook event factory. + * @param WebhookSimulation $simulation The simulation handler. + * @param RequestHandler ...$handlers The handlers, which process a request in the end. */ public function __construct( WebhookEndpoint $webhook_endpoint, ?Webhook $webhook, LoggerInterface $logger, bool $verify_request, + WebhookEventFactory $webhook_event_factory, + WebhookSimulation $simulation, RequestHandler ...$handlers ) { - $this->webhook_endpoint = $webhook_endpoint; - $this->webhook = $webhook; - $this->handlers = $handlers; - $this->logger = $logger; - $this->verify_request = $verify_request; + $this->webhook_endpoint = $webhook_endpoint; + $this->webhook = $webhook; + $this->handlers = $handlers; + $this->logger = $logger; + $this->verify_request = $verify_request; + $this->webhook_event_factory = $webhook_event_factory; + $this->simulation = $simulation; } /** @@ -110,9 +134,11 @@ class IncomingWebhookEndpoint { /** * Verifies the current request. * + * @param \WP_REST_Request $request The request. + * * @return bool */ - public function verify_request(): bool { + public function verify_request( \WP_REST_Request $request ): bool { if ( ! $this->verify_request ) { return true; } @@ -123,6 +149,12 @@ class IncomingWebhookEndpoint { } try { + $event = $this->event_from_request( $request ); + + if ( $this->simulation->is_simulation_event( $event ) ) { + return true; + } + $result = $this->webhook_endpoint->verify_current_request_for_webhook( $this->webhook ); if ( ! $result ) { $this->logger->error( 'Webhook verification failed.' ); @@ -142,6 +174,17 @@ class IncomingWebhookEndpoint { * @return \WP_REST_Response */ public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { + $event = $this->event_from_request( $request ); + + if ( $this->simulation->is_simulation_event( $event ) ) { + $this->logger->info( 'Received simulated webhook.' ); + $this->simulation->receive( $event ); + return rest_ensure_response( + array( + 'success' => true, + ) + ); + } foreach ( $this->handlers as $handler ) { if ( $handler->responsible_for_request( $request ) ) { @@ -202,4 +245,16 @@ class IncomingWebhookEndpoint { } return array_unique( $event_types ); } + + /** + * Creates WebhookEvent from request data. + * + * @param \WP_REST_Request $request The request with event data. + * + * @return WebhookEvent + * @throws RuntimeException When failed to create. + */ + private function event_from_request( \WP_REST_Request $request ): WebhookEvent { + return $this->webhook_event_factory->from_array( $request->get_params() ); + } } diff --git a/modules/ppcp-webhooks/src/class-webhookmodule.php b/modules/ppcp-webhooks/src/class-webhookmodule.php index ed464b993..fae5c5a3a 100644 --- a/modules/ppcp-webhooks/src/class-webhookmodule.php +++ b/modules/ppcp-webhooks/src/class-webhookmodule.php @@ -17,6 +17,8 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\WcGateway\Assets\WebhooksStatusPageAssets; use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint; +use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint; use WooCommerce\PayPalCommerce\Webhooks\Status\WebhooksStatusPage; /** @@ -94,6 +96,25 @@ class WebhookModule implements ModuleInterface { } ); + add_action( + 'wc_ajax_' . SimulateEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'webhook.endpoint.simulate' ); + assert( $endpoint instanceof SimulateEndpoint ); + + $endpoint->handle_request(); + } + ); + add_action( + 'wc_ajax_' . SimulationStateEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'webhook.endpoint.simulation-state' ); + assert( $endpoint instanceof SimulationStateEndpoint ); + + $endpoint->handle_request(); + } + ); + $page_id = $container->get( 'wcgateway.current-ppcp-settings-page-id' ); if ( WebhooksStatusPage::ID === $page_id ) { $GLOBALS['hide_save_button'] = true;