diff --git a/docs/classes/Response.md b/docs/classes/Response.md new file mode 100644 index 00000000..b7a5a8aa --- /dev/null +++ b/docs/classes/Response.md @@ -0,0 +1,106 @@ +# `Joomla\Http\Response` + +The object every transport returns. It extends `Laminas\Diactoros\Response`, which implements +`Psr\Http\Message\ResponseInterface`, so the full PSR-7 API is available and nothing beyond it is +added. + +## Reading a response + +```php +$response = $http->get('https://example.com/api/items'); + +$response->getStatusCode(); // 200 +$response->getReasonPhrase(); // 'OK' +$response->getProtocolVersion(); // '1.1' + +(string) $response->getBody(); // the body as a string +$response->getHeaderLine('Content-Type'); // 'application/json; charset=UTF-8' +$response->getHeaders(); // ['Content-Type' => ['application/json; …'], …] +$response->hasHeader('Location'); // bool +``` + +## Reading the body + +`getBody()` returns a PSR-7 stream. Cast it rather than calling `getContents()`: + +```php +$body = (string) $response->getBody(); +``` + +The cast rewinds the stream first, so a second read returns the body again. `getContents()` reads +from the current position, which is the end of the stream after the first call — a second call +returns an empty string. + +```php +$data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); +``` + +## Headers are lists + +PSR-7 models a header as a list of values, because a header may legitimately appear more than once: + +```php +$response->getHeader('Set-Cookie'); // ['a=1; Path=/', 'b=2; Path=/'] +$response->getHeaderLine('Set-Cookie'); // 'a=1; Path=/,b=2; Path=/' +``` + +Use `getHeaderLine()` when you want one string, `getHeader()` when you need the individual values. + +Header names are matched case-insensitively, so `getHeaderLine('content-type')` and +`getHeaderLine('Content-Type')` are equivalent. + +## Immutability + +PSR-7 messages are immutable. The `with*()` methods return a **new** instance and leave the +original untouched: + +```php +$modified = $response->withHeader('X-Trace', $id); + +$response->hasHeader('X-Trace'); // false +$modified->hasHeader('X-Trace'); // true +``` + +This rarely matters for a response you received, but it does when you build one to hand back — see +`Joomla\Application\AbstractWebApplication`, which replaces its stored response on every +`setHeader()` call. + +## Status codes + +The transports do not treat a 4xx or 5xx status as an error: a response is returned and it is up +to you to check. + +```php +$response = $http->get($url); + +if ($response->getStatusCode() >= 400) { + throw new \RuntimeException( + sprintf('Request to %s failed with status %d', $url, $response->getStatusCode()) + ); +} +``` + +`Joomla\Http\Exception\UnexpectedResponseException` exists for this purpose and carries the +response: + +```php +use Joomla\Http\Exception\UnexpectedResponseException; + +throw new UnexpectedResponseException($response, 'Unexpected status', $response->getStatusCode()); +``` + +Catching it gives you the response back through `getResponse()`. + +## The 1.x property access is gone + +Until 3.x the class carried a `__get()` that mapped the original 1.x property names onto the PSR-7 +methods, with a deprecation notice. It was removed in 4.0.0: + +```php +// Removed in 4.0.0 +$response->body; +$response->code; +$response->headers; +``` + +See [Updating from v3 to v4](../v3-to-v4-update.md#the-response-compatibility-getters-were-removed). diff --git a/docs/classes/TransportInterface.md b/docs/classes/TransportInterface.md new file mode 100644 index 00000000..ffb364a5 --- /dev/null +++ b/docs/classes/TransportInterface.md @@ -0,0 +1,114 @@ +# `Joomla\Http\TransportInterface` + +The contract every transport implements. `Http` holds one and delegates each request to it, so the +interface is the seam where you replace how requests are actually sent — with cURL, with a stream, +with a socket, or with a stub in tests. + +## The interface + +```php +namespace Joomla\Http; + +use Joomla\Uri\UriInterface; +use Psr\Http\Message\ResponseInterface; + +interface TransportInterface +{ + public function request($method, UriInterface $uri, $data = null, array $headers = [], $timeout = null, $userAgent = null); + + public static function isSupported(); +} +``` + +| Member | Meaning | +|---|---| +| `request()` | Perform the request and return a PSR-7 `ResponseInterface` | +| `isSupported()` | Whether this transport can run in the current environment — static, so it can be asked before instantiating | + +`AbstractTransport` implements the constructor (`__construct($options = [])`) and the option +accessors, so a custom transport normally extends it rather than implementing the interface +directly. + +## The bundled transports + +| Class | `isSupported()` checks | Notes | +|---|---|---| +| `Transport\Curl` | `ext-curl` loaded | The default when available | +| `Transport\Stream` | `allow_url_fopen` enabled | Configurable TLS via a stream context | +| `Transport\Socket` | `fsockopen()` exists | Cannot be configured for TLS — see below | + +`HttpFactory` picks one for you: + +```php +use Joomla\Http\HttpFactory; + +$http = (new HttpFactory())->getHttp(); // first supported transport +$http = (new HttpFactory())->getHttp([], 'curl'); // force one +$http = (new HttpFactory())->getHttp([], ['curl', 'stream']); // first supported of these +``` + +> Prefer `curl` or `stream`. `Socket` connects with `fsockopen()`, which accepts no stream context, +> so no CA bundle, minimum TLS version or client certificate can be supplied for it. + +## Writing a transport + +```php +use Joomla\Http\AbstractTransport; +use Joomla\Uri\UriInterface; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\Stream; +use Psr\Http\Message\ResponseInterface; + +final class RecordingTransport extends AbstractTransport +{ + /** @var array */ + public array $requests = []; + + public function request($method, UriInterface $uri, $data = null, array $headers = [], $timeout = null, $userAgent = null) + { + $this->requests[] = ['method' => $method, 'uri' => (string) $uri]; + + $body = new Stream('php://memory', 'rw'); + $body->write('{"ok":true}'); + + return new Response($body, 200, ['Content-Type' => 'application/json']); + } + + public static function isSupported() + { + return true; + } +} +``` + +Hand it to the client directly: + +```php +use Joomla\Http\Http; + +$transport = new RecordingTransport(); +$http = new Http([], $transport); + +$http->get('https://example.com/items'); + +// $transport->requests now holds what was asked for. +``` + +This is the cleanest way to test code that makes HTTP calls: no network, no stub server, and the +recorded requests are available for assertions. + +## Options + +`AbstractTransport::__construct($options = [])` accepts an array or `ArrayAccess`. Options are read +with `getOption($key, $default)` and are transport-specific; the commonly used ones are: + +| Option | Used by | Meaning | +|---|---|---| +| `userauth` / `passwordauth` | Curl | HTTP Basic credentials | +| `follow_location` | Curl | Follow redirects, default `true` | +| `transport.curl` | Curl | Raw `CURLOPT_*` overrides, applied last | +| `stream.certpath` | Stream | CA bundle file or directory | +| `protocolVersion` | Curl | `1.0`, `1.1` or `2.0` | + +See [the overview](../overview.md#things-to-know-before-you-build-on-this) for the caveats around +`transport.curl` and redirect handling. diff --git a/docs/index.md b/docs/index.md index b83e6c32..d2a41e46 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,10 @@ -* [Overview](overview.md) -* [Updating from v1 to v2](v1-to-v2-update.md) -* [Updating from v2 to v3](v2-to-v3-update.md) -* [Updating from v3 to v4](v3-to-v4-update.md) -* Classes - * [Http](classes/Http.md) - * [HttpFactory](classes/HttpFactory.md) +* Guide + * [Overview](overview.md) + * [Http](classes/Http.md) + * [HttpFactory](classes/HttpFactory.md) + * [Response](classes/Response.md) + * [TransportInterface](classes/TransportInterface.md) +* Upgrading + * [Updating from v1 to v2](v1-to-v2-update.md) + * [Updating from v2 to v3](v2-to-v3-update.md) + * [Updating from v3 to v4](v3-to-v4-update.md) diff --git a/docs/overview.md b/docs/overview.md index f03440d2..b49bb6d1 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -114,3 +114,50 @@ $headers = ['Accept' => 'application/foo']; // In this case, the Accept header in $headers will override the options header. $pull = $http->get('https://api.github.com/repos/joomla-framework/http/pulls/1', $headers); ``` + +## Things to know before you build on this + +**There is no SSRF protection.** Any URI is accepted and fetched, including private address ranges +and non-HTTP schemes on the stream transport. If a target URL can come from user input or +configuration, validate it before the call — resolve the host and reject private ranges yourself. + +**`transport.curl` overrides everything, including the security defaults.** The custom options are +applied last, so a value such as `CURLOPT_SSL_VERIFYPEER => false` silently disables certificate +verification for that client: + +```php +// Anything set here wins over the defaults the transport computed. +$http = (new HttpFactory())->getHttp(['transport.curl' => [CURLOPT_TIMEOUT => 5]]); +``` + +Keep that array to non-security options, or re-assert the ones that matter afterwards. + +**Redirects are followed without a limit.** `CURLOPT_FOLLOWLOCATION` is enabled by default and +neither `CURLOPT_MAXREDIRS` nor `CURLOPT_REDIR_PROTOCOLS` is set. Disable following, or set both, +when the target is not fully trusted: + +```php +$http = (new HttpFactory())->getHttp([ + 'follow_location' => false, +]); +``` + +**The whole response body is buffered in memory.** There is no size limit and no streaming option, +so a large or hostile response can exhaust `memory_limit`. Fetch untrusted URLs with your own +limit in place. + +**The socket transport cannot be configured for TLS.** It connects with `fsockopen()`, which takes +no stream context, so no CA bundle, minimum TLS version or client certificate can be supplied — and +the connection error is suppressed, so a certificate failure is indistinguishable from an +unreachable host. Prefer the cURL or stream transport. + +**The stream transport accepts any wrapper.** The URI goes to `fopen()` unfiltered, so a `file://`, +`php://` or `phar://` URL is opened rather than rejected. Check the scheme before calling if the +URL is not your own. + +**`Http::get()` and friends take positional arguments.** There is no request object, so headers and +timeouts are passed per call: + +```php +$response = $http->get($url, ['Accept' => 'application/json'], 10); +``` diff --git a/docs/v2-to-v3-update.md b/docs/v2-to-v3-update.md index bdb1f3e8..3328a35e 100644 --- a/docs/v2-to-v3-update.md +++ b/docs/v2-to-v3-update.md @@ -1,5 +1,41 @@ -## Updating from v2 to v3 +# Updating from v2 to v3 -### Minimum supported PHP version raised +Release 3.0.0 raises the PHP requirement and reformats the codebase. **No public or protected +method signature changed**, so code written against 2.x keeps working on PHP 8.1. -All Framework packages now require PHP 8.1 or newer. +## At a glance + +| | v2 (2.0.x) | v3 (3.0.0) | +|---|---|---| +| PHP | `^7.2.5` | `^8.1.0` | +| Public API | — | unchanged | +| Coding style | Joomla Coding Standard | PSR-12 | + +## Minimum supported PHP version raised + +All Framework packages now require **PHP 8.1** or newer. + +## No API changes + +`Http`, `HttpFactory`, `AbstractTransport`, `Response`, the three transports and both exception +classes have the same signatures in 3.0.0 as in 2.0.0. + +The magic getters on `Response` that emulate the 1.x API (`$response->body`, `->code`, +`->headers`) are still present in 3.x and still emit a deprecation notice. They were removed in +4.0.0 — see [Updating from v3 to v4](v3-to-v4-update.md). + +## Codebase converted to PSR-12 + +The package was reformatted from the Joomla Coding Standard to PSR-12. This touches nearly every +line and changes no behaviour. + +## Dependency changes + +| Package | v2 (2.0.x) | v3 (3.0.0) | +|---|---|---| +| `php` | `^7.2.5` | `^8.1.0` | +| `joomla/uri` | `^1.0 \| ^2.0` | `^3.0` | +| `composer/ca-bundle` | `^1.0` | `^1.3.5` | +| `laminas/laminas-diactoros` | `^2.2.2` | `^2.24.0` | +| `psr/http-client` | `^1.0` | `^1.0` | +| `psr/http-message` | `^1.0` | `^1.0` | diff --git a/docs/v3-to-v4-update.md b/docs/v3-to-v4-update.md index 04aebf27..e3e4752d 100644 --- a/docs/v3-to-v4-update.md +++ b/docs/v3-to-v4-update.md @@ -1,12 +1,83 @@ -## Updating from v3 to v4 +# Updating from v3 to v4 -### Minimum supported PHP version raised +Release 4.0.0 raises the PHP requirement and removes the backwards compatibility layer that let a +`Response` be read like the 1.x object. -All Framework packages now require PHP 8.3 or newer. +## At a glance -### Dependency `laminas/laminas-diactoros` updated to major version 3 +| | v3 (3.1.1) | v4 (4.0.0) | +|---|---|---| +| PHP | `^8.1.0` | `^8.3.0` | +| `$response->body`, `->code`, `->headers` | deprecated, work | **removed** | +| `psr/http-message` | `^1.0` | `^2.0` | +| `laminas/laminas-diactoros` | `^2.24.0` | `^3.6.0` | -[`laminas-diactoros`](https://github.com/laminas/laminas-diactoros) is a PSR-7 HTTP Message implementation and the former v2 series is not PHP 8.4 compliant. The newer v3 series is now fully PHP 8.4+ compliant and thus an update was inevitable. For differences please read the documentation of [`laminas/laminas-diactoros`](https://github.com/laminas/laminas-diactoros). +## Minimum supported PHP version raised -### Magic getter for internal properties of Response class removed -For backwards compatibilty purposes from v1 of the framework, `Joomla\HTTP\Response` contained a magic getter to access headers, body and code of the request. This was deprecated in v2 of the framework and has now been removed with v4. Please use the proper PSR-7 getters like `getHeaders()`, `getStatusCode()` and `getContent()` instead. +All Framework packages now require **PHP 8.3** or newer. + +## The `Response` compatibility getters were removed + +`Joomla\Http\Response` extends the PSR-7 response. Until 3.x it also carried a `__get()` that +mapped the 1.x property names onto the PSR-7 methods and emitted a deprecation notice. 4.0.0 +removes it, along with the `@property-read` annotations. + +```php +// Removed in 4.0.0 +$response->body; +$response->code; +$response->headers; + +// Use the PSR-7 API +(string) $response->getBody(); +$response->getStatusCode(); +$response->getHeaders(); +``` + +Reading one of the old names now raises *Undefined property* and evaluates to `null`, so the +failure usually surfaces later as an error on `null`. + +Two notes on the replacements: + +* Prefer `(string) $response->getBody()` over `$response->getBody()->getContents()`. The cast + rewinds the stream first, so reading the body twice still works; `getContents()` returns + everything from the current position, which is the end after the first call. +* `getHeaders()` returns `array` — a list of values per header, not a flat + string. Use `$response->getHeaderLine('Content-Type')` when you want one string. + +To find the call sites: + +```bash +grep -rnE -- '->(body|code|headers)\b' src/ +``` + +## PSR-7 2.0 + +`psr/http-message` moved from `^1.0` to `^2.0` and Diactoros from `^2.24` to `^3.6`. PSR-7 2.0 adds +return types to every interface method: + +```php +// PSR-7 1.x +public function withStatus($code, $reasonPhrase = '') + +// PSR-7 2.0 +public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface +``` + +This affects you only if you implement a PSR-7 interface yourself — a custom response, or a +decorator around one. Consuming the objects needs no change. + +`psr/http-client` stays at `^1.0`. + +## Dependency changes + +| Package | v3 (3.1.1) | v4 (4.0.0) | +|---|---|---| +| `php` | `^8.1.0` | `^8.3.0` | +| `psr/http-message` | `^1.0` | `^2.0` | +| `laminas/laminas-diactoros` | `^2.24.0` | `^3.6.0` | +| `joomla/uri` | `^3.0` | `^4.0` | +| `psr/http-client` | `^1.0` | unchanged | +| `composer/ca-bundle` | `^1.3.5` | unchanged | + +`ext-curl` is listed in `suggest` for the cURL transport.