diff --git a/README.md b/README.md index 81d9ff9..5ffc6a6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Coding Standards](https://github.com/alleyinteractive/wp-proxy-service/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/alleyinteractive/wp-proxy-service/actions/workflows/coding-standards.yml) [![Testing Suite](https://github.com/alleyinteractive/wp-proxy-service/actions/workflows/unit-test.yml/badge.svg)](https://github.com/alleyinteractive/wp-proxy-service/actions/workflows/unit-test.yml) -A library to proxy a remote request through a WP REST API endpoint +A library to proxy a remote request through a WP REST API endpoint. ## Installation @@ -18,8 +18,8 @@ composer require alleyinteractive/wp-proxy-service Use this package like so: ```php -$package = Alley\WP\Proxy_Service\WP_Proxy_Service\WP_Proxy_Service(); -$package->perform_magic(); +$package = Alley\WP\Proxy_Service\Service(); +$package->init(); ``` ## Changelog @@ -30,11 +30,11 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re This project is actively maintained by [Alley Interactive](https://github.com/alleyinteractive). Like what you see? [Come work -with us](https://alley.co/careers/). +with us](https://alley.com/careers/). - [Alley](https://github.com/Alley) - [All Contributors](../../contributors) ## License -The GNU General Public License (GPL) license. Please see [License File](LICENSE) for more information. \ No newline at end of file +The GNU General Public License (GPL) license. Please see [License File](LICENSE) for more information. diff --git a/phpcs.xml b/phpcs.xml index 4304bab..7b5349a 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -36,7 +36,7 @@ - + diff --git a/phpstan.neon b/phpstan.neon index 88adee0..1c282a7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,15 +3,9 @@ includes: parameters: # Level 9 is the highest level - level: max + level: 8 paths: - src/ -# ignoreErrors: -# - '#PHPDoc tag @var#' -# -# excludePaths: -# - ./*/*/FileToBeExcluded.php -# -# checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false diff --git a/src/class-service.php b/src/class-service.php new file mode 100644 index 0000000..491d88d --- /dev/null +++ b/src/class-service.php @@ -0,0 +1,288 @@ +match_request_to_handler( $server, $request ); + + if ( is_wp_error( $matched ) ) { + return $matched; + } + + list( $route, $handler ) = $matched; + + // Validate params. + $check_required = $request->has_valid_params(); + if ( is_wp_error( $check_required ) ) { + return $check_required; + } + + // Sanitize params. + $check_sanitized = $request->sanitize_params(); + if ( is_wp_error( $check_sanitized ) ) { + return $check_sanitized; + } + + // Check permission. + $check_permission = $this->has_permission( $handler, $request ); + if ( is_wp_error( $check_permission ) ) { + return $check_permission; + } + + // Build URL. + $url = $this->get_url( $request ); + if ( is_wp_error( $url ) ) { + return $url; + } + + // Get request args. + $request_args = $this->get_request_args( $request ); + + // Get response. + $response = $this->get_response( $request, $url, $request_args ); + + return $response; + } + + /** + * Match request to handler. + * + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * @return mixed[]|WP_Error Array containing the route and handler on success, or WP_Error object on failure. + */ + protected function match_request_to_handler( WP_REST_Server $server, WP_REST_Request $request ): array|WP_Error { + $method = new ReflectionMethod( $server, 'match_request_to_handler' ); + return $method->invoke( $server, $request ); + } + + /** + * Check permission for request. + * + * @param mixed[] $handler The handler. + * @param WP_REST_Request $request The request. + * @return bool|WP_Error True if the request has permission, WP_Error object otherwise. + */ + protected function has_permission( array $handler, WP_REST_Request $request ): bool|WP_Error { + if ( empty( $handler['permission_callback'] ) ) { + return true; + } + + $permission = call_user_func( $handler['permission_callback'], $request ); + + if ( is_wp_error( $permission ) ) { + return $permission; + } + + if ( false === $permission || null === $permission ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to do that.', 'wp-proxy-service' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + return true; + } + + /** + * Build the destination URL. + * + * @param WP_REST_Request $request The request. + * @return string|WP_Error The URL or WP_Error object on failure. + */ + protected function get_url( WP_REST_Request $request ): string|WP_Error { + /** + * Filter the destination URL. + * + * @param string $url URL. + * @param WP_REST_Request $request The request. + */ + $url = apply_filters( 'wp_proxy_service_url', '', $request ); + + if ( empty( $url ) ) { + return new WP_Error( + 'missing_destination_url', + __( 'A destination URL must be specified.', 'wp-proxy-service' ), + [ 'status' => 500 ] + ); + } + + $request_params = $this->get_request_params( $request ); + return add_query_arg( $request_params, $url ); + } + + /** + * Get request args. + * + * @param WP_REST_Request $request The request. + * @return array { + * The request arguments. + * + * @type string|string[] $headers Optional. The request headers. Defaults to empty array. + * @type string $method Optional. The request method. Defaults to 'GET'. + * @type float $timeout Optional. The request timeout. Defaults to 5. + * } + * + * @phpstan-return array{headers?: string|string[], method?: string, timeout?: float} + */ + protected function get_request_args( WP_REST_Request $request ): array { + $defaults = [ + 'headers' => [], + ]; + + /** + * Filter the request args. + * + * @param array $defaults The request args. + * @param WP_REST_Request $request The request. + */ + return apply_filters( 'wp_proxy_service_request_args', $defaults, $request ); + } + + /** + * Get request params. + * + * @param WP_REST_Request $request The request. + * @return mixed[] Request params. + * + * @phpstan-return mixed[] + */ + protected function get_request_params( WP_REST_Request $request ): array { + /** + * Filter the request params. + * + * @param array $params The request params. + */ + return apply_filters( 'wp_proxy_service_request_params', $request->get_params() ); + } + + /** + * Get response. + * + * @param WP_REST_Request $request The request. + * @param string $url The URL. + * @param array $args { + * Optional. The request arguments. + * + * @type string|string[] $headers Optional. The request headers. Defaults to empty array. + * @type string $method Optional. The request method. Defaults to 'GET'. + * @type float $timeout Optional. The request timeout. Defaults to 5. + * } + * @return WP_REST_Response|WP_Error The response. + * + * @phpstan-param array{headers?: string|string[], method?: string, timeout?: float} $args + */ + protected function get_response( WP_REST_Request $request, string $url, array $args = [] ): WP_REST_Response|WP_Error { + $response = $this->safe_wp_remote_request( $url, $args ); + + /** + * Filter the response. + * + * @param array|WP_Error $response The response. + * @param WP_REST_Request $request The request. + */ + $response = apply_filters( 'wp_proxy_service_response', $response, $request ); + + return rest_ensure_response( $response ); + } + + /** + * Wrapper for wp_remote_request. + * + * Similar to vip_safe_wp_remote_get, as it ensures a max timeout of 3 seconds. + * Less forgiving than vip_safe_wp_remote_get which will retry a request 3 times. + * This starts to pull back requests after just one failure. + * + * @todo Update with retry and threshold support. + * + * @param string $url URL. + * @param array $request_args { + * The request arguments. + * + * @type string|string[] $headers Optional. The request headers. Defaults to empty array. + * @type string $method Optional. The request method. Defaults to 'GET'. + * @type float $timeout Optional. The request timeout. Defaults to 5. + * } + * @return array|WP_Error { + * The response array or a WP_Error on failure. + * + * @type CaseInsensitiveDictionary $headers Array of response headers keyed by their name. + * @type string $body Response body. + * @type array $response { + * Data about the HTTP response. + * + * @type int $code HTTP response code. + * @type string $message HTTP response message. + * } + * @type int|WP_Http_Cookie[] $cookies Array of response cookies. + * @type WP_HTTP_Requests_Response|null $http_response Raw HTTP response object. + * } + * + * @phpstan-param array{headers?: string|string[], method?: string, timeout?: float} $request_args + * @phpstan-return array{'headers': CaseInsensitiveDictionary, 'body': string, 'response': array{'code': int, 'message': string}, 'cookies': array, 'http_response': WP_HTTP_Requests_Response|null}|WP_Error + */ + protected function safe_wp_remote_request( string $url, array $request_args ): array|WP_Error { + // Ensure a max timeout is set. + if ( empty( $request_args['timeout'] ) ) { + $request_args['timeout'] = 1; + } + + // Ensure the timeout is at most 3 seconds. + $request_args['timeout'] = min( 3, (float) $request_args['timeout'] ); + + return wp_remote_request( $url, $request_args ); + } +} diff --git a/src/class-wp-proxy-service.php b/src/class-wp-proxy-service.php deleted file mode 100644 index 4acd0e4..0000000 --- a/src/class-wp-proxy-service.php +++ /dev/null @@ -1,24 +0,0 @@ -maybe_rsync_plugin() ->install(); diff --git a/tests/class-test-case.php b/tests/class-test-case.php index 065e628..2a42056 100644 --- a/tests/class-test-case.php +++ b/tests/class-test-case.php @@ -1,4 +1,12 @@ intercept_next_request() + * ->with_response_code( 404 ) + * ->with_body( '{"error":true}' ) + * ->with_header( 'Content-Type', 'application/json' ); + */ +class Mock_Http_Response { + /** + * Response data. + * + * @var array + */ + public $response = []; + + /** + * Mock_Http_Response constructor. + */ + public function __construct() { + $this->response = [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => 200, + 'message' => get_status_header_desc( 200 ), + ], + 'cookies' => [], + 'filename' => '', + ]; + + $this->intercept_next_request(); + } + + /** + * Add a header to the response. + * + * @param string $key Header key. + * @param string $value Header value. + * @return Mock_Http_Response This object. + */ + public function with_header( string $key, string $value ): Mock_Http_Response { + $this->response['headers'][ $key ] = $value; + + return $this; + } + + /** + * Set the response code. The response message will be inferred from that. + * + * @param int $code HTTP response code. + * @return Mock_Http_Response This object. + */ + public function with_response_code( int $code ): Mock_Http_Response { + $this->response['response'] = [ + 'code' => $code, + 'message' => get_status_header_desc( $code ), + ]; + + return $this; + } + + /** + * Set the response body. + * + * @param string $body Response body. + * @return Mock_Http_Response This object. + */ + public function with_body( string $body ): Mock_Http_Response { + $this->response['body'] = $body; + + return $this; + } + + /** + * Set a response cookie. + * + * @param \WP_Http_Cookie $cookie Cookie. + * @return Mock_Http_Response This object. + */ + public function with_cookie( \WP_Http_Cookie $cookie ): Mock_Http_Response { + $this->response['cookies'][] = $cookie; + + return $this; + } + + /** + * Set the filename value for the mock response. + * + * @param string $filename Filename. + * @return Mock_Http_Response This object. + */ + public function with_filename( string $filename ): Mock_Http_Response { + $this->response['filename'] = $filename; + + return $this; + } + + /** + * Filters pre_http_request to intercept the request, mock a response, and + * return it. If the response has already been preempted, the preempt will + * be returned instead. Regardless, this object unhooks itself from the + * pre_http_request filter. + * + * @param false|array|\WP_Error $preempt Whether to preempt an HTTP request's return value. Default false. + * @param array $request_args HTTP request arguments. + * @param string $url The request URL. + * @return mixed Array if the request has been preempted, any value that's + * not false otherwise. + */ + public function pre_http_request( $preempt, $request_args, $url ) { + remove_filter( 'pre_http_request', [ $this, 'pre_http_request' ], PHP_INT_MAX ); + return false === $preempt + ? $this->with_header( 'x-req-url', $url )->to_array() + : $preempt; + } + + /** + * Returns the combined response array. + * + * @return array WP_Http response array, per WP_Http::request(). + */ + public function to_array() { + return $this->response; + } + + /** + * Add the filter to intercept the next request. + * + * @return Mock_Http_Response This object. + */ + public function intercept_next_request(): Mock_Http_Response { + add_filter( 'pre_http_request', [ $this, 'pre_http_request' ], PHP_INT_MAX, 3 ); + + return $this; + } +} diff --git a/tests/unit/test-example-unit-test.php b/tests/unit/test-example-unit-test.php deleted file mode 100644 index b9e0741..0000000 --- a/tests/unit/test-example-unit-test.php +++ /dev/null @@ -1,13 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/unit/test-service.php b/tests/unit/test-service.php new file mode 100644 index 0000000..87db8e6 --- /dev/null +++ b/tests/unit/test-service.php @@ -0,0 +1,278 @@ +namespace, + $this->route, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => '', + 'permission_callback' => '__return_true', + ] + ); + } + + /** + * Data provider for test_dispatch method. + * + * @return array Array of data. + */ + public function data_test_dispatch(): array { + return [ + 'should not proxy' => [ + [ + 'should_proxy_callback' => '__return_false', + ], + 'is_null', + ], + 'should proxy' => [ + [ + 'should_proxy_callback' => '__return_true', + ], + fn( $value ): bool => $value instanceof WP_REST_Response, + ], + ]; + } + + /** + * Test the functionality of the dispatch method. + * + * @dataProvider data_test_dispatch + * + * @param array $original The context to test. + * @param mixed $expected The expected result. + */ + public function test_dispatch( array $original, $expected ): void { + $server = rest_get_server(); + $service = new Service(); + $service->init(); + + $mock = new Mock_Http_Response(); + $mock->intercept_next_request() + ->with_body( 'pear' ); + + add_filter( 'wp_proxy_service_should_proxy_request', $original['should_proxy_callback'] ); + add_filter( 'wp_proxy_service_url', fn() => 'https://example.org' ); + + $request = new WP_REST_Request( 'GET', "/{$this->namespace}{$this->route}" ); + + $result = $service->dispatch( null, $server, $request ); + + $this->assertTrue( $expected( $result ) ); + } + + /** + * Data provider for test_get_response method. + * + * @return array Array of data. + */ + public function data_test_get_response(): array { + return [ + 'unfiltered response' => [ + [ + 'body' => 'apple', + 'filter_callback' => fn( $value ) => $value, + ], + 'apple', + ], + 'filtered response' => [ + [ + 'body' => 'orange', + 'filter_callback' => function ( $value ) { + $value['body'] = 'peach'; + return $value; + }, + ], + 'peach', + ], + ]; + } + + /** + * Test the functionality of the get_response method. + * + * @dataProvider data_test_get_response + * + * @param array $original The context to test. + * @param string $expected The expected result. + */ + public function test_get_response( array $original, string $expected ): void { + $service = new Service(); + $service->init(); + + $class = new ReflectionClass( 'Alley\WP\Proxy_Service\Service' ); + $method = $class->getMethod( 'get_response' ); + $method->setAccessible( true ); + + $mock = new Mock_Http_Response(); + $mock->intercept_next_request() + ->with_body( $original['body'] ); + + $request = new WP_REST_Request( 'GET', "/{$this->namespace}{$this->route}" ); + + add_filter( 'wp_proxy_service_response', $original['filter_callback'] ); + + $result = $method->invoke( $service, $request, 'https://example.org' ); + $body = $result->get_data()['body'] ?? ''; + + $this->assertEquals( $expected, $body ); + } + + /** + * Data provider for test_get_url method. + * + * @return array Array of data. + */ + public function data_test_get_url(): array { + return [ + 'unfiltered url' => [ + [ + 'filter_callback' => fn( string $value ): string => $value, + ], + 'is_wp_error', + ], + 'filtered url' => [ + [ + 'filter_callback' => fn( string $value ): string => 'https://example.com', + ], + fn( $value ): bool => 'https://example.com' === $value, + ], + ]; + } + + /** + * Test the functionality of the get_url method. + * + * @dataProvider data_test_get_url + * + * @param array $original The context to test. + * @param callable $expected Booleann callback to test the expected result. + */ + public function test_get_url( array $original, $expected ): void { + $service = new Service(); + $service->init(); + + $class = new ReflectionClass( 'Alley\WP\Proxy_Service\Service' ); + $method = $class->getMethod( 'get_url' ); + $method->setAccessible( true ); + + $request = new WP_REST_Request( 'GET', "/{$this->namespace}{$this->route}" ); + + add_filter( 'wp_proxy_service_url', $original['filter_callback'] ); + $result = $method->invoke( $service, $request ); + + $this->assertTrue( $expected( $result ) ); + } + + /** + * Data provider for test_get_request_params method. + * + * @return array Array of data. + */ + public function data_test_get_request_params(): array { + return [ + 'unfiltered params' => [ + [ + 'key' => 'poultry', + 'value' => 'chicken', + 'filter_callback' => fn( array $value ): array => $value, + ], + [ + 'poultry' => 'chicken', + ], + ], + 'filtered params' => [ + [ + 'key' => 'poultry', + 'value' => 'chicken', + 'filter_callback' => fn( array $value ): array => [ 'poultry' => 'turkey' ], + ], + [ + 'poultry' => 'turkey', + ], + ], + ]; + } + + /** + * Test the functionality of the get_request_params method. + * + * @dataProvider data_test_get_request_params + * + * @param array $original The context to test. + * @param array $expected The expected result. + */ + public function test_get_request_params( array $original, array $expected ): void { + $service = new Service(); + $service->init(); + + $class = new ReflectionClass( 'Alley\WP\Proxy_Service\Service' ); + $method = $class->getMethod( 'get_request_params' ); + $method->setAccessible( true ); + + $request = new WP_REST_Request( 'GET', "/{$this->namespace}{$this->route}" ); + $request->set_param( $original['key'], $original['value'] ); + + add_filter( 'wp_proxy_service_request_params', $original['filter_callback'] ); + + $result = $method->invoke( $service, $request ); + + $this->assertEquals( $expected, $result ); + } +}