diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..043dd21 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.3.0] + +### Added + +- `Http::pool()` sends many HTTP requests concurrently through WordPress's bundled + `WpOrg\Requests` transport, in batches of a configurable `concurrency` (default 10). + Pooled requests keep the full fluent request API and the WordPress HTTP API + behaviour: default-argument filters, `http_request_args`, `pre_http_request`, + URL safety checks, proxy, `http_api_debug` and `http_response`. A failed request + returns a `Response` that wraps a `WP_Error` and does not affect the others. + See `docs/http-client.md`. + +### Fixed + +- `Response::failed()` now returns `true` when the request failed with a `WP_Error`. + It previously returned `false`, because the status read as `0`. diff --git a/composer.json b/composer.json index 263f6ba..b3cd234 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "library", "description": "Elegant PHP framework for WordPress plugins", "license": "GPL-2.0-or-later", - "version": "3.2.8", + "version": "3.3.0", "keywords": [ "wordpress", "framework", diff --git a/docs/http-client.md b/docs/http-client.md new file mode 100644 index 0000000..1b5e79e --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,173 @@ +# HTTP client + +The `Http` facade is a fluent client on top of `wp_remote_request()`. This guide covers `Http::pool()`, which sends many requests at the same time. The API follows Laravel's `Http::pool()`, adapted to the framework's snake_case method naming and to the WordPress HTTP API. `Http::pool()` is available from framework 3.3.0. + +## Table of contents + +1. [Quick start](#1-quick-start) +2. [Building pooled requests](#2-building-pooled-requests) +3. [Keys and ordering](#3-keys-and-ordering) +4. [Errors](#4-errors) +5. [Concurrency](#5-concurrency) +6. [WordPress filters and actions](#6-wordpress-filters-and-actions) +7. [Behaviour worth knowing](#7-behaviour-worth-knowing) +8. [Scoping](#8-scoping) +9. [Where this differs from Laravel](#9-where-this-differs-from-laravel) + +--- + +## 1. Quick start + +Sending requests one after another adds up their times: 22 image downloads of about 2 s each take about 45 s. A pool sends them together, so the total time is close to the time of the slowest request. + +```php +use Framework\Http\Client\Pool; +use Framework\Supports\Facades\Http; + +$responses = Http::pool(function (Pool $pool) use ($urls) { + foreach ($urls as $key => $url) { + $pool->as($key)->timeout(15)->get($url); + } +}, ['concurrency' => 10]); + +$responses['canvas-tote-bag-1.jpg']->successful(); +$responses['canvas-tote-bag-1.jpg']->body(); +``` + +`pool()` returns an array of `Framework\Http\Client\Response`, one for each pooled request, with the same API as the response of a single request. Inside a scoped plugin, `Framework\` becomes your prefixed namespace, for example `Themeum\Framework\Http\Client\Pool`. + +--- + +## 2. Building pooled requests + +Each call on the pool starts a new request: + +```php +$pool->as('avatar')->with_token($token)->get($url); // keyed +$pool->post($endpoint, ['sku' => 'tote-1']); // unkeyed +``` + +Every fluent method of a single request works in the same way on a pooled request: `with_headers`, `timeout`, `with_token`, `with_user_agent`, `with_cookies`, `with_body`, `as_json`, `as_form`, `as_multipart`, `attach`, `with_options`, `without_verifying`, `accept_json`, `base_url`, and macros. The final URL and arguments are built by the same code as for a single request. + +Inside a pool, the verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `send`) **record** the request and return the pooled request. They do not send it, and they do not return a `Response`. The pool sends everything after the callback returns. The callback's return value is ignored, so you don't need to collect the requests yourself. + +Pooled requests start with the defaults of a new request. They do **not** inherit the settings of the request that `pool()` was called on. In `Http::with_token($t)->pool(...)`, the token does not reach the pooled requests. Put shared settings inside the callback, for example with a local closure: + +```php +Http::pool(function (Pool $pool) use ($urls, $token) { + $authed = function ($key) use ($pool, $token) { + return $pool->as($key)->with_token($token)->timeout(15); + }; + + foreach ($urls as $key => $url) { + $authed($key)->get($url); + } +}); +``` + +--- + +## 3. Keys and ordering + +- `as($key)` stores the request's response under `$key`, which is a string or an integer. +- A request without a key gets the next integer index under PHP's array-append rules: `0`, `1`, … or, after `as(5)`, `6`. +- The result keeps the order in which the requests were added, whatever order they finish in. +- Using a key twice in one pool throws `InvalidArgumentException` from `as()`, before anything is sent. This includes `as(0)` after an unkeyed request already took `0`. As in PHP, `'5'` and `5` are the same key. +- A pooled request that never gets a verb method (`$pool->as('x')->timeout(5);`) throws `RuntimeException` when the callback returns, before anything is sent. +- An empty pool returns `[]` and sends nothing. + +--- + +## 4. Errors + +A failed request never throws and never affects the other requests in the pool. Its entry is a `Response` that wraps a `WP_Error`, the same as for a failed single request: + +| Cause | `WP_Error` code | +|---|---| +| Transport failure (timeout, DNS, TLS, …) | `http_request_failed`, with the transport's message | +| URL without a valid scheme, or rejected by `reject_unsafe_urls` | `http_request_failed` | +| Host blocked by `WP_HTTP_BLOCK_EXTERNAL` | `http_request_not_executed` | +| Stream destination not writable | `http_request_failed` | +| `pre_http_request` returned a `WP_Error` | whatever the filter returned | + +For these entries, `successful()` is `false`, `failed()` is `true`, `on_error()` runs its callback, and `status()` is `0`. An HTTP error status, such as a 500, is a normal response with that status. + +Programmer errors still throw: an invalid HTTP method or body format (when the verb is called), a duplicate key, a request with no verb, and an invalid `concurrency`. + +> Since 3.3.0, `Response::failed()` also returns `true` for a `WP_Error` on a **single** request. Before 3.3.0 it returned `false`, because the status read as `0`. + +--- + +## 5. Concurrency + +`concurrency` (default `10`) is the maximum number of requests in flight at the same time. It must be a positive integer. Any other value (`0`, `-1`, `'10'`) throws `InvalidArgumentException` before the callback runs. + +A larger pool is sent in consecutive batches of `concurrency` requests, in the order the requests were added. Each batch must finish before the next one starts, so a batch takes as long as its slowest request. For 25 requests at concurrency 10, the pool sends three batches: 10, 10 and 5. + +Requests that are answered before the network (by a `pre_http_request` short-circuit, or by a blocked or unsafe URL) do not take a place in a batch. + +The requests are sent with `WpOrg\Requests\Requests::request_multiple()`, the parallel transport that WordPress bundles. With cURL they run in parallel. With only the fsockopen transport, Requests sends them one after another. The results are still correct, but you get no speed-up. + +--- + +## 6. WordPress filters and actions + +`request_multiple()` bypasses `WP_Http::request()`, so the pool repeats what `WP_Http::request()` (WordPress 6.9.4) does for each request. These run for every pooled request, in the order WordPress runs them: + +| Hook | Arguments | Notes | +|---|---|---| +| `http_request_timeout`, `http_request_redirection_count`, `http_request_version`, `http_headers_useragent`, `http_request_reject_unsafe_urls` | default, `$url` | Defaults only. The values set on the request win, as with `wp_remote_request()`. | +| `http_request_args` | `$args, $url` | | +| `pre_http_request` | `false, $args, $url` | A non-`false` result becomes that request's response, and that request is not sent. As in WordPress, a short-circuited request gets no `http_api_debug` and no `http_response`. | +| `https_ssl_verify` | `$verify, $url` | `$verify` is the WordPress CA bundle, or `false` after `without_verifying()`. | +| `requests-requests.before_redirect` and the other `requests-*` hooks | as WordPress | Through `WP_HTTP_Requests_Hooks`, with the same redirect compatibility and redirect validation as in WordPress. | +| `http_api_debug` (action) | `$response, 'response', 'WpOrg\Requests\Requests', $args, $url` | Fires for every sent or rejected request. | +| `http_response` | `$response, $args, $url` | Runs on every successful blocking response. | + +URL checks match WordPress too. With `reject_unsafe_urls` set, `wp_http_validate_url()` checks the URL, and redirects are validated. `WP_Http::block_request()` applies `WP_HTTP_BLOCK_EXTERNAL` and `WP_ACCESSIBLE_HOSTS`. The pool also honours proxy settings (`WP_HTTP_Proxy`), cookies, `stream` with `filename`, `limit_response_size` and non-blocking requests. + +Since the hooks are the same, tests that stub HTTP with `pre_http_request` also stub pooled requests. + +--- + +## 7. Behaviour worth knowing + +**`https_local_ssl_verify` does not apply.** WordPress applies it only in the legacy Curl and Streams transports and in the cron loopback, never on the `wp_remote_request()` path. The pool follows `wp_remote_request()`, so a request verifies SSL in the same way whether it is pooled or sent alone. + +**Use `reject_unsafe_urls` (plural).** WordPress reads `reject_unsafe_urls`. The client's own default, `reject_unsafe_url` (singular), has never had an effect on single requests, and it has none on pooled requests either. Turn the check on with `with_options(['reject_unsafe_urls' => true])` or with the `http_request_reject_unsafe_urls` filter. + +**HEAD requests follow redirects.** In WordPress, HEAD requests follow no redirects by default. The client always sets `redirection` to 5 itself, so its HEAD requests follow up to 5 redirects, on single and pooled requests alike. Set `with_options(['redirection' => 0])` to turn this off. + +**Batches, not a sliding window.** See [Concurrency](#5-concurrency). + +--- + +## 8. Scoping + +The pool uses global WordPress symbols: `WpOrg\Requests\*`, `WP_Http`, `WP_HTTP_Proxy`, `WP_HTTP_Requests_Response`, `WP_HTTP_Requests_Hooks` and `WP_Error`. Your php-scoper config must leave them unprefixed. The recommended config excludes every namespace except `Framework`, which covers all of them: + +```php +'exclude-namespaces' => [ + '~^$~', + '/^(?!Framework($|\\\\))/', +], +'expose-global-classes' => true, +``` + +If your config lists the namespaces to exclude one by one, add `WpOrg\Requests`. + +--- + +## 9. Where this differs from Laravel + +**No promises, no async.** Laravel's pool runs on Guzzle promises. Here, `pool()` blocks until every request has finished and returns plain `Response` objects. Pooled verbs return the pooled request, not a promise. + +**Batches instead of a rolling window.** Laravel's `concurrency` keeps N requests in flight at all times. WordPress's `request_multiple()` has no such option, so this pool sends fixed batches, and each batch waits for its slowest request. + +**Duplicate keys throw.** In Laravel, a later request with the same key silently replaces the earlier response. Here, `as()` throws, so no response is ever lost. + +**No inheritance from the parent request.** This matches Laravel, where pooled requests are also fresh. It is stated here because `Http::with_token()->pool()` looks as if it should share the token. + +**Failures are `WP_Error`, not exceptions.** Laravel can return a `ConnectionException` object for a failed request. Here, a failed request is a `Response` that wraps a `WP_Error`, as for a single `Http::` call. + +**No retries.** `retry()` is not available for single requests either. diff --git a/openspec/changes/archive/2026-11-04-add-http-pool/.openspec.yaml b/openspec/changes/archive/2026-11-04-add-http-pool/.openspec.yaml new file mode 100644 index 0000000..4ccb2b4 --- /dev/null +++ b/openspec/changes/archive/2026-11-04-add-http-pool/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-04 diff --git a/openspec/changes/archive/2026-11-04-add-http-pool/design.md b/openspec/changes/archive/2026-11-04-add-http-pool/design.md new file mode 100644 index 0000000..222a0c1 --- /dev/null +++ b/openspec/changes/archive/2026-11-04-add-http-pool/design.md @@ -0,0 +1,114 @@ +## Context + +See proposal.md (Why) and specs/http-client-pool/spec.md (requirements). + +State of the code today: + +- `Request::send_request()` validates the method, then calls `parse_request_data()`, `prepare_request_url()`, `prepare_request_body()` and `prepare_request_args()`, then `wp_remote_request($url, $args)`, then `new_response()`. Note that `prepare_request_body()` for multipart changes `$this->options['headers']` (the boundary) *before* `prepare_request_args()` reads them, so the build order must stay as it is. +- The `Http` facade (`$is_cacheable = false`) gives a new `Request` for each static call through the container key `client-request`. +- `WP_Http::request()` (read from WP 6.9.4 source, the same as on 6.8) is the behavior that the pool must reproduce. `WpOrg\Requests\Requests::request_multiple()` skips it completely. +- Unit tests run without WordPress. `WpOrg\Requests` is not in `vendor/`, `add_filter()` is a no-op, `apply_filters()` returns the value unchanged, and there is no `do_action()`, `wp_parse_args()`, `get_bloginfo()` or `wp_remote_request()` stub. +- Scoping: kirki-ecommerce's `scoper.config.php` and `example/scoper.config.php` both use `exclude-namespaces: ['~^$~', '/^(?!Framework($|\\\\))/']` with `expose-global-classes`, so `WpOrg\Requests\*` and global `WP_*` classes are never prefixed. + +## Goals / Non-Goals + +**Goals:** +- The pool and a single request share one request-building path, with no copied code. +- `WP_Http::request()` is copied step by step for each request, so a pooled request is the same as the same request sent alone, apart from timing. +- A test seam lets unit tests check batching, filters and URL checks with no network and no WordPress. + +**Non-Goals:** +- A sliding-window scheduler. `request_multiple()` has no concurrency option, so batches are sent one after the other and each batch waits for its slowest request. +- Async/promises and retries. The only change to `Response` is the `failed()` fix for `WP_Error` (decided during apply: the brief's rule 5 assumed `failed()` was already true for a `WP_Error`, but it was not). +- Fixing the framework's singular `reject_unsafe_url` default key (decided: keep it as it is in both paths, and document it). + +## Decisions + +### D1. Split `send_request()` into `build_request()` + `dispatch()` + +- `build_request(string $method, string $url, array $options): array` validates the method and returns `[$url, $args]`. It uses the same four `prepare_*` calls in the same order. +- `dispatch(string $url, array $args)` calls `wp_remote_request()` and wraps the result with `new_response()`. +- `send_request()` becomes `[$url, $args] = $this->build_request(...); return $this->dispatch($url, $args);`. + +Both new methods are `protected` with `@since 3.3.0`. A single request makes the same calls, in the same order, with the same values as before. *Alternative considered:* a separate builder class. It was rejected because subclasses and macros already rely on the `prepare_*` methods of `Request`, and moving them is churn with no gain. + +### D2. `PooledRequest extends Request` overrides only `dispatch()` + +`PooledRequest::dispatch()` stores `$url` and `$args` on the instance and returns `$this`. Every fluent method, `send()` (base URL joining) and `build_request()` are inherited, so rule 1 holds by construction. The verb docblocks say `@return Response`. `PooledRequest` overrides nothing visible, and its class docblock says that verbs return the pooled request in a pool. + +A pooled request with no recorded `[$url, $args]` (for example `$pool->as('x')` with no verb call) is a programmer error. `Pool` throws `RuntimeException` naming the key when the callback returns, before anything is sent. *Alternative:* skip it silently. That breaks "every pooled request gets an entry". + +*Alternative considered:* a flag on `Request` (`$this->pooled`). It was rejected because it puts pool state on every single request. + +### D3. `Pool` owns keys, preflight, batching and transport + +`Framework\Http\Client\Pool`: + +- `as($key)` checks for duplicates (`InvalidArgumentException`), then creates and registers a `PooledRequest` under the key. Unkeyed requests are registered with append semantics. Registration uses an internal ordered list of `[key, request]`. The append index is the highest integer key used so far + 1, the same as PHP's `$array[]`, so `as(5)` followed by an unkeyed request gives `6`. +- `__call($method, $args)` registers an unkeyed `PooledRequest` and forwards the call to it, so `$pool->timeout(15)->get($url)` works. +- `new_request()` (protected) gives `new PooledRequest()`. This is the extension point, the same pattern as `Request::new_response()`. +- `Request::pool(callable $callback, array $options = [])` validates `concurrency` (`is_int($value) && $value >= 1`, otherwise `InvalidArgumentException`, with default 10). It then builds `$this->new_pool()`, calls the callback, and returns `$pool->send($concurrency)`. `new_pool()` gives `new Pool()`. + +`Pool::send(int $concurrency): array` works in this order: + +1. Preflight each request in the order added (D4). The result is either a final response value (an array or a `WP_Error`) or a "needs network" entry with the prepared Requests payload. +2. Split the "needs network" entries with `array_chunk($pending, $concurrency, true)`. +3. For each chunk, call `send_batch(array $requests): array`. **This is the test seam.** It is the only place that calls `WpOrg\Requests\Requests::request_multiple($requests)` (inside `mbstring_binary_safe_encoding()`/`reset_mbstring_encoding()`), and it returns the Requests results keyed like its input. +4. Convert each result (D5) and fill it into its slot. Wrap every slot with `Response` and return the array with keys in the order the requests were added. + +*Alternative considered:* put the WP_Http copy in a separate `PoolTransport` class. It was rejected for now because it would be a single-use abstraction. `Pool`'s protected methods are already the seam, and consumers can subclass. + +### D4. Preflight copies `WP_Http::request()` up to the transport call + +For each request with `[$url, $args]`: + +1. `$args = wp_parse_args($args)`. Build the defaults array as WordPress does: `method` GET, `timeout` from `http_request_timeout` (5), `redirection` from `http_request_redirection_count` (5), `httpversion` from `http_request_version` ('1.0'), `user-agent` from `http_headers_useragent` (`'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url')`), `reject_unsafe_urls` from `http_request_reject_unsafe_urls` (false), `blocking`, `headers`, `cookies`, `body`, `compress`, `decompress`, `sslverify` true, `sslcertificates` `ABSPATH . WPINC . '/certificates/ca-bundle.crt'`, `stream`, `filename`, `limit_response_size`. For HEAD, the default `redirection` is 0. `$args = wp_parse_args($args, $defaults)`. +2. `$args = apply_filters('http_request_args', $args, $url)`. Set `_redirection` if it is not set. +3. `$pre = apply_filters('pre_http_request', false, $args, $url)`. If it is not `false`, it is the final value. **No `http_api_debug` and no `http_response`**, because WordPress returns `$pre` directly. +4. If `wp_kses_bad_protocol` exists: if `reject_unsafe_urls`, then `$url = wp_http_validate_url($url)`. If `$url` is set, then `$url = wp_kses_bad_protocol($url, ['http', 'https', 'ssl'])`. +5. An empty URL or a URL with no scheme gives `WP_Error('http_request_failed', __('A valid URL was not provided.'))` and fires `http_api_debug`. +6. `(new \WP_Http())->block_request($url)` gives `WP_Error('http_request_not_executed', ...)` and fires `http_api_debug`. `block_request()` is public, not static, so an instance is created once for each `send()`. +7. For a stream request: if `filename` is empty, use `get_temp_dir() . basename($url)`. Force `blocking` to true. If `! wp_is_writable(dirname(filename))`, return `WP_Error` and fire `http_api_debug`. +8. Normalize the headers: if they are null, use `[]`. If they are a string, use `WP_Http::processHeaders()`. +9. Build the Requests options as WordPress does: `timeout`, `useragent`, `blocking`, `hooks` = `new \WP_HTTP_Requests_Hooks($url, $args)` with `before_redirect` → `[\WP_Http::class, 'browser_redirect_compatibility']`, and `validate_redirects` when `reject_unsafe_urls`. Then `filename` (stream), `follow_redirects` false or `redirects`, `max_bytes`, `cookies` via `WP_Http::normalize_cookies()`, `verify`/`verifyname` false or `sslcertificates`, `data_format` 'body' for non-GET/HEAD, `verify` filtered by `https_ssl_verify`, and the proxy via `new \WP_HTTP_Proxy()` (`is_enabled() && send_through_proxy($url)` → `new \WpOrg\Requests\Proxy\Http(host:port)` with authentication). +10. The "needs network" entry is `['url' => $url, 'headers' => $headers, 'data' => $body, 'type' => $method, 'options' => $options]`, the per-request shape that `request_multiple()` takes. Keep `$args` and `$url` next to it for D5. + +`WP_HTTP_Requests_Hooks` dispatches `requests-{$hook}` actions, so `requests-requests.before_redirect` and the other `requests-*` hooks fire as on the single path. + +### D5. Converting results + +`request_multiple()` returns either a `WpOrg\Requests\Response` or a `WpOrg\Requests\Exception` for each request. It does not throw for a failure of one request. + +- Response → `$http = new \WP_HTTP_Requests_Response($result, $args['filename'])`, `$response = $http->to_array()`, `$response['http_response'] = $http`. +- Exception → `new \WP_Error('http_request_failed', $e->getMessage())`. +- Then `do_action('http_api_debug', $response, 'response', 'WpOrg\Requests\Requests', $args, $url)`. If it is a `WP_Error`, it is final. If not `blocking`, use the empty WordPress response array. Otherwise use `apply_filters('http_response', $response, $args, $url)`. + +The `'WpOrg\Requests\Requests'` string literal must survive scoping unchanged. With the current configs it does, because the namespace is excluded. Writing it as `\WpOrg\Requests\Requests::class` gives the same value and is clearer, so use that. + +### D6. Global symbols are written fully qualified + +`use WpOrg\Requests\Requests;` / `use WP_Error;` imports are fine with the current scoper configs. Write global WP classes with the leading backslash or a `use` import, as `Request.php` does for `WP_Http_Cookie`. Verification (task) runs `make scope` and greps the scoped `Pool.php` for `Themeum\\WpOrg` / `Themeum\\WP_` (there must be no matches). The docs tell consumers that a scoper config must not prefix `WpOrg\Requests`. + +### D7. Test seam and doubles + +- `tests/Support/StubsWordPressFunctions.php`: + - `add_filter`/`apply_filters` use `$GLOBALS['framework_test_filters'][$hook][$priority][] = [$callback, $accepted_args]` and run the callbacks in priority order. With no callbacks registered, they return the value unchanged, as now. + - New stubs, each with a `function_exists` guard: `do_action` (records calls in `$GLOBALS['framework_test_did_actions']` and runs callbacks registered through `add_action`, with no consumption, unlike `framework_test_do_action`), `remove_all_filters`, `wp_parse_args`, `get_bloginfo`, `wp_http_validate_url`, `wp_kses_bad_protocol`, `get_temp_dir`, `mbstring_binary_safe_encoding`, `reset_mbstring_encoding`, `wp_remote_request` (records `[$url, $args]`, applies `pre_http_request`, and otherwise returns a canned 200 array), and `WPINC`. +- `TestCase::tearDown()` resets `framework_test_filters` and `framework_test_did_actions`. +- `tests/Support/Http/`: stand-ins for `WP_Http` (`block_request` honoring the `WP_HTTP_BLOCK_EXTERNAL`/`WP_ACCESSIBLE_HOSTS` logic through injectable globals, because constants cannot be undefined between tests), `WP_HTTP_Proxy` (disabled), `WP_HTTP_Requests_Hooks`, and `WP_HTTP_Requests_Response` (`to_array()` from a fake result). A `RecordingPool extends Pool` overrides `send_batch()`: it records each batch's payloads and returns canned results, or an exception object, for each URL. A `RecordingRequest extends Request` overrides `new_pool()` to return `RecordingPool`. +- The WP class stand-ins are loaded only when the real classes are absent (`class_exists` guard), the same as the existing `WP_Error` stub. + +*Alternative considered (rejected in grilling):* add `rmccue/requests` to require-dev, or run the tests in the Docker playground. + +## Risks / Trade-offs + +- [Existing tests relied on `add_filter`/`apply_filters` being no-ops, and src code registers filters during boot in tests] → The registry is reset in `tearDown()`. Run the full `composer test:unit` before and after the stub change, and fix any test that breaks by resetting the filter state, not by weakening the stub. +- [Drift from future `WP_Http::request()` changes] → The docs name the WP version that was copied (6.9.4). Each step in `Pool` has a short "why" comment pointing to the matching WP behavior. Requests that need exact parity in the future can still use single `Http::` calls. +- [Batches wait for their slowest request] → This is acceptable for the target use (about 22 images, concurrency 10, 3 batches). It is documented, and a sliding window is a non-goal. +- [The fsockopen transport has no parallel support] → `request_multiple()` then sends the requests one after the other. Results stay correct, and the docs say so. +- [Test doubles of WP classes can drift from the real ones] → The Docker timing check (task) runs the real WP classes and real Requests end to end. +- [`PooledRequest` verbs return the request, not a `Response`, against the docblock] → This is documented in the `PooledRequest` class docblock and in `docs/http-client.md`. + +## Migration Plan + +This change only adds API, so no migration is needed. Release 3.3.0. Consumers bump the framework, re-run their scoper, and can then replace sequential loops with `Http::pool()`. To roll back, return the loop to single requests. No data or config migration. diff --git a/openspec/changes/archive/2026-11-04-add-http-pool/proposal.md b/openspec/changes/archive/2026-11-04-add-http-pool/proposal.md new file mode 100644 index 0000000..28737f5 --- /dev/null +++ b/openspec/changes/archive/2026-11-04-add-http-pool/proposal.md @@ -0,0 +1,33 @@ +## Why + +The `Http` facade can only send one request at a time. kirki-ecommerce downloads about 22 sample product images from S3 with `Http::timeout(15)->get($url)`. Each request takes about 2 s, so the full download takes about 45 s. If the requests run in parallel, the total time is about the time of the slowest request (2–3 s). WordPress already ships a parallel transport (`WpOrg\Requests\Requests::request_multiple()`), but nothing in the framework gives access to it. + +## What Changes + +- Add `Http::pool(callable $callback, array $options = [])`, modelled on Laravel's `Http::pool()` with snake_case names. The callback receives a `Framework\Http\Client\Pool`. It records requests with `$pool->as($key)->...->get($url)` (or `$pool->...` with no key). `pool()` returns `array` in the order the requests were added. +- Add `Framework\Http\Client\Pool` and `Framework\Http\Client\PooledRequest`. `PooledRequest` extends `Request`, so every fluent method works unchanged. Its verb methods record the built request and do not send it. +- Split `Request::send_request()` into a build step (final `$url` and `$args`) and a dispatch step. A single request still dispatches through `wp_remote_request()` and behaves exactly as before. +- The pool does for each request what `WP_Http::request()` does: it merges the defaults, applies the filters (including `pre_http_request` and `http_request_args`), checks the URL, converts the arguments to Requests options, builds the same response shape, and fires `http_api_debug` and `http_response`. It then sends the requests that still need the network with `request_multiple()`, in batches of `concurrency` (default 10). +- A failed request never throws and never affects the other requests. Its entry wraps a `WP_Error`. Programmer errors throw before anything is sent: a duplicate key, an invalid `concurrency`, an invalid method or an invalid body format. +- Fix `Response::failed()` so that it returns `true` when the wrapped value is a `WP_Error`. Today it returns `false`, because the status reads as `0`. This also changes single requests: `failed()`/`on_error()` now fire on transport errors. +- Add the `@method static array pool(...)` line to the `Http` facade docblock. +- Add `docs/http-client.md` (pool only), a new `CHANGELOG.md` with a `3.3.0` entry, and bump `composer.json` to `3.3.0`. +- Test support: the `add_filter`/`apply_filters` stubs will store and run callbacks, and new doubles stand in for the WP HTTP classes and the Requests batch call. + +No breaking changes. The change adds API. The only change to existing behavior is the `failed()` fix above. Nothing else on the single-request path changes. + +## Capabilities + +### New Capabilities +- `http-client-pool`: Concurrent HTTP requests through `Http::pool()`. Covers the pool API and keys, response ordering and errors, `concurrency` batching, and the WordPress HTTP API behavior (filters, short-circuit, URL checks, response shape, hooks) that a pooled request must keep. + +### Modified Capabilities + + +## Impact + +- **Code**: `src/Http/Client/Request.php` (split `send_request()`, add `pool()`), `src/Http/Client/Response.php` (`failed()` fix), new `src/Http/Client/Pool.php` and `src/Http/Client/PooledRequest.php`, and `src/Supports/Facades/Http.php` (docblock). +- **Runtime dependency**: `WpOrg\Requests` (bundled with WordPress since 6.2; the consumer requires 6.8 or later), `WP_Http`, `WP_HTTP_Proxy`, `WP_HTTP_Requests_Response`, `WP_HTTP_Requests_Hooks` and `WP_Error`. No new Composer dependency. +- **Scoping**: these symbols must stay global after php-scoper. The scoper configs of kirki-ecommerce and of `example/` already exclude every namespace except `Framework`. A verification task checks this in the scoped output. +- **Tests**: `tests/Support/StubsWordPressFunctions.php` (filters now run callbacks; reset in `TestCase::tearDown()`), new doubles in `tests/Support/Http/`, and new `tests/Unit/Http/Client/` tests. +- **Docs and release**: `docs/http-client.md`, `CHANGELOG.md`, and `composer.json` version 3.3.0. diff --git a/openspec/changes/archive/2026-11-04-add-http-pool/specs/http-client-pool/spec.md b/openspec/changes/archive/2026-11-04-add-http-pool/specs/http-client-pool/spec.md new file mode 100644 index 0000000..5f93e7d --- /dev/null +++ b/openspec/changes/archive/2026-11-04-add-http-pool/specs/http-client-pool/spec.md @@ -0,0 +1,159 @@ +## Purpose + +Lets consuming plugins send many outbound HTTP requests at the same time through the `Http` facade. Each request keeps the request-building API and the WordPress HTTP API behavior of a single request. Each request gets its own response, in the order the requests were added. + +## ADDED Requirements + +### Requirement: Pooled requests are built with the full fluent request API + +The `Http` facade SHALL provide `pool(callable $callback, array $options = [])`. The callback MUST receive a pool object. Each call on the pool (keyed with `as()`, or a fluent method called on the pool directly) MUST start a new pending request. That request MUST support every fluent method of a single request with the same effect, including headers, timeout, token, user agent, cookies, body, body format, query, options and SSL verification. + +A pooled request MUST start with the defaults of a new single request. It MUST NOT inherit settings from the request that `pool()` was called on. + +#### Scenario: Fluent options build the same request as a single request +- **WHEN** a pooled request and a single request are built with the same headers, timeout, JSON body and query parameters for the same URL and method +- **THEN** the final URL and the arguments given to the WordPress HTTP layer MUST be identical for both + +#### Scenario: Parent request settings are not inherited +- **WHEN** `pool()` is called on a request that already has a base URL and an authorization header +- **AND** the callback adds a pooled request with no base URL or headers of its own +- **THEN** the pooled request MUST be built without the parent's base URL and authorization header + +### Requirement: Verb methods record requests instead of sending them + +Inside a pool, the verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `send`) MUST NOT send a network request. They MUST record the request and return the pooled request. The pool MUST send the recorded requests only after the callback returns. The callback's return value MUST be ignored. + +An invalid HTTP method or an invalid body format MUST throw when the verb is called, as for a single request, and nothing MUST be sent. + +#### Scenario: Nothing is sent while the callback runs +- **WHEN** the callback records three requests and then returns +- **THEN** no request MUST be sent before the callback returns +- **AND** all three requests MUST be sent after it returns + +#### Scenario: Return value of the callback is ignored +- **WHEN** the callback records two requests and returns nothing +- **THEN** `pool()` MUST still return a response for each of the two requests + +### Requirement: Responses are keyed and returned in the order added + +`pool()` SHALL return an array with one response for every pooled request, in the order the requests were added. A request keyed with `as($key)` (string or integer) MUST be stored under that key. A request with no key MUST get the next integer index under PHP array-append rules. + +Using a key that is already taken by an earlier request in the same pool MUST throw an invalid-argument error when `as()` is called, and nothing MUST be sent. + +#### Scenario: Mixed keyed and unkeyed requests +- **WHEN** the callback adds a request keyed `'a'`, then an unkeyed request, then a request keyed `'b'`, then another unkeyed request +- **THEN** the result keys MUST be `'a'`, `0`, `'b'`, `1` in that order +- **AND** each key MUST hold the response of the request that was added at that position + +#### Scenario: Duplicate key is rejected +- **WHEN** the callback adds two requests with the key `'a'` +- **THEN** the second `as('a')` MUST throw an invalid-argument error +- **AND** no request MUST be sent + +#### Scenario: Empty pool +- **WHEN** the callback adds no requests +- **THEN** `pool()` MUST return an empty array +- **AND** nothing MUST be sent + +### Requirement: Failures are isolated per request + +A failed pooled request MUST NOT throw, and it MUST NOT change the responses of the other requests. A transport failure, a short-circuit `WP_Error`, a blocked URL or an unsafe URL MUST give that request a response that wraps a `WP_Error`. `successful()` MUST return `false` and `failed()` MUST return `true` for it. This is a fix to the shared response object, so it applies to single requests as well: before this change, `failed()` returned `false` for a `WP_Error`, because the status read as `0`. An HTTP error status (for example 500) MUST give a normal response with that status. + +#### Scenario: One request errors and the others succeed +- **WHEN** a pool has three requests and `pre_http_request` returns a `WP_Error` for the second one +- **THEN** the second response MUST wrap that `WP_Error` and report `failed()` as `true` +- **AND** the first and third responses MUST be the same as they would be without the failing request + +#### Scenario: A WP_Error response reports failure on a single request too +- **WHEN** a single request (not pooled) gets a `WP_Error` from the WordPress HTTP layer +- **THEN** `failed()` MUST return `true` and `on_error()` MUST run its callback +- **AND** `status()` MUST still return `0`, and `client_error()` and `server_error()` MUST still return `false` + +#### Scenario: Server error status +- **WHEN** one pooled request receives a 500 response +- **THEN** its response MUST report status 500 and `successful()` as `false` +- **AND** the other responses MUST be unaffected + +### Requirement: Concurrency limits requests in flight + +The `concurrency` option SHALL set the maximum number of requests that are sent at the same time. The default MUST be 10. Requests that need the network MUST be sent in consecutive batches of at most `concurrency`, in the order they were added. Requests that are answered before the network (short-circuited, blocked or unsafe) MUST NOT count toward a batch. + +A `concurrency` that is not a positive integer MUST throw an invalid-argument error before the callback runs. + +#### Scenario: Pool split into batches +- **WHEN** a pool of 25 network requests runs with `concurrency` set to 10 +- **THEN** the requests MUST be sent in three batches of 10, 10 and 5, in the order they were added + +#### Scenario: Short-circuited requests do not take a batch slot +- **WHEN** a pool of 12 requests runs with `concurrency` set to 10 and `pre_http_request` answers 2 of them +- **THEN** the remaining 10 requests MUST be sent in one batch + +#### Scenario: Invalid concurrency +- **WHEN** `pool()` is called with `concurrency` set to `0`, a negative number, or a value that is not a number +- **THEN** it MUST throw an invalid-argument error +- **AND** the callback MUST NOT be called + +### Requirement: Pooled requests keep WordPress HTTP API behavior + +Each pooled request SHALL go through the same argument handling as `wp_remote_request()` in WordPress 6.8 and later: + +- the WordPress default arguments, with the `http_request_timeout`, `http_request_redirection_count`, `http_request_version`, `http_headers_useragent` and `http_request_reject_unsafe_urls` filters, merged under the request's own arguments (HEAD requests default to no redirects); +- then the `http_request_args` filter with the arguments and the URL; +- then the `pre_http_request` filter with `false`, the final arguments and the final URL. + +A result from `pre_http_request` that is not `false` MUST be used as that request's response, and that request MUST NOT be sent. + +#### Scenario: Short-circuit receives the final URL and arguments +- **WHEN** a pooled GET request with query parameters and a custom header is answered by a `pre_http_request` callback +- **THEN** the callback MUST receive the URL with the query string and arguments that contain the header +- **AND** that request MUST NOT be sent +- **AND** its response MUST wrap the value that the callback returned + +#### Scenario: Request arguments filter applies to each pooled request +- **WHEN** an `http_request_args` callback adds a header +- **THEN** every pooled request MUST be sent (or short-circuited) with that header + +### Requirement: Pooled requests keep WordPress URL safety checks + +A pooled request MUST be rejected with a `WP_Error`, and MUST NOT be sent, if any of these is true: + +- `reject_unsafe_urls` is true and the URL fails WordPress URL validation; +- the URL has no valid scheme; +- WordPress blocks external requests to that host (`WP_HTTP_BLOCK_EXTERNAL` without `WP_ACCESSIBLE_HOSTS` allowing it); +- streaming is requested to a directory that is not writable. + +Only the plural WordPress key `reject_unsafe_urls` controls URL validation, as on the single-request path. The framework's singular `reject_unsafe_url` default has no effect. A rejection MUST affect only that request. + +#### Scenario: Blocked external host +- **WHEN** external requests are blocked, and a pool has one request to an allowed host and one to a blocked host +- **THEN** the blocked request's response MUST wrap a `WP_Error` +- **AND** the allowed request MUST be sent and answered normally + +#### Scenario: Unsafe URL rejected +- **WHEN** a pooled request has `reject_unsafe_urls` set to true and its URL fails validation +- **THEN** its response MUST wrap a `WP_Error` +- **AND** the other pooled requests MUST be unaffected + +### Requirement: Pooled responses match the single-request response shape + +A sent pooled request SHALL produce the same response array that `wp_remote_request()` returns: headers, body, response code and message, cookies, filename, and the `http_response` object. A transport exception MUST become a `WP_Error` with the code `http_request_failed`. A non-blocking request MUST give the same empty response that WordPress returns for one. + +After each sent or rejected request, the `http_api_debug` action MUST fire with the response, `'response'`, the Requests class name, the arguments and the URL. A response that is not an error MUST then go through the `http_response` filter with the arguments and the URL. SSL verification MUST use the WordPress CA bundle and the `https_ssl_verify` filter. The proxy settings, redirect handling and `requests-*` hooks of WordPress MUST apply. + +#### Scenario: Debug action and response filter run for pooled requests +- **WHEN** a pooled request is sent and gets a 200 response +- **THEN** `http_api_debug` MUST fire once for it with that request's arguments and URL +- **AND** its response MUST be the value returned by the `http_response` filter + +#### Scenario: Transport exception becomes a WP_Error +- **WHEN** the transport raises an exception for one pooled request +- **THEN** that request's response MUST wrap a `WP_Error` with the code `http_request_failed` and the exception message +- **AND** the other requests in the same batch MUST be unaffected + +### Requirement: Single requests are unchanged + +Adding the pool MUST NOT change how a single request is built or sent. A single request MUST still be dispatched through `wp_remote_request()` with the same URL and arguments as before. + +#### Scenario: Existing single-request behavior +- **WHEN** a single request is sent with any combination of fluent options +- **THEN** `wp_remote_request()` MUST receive the same URL and arguments as before this change diff --git a/openspec/changes/archive/2026-11-04-add-http-pool/tasks.md b/openspec/changes/archive/2026-11-04-add-http-pool/tasks.md new file mode 100644 index 0000000..4c3118e --- /dev/null +++ b/openspec/changes/archive/2026-11-04-add-http-pool/tasks.md @@ -0,0 +1,58 @@ +## 1. Baseline and test support + +- [x] 1.1 Run `composer phpcs && composer test:unit` on a clean tree, and record the pass count as the baseline +- [x] 1.2 Make the `add_filter`/`apply_filters` stubs in `tests/Support/StubsWordPressFunctions.php` store callbacks in `$GLOBALS['framework_test_filters']` and run them in priority order, honoring `$accepted_args`. With no callbacks, the value must come back unchanged. Add `remove_all_filters` +- [x] 1.3 Add guarded stubs: `do_action` (records each call in `$GLOBALS['framework_test_did_actions']` and runs `add_action` callbacks without consuming them), `wp_parse_args`, `get_bloginfo`, `wp_http_validate_url`, `wp_kses_bad_protocol`, `get_temp_dir`, `mbstring_binary_safe_encoding`, `reset_mbstring_encoding`, `wp_remote_request` (records `[$url, $args]`, applies `pre_http_request`, and otherwise returns a canned 200 response array), and a `WPINC` constant +- [x] 1.4 Reset `framework_test_filters` and `framework_test_did_actions` in `TestCase::tearDown()` +- [x] 1.5 Re-run `composer test:unit`, and confirm that the baseline from 1.1 still passes. Fix any breakage by resetting state, not by weakening the stubs +- [x] 1.6 Add `class_exists`-guarded stand-ins in `tests/Support/Http/` for `WP_Http` (`block_request()` driven by test globals that mirror `WP_HTTP_BLOCK_EXTERNAL`/`WP_ACCESSIBLE_HOSTS`, plus `normalize_cookies`, `processHeaders`, `browser_redirect_compatibility`, `validate_redirects`), `WP_HTTP_Proxy` (disabled by default), `WP_HTTP_Requests_Hooks` (records `register()` calls), and `WP_HTTP_Requests_Response` (`to_array()` from a fake result object). Also add a fake Requests response and exception for the doubles + +## 2. Split the single-request path + +- [x] 2.1 Add `protected function build_request(string $method, string $url, array $options = [])` to `src/Http/Client/Request.php`. It does the method validation and the four `prepare_*` calls in their current order, and returns `[$url, $args]` (`@since 3.3.0`) +- [x] 2.2 Add `protected function dispatch(string $url, array $args)`, which calls `wp_remote_request()` and returns `new_response()` (`@since 3.3.0`) +- [x] 2.3 Reduce `send_request()` to `build_request()` + `dispatch()`. The `@throws` docblock moves to `build_request()` +- [x] 2.4 Add a characterization test in `tests/Unit/Http/Client/RequestDispatchTest.php`. For GET with a query, POST JSON, form, multipart (boundary header present), `with_body()`, base URL joining, and an invalid method throwing, it asserts the `[$url, $args]` that the `wp_remote_request` stub received + +## 3. Pool API + +- [x] 3.1 Create `src/Http/Client/PooledRequest.php` (extends `Request`). `dispatch()` stores `$url`/`$args` and returns `$this`. Add `is_recorded()` and a getter for the recorded pair. The class docblock says that verbs return the pooled request in a pool +- [x] 3.2 Create `src/Http/Client/Pool.php` with an ordered registry: `as(string|int $key)` (PHP 7.4: an untyped param with a `@param string|int` docblock; throws `InvalidArgumentException` on a duplicate key), `__call()` registering an unkeyed request with append semantics (highest integer key + 1), and `protected new_request()` +- [x] 3.3 Add `public function pool(callable $callback, array $options = [])` and `protected function new_pool()` to `Request`. Validate `concurrency` (default 10; anything that is not an `int >= 1` throws `InvalidArgumentException` before the callback runs), run the callback, and return `$pool->send($concurrency)` +- [x] 3.4 In `Pool::send()`, throw `RuntimeException` naming the key if any registered request has no verb call. Return `[]` for an empty pool without calling the seam +- [x] 3.6 Make `Response::failed()` return true when the wrapped value is a `WP_Error`. Leave `status()`, `client_error()` and `server_error()` unchanged. Add a test in `tests/Unit/Http/Client/ResponseTest.php` covering `failed()`/`on_error()` for a `WP_Error`, and that a 200/404/500 keep their current results +- [x] 3.5 Add `@method static array pool(callable $callback, array $options = [])` to `src/Supports/Facades/Http.php` + +## 4. WordPress HTTP API parity in the pool + +- [x] 4.1 Implement the preflight in `Pool` (design D4 steps 1–8): defaults with the five default filters and the HEAD redirection rule, `http_request_args`, `_redirection`, a `pre_http_request` short-circuit (with no debug action and no response filter), `wp_http_validate_url` when `reject_unsafe_urls`, `wp_kses_bad_protocol`, the scheme check, `WP_Http::block_request()`, stream filename and writability, and header normalization. Fire `http_api_debug` on each early `WP_Error`, as WordPress does +- [x] 4.2 Implement the Requests options build (D4 step 9): `timeout`, `useragent`, `blocking`, `WP_HTTP_Requests_Hooks` with the `browser_redirect_compatibility` and conditional `validate_redirects` registrations, `filename`, `follow_redirects`/`redirects`, `max_bytes`, `cookies`, `verify`/`verifyname`/CA bundle, `data_format`, the `https_ssl_verify` filter, and the `WP_HTTP_Proxy` → `WpOrg\Requests\Proxy\Http` with authentication +- [x] 4.3 Implement batching: `array_chunk` with keys preserved over the network-bound entries only, and `protected function send_batch(array $requests)` as the only caller of `\WpOrg\Requests\Requests::request_multiple()`, wrapped in `mbstring_binary_safe_encoding()`/`reset_mbstring_encoding()` +- [x] 4.4 Implement result conversion (D5): `WP_HTTP_Requests_Response` → `to_array()` + `http_response`, a Requests exception → `WP_Error('http_request_failed', $message)`, `http_api_debug`, the non-blocking empty response, and the `http_response` filter. Wrap every slot in `Response`, in the order the requests were added +- [x] 4.5 Write global classes fully qualified or imported. Use `\WpOrg\Requests\Requests::class` as the `http_api_debug` transport name + +## 5. Pool tests (`tests/Unit/Http/Client/PoolTest.php`, using `RecordingPool`/`RecordingRequest`) + +- [x] 5.1 Order and keys: `'a'`, unkeyed, `'b'`, unkeyed → `['a', 0, 'b', 1]`. `as(5)` then unkeyed → `6`. Each key holds its own response +- [x] 5.2 A duplicate `as()` key throws, and the seam is never called. A request registered with no verb call throws on send +- [x] 5.3 Failure isolation: a `WP_Error` from `pre_http_request`, a 500 result, and a Requests exception in the same batch each affect only their own entry (`failed()` true / `successful()` false), and the other entries are intact +- [x] 5.4 The `pre_http_request` callback receives the final URL (with the query string) and arguments (with the custom header). That request is absent from every recorded batch, and its response wraps the returned value. It gets no `http_api_debug` call and no `http_response` filter +- [x] 5.5 An `http_request_args` callback adds a header that shows up in every recorded batch payload +- [x] 5.6 A host blocked by the `WP_Http` stand-in, and an unsafe URL with `reject_unsafe_urls` true, each give a `WP_Error` for that request only. The other requests are sent +- [x] 5.7 Parity: for the same fluent chain (headers, timeout, JSON body, query), the `[$url, $args]` that `PooledRequest` records equals what the `wp_remote_request` stub received from a single request +- [x] 5.8 Concurrency: 25 requests at 10 → batches of 10/10/5 in add order. 12 requests with 2 short-circuited at 10 → one batch of 10. The default is 10. `0`, `-1` and `'10'` each throw before the callback runs +- [x] 5.9 An empty pool returns `[]` with no seam call +- [x] 5.10 Response shape: `http_api_debug` fires once for each sent request with that request's arguments and URL. The `http_response` filter's return value is what `Response` wraps. A non-blocking request gives the WordPress empty response. The SSL `verify` option is the CA bundle path, or the value from `https_ssl_verify` +- [x] 5.11 Run `composer phpcs && composer test:unit`. All tests pass, including every existing single-request test + +## 6. Docs and release + +- [x] 6.1 Create `docs/http-client.md`, structured like `docs/cache.md` (table of contents, numbered sections): intro; quick start (the S3 example); keys and ordering; errors and `Response` helpers; `concurrency` and batching; WordPress filters and actions that apply; arguments and behaviors that differ (no `https_local_ssl_verify`, only plural `reject_unsafe_urls`, batches wait for their slowest request, sequential fallback without cURL, verbs return the pooled request); scoping note (consumers must not prefix `WpOrg\Requests`); "Where this differs from Laravel" (no promises/async, no `Pool::response()`, duplicate keys throw, no parent inheritance) +- [x] 6.2 Create `CHANGELOG.md` (Keep a Changelog format) with a single `## [3.3.0]` entry: "Added" for `Http::pool()`, and "Fixed" for `Response::failed()` on `WP_Error` +- [x] 6.3 Bump `"version"` in `composer.json` from `3.2.8` to `3.3.0` + +## 7. Verification outside the unit suite + +- [x] 7.1 Run `make scope`. Grep the scoped `example/libraries/themeum/framework/Http/Client/Pool.php` and confirm that there are no `Themeum\WpOrg` or `Themeum\WP_` references, and that `\WpOrg\Requests\Requests` and the `WP_*` classes are still global +- [x] 7.2 In the Docker playground, start a `php -S` endpoint that sleeps 1 s for each request. Use `make wp CMD="eval-file …"` to pool 5 requests to it with concurrency 5, and confirm a total of about 1–2 s compared with about 5 s sequentially. Record both timings for the PR description +- [x] 7.3 In the same playground, confirm that a `pre_http_request` filter that short-circuits one URL stops that URL from being sent, while the others are sent diff --git a/openspec/specs/http-client-pool/spec.md b/openspec/specs/http-client-pool/spec.md new file mode 100644 index 0000000..2598c38 --- /dev/null +++ b/openspec/specs/http-client-pool/spec.md @@ -0,0 +1,159 @@ +## Purpose + +Lets consuming plugins send many outbound HTTP requests at the same time through the `Http` facade. Each request keeps the request-building API and the WordPress HTTP API behavior of a single request. Each request gets its own response, in the order the requests were added. + +## Requirements + +### Requirement: Pooled requests are built with the full fluent request API + +The `Http` facade SHALL provide `pool(callable $callback, array $options = [])`. The callback MUST receive a pool object. Each call on the pool (keyed with `as()`, or a fluent method called on the pool directly) MUST start a new pending request. That request MUST support every fluent method of a single request with the same effect, including headers, timeout, token, user agent, cookies, body, body format, query, options and SSL verification. + +A pooled request MUST start with the defaults of a new single request. It MUST NOT inherit settings from the request that `pool()` was called on. + +#### Scenario: Fluent options build the same request as a single request +- **WHEN** a pooled request and a single request are built with the same headers, timeout, JSON body and query parameters for the same URL and method +- **THEN** the final URL and the arguments given to the WordPress HTTP layer MUST be identical for both + +#### Scenario: Parent request settings are not inherited +- **WHEN** `pool()` is called on a request that already has a base URL and an authorization header +- **AND** the callback adds a pooled request with no base URL or headers of its own +- **THEN** the pooled request MUST be built without the parent's base URL and authorization header + +### Requirement: Verb methods record requests instead of sending them + +Inside a pool, the verb methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `send`) MUST NOT send a network request. They MUST record the request and return the pooled request. The pool MUST send the recorded requests only after the callback returns. The callback's return value MUST be ignored. + +An invalid HTTP method or an invalid body format MUST throw when the verb is called, as for a single request, and nothing MUST be sent. + +#### Scenario: Nothing is sent while the callback runs +- **WHEN** the callback records three requests and then returns +- **THEN** no request MUST be sent before the callback returns +- **AND** all three requests MUST be sent after it returns + +#### Scenario: Return value of the callback is ignored +- **WHEN** the callback records two requests and returns nothing +- **THEN** `pool()` MUST still return a response for each of the two requests + +### Requirement: Responses are keyed and returned in the order added + +`pool()` SHALL return an array with one response for every pooled request, in the order the requests were added. A request keyed with `as($key)` (string or integer) MUST be stored under that key. A request with no key MUST get the next integer index under PHP array-append rules. + +Using a key that is already taken by an earlier request in the same pool MUST throw an invalid-argument error when `as()` is called, and nothing MUST be sent. + +#### Scenario: Mixed keyed and unkeyed requests +- **WHEN** the callback adds a request keyed `'a'`, then an unkeyed request, then a request keyed `'b'`, then another unkeyed request +- **THEN** the result keys MUST be `'a'`, `0`, `'b'`, `1` in that order +- **AND** each key MUST hold the response of the request that was added at that position + +#### Scenario: Duplicate key is rejected +- **WHEN** the callback adds two requests with the key `'a'` +- **THEN** the second `as('a')` MUST throw an invalid-argument error +- **AND** no request MUST be sent + +#### Scenario: Empty pool +- **WHEN** the callback adds no requests +- **THEN** `pool()` MUST return an empty array +- **AND** nothing MUST be sent + +### Requirement: Failures are isolated per request + +A failed pooled request MUST NOT throw, and it MUST NOT change the responses of the other requests. A transport failure, a short-circuit `WP_Error`, a blocked URL or an unsafe URL MUST give that request a response that wraps a `WP_Error`. `successful()` MUST return `false` and `failed()` MUST return `true` for it. This is a fix to the shared response object, so it applies to single requests as well: before this change, `failed()` returned `false` for a `WP_Error`, because the status read as `0`. An HTTP error status (for example 500) MUST give a normal response with that status. + +#### Scenario: One request errors and the others succeed +- **WHEN** a pool has three requests and `pre_http_request` returns a `WP_Error` for the second one +- **THEN** the second response MUST wrap that `WP_Error` and report `failed()` as `true` +- **AND** the first and third responses MUST be the same as they would be without the failing request + +#### Scenario: A WP_Error response reports failure on a single request too +- **WHEN** a single request (not pooled) gets a `WP_Error` from the WordPress HTTP layer +- **THEN** `failed()` MUST return `true` and `on_error()` MUST run its callback +- **AND** `status()` MUST still return `0`, and `client_error()` and `server_error()` MUST still return `false` + +#### Scenario: Server error status +- **WHEN** one pooled request receives a 500 response +- **THEN** its response MUST report status 500 and `successful()` as `false` +- **AND** the other responses MUST be unaffected + +### Requirement: Concurrency limits requests in flight + +The `concurrency` option SHALL set the maximum number of requests that are sent at the same time. The default MUST be 10. Requests that need the network MUST be sent in consecutive batches of at most `concurrency`, in the order they were added. Requests that are answered before the network (short-circuited, blocked or unsafe) MUST NOT count toward a batch. + +A `concurrency` that is not a positive integer MUST throw an invalid-argument error before the callback runs. + +#### Scenario: Pool split into batches +- **WHEN** a pool of 25 network requests runs with `concurrency` set to 10 +- **THEN** the requests MUST be sent in three batches of 10, 10 and 5, in the order they were added + +#### Scenario: Short-circuited requests do not take a batch slot +- **WHEN** a pool of 12 requests runs with `concurrency` set to 10 and `pre_http_request` answers 2 of them +- **THEN** the remaining 10 requests MUST be sent in one batch + +#### Scenario: Invalid concurrency +- **WHEN** `pool()` is called with `concurrency` set to `0`, a negative number, or a value that is not a number +- **THEN** it MUST throw an invalid-argument error +- **AND** the callback MUST NOT be called + +### Requirement: Pooled requests keep WordPress HTTP API behavior + +Each pooled request SHALL go through the same argument handling as `wp_remote_request()` in WordPress 6.8 and later: + +- the WordPress default arguments, with the `http_request_timeout`, `http_request_redirection_count`, `http_request_version`, `http_headers_useragent` and `http_request_reject_unsafe_urls` filters, merged under the request's own arguments (HEAD requests default to no redirects); +- then the `http_request_args` filter with the arguments and the URL; +- then the `pre_http_request` filter with `false`, the final arguments and the final URL. + +A result from `pre_http_request` that is not `false` MUST be used as that request's response, and that request MUST NOT be sent. + +#### Scenario: Short-circuit receives the final URL and arguments +- **WHEN** a pooled GET request with query parameters and a custom header is answered by a `pre_http_request` callback +- **THEN** the callback MUST receive the URL with the query string and arguments that contain the header +- **AND** that request MUST NOT be sent +- **AND** its response MUST wrap the value that the callback returned + +#### Scenario: Request arguments filter applies to each pooled request +- **WHEN** an `http_request_args` callback adds a header +- **THEN** every pooled request MUST be sent (or short-circuited) with that header + +### Requirement: Pooled requests keep WordPress URL safety checks + +A pooled request MUST be rejected with a `WP_Error`, and MUST NOT be sent, if any of these is true: + +- `reject_unsafe_urls` is true and the URL fails WordPress URL validation; +- the URL has no valid scheme; +- WordPress blocks external requests to that host (`WP_HTTP_BLOCK_EXTERNAL` without `WP_ACCESSIBLE_HOSTS` allowing it); +- streaming is requested to a directory that is not writable. + +Only the plural WordPress key `reject_unsafe_urls` controls URL validation, as on the single-request path. The framework's singular `reject_unsafe_url` default has no effect. A rejection MUST affect only that request. + +#### Scenario: Blocked external host +- **WHEN** external requests are blocked, and a pool has one request to an allowed host and one to a blocked host +- **THEN** the blocked request's response MUST wrap a `WP_Error` +- **AND** the allowed request MUST be sent and answered normally + +#### Scenario: Unsafe URL rejected +- **WHEN** a pooled request has `reject_unsafe_urls` set to true and its URL fails validation +- **THEN** its response MUST wrap a `WP_Error` +- **AND** the other pooled requests MUST be unaffected + +### Requirement: Pooled responses match the single-request response shape + +A sent pooled request SHALL produce the same response array that `wp_remote_request()` returns: headers, body, response code and message, cookies, filename, and the `http_response` object. A transport exception MUST become a `WP_Error` with the code `http_request_failed`. A non-blocking request MUST give the same empty response that WordPress returns for one. + +After each sent or rejected request, the `http_api_debug` action MUST fire with the response, `'response'`, the Requests class name, the arguments and the URL. A response that is not an error MUST then go through the `http_response` filter with the arguments and the URL. SSL verification MUST use the WordPress CA bundle and the `https_ssl_verify` filter. The proxy settings, redirect handling and `requests-*` hooks of WordPress MUST apply. + +#### Scenario: Debug action and response filter run for pooled requests +- **WHEN** a pooled request is sent and gets a 200 response +- **THEN** `http_api_debug` MUST fire once for it with that request's arguments and URL +- **AND** its response MUST be the value returned by the `http_response` filter + +#### Scenario: Transport exception becomes a WP_Error +- **WHEN** the transport raises an exception for one pooled request +- **THEN** that request's response MUST wrap a `WP_Error` with the code `http_request_failed` and the exception message +- **AND** the other requests in the same batch MUST be unaffected + +### Requirement: Single requests are unchanged + +Adding the pool MUST NOT change how a single request is built or sent. A single request MUST still be dispatched through `wp_remote_request()` with the same URL and arguments as before. + +#### Scenario: Existing single-request behavior +- **WHEN** a single request is sent with any combination of fluent options +- **THEN** `wp_remote_request()` MUST receive the same URL and arguments as before this change diff --git a/src/Http/Client/Pool.php b/src/Http/Client/Pool.php new file mode 100644 index 0000000..0f989fa --- /dev/null +++ b/src/Http/Client/Pool.php @@ -0,0 +1,507 @@ + + * + * @since 3.3.0 + */ + protected array $requests = []; + + /** + * The integer key the next unkeyed request receives, following PHP array-append rules. + * + * @var int + * + * @since 3.3.0 + */ + protected int $next_index = 0; + + /** + * Start a pooled request whose response is stored under the given key. + * + * @param string|int $key The response key. + * + * @return PooledRequest + * + * @throws \InvalidArgumentException When the key is not a string or integer, or is already used. + * + * @since 3.3.0 + */ + public function as($key) + { + throw_unless( + is_string($key) || is_int($key), + 'A pooled request key must be a string or an integer.', + InvalidArgumentException::class + ); + + // Normalise the key the way PHP does, so '5' and 5 are the same slot. + $key = array_key_first([$key => true]); + + throw_if( + array_key_exists($key, $this->requests), + sprintf('The pooled request key [%s] is already used.', $key), + InvalidArgumentException::class + ); + + return $this->add_request($key); + } + + /** + * Start an unkeyed pooled request and forward the call to it. + * + * @param string $method The method name. + * @param array $parameters The parameters array. + * + * @return mixed + * + * @since 3.3.0 + */ + public function __call($method, $parameters) + { + return $this->add_request($this->next_index)->$method(...$parameters); + } + + /** + * Register a new pooled request under the given key. + * + * @param string|int $key The response key. + * + * @return PooledRequest + * + * @since 3.3.0 + */ + protected function add_request($key) + { + if (is_int($key) && $key >= $this->next_index) { + $this->next_index = $key + 1; + } + + return $this->requests[$key] = $this->new_request(); + } + + /** + * Create a new pooled request. + * + * @return PooledRequest + * + * @since 3.3.0 + */ + protected function new_request() + { + return new PooledRequest(); + } + + /** + * Send every pooled request and return their responses in the order they were added. + * + * @param int $concurrency The maximum number of requests in flight at once. + * + * @return array + * + * @throws \RuntimeException When a pooled request was never given a verb method. + * + * @since 3.3.0 + */ + public function send(int $concurrency = self::DEFAULT_CONCURRENCY) + { + if (empty($this->requests)) { + return []; + } + + foreach ($this->requests as $key => $request) { + throw_unless( + $request->is_recorded(), + sprintf('The pooled request [%s] was never sent with a verb method.', $key), + RuntimeException::class + ); + } + + $http = new \WP_Http(); + $results = []; + $pending = []; + + foreach ($this->requests as $key => $request) { + [$url, $args] = $request->get_recorded(); + + $prepared = $this->prepare_request($http, $url, $args); + + if (array_key_exists('response', $prepared)) { + $results[$key] = $prepared['response']; + } else { + $results[$key] = null; + $pending[$key] = $prepared; + } + } + + foreach (array_chunk($pending, $concurrency, true) as $batch) { + foreach ($this->send_prepared_batch($batch) as $key => $response) { + $results[$key] = $response; + } + } + + return array_map(function ($response) { + return new Response($response); + }, $results); + } + + /** + * Send one batch of prepared requests and convert each result. + * + * A Requests exception for the whole batch, such as no working transport, fails + * every request in it rather than escaping the pool. + * + * @param array $batch The prepared requests keyed by response key. + * + * @return array + * + * @since 3.3.0 + */ + protected function send_prepared_batch(array $batch) + { + $requests = array_map(function ($prepared) { + return $prepared['request']; + }, $batch); + + try { + $results = $this->send_batch($requests); + } catch (\WpOrg\Requests\Exception $exception) { + $results = array_fill_keys(array_keys($batch), $exception); + } + + $responses = []; + + foreach ($batch as $key => $prepared) { + $responses[$key] = $this->convert_result($results[$key], $prepared['url'], $prepared['args']); + } + + return $responses; + } + + /** + * Send a batch of requests concurrently. This is the only place the network is touched. + * + * @param array $requests The Requests payloads keyed by response key. + * + * @return array The Requests response or exception for each key. + * + * @since 3.3.0 + */ + protected function send_batch(array $requests) + { + mbstring_binary_safe_encoding(); + + try { + return \WpOrg\Requests\Requests::request_multiple($requests); + } finally { + reset_mbstring_encoding(); + } + } + + /** + * Run everything WP_Http::request() does before the transport for one request. + * + * Returns ['response' => $value] when the request is answered without the network + * (short-circuited, rejected or blocked), otherwise the Requests payload with the + * final URL and arguments. + * + * @param \WP_Http $http The WordPress HTTP instance used for host blocking. + * @param string $url The url. + * @param array $args The request arguments. + * + * @return array + * + * @since 3.3.0 + */ + protected function prepare_request($http, string $url, array $args) + { + $args = wp_parse_args($args, $this->default_args($url, $args)); + $args = apply_filters('http_request_args', $args, $url); + + if (!isset($args['_redirection'])) { + $args['_redirection'] = $args['redirection']; + } + + // WordPress returns a short-circuit as is, with no debug action or response filter. + $pre = apply_filters('pre_http_request', false, $args, $url); + + if ($pre !== false) { + return ['response' => $pre]; + } + + if (function_exists('wp_kses_bad_protocol')) { + if ($args['reject_unsafe_urls']) { + $url = wp_http_validate_url($url); + } + + if ($url) { + $url = wp_kses_bad_protocol($url, ['http', 'https', 'ssl']); + } + } + + $parsed_url = parse_url((string) $url); + + if (empty($url) || empty($parsed_url['scheme'])) { + return ['response' => $this->reject( + new \WP_Error('http_request_failed', __('A valid URL was not provided.')), + (string) $url, + $args + )]; + } + + if ($http->block_request($url)) { + return ['response' => $this->reject( + new \WP_Error('http_request_not_executed', __('User has blocked requests through HTTP.')), + $url, + $args + )]; + } + + if ($args['stream']) { + if (empty($args['filename'])) { + $args['filename'] = get_temp_dir() . basename($url); + } + + $args['blocking'] = true; + + if (!wp_is_writable(dirname($args['filename']))) { + return ['response' => $this->reject( + new \WP_Error( + 'http_request_failed', + __('Destination directory for file streaming does not exist or is not writable.') + ), + $url, + $args + )]; + } + } + + if (is_null($args['headers'])) { + $args['headers'] = []; + } + + if (!is_array($args['headers'])) { + $processed_headers = \WP_Http::processHeaders($args['headers']); + $args['headers'] = $processed_headers['headers']; + } + + return [ + 'request' => [ + 'url' => $url, + 'headers' => $args['headers'], + 'data' => $args['body'], + 'type' => $args['method'], + 'options' => $this->requests_options($url, $args), + ], + 'url' => $url, + 'args' => $args, + ]; + } + + /** + * Build the default arguments WP_Http::request() merges under the caller's arguments. + * + * @param string $url The url. + * @param array $args The caller's arguments, read only for the HEAD redirection rule. + * + * @return array + * + * @since 3.3.0 + */ + protected function default_args(string $url, array $args) + { + $defaults = [ + 'method' => 'GET', + 'timeout' => apply_filters('http_request_timeout', 5, $url), + 'redirection' => apply_filters('http_request_redirection_count', 5, $url), + 'httpversion' => apply_filters('http_request_version', '1.0', $url), + 'user-agent' => apply_filters( + 'http_headers_useragent', + 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'), + $url + ), + 'reject_unsafe_urls' => apply_filters('http_request_reject_unsafe_urls', false, $url), + 'blocking' => true, + 'headers' => [], + 'cookies' => [], + 'body' => null, + 'compress' => false, + 'decompress' => true, + 'sslverify' => true, + 'sslcertificates' => ABSPATH . WPINC . '/certificates/ca-bundle.crt', + 'stream' => false, + 'filename' => null, + 'limit_response_size' => null, + ]; + + // By default, HEAD requests do not follow redirects. + if (isset($args['method']) && $args['method'] === 'HEAD') { + $defaults['redirection'] = 0; + } + + return $defaults; + } + + /** + * Convert WordPress request arguments to Requests options, as WP_Http::request() does. + * + * @param string $url The final url. + * @param array $args The final request arguments. + * + * @return array + * + * @since 3.3.0 + */ + protected function requests_options(string $url, array $args) + { + $options = [ + 'timeout' => $args['timeout'], + 'useragent' => $args['user-agent'], + 'blocking' => $args['blocking'], + 'hooks' => new \WP_HTTP_Requests_Hooks($url, $args), + ]; + + $options['hooks']->register('requests.before_redirect', [\WP_Http::class, 'browser_redirect_compatibility']); + + if (function_exists('wp_kses_bad_protocol') && $args['reject_unsafe_urls']) { + $options['hooks']->register('requests.before_redirect', [\WP_Http::class, 'validate_redirects']); + } + + if ($args['stream']) { + $options['filename'] = $args['filename']; + } + + if (empty($args['redirection'])) { + $options['follow_redirects'] = false; + } else { + $options['redirects'] = $args['redirection']; + } + + if (isset($args['limit_response_size'])) { + $options['max_bytes'] = $args['limit_response_size']; + } + + if (!empty($args['cookies'])) { + $options['cookies'] = \WP_Http::normalize_cookies($args['cookies']); + } + + if (!$args['sslverify']) { + $options['verify'] = false; + $options['verifyname'] = false; + } else { + $options['verify'] = $args['sslcertificates']; + } + + if ($args['method'] !== 'HEAD' && $args['method'] !== 'GET') { + $options['data_format'] = 'body'; + } + + $options['verify'] = apply_filters('https_ssl_verify', $options['verify'], $url); + + $proxy = new \WP_HTTP_Proxy(); + + if ($proxy->is_enabled() && $proxy->send_through_proxy($url)) { + $options['proxy'] = new \WpOrg\Requests\Proxy\Http($proxy->host() . ':' . $proxy->port()); + + if ($proxy->use_authentication()) { + $options['proxy']->use_authentication = true; + $options['proxy']->user = $proxy->username(); + $options['proxy']->pass = $proxy->password(); + } + } + + return $options; + } + + /** + * Convert one Requests result to the value wp_remote_request() would return. + * + * @param mixed $result The Requests response or exception. + * @param string $url The final url. + * @param array $args The final request arguments. + * + * @return array|\WP_Error + * + * @since 3.3.0 + */ + protected function convert_result($result, string $url, array $args) + { + if ($result instanceof \WpOrg\Requests\Exception) { + $response = new \WP_Error('http_request_failed', $result->getMessage()); + } else { + $http_response = new \WP_HTTP_Requests_Response($result, $args['filename']); + $response = $http_response->to_array(); + $response['http_response'] = $http_response; + } + + do_action('http_api_debug', $response, 'response', \WpOrg\Requests\Requests::class, $args, $url); + + if (is_wp_error($response)) { + return $response; + } + + if (!$args['blocking']) { + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => false, + 'message' => false, + ], + 'cookies' => [], + 'http_response' => null, + ]; + } + + return apply_filters('http_response', $response, $args, $url); + } + + /** + * Fire the debug action for a request rejected before the transport and return the error. + * + * @param \WP_Error $error The rejection. + * @param string $url The url. + * @param array $args The request arguments. + * + * @return \WP_Error + * + * @since 3.3.0 + */ + protected function reject($error, string $url, array $args) + { + do_action('http_api_debug', $error, 'response', \WpOrg\Requests\Requests::class, $args, $url); + + return $error; + } +} diff --git a/src/Http/Client/PooledRequest.php b/src/Http/Client/PooledRequest.php new file mode 100644 index 0000000..f7479a4 --- /dev/null +++ b/src/Http/Client/PooledRequest.php @@ -0,0 +1,74 @@ +recorded = [$url, $args]; + + return $this; + } + + /** + * Determine if a verb method has recorded this request. + * + * @return bool + * + * @since 3.3.0 + */ + public function is_recorded() + { + return $this->recorded !== null; + } + + /** + * Get the recorded URL and arguments as a [$url, $args] pair. + * + * @return array|null + * + * @since 3.3.0 + */ + public function get_recorded() + { + return $this->recorded; + } +} diff --git a/src/Http/Client/Request.php b/src/Http/Client/Request.php index 4bda12c..9c627a9 100644 --- a/src/Http/Client/Request.php +++ b/src/Http/Client/Request.php @@ -13,6 +13,7 @@ defined('ABSPATH') || exit; use BadMethodCallException; +use InvalidArgumentException; use Framework\Http\Cookie; use Framework\Supports\Arr; use Framework\Supports\Str; @@ -716,6 +717,51 @@ public function send(string $method, string $url, array $options = []) return $this->send_request($method, $url, $options); } + /** + * Send several requests concurrently and return their responses. + * + * The callback receives a Pool. Each $pool->as($key) or $pool->method() call starts + * a fresh request that does not inherit this request's settings. Verb methods on a + * pooled request record it; the pool sends everything once the callback returns. + * + * @param callable $callback Receives the Pool to add requests to. + * @param array $options Pool options: 'concurrency', the maximum requests in flight (default 10). + * + * @return array + * + * @throws \InvalidArgumentException When the concurrency is not a positive integer. + * + * @since 3.3.0 + */ + public function pool(callable $callback, array $options = []) + { + $concurrency = $options['concurrency'] ?? Pool::DEFAULT_CONCURRENCY; + + throw_unless( + is_int($concurrency) && $concurrency >= 1, + 'The pool concurrency must be a positive integer.', + InvalidArgumentException::class + ); + + $pool = $this->new_pool(); + + $callback($pool); + + return $pool->send($concurrency); + } + + /** + * Create a new pool. + * + * @return Pool + * + * @since 3.3.0 + */ + protected function new_pool() + { + return new Pool(); + } + /** * Send a request. * @@ -725,11 +771,32 @@ public function send(string $method, string $url, array $options = []) * * @return Response * - * @throws \RuntimeException - * * @since 1.0.0 */ protected function send_request(string $method, string $url, array $options = []) + { + [$url, $args] = $this->build_request($method, $url, $options); + + return $this->dispatch($url, $args); + } + + /** + * Build the final URL and the arguments the WordPress HTTP layer receives. + * + * Kept apart from dispatch so a pooled request is built by exactly the same + * code as a single one and only the sending differs. + * + * @param string $method The method name. + * @param string $url The url. + * @param array $options The options array. + * + * @return array + * + * @throws \RuntimeException + * + * @since 3.3.0 + */ + protected function build_request(string $method, string $url, array $options = []) { throw_unless( $this->is_valid_method($method), @@ -742,9 +809,22 @@ protected function send_request(string $method, string $url, array $options = [] $body = $this->prepare_request_body($method, $data); $args = $this->prepare_request_args($body); - $response = wp_remote_request($url, $args); + return [$url, $args]; + } - return $this->new_response($response); + /** + * Dispatch a built request through the WordPress HTTP API. + * + * @param string $url The final url. + * @param array $args The request arguments. + * + * @return Response + * + * @since 3.3.0 + */ + protected function dispatch(string $url, array $args) + { + return $this->new_response(wp_remote_request($url, $args)); } /** diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index a8c0f8b..465052e 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -237,7 +237,7 @@ public function server_error() } /** - * Determine if the response status code was a client or server error. + * Determine if the request failed: a transport error, or a client or server error status. * * @return bool * @@ -245,7 +245,7 @@ public function server_error() */ public function failed() { - return $this->server_error() || $this->client_error(); + return is_wp_error($this->response) || $this->server_error() || $this->client_error(); } /** diff --git a/src/Supports/Facades/Http.php b/src/Supports/Facades/Http.php index cd0e2cd..b885280 100644 --- a/src/Supports/Facades/Http.php +++ b/src/Supports/Facades/Http.php @@ -41,6 +41,7 @@ * @method static \Framework\Http\Client\Request method(string $method) * @method static \Framework\Http\Client\Request attach(string $name, string $content, ?string $filename = null, array $headers = []) * @method static \Framework\Http\Client\Request macro($name, callable $macro) + * @method static array pool(callable $callback, array $options = []) * @see \Framework\Http\Client\Request */ diff --git a/tests/Support/Http/RecordingPool.php b/tests/Support/Http/RecordingPool.php new file mode 100644 index 0000000..ded2f44 --- /dev/null +++ b/tests/Support/Http/RecordingPool.php @@ -0,0 +1,41 @@ + + */ + public array $batches = []; + + /** + * Produces the Requests result for one payload. + * + * @var callable|null + */ + public $responder; + + protected function send_batch(array $requests) + { + $this->batches[] = $requests; + + $responder = $this->responder ?? function (array $request) { + return new RequestsResponse(200, $request['url']); + }; + + return array_map($responder, $requests); + } +} diff --git a/tests/Support/Http/RecordingRequest.php b/tests/Support/Http/RecordingRequest.php new file mode 100644 index 0000000..7b90e41 --- /dev/null +++ b/tests/Support/Http/RecordingRequest.php @@ -0,0 +1,33 @@ +pool = new RecordingPool(); + $this->pool->responder = $this->responder; + + return $this->pool; + } +} diff --git a/tests/Support/StubsWordPressFunctions.php b/tests/Support/StubsWordPressFunctions.php index 4cdfedd..3751f90 100644 --- a/tests/Support/StubsWordPressFunctions.php +++ b/tests/Support/StubsWordPressFunctions.php @@ -41,6 +41,21 @@ function framework_test_do_action($hook_name) if (!function_exists('add_filter')) { function add_filter($hook_name, $callback, $priority = 10, $accepted_args = 1) { + $GLOBALS['framework_test_filters'][$hook_name][$priority][] = [$callback, (int) $accepted_args]; + + return true; + } +} + +if (!function_exists('remove_all_filters')) { + function remove_all_filters($hook_name, $priority = false) + { + if ($priority === false) { + unset($GLOBALS['framework_test_filters'][$hook_name]); + } else { + unset($GLOBALS['framework_test_filters'][$hook_name][$priority]); + } + return true; } } @@ -108,6 +123,16 @@ function wp_parse_url($url, $component = -1) if (!function_exists('apply_filters')) { function apply_filters($hook_name, $value, ...$args) { + $callbacks = $GLOBALS['framework_test_filters'][$hook_name] ?? []; + + ksort($callbacks); + + foreach ($callbacks as $priority_callbacks) { + foreach ($priority_callbacks as [$callback, $accepted_args]) { + $value = $callback(...array_slice(array_merge([$value], $args), 0, $accepted_args)); + } + } + return $value; } } @@ -1016,3 +1041,145 @@ public static function error($message, $exit = true) } } } + +if (!defined('WPINC')) { + define('WPINC', 'wp-includes'); +} + +if (!function_exists('do_action')) { + function do_action($hook_name, ...$args) + { + $GLOBALS['framework_test_did_actions'][] = [$hook_name, $args]; + + foreach ($GLOBALS['framework_test_actions'][$hook_name] ?? [] as $callback) { + $callback(...$args); + } + } +} + +if (!function_exists('wp_parse_args')) { + function wp_parse_args($args, $defaults = []) + { + if (is_object($args)) { + $parsed_args = get_object_vars($args); + } elseif (is_array($args)) { + $parsed_args = &$args; + } else { + parse_str((string) $args, $parsed_args); + } + + return is_array($defaults) && $defaults ? array_merge($defaults, $parsed_args) : $parsed_args; + } +} + +if (!function_exists('get_bloginfo')) { + function get_bloginfo($show = '', $filter = 'raw') + { + $info = [ + 'version' => '6.9.4', + 'url' => 'https://example.test', + ]; + + return $info[$show] ?? ''; + } +} + +if (!function_exists('wp_http_validate_url')) { + function wp_http_validate_url($url) + { + return in_array($url, $GLOBALS['framework_test_unsafe_urls'] ?? [], true) ? false : $url; + } +} + +if (!function_exists('wp_kses_bad_protocol')) { + function wp_kses_bad_protocol($content, $allowed_protocols) + { + return $content; + } +} + +if (!function_exists('get_temp_dir')) { + function get_temp_dir() + { + return rtrim(sys_get_temp_dir(), '/') . '/'; + } +} + +if (!function_exists('mbstring_binary_safe_encoding')) { + function mbstring_binary_safe_encoding($reset = false) + { + } +} + +if (!function_exists('reset_mbstring_encoding')) { + function reset_mbstring_encoding() + { + } +} + +if (!function_exists('wp_remote_request')) { + function wp_remote_request($url, $args = []) + { + $GLOBALS['framework_test_remote_requests'][] = [$url, $args]; + + $pre = apply_filters('pre_http_request', false, $args, $url); + + if ($pre !== false) { + return $pre; + } + + return $GLOBALS['framework_test_remote_response'] ?? [ + 'headers' => [], + 'body' => '', + 'response' => ['code' => 200, 'message' => 'OK'], + 'cookies' => [], + 'filename' => null, + ]; + } +} + +if (!function_exists('wp_remote_retrieve_body')) { + function wp_remote_retrieve_body($response) + { + if (is_wp_error($response) || !isset($response['body'])) { + return ''; + } + + return $response['body']; + } +} + +if (!function_exists('wp_remote_retrieve_response_code')) { + function wp_remote_retrieve_response_code($response) + { + if (is_wp_error($response) || !isset($response['response']) || !is_array($response['response'])) { + return ''; + } + + return $response['response']['code']; + } +} + +if (!function_exists('wp_remote_retrieve_response_message')) { + function wp_remote_retrieve_response_message($response) + { + if (is_wp_error($response) || !isset($response['response']) || !is_array($response['response'])) { + return ''; + } + + return $response['response']['message']; + } +} + +if (!function_exists('wp_remote_retrieve_header')) { + function wp_remote_retrieve_header($response, $header) + { + if (is_wp_error($response) || !isset($response['headers'])) { + return ''; + } + + $headers = array_change_key_case((array) $response['headers'], CASE_LOWER); + + return $headers[strtolower($header)] ?? ''; + } +} diff --git a/tests/Support/StubsWordPressHttp.php b/tests/Support/StubsWordPressHttp.php new file mode 100644 index 0000000..c8f236c --- /dev/null +++ b/tests/Support/StubsWordPressHttp.php @@ -0,0 +1,195 @@ +status_code = $status_code; + $this->body = $body; + $this->headers = $headers; + } + } + } +} + +namespace WpOrg\Requests\Proxy { + if (!class_exists(Http::class)) { + class Http + { + public $proxy; + + public $use_authentication = false; + + public $user; + + public $pass; + + public function __construct($args = null) + { + $this->proxy = $args; + } + } + } +} + +namespace { + if (!class_exists('WP_Http')) { + class WP_Http + { + public function block_request($uri) + { + if (empty($GLOBALS['framework_test_block_external'])) { + return false; + } + + $host = parse_url($uri, PHP_URL_HOST); + + if ($host === 'localhost' || $host === parse_url(get_bloginfo('url'), PHP_URL_HOST)) { + return false; + } + + return !in_array($host, $GLOBALS['framework_test_accessible_hosts'] ?? [], true); + } + + public static function normalize_cookies($cookies) + { + return $cookies; + } + + public static function processHeaders($headers, $url = '') + { + $parsed = []; + + foreach (preg_split('/\r?\n/', (string) $headers) as $line) { + if (strpos($line, ':') !== false) { + [$name, $value] = explode(':', $line, 2); + $parsed[strtolower(trim($name))] = trim($value); + } + } + + return ['headers' => $parsed]; + } + + public static function browser_redirect_compatibility($location, $headers, $data, &$options, $original) + { + } + + public static function validate_redirects($location) + { + } + } + } + + if (!class_exists('WP_HTTP_Proxy')) { + class WP_HTTP_Proxy + { + public function is_enabled() + { + return !empty($GLOBALS['framework_test_proxy']); + } + + public function send_through_proxy($uri) + { + return true; + } + + public function host() + { + return $GLOBALS['framework_test_proxy']['host'] ?? ''; + } + + public function port() + { + return $GLOBALS['framework_test_proxy']['port'] ?? ''; + } + + public function use_authentication() + { + return isset($GLOBALS['framework_test_proxy']['user']); + } + + public function username() + { + return $GLOBALS['framework_test_proxy']['user'] ?? ''; + } + + public function password() + { + return $GLOBALS['framework_test_proxy']['pass'] ?? ''; + } + } + } + + if (!class_exists('WP_HTTP_Requests_Hooks')) { + class WP_HTTP_Requests_Hooks + { + public $url; + + public $request; + + public $registered = []; + + public function __construct($url, $request) + { + $this->url = $url; + $this->request = $request; + } + + public function register($hook, $callback, $priority = 0) + { + $this->registered[$hook][] = $callback; + } + } + } + + if (!class_exists('WP_HTTP_Requests_Response')) { + class WP_HTTP_Requests_Response + { + public $response; + + public $filename; + + public function __construct($response, $filename = '') + { + $this->response = $response; + $this->filename = $filename; + } + + public function to_array() + { + return [ + 'headers' => $this->response->headers, + 'body' => $this->response->body, + 'response' => [ + 'code' => $this->response->status_code, + 'message' => $this->response->status_code === 200 ? 'OK' : 'Error', + ], + 'cookies' => [], + 'filename' => $this->filename, + ]; + } + } + } +} diff --git a/tests/Unit/Http/Client/PoolTest.php b/tests/Unit/Http/Client/PoolTest.php new file mode 100644 index 0000000..396cf91 --- /dev/null +++ b/tests/Unit/Http/Client/PoolTest.php @@ -0,0 +1,518 @@ +pool->batches as $batch) { + foreach ($batch as $request) { + $urls[] = $request['url']; + } + } + + return $urls; + } + + protected function batch_sizes(RecordingRequest $client): array + { + return array_map('count', $client->pool->batches); + } + + protected function debug_actions(): array + { + return array_values(array_filter($GLOBALS['framework_test_did_actions'] ?? [], function ($action) { + return $action[0] === 'http_api_debug'; + })); + } + + public function test_responses_keep_insertion_order_with_keyed_and_unkeyed_requests(): void + { + $client = new RecordingRequest(); + + $responses = $client->pool(function ($pool) { + $pool->as('a')->get('https://a.test/1'); + $pool->get('https://a.test/2'); + $pool->as('b')->get('https://a.test/3'); + $pool->get('https://a.test/4'); + }); + + $this->assertSame(['a', 0, 'b', 1], array_keys($responses)); + $this->assertContainsOnlyInstancesOf(Response::class, $responses); + $this->assertSame('https://a.test/1', $responses['a']->body()); + $this->assertSame('https://a.test/2', $responses[0]->body()); + $this->assertSame('https://a.test/3', $responses['b']->body()); + $this->assertSame('https://a.test/4', $responses[1]->body()); + } + + public function test_unkeyed_request_follows_php_append_rules_after_an_integer_key(): void + { + $responses = (new RecordingRequest())->pool(function ($pool) { + $pool->as(5)->get('https://a.test/5'); + $pool->get('https://a.test/6'); + }); + + $this->assertSame([5, 6], array_keys($responses)); + $this->assertSame('https://a.test/6', $responses[6]->body()); + } + + public function test_verb_methods_return_the_pooled_request_and_send_nothing_until_the_callback_returns(): void + { + $client = new RecordingRequest(); + $seen = []; + + $client->pool(function ($pool) use ($client, &$seen) { + $seen[] = $pool->as('a')->timeout(15)->get('https://a.test/1'); + $seen[] = $pool->post('https://a.test/2', ['x' => 1]); + $seen[] = count($client->pool->batches); + }); + + $this->assertInstanceOf(PooledRequest::class, $seen[0]); + $this->assertInstanceOf(PooledRequest::class, $seen[1]); + $this->assertSame(0, $seen[2]); + $this->assertSame(['https://a.test/1', 'https://a.test/2'], $this->sent_urls($client)); + } + + public function test_duplicate_key_throws_and_nothing_is_sent(): void + { + $client = new RecordingRequest(); + + try { + $client->pool(function ($pool) { + $pool->as('a')->get('https://a.test/1'); + $pool->as('a')->get('https://a.test/2'); + }); + $this->fail('A duplicate key must throw.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString('[a]', $exception->getMessage()); + } + + $this->assertSame([], $client->pool->batches); + } + + public function test_explicit_key_colliding_with_an_unkeyed_index_throws(): void + { + $this->expectException(InvalidArgumentException::class); + + (new RecordingRequest())->pool(function ($pool) { + $pool->get('https://a.test/1'); + $pool->as(0)->get('https://a.test/2'); + }); + } + + public function test_request_without_a_verb_throws_before_anything_is_sent(): void + { + $client = new RecordingRequest(); + + try { + $client->pool(function ($pool) { + $pool->as('a')->get('https://a.test/1'); + $pool->as('forgotten')->timeout(5); + }); + $this->fail('A pooled request with no verb must throw.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('[forgotten]', $exception->getMessage()); + } + + $this->assertSame([], $client->pool->batches); + } + + public function test_failures_are_isolated_per_request(): void + { + add_filter('pre_http_request', function ($pre, $args, $url) { + return $url === 'https://a.test/wp-error' ? new WP_Error('http_request_failed', 'stubbed') : $pre; + }, 10, 3); + + $client = new RecordingRequest(); + $client->responder = function (array $request) { + switch ($request['url']) { + case 'https://a.test/500': + return new RequestsResponse(500, 'boom'); + case 'https://a.test/exception': + return new RequestsException('cURL error 28: timed out'); + default: + return new RequestsResponse(200, $request['url']); + } + }; + + $responses = $client->pool(function ($pool) { + $pool->as('ok')->get('https://a.test/ok'); + $pool->as('wp-error')->get('https://a.test/wp-error'); + $pool->as('500')->get('https://a.test/500'); + $pool->as('exception')->get('https://a.test/exception'); + $pool->as('ok2')->get('https://a.test/ok2'); + }); + + $this->assertTrue($responses['ok']->successful()); + $this->assertSame('https://a.test/ok', $responses['ok']->body()); + $this->assertTrue($responses['ok2']->successful()); + $this->assertSame('https://a.test/ok2', $responses['ok2']->body()); + + $this->assertFalse($responses['wp-error']->successful()); + $this->assertTrue($responses['wp-error']->failed()); + + $this->assertSame(500, $responses['500']->status()); + $this->assertFalse($responses['500']->successful()); + $this->assertTrue($responses['500']->failed()); + + $this->assertFalse($responses['exception']->successful()); + $this->assertTrue($responses['exception']->failed()); + $this->assertSame(0, $responses['exception']->status()); + } + + public function test_transport_exception_becomes_a_wp_error_with_the_message(): void + { + $captured = null; + + add_filter('http_response', function ($response) { + return $response; + }); + add_action('http_api_debug', function ($response) use (&$captured) { + $captured = $response; + }); + + $client = new RecordingRequest(); + $client->responder = function () { + return new RequestsException('cURL error 6: could not resolve host'); + }; + + $client->pool(function ($pool) { + $pool->get('https://a.test/1'); + }); + + $this->assertInstanceOf(WP_Error::class, $captured); + $this->assertSame('http_request_failed', $captured->get_error_code()); + $this->assertSame('cURL error 6: could not resolve host', $captured->get_error_message()); + } + + public function test_pre_http_request_receives_the_final_url_and_args_and_the_request_is_not_sent(): void + { + $calls = []; + $filtered_responses = 0; + + add_filter('pre_http_request', function ($pre, $args, $url) use (&$calls) { + $calls[] = [$url, $args]; + + return strpos($url, 'stubbed') !== false + ? ['headers' => [], 'body' => 'from-filter', 'response' => ['code' => 201, 'message' => 'Created']] + : $pre; + }, 10, 3); + add_filter('http_response', function ($response) use (&$filtered_responses) { + $filtered_responses++; + + return $response; + }); + + $client = new RecordingRequest(); + + $responses = $client->pool(function ($pool) { + $pool->as('stubbed')->with_headers(['X-Custom' => 'yes'])->get('https://a.test/stubbed', ['page' => 2]); + $pool->as('sent')->get('https://a.test/sent'); + }); + + [$url, $args] = $calls[0]; + + $this->assertSame('https://a.test/stubbed?page=2', $url); + $this->assertSame('yes', $args['headers']['X-Custom']); + $this->assertSame(['https://a.test/sent'], $this->sent_urls($client)); + $this->assertSame('from-filter', $responses['stubbed']->body()); + $this->assertSame(201, $responses['stubbed']->status()); + + $debugged_urls = array_map(function ($action) { + return $action[1][4]; + }, $this->debug_actions()); + + $this->assertSame(['https://a.test/sent'], $debugged_urls); + $this->assertSame(1, $filtered_responses); + } + + public function test_http_request_args_changes_every_pooled_request(): void + { + add_filter('http_request_args', function ($args, $url) { + $args['headers']['X-Added'] = 'for ' . $url; + + return $args; + }, 10, 2); + + $client = new RecordingRequest(); + + $client->pool(function ($pool) { + $pool->get('https://a.test/1'); + $pool->post('https://a.test/2', ['x' => 1]); + }); + + $batch = $client->pool->batches[0]; + + $this->assertSame('for https://a.test/1', $batch[0]['headers']['X-Added']); + $this->assertSame('for https://a.test/2', $batch[1]['headers']['X-Added']); + } + + public function test_blocked_external_host_fails_only_that_request(): void + { + $GLOBALS['framework_test_block_external'] = true; + $GLOBALS['framework_test_accessible_hosts'] = ['allowed.test']; + + $client = new RecordingRequest(); + + $responses = $client->pool(function ($pool) { + $pool->as('allowed')->get('https://allowed.test/a.jpg'); + $pool->as('blocked')->get('https://blocked.test/b.jpg'); + }); + + $this->assertSame(['https://allowed.test/a.jpg'], $this->sent_urls($client)); + $this->assertTrue($responses['allowed']->successful()); + $this->assertTrue($responses['blocked']->failed()); + + $blocked_debug = $this->debug_actions()[0][1]; + + $this->assertInstanceOf(WP_Error::class, $blocked_debug[0]); + $this->assertSame('http_request_not_executed', $blocked_debug[0]->get_error_code()); + } + + public function test_unsafe_url_with_reject_unsafe_urls_fails_only_that_request(): void + { + $GLOBALS['framework_test_unsafe_urls'] = ['https://unsafe.test/x']; + + $client = new RecordingRequest(); + + $responses = $client->pool(function ($pool) { + $pool->as('unsafe')->with_options(['reject_unsafe_urls' => true])->get('https://unsafe.test/x'); + $pool->as('unchecked')->get('https://unsafe.test/x'); + $pool->as('safe')->with_options(['reject_unsafe_urls' => true])->get('https://safe.test/y'); + }); + + $this->assertTrue($responses['unsafe']->failed()); + $this->assertTrue($responses['unchecked']->successful()); + $this->assertTrue($responses['safe']->successful()); + $this->assertSame(['https://unsafe.test/x', 'https://safe.test/y'], $this->sent_urls($client)); + } + + public function test_fluent_options_build_the_same_url_and_args_as_a_single_request(): void + { + $build = function (Request $request) { + return $request + ->with_headers(['X-Trace' => 'abc']) + ->with_token('secret') + ->timeout(15) + ->accept_json(); + }; + + $build(new Request())->post('https://a.test/items?ignored=1', ['name' => 'Tote']); + $build(new Request())->get('https://a.test/items', ['page' => 2]); + + $single = $GLOBALS['framework_test_remote_requests']; + $pooled = []; + + (new RecordingRequest())->pool(function ($pool) use ($build, &$pooled) { + $pooled[] = $build($pool->as('post'))->post('https://a.test/items?ignored=1', ['name' => 'Tote'])->get_recorded(); + $pooled[] = $build($pool->as('get'))->get('https://a.test/items', ['page' => 2])->get_recorded(); + }); + + $this->assertEquals($single, $pooled); + $this->assertSame('https://a.test/items?page=2', $pooled[1][0]); + $this->assertSame('{"name":"Tote"}', $pooled[0][1]['body']); + } + + public function test_pooled_requests_do_not_inherit_the_parent_request_settings(): void + { + $client = (new RecordingRequest())->base_url('https://parent.test')->with_token('parent'); + $recorded = null; + + $client->pool(function ($pool) use (&$recorded) { + $recorded = $pool->get('https://a.test/1')->get_recorded(); + }); + + $this->assertArrayNotHasKey('Authorization', $recorded[1]['headers']); + } + + public function test_concurrency_splits_network_requests_into_batches_in_order(): void + { + $client = new RecordingRequest(); + + $client->pool(function ($pool) { + for ($i = 0; $i < 25; $i++) { + $pool->get('https://a.test/' . $i); + } + }, ['concurrency' => 10]); + + $this->assertSame([10, 10, 5], $this->batch_sizes($client)); + $this->assertSame(array_map(function ($i) { + return 'https://a.test/' . $i; + }, range(0, 24)), $this->sent_urls($client)); + } + + public function test_short_circuited_requests_do_not_take_a_batch_slot(): void + { + add_filter('pre_http_request', function ($pre, $args, $url) { + return in_array($url, ['https://a.test/3', 'https://a.test/7'], true) + ? ['headers' => [], 'body' => '', 'response' => ['code' => 200, 'message' => 'OK']] + : $pre; + }, 10, 3); + + $client = new RecordingRequest(); + + $responses = $client->pool(function ($pool) { + for ($i = 0; $i < 12; $i++) { + $pool->get('https://a.test/' . $i); + } + }, ['concurrency' => 10]); + + $this->assertSame([10], $this->batch_sizes($client)); + $this->assertCount(12, $responses); + } + + public function test_default_concurrency_is_ten(): void + { + $client = new RecordingRequest(); + + $client->pool(function ($pool) { + for ($i = 0; $i < 11; $i++) { + $pool->get('https://a.test/' . $i); + } + }); + + $this->assertSame([10, 1], $this->batch_sizes($client)); + } + + /** + * @dataProvider invalid_concurrency_provider + */ + public function test_invalid_concurrency_throws_before_the_callback_runs($concurrency): void + { + $called = false; + + try { + (new RecordingRequest())->pool(function () use (&$called) { + $called = true; + }, ['concurrency' => $concurrency]); + $this->fail('An invalid concurrency must throw.'); + } catch (InvalidArgumentException $exception) { + $this->assertFalse($called); + } + } + + public function invalid_concurrency_provider(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'numeric string' => ['10'], + 'float' => [2.5], + ]; + } + + public function test_empty_pool_returns_an_empty_array_and_sends_nothing(): void + { + $client = new RecordingRequest(); + + $this->assertSame([], $client->pool(function () { + })); + $this->assertSame([], $client->pool->batches); + } + + public function test_debug_action_fires_once_per_sent_request_and_response_filter_wins(): void + { + add_filter('http_response', function ($response, $args, $url) { + $response['body'] = 'filtered ' . $url; + + return $response; + }, 10, 3); + + $responses = (new RecordingRequest())->pool(function ($pool) { + $pool->as('a')->timeout(7)->get('https://a.test/1'); + $pool->as('b')->get('https://a.test/2'); + }); + + $debug = $this->debug_actions(); + + $this->assertCount(2, $debug); + [$response, $context, $class, $args, $url] = $debug[0][1]; + $this->assertSame('response', $context); + $this->assertSame('WpOrg\Requests\Requests', $class); + $this->assertSame(7, $args['timeout']); + $this->assertSame('https://a.test/1', $url); + $this->assertArrayHasKey('http_response', $response); + $this->assertSame('filtered https://a.test/1', $responses['a']->body()); + $this->assertSame('filtered https://a.test/2', $responses['b']->body()); + } + + public function test_non_blocking_request_gets_the_wordpress_empty_response(): void + { + $responses = (new RecordingRequest())->pool(function ($pool) { + $pool->with_options(['blocking' => false])->get('https://a.test/1'); + }); + + $this->assertSame('', $responses[0]->body()); + $this->assertSame(0, $responses[0]->status()); + } + + public function test_requests_options_follow_wordpress(): void + { + $client = new RecordingRequest(); + + $client->pool(function ($pool) { + $pool->as('get')->timeout(9)->get('https://a.test/1'); + $pool->as('post')->without_verifying()->with_options(['limit_response_size' => 1024])->post('https://a.test/2', ['x' => 1]); + $pool->as('head')->head('https://a.test/3'); + }); + + [$get, $post, $head] = array_column($client->pool->batches[0], 'options'); + + $this->assertSame(9, $get['timeout']); + $this->assertSame(ABSPATH . 'wp-includes/certificates/ca-bundle.crt', $get['verify']); + $this->assertSame(5, $get['redirects']); + $this->assertArrayNotHasKey('data_format', $get); + $this->assertSame( + [['WP_Http', 'browser_redirect_compatibility']], + $get['hooks']->registered['requests.before_redirect'] + ); + + $this->assertFalse($post['verify']); + $this->assertFalse($post['verifyname']); + $this->assertSame('body', $post['data_format']); + $this->assertSame(1024, $post['max_bytes']); + + // Request sets redirection 5 itself, so HEAD keeps it rather than WordPress's HEAD default of 0. + $this->assertSame(5, $head['redirects']); + } + + public function test_https_ssl_verify_filter_sets_verify(): void + { + add_filter('https_ssl_verify', function () { + return '/custom/ca.pem'; + }); + + $client = new RecordingRequest(); + + $client->pool(function ($pool) { + $pool->get('https://a.test/1'); + }); + + $this->assertSame('/custom/ca.pem', $client->pool->batches[0][0]['options']['verify']); + } +} diff --git a/tests/Unit/Http/Client/RequestDispatchTest.php b/tests/Unit/Http/Client/RequestDispatchTest.php new file mode 100644 index 0000000..2f90ddc --- /dev/null +++ b/tests/Unit/Http/Client/RequestDispatchTest.php @@ -0,0 +1,97 @@ +assertNotEmpty($requests, 'wp_remote_request() was not called.'); + + return end($requests); + } + + public function test_get_with_query_appends_the_query_and_sends_no_body(): void + { + $response = (new Request())->get('https://api.example.test/items', ['page' => 2, 'q' => 'tote']); + + [$url, $args] = $this->last_remote_request(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame('https://api.example.test/items?page=2&q=tote', $url); + $this->assertSame('GET', $args['method']); + $this->assertNull($args['body']); + $this->assertSame('application/json', $args['headers']['Content-Type']); + } + + public function test_post_json_encodes_the_body(): void + { + (new Request())->timeout(15)->with_headers(['X-Trace' => 'abc'])->post('https://api.example.test/items', ['name' => 'Tote']); + + [$url, $args] = $this->last_remote_request(); + + $this->assertSame('https://api.example.test/items', $url); + $this->assertSame('POST', $args['method']); + $this->assertSame('{"name":"Tote"}', $args['body']); + $this->assertSame(15, $args['timeout']); + $this->assertSame('abc', $args['headers']['X-Trace']); + } + + public function test_form_body_is_sent_as_an_array(): void + { + (new Request())->as_form()->post('https://api.example.test/items', ['name' => 'Tote']); + + [, $args] = $this->last_remote_request(); + + $this->assertSame(['name' => 'Tote'], $args['body']); + $this->assertSame('application/x-www-form-urlencoded', $args['headers']['Content-Type']); + } + + public function test_multipart_body_sets_the_boundary_header_before_args_are_built(): void + { + (new Request())->as_multipart()->post('https://api.example.test/upload', ['name' => 'Tote']); + + [, $args] = $this->last_remote_request(); + + $this->assertStringStartsWith('multipart/form-data; boundary=', $args['headers']['Content-Type']); + $this->assertStringContainsString('name="name"', $args['body']); + } + + public function test_with_body_sends_the_raw_content(): void + { + (new Request())->with_body('', 'application/xml')->put('https://api.example.test/doc'); + + [, $args] = $this->last_remote_request(); + + $this->assertSame('PUT', $args['method']); + $this->assertSame('', $args['body']); + $this->assertSame('application/xml', $args['headers']['Content-Type']); + } + + public function test_relative_url_is_joined_to_the_base_url(): void + { + (new Request())->base_url('https://api.example.test/v1/')->get('/items'); + + [$url] = $this->last_remote_request(); + + $this->assertSame('https://api.example.test/v1/items', $url); + } + + public function test_invalid_method_throws_before_anything_is_sent(): void + { + $this->expectException(RuntimeException::class); + + try { + (new Request())->send('TRACE', 'https://api.example.test/items'); + } finally { + $this->assertEmpty($GLOBALS['framework_test_remote_requests'] ?? []); + } + } +} diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php new file mode 100644 index 0000000..a53a2c1 --- /dev/null +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -0,0 +1,49 @@ + [], + 'body' => '', + 'response' => ['code' => $code, 'message' => ''], + 'cookies' => [], + ]); + } + + public function test_wp_error_counts_as_failed(): void + { + $response = new Response(new WP_Error('http_request_failed', 'timed out')); + + $this->assertTrue($response->failed()); + $this->assertFalse($response->successful()); + $this->assertSame(0, $response->status()); + $this->assertFalse($response->client_error()); + $this->assertFalse($response->server_error()); + } + + public function test_on_error_runs_for_a_wp_error(): void + { + $called = false; + + (new Response(new WP_Error('http_request_failed', 'timed out')))->on_error(function () use (&$called) { + $called = true; + }); + + $this->assertTrue($called); + } + + public function test_status_based_results_are_unchanged(): void + { + $this->assertFalse($this->response_with_status(200)->failed()); + $this->assertTrue($this->response_with_status(404)->failed()); + $this->assertTrue($this->response_with_status(500)->failed()); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 4b0a252..612d41e 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -29,10 +29,21 @@ protected function tearDown(): void $this->reset_test_wpdb(); $this->reset_route_state(); $this->reset_facade_cache(); + $this->reset_wordpress_hooks(); parent::tearDown(); } + protected function reset_wordpress_hooks(): void + { + unset( + $GLOBALS['framework_test_filters'], + $GLOBALS['framework_test_did_actions'], + $GLOBALS['framework_test_remote_requests'], + $GLOBALS['framework_test_remote_response'] + ); + } + protected static function framework_path(): string { return dirname(__DIR__, 2); diff --git a/tests/Unit/bootstrap.php b/tests/Unit/bootstrap.php index 9858741..a3148ca 100644 --- a/tests/Unit/bootstrap.php +++ b/tests/Unit/bootstrap.php @@ -25,6 +25,8 @@ require_once dirname(__DIR__) . '/Support/StubsWordPressFunctions.php'; +require_once dirname(__DIR__) . '/Support/StubsWordPressHttp.php'; + require_once $framework_dir . '/vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php'; define('FRAMEWORK_UNIT_TESTS', true);