Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions docs/classes/Response.md
Original file line number Diff line number Diff line change
@@ -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).
114 changes: 114 additions & 0 deletions docs/classes/TransportInterface.md
Original file line number Diff line number Diff line change
@@ -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<int, array{method: string, uri: string}> */
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.
17 changes: 10 additions & 7 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 47 additions & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
42 changes: 39 additions & 3 deletions docs/v2-to-v3-update.md
Original file line number Diff line number Diff line change
@@ -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` |
Loading